feat(ci): layered promotion-gated test pipeline (build once → dev → rc → prod)
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Generic: Project CI / Lint & Validate (pull_request) Successful in 10s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Universal: PR Check / Secret Scan (pull_request) Successful in 12s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled

Add .mokogitea/workflows/test-pipeline.yml implementing the tiered
"build once, test, promote the artifact" model from Template-Go#4:

- dev  (push dev):  build the image ONCE, run the suite, push the tested
                    artifact, move the `dev-tested` pointer, deploy to dev.
- rc   (push rc):   PULL the dev-tested artifact (no rebuild), promote it to
                    the RC registry, deploy that exact digest, run suite +
                    smoke vs the live RC instance, move `rc-healthy`.
- prod (push main): gate on an `rc-healthy` artifact existing (fail closed),
                    then promote the SAME digest to prod :version + :latest
                    and deploy — replacing the removed "verify dev healthy"
                    gate (MokoGitea-Fork#758).

Three independent `if:`-gated jobs, no `needs:` chain, so it avoids the Gitea
scheduler stall on dependent 2nd jobs (MokoGitea-Fork#756). All tier config is
driven from repo vars.*/secrets.*, reusing the DEV_*/RC_*/PROD_* variables and
shared DEPLOY_SSH_KEY/MOKOGITEA_TOKEN secrets from the deploy workflows (PR#5).
Gitea commit statuses pipeline/dev + pipeline/rc are published per tier.

First draft for review; env-specific wiring left as TODO in the header.

Refs #4

Claude-Session: https://claude.ai/code/session_01Wsno14cxE49MstXFs9G5KT
This commit is contained in:
2026-07-06 16:17:40 -05:00
parent ac16906266
commit 0f4b6848a2
+447
View File
@@ -0,0 +1,447 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Go
# PATH: /.mokogitea/workflows/test-pipeline.yml
# VERSION: 00.01.00
# BRIEF: Layered, promotion-gated test pipeline for Go server repos.
# Build ONCE at dev, test it, and promote the SAME tested artifact up the
# tiers (dev -> rc -> prod). Tests run at dev AND rc; prod is gated on the
# RC build being healthy (not dev). Portable/template-owned: ALL config
# comes from repo Actions variables (vars.*) and secrets (secrets.*),
# nothing hardcoded. Pairs with the deploy workflows (Template-Go#3 / PR#5:
# deploy-dev.yml / deploy-rc.yml / deploy-prod.yml) and reuses their
# DEV_*/RC_*/PROD_* variables + shared secrets. Refs Template-Go#4.
#
# ─────────────────────────────────────────────────────────────────────────────
# DESIGN — build once, test, promote the artifact
# ─────────────────────────────────────────────────────────────────────────────
# | Tier | Trigger | What runs | Gate to promote |
# |------|------------------|----------------------------------|-----------------|
# | dev | push to `dev` | build image ONCE + full suite | tests green -> |
# | | | -> push tested artifact + mark | eligible for rc |
# | | | -> deploy tested image to dev | |
# | rc | push to `rc` | PULL the dev-tested artifact (no | suite + smoke |
# | | (promote-rc) | rebuild) -> deploy to RC -> run | green -> mark |
# | | | suite + smoke vs live RC | rc-healthy |
# | prod | push to `main` | verify an rc-healthy artifact | RC healthy -> |
# | | | exists -> promote SAME digest to | deploy prod |
# | | | prod (:version + :latest) | |
#
# The promotable artifact is a content-addressed image tag `sha-<shortsha>`.
# Promotion never rebuilds: `rc` and `prod` `docker pull` the exact tested digest
# and re-tag it, so the bits that ship to prod are the bits that passed dev+rc.
#
# Promotion pointers (durable, registry-native, cross-workflow-run state):
# <IMAGE>:dev-tested -> digest that passed the dev suite (moved by dev job)
# <IMAGE>:rc-healthy -> digest that passed rc suite+smoke (moved by rc job)
# The prod gate reads `rc-healthy`; if it is missing the promotion FAILS closed.
# Gitea commit statuses (`pipeline/dev`, `pipeline/rc`) are also published so the
# UI / branch-protection can read a green check per tier (the "status/label the
# promotion checks read" from Template-Go#4).
#
# ─────────────────────────────────────────────────────────────────────────────
# WHY THREE INDEPENDENT `if:`-GATED JOBS (not a needs-chain)
# ─────────────────────────────────────────────────────────────────────────────
# The Gitea Actions scheduler does NOT reliably start the dependent 2nd job of a
# `needs:` chain — it can stall the follow-on job in `waiting` forever (see
# MokoGitea-Fork#756, which made the Tests job independent). So this pipeline uses
# NO `needs:` between tiers. Each tier is a single self-contained job whose steps
# run sequentially (build -> test -> promote -> deploy), and the three jobs are
# mutually exclusive via `if:` on the pushed branch. Cross-tier ordering is the
# natural git flow (dev -> rc -> main), not an in-workflow dependency.
#
# ─────────────────────────────────────────────────────────────────────────────
# Required repo VARIABLES (tier-scoped; SHARED with the deploy workflows / PR#5):
# DEV_REGISTRY, DEV_REGISTRY_USER, DEV_IMAGE RC_REGISTRY, RC_REGISTRY_USER, RC_IMAGE
# DEV_SSH_HOST, DEV_SSH_PORT, DEV_SSH_USERNAME RC_SSH_HOST, RC_SSH_PORT, RC_SSH_USERNAME
# DEV_CONTAINER, DEV_COMPOSE_PROJECT, RC_CONTAINER, RC_COMPOSE_PROJECT,
# DEV_COMPOSE_DIR, DEV_TAG_ENV, DEV_HEALTH_URL RC_COMPOSE_DIR, RC_TAG_ENV, RC_HEALTH_URL
# PROD_REGISTRY, PROD_REGISTRY_USER, PROD_IMAGE, PROD_HEALTH_URL (+ PROD_* deploy vars)
# Pipeline-specific variables (all optional, sensible generic defaults):
# TEST_CMD - suite to run at dev + rc (default: `go test ./...`)
# SMOKE_CMD - live smoke check at rc (default: curl -sf "$RC_HEALTH_URL")
# GO_VERSION - toolchain for setup-go (default: reads go.mod, else `stable`)
# Required SECRETS (already configured org-wide; reused, not re-set):
# DEPLOY_SSH_KEY - deploy private key
# MOKOGITEA_TOKEN - registry login + Gitea API (commit-status) token
#
# TODO(template): This is a FIRST DRAFT for review. Before enabling in a real Go
# server repo, confirm: (a) the repo ships a Dockerfile at root; (b) TEST_CMD is
# correct for the repo (module layout / build tags / integration deps); (c) the
# tier-scoped vars above are populated; (d) whether the tier build step should
# also run tests INSIDE the image (`docker run ... go test`) vs. on the runner.
name: Test Pipeline (Tiered)
on:
push:
branches:
- dev
- rc
- main
# Manual re-run of a single tier. `tier` selects which job runs when dispatched.
workflow_dispatch:
inputs:
tier:
description: "Tier to run (dev | rc | prod)"
required: true
default: dev
type: choice
options:
- dev
- rc
- prod
# One pipeline run per tier at a time; never cancel a prod promotion mid-flight.
concurrency:
group: test-pipeline-${{ github.ref_name }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── DEV TIER ───────────────────────────────────────────────────────────────
# Build the promotable artifact ONCE, run the full suite, and only on green:
# push the tested image, move the `dev-tested` pointer, and deploy it to dev.
dev:
name: "dev · build once → test → deploy"
runs-on: ubuntu-latest
if: >-
(github.event_name == 'push' && github.ref == 'refs/heads/dev') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.tier == 'dev')
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Derive artifact coordinates
id: art
run: |
SHA="$(git rev-parse --short HEAD)"
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
echo "content_tag=sha-${SHA}" >> "$GITHUB_OUTPUT"
echo "Promotable artifact tag: sha-${SHA}"
- name: Set up Go
uses: actions/setup-go@v5
with:
# Prefer the repo's go.mod; fall back to GO_VERSION var, else `stable`.
go-version: ${{ vars.GO_VERSION || 'stable' }}
go-version-file: ${{ hashFiles('go.mod') != '' && 'go.mod' || '' }}
cache: false
- name: Build the image ONCE (the promotable artifact)
run: |
set -e
IMG="${{ vars.DEV_REGISTRY }}/${{ vars.DEV_IMAGE }}:${{ steps.art.outputs.content_tag }}"
echo "Building ${IMG}"
# Build once here; rc/prod re-tag this exact image, never rebuild.
docker build --build-arg GOFLAGS='-p 1' --tag "${IMG}" -f Dockerfile .
- name: Run test suite
run: |
set -e
# Generic/config-driven: template default is `go test ./...`. Repos with
# a Makefile or special layout override via the TEST_CMD repo variable.
TEST_CMD='${{ vars.TEST_CMD }}'
if [ -z "$TEST_CMD" ]; then TEST_CMD='go test ./...'; fi
echo "Running suite: $TEST_CMD"
eval "$TEST_CMD"
- name: Push tested artifact + move dev-tested pointer
env:
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
set -e
REG="${{ vars.DEV_REGISTRY }}"
REPO="${{ vars.DEV_REGISTRY }}/${{ vars.DEV_IMAGE }}"
CONTENT_TAG="${{ steps.art.outputs.content_tag }}"
echo "$REGISTRY_TOKEN" | docker login "$REG" -u ${{ vars.DEV_REGISTRY_USER }} --password-stdin
# Publish the immutable content-addressed artifact...
docker push "${REPO}:${CONTENT_TAG}"
# ...then move the `dev-tested` promotion pointer onto the same digest.
docker tag "${REPO}:${CONTENT_TAG}" "${REPO}:dev-tested"
docker push "${REPO}:dev-tested"
echo "dev-tested -> ${REPO}:${CONTENT_TAG}"
- name: Deploy tested image to dev
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
TAG: ${{ steps.art.outputs.content_tag }}
run: |
set -e
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
# Same ssh/compose/health pattern as deploy-dev.yml, but PULLS the tested
# image instead of rebuilding — the artifact that tested is the artifact
# that ships. (Alternative: hand off to deploy-dev.yml; see header TODO.)
ssh -i ~/.ssh/deploy_key -p ${{ vars.DEV_SSH_PORT }} \
-o ConnectTimeout=30 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
${{ vars.DEV_SSH_USERNAME }}@${{ vars.DEV_SSH_HOST }} \
"TAG='$TAG' REGISTRY_TOKEN='$REGISTRY_TOKEN' bash -s" <<'DEPLOY_EOF'
set -e
echo 'SSH connected to dev environment'
[ -n "$TAG" ] || { echo 'ERROR: empty TAG' >&2; exit 1; }
HEALTH_FMT='{{.State.Health.Status}}'
echo "$REGISTRY_TOKEN" | docker login ${{ vars.DEV_REGISTRY }} -u ${{ vars.DEV_REGISTRY_USER }} --password-stdin
docker pull "${{ vars.DEV_REGISTRY }}/${{ vars.DEV_IMAGE }}:$TAG"
cd '${{ vars.DEV_COMPOSE_DIR }}'
docker rm -f '${{ vars.DEV_CONTAINER }}' 2>/dev/null || true
${{ vars.DEV_TAG_ENV }}="$TAG" docker compose -p '${{ vars.DEV_COMPOSE_PROJECT }}' up -d --force-recreate '${{ vars.DEV_CONTAINER }}'
for i in 1 2 3 4 5 6 7 8; do
sleep 15
if docker inspect --format="$HEALTH_FMT" '${{ vars.DEV_CONTAINER }}' 2>/dev/null | grep -q healthy; then
echo 'Dev container healthy!'; exit 0
fi
echo "Waiting... (attempt $i/8)"
done
echo 'Health check failed'; docker logs '${{ vars.DEV_CONTAINER }}' --tail 20; exit 1
DEPLOY_EOF
- name: Verify dev instance
run: |
sleep 5
curl -sf "${{ vars.DEV_HEALTH_URL }}" && echo " Dev API healthy"
- name: Publish dev commit status
if: always()
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
# Best-effort green/red check the promotion + branch-protection can read.
STATE='success'; [ '${{ job.status }}' = 'success' ] || STATE='failure'
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H 'Content-Type: application/json' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}" \
-d "{\"state\":\"${STATE}\",\"context\":\"pipeline/dev\",\"description\":\"dev build+test\"}" \
|| echo '::warning::could not publish dev commit status'
# ── RC TIER ────────────────────────────────────────────────────────────────
# Do NOT rebuild. Pull the dev-tested artifact, promote it to the RC registry,
# deploy that exact digest to RC, then run the suite + smoke tests against the
# LIVE RC instance. On green, move the `rc-healthy` promotion pointer.
rc:
name: "rc · promote tested artifact → deploy → suite+smoke"
runs-on: ubuntu-latest
if: >-
(github.event_name == 'push' && github.ref == 'refs/heads/rc') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.tier == 'rc')
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ vars.GO_VERSION || 'stable' }}
go-version-file: ${{ hashFiles('go.mod') != '' && 'go.mod' || '' }}
cache: false
- name: Resolve + promote the dev-tested artifact to RC (no rebuild)
id: promote
env:
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
set -e
DEV_REPO="${{ vars.DEV_REGISTRY }}/${{ vars.DEV_IMAGE }}"
RC_REPO="${{ vars.RC_REGISTRY }}/${{ vars.RC_IMAGE }}"
# GATE: there must be a dev-tested artifact to promote.
echo "$REGISTRY_TOKEN" | docker login ${{ vars.DEV_REGISTRY }} -u ${{ vars.DEV_REGISTRY_USER }} --password-stdin
if ! docker pull "${DEV_REPO}:dev-tested"; then
echo '::error::no dev-tested artifact to promote to RC — run the dev tier first'
exit 1
fi
# Content-address the promoted image by its source digest so rc/prod
# promotion is verifiably the same bits (RepoDigests -> sha256:...).
DIGEST="$(docker inspect --format='{{index .RepoDigests 0}}' "${DEV_REPO}:dev-tested" | sed 's/.*@sha256:/sha256-/' | cut -c1-19)"
RC_TAG="${DIGEST:-rc-$(git rev-parse --short HEAD)}"
echo "tag=${RC_TAG}" >> "$GITHUB_OUTPUT"
# Re-tag the SAME image into the RC registry (no docker build anywhere).
if [ "${{ vars.RC_REGISTRY }}" != "${{ vars.DEV_REGISTRY }}" ]; then
echo "$REGISTRY_TOKEN" | docker login ${{ vars.RC_REGISTRY }} -u ${{ vars.RC_REGISTRY_USER }} --password-stdin
fi
docker tag "${DEV_REPO}:dev-tested" "${RC_REPO}:${RC_TAG}"
docker push "${RC_REPO}:${RC_TAG}"
echo "Promoted dev-tested -> ${RC_REPO}:${RC_TAG}"
- name: Deploy tested artifact to RC
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
TAG: ${{ steps.promote.outputs.tag }}
run: |
set -e
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
# Mirrors deploy-rc.yml but PULLS the promoted digest (no rebuild).
ssh -i ~/.ssh/deploy_key -p ${{ vars.RC_SSH_PORT }} \
-o ConnectTimeout=30 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
${{ vars.RC_SSH_USERNAME }}@${{ vars.RC_SSH_HOST }} \
"TAG='$TAG' REGISTRY_TOKEN='$REGISTRY_TOKEN' bash -s" <<'DEPLOY_EOF'
set -e
echo 'SSH connected to RC environment'
[ -n "$TAG" ] || { echo 'ERROR: empty TAG' >&2; exit 1; }
HEALTH_FMT='{{.State.Health.Status}}'
echo "$REGISTRY_TOKEN" | docker login ${{ vars.RC_REGISTRY }} -u ${{ vars.RC_REGISTRY_USER }} --password-stdin
docker pull "${{ vars.RC_REGISTRY }}/${{ vars.RC_IMAGE }}:$TAG"
cd '${{ vars.RC_COMPOSE_DIR }}'
docker rm -f '${{ vars.RC_CONTAINER }}' 2>/dev/null || true
${{ vars.RC_TAG_ENV }}="$TAG" docker compose -p '${{ vars.RC_COMPOSE_PROJECT }}' up -d --force-recreate '${{ vars.RC_CONTAINER }}'
for i in 1 2 3 4 5 6 7 8; do
sleep 15
if docker inspect --format="$HEALTH_FMT" '${{ vars.RC_CONTAINER }}' 2>/dev/null | grep -q healthy; then
echo 'RC container healthy!'; exit 0
fi
echo "Waiting... (attempt $i/8)"
done
echo 'Health check failed'; docker logs '${{ vars.RC_CONTAINER }}' --tail 20; exit 1
DEPLOY_EOF
- name: Run test suite against RC source
run: |
set -e
TEST_CMD='${{ vars.TEST_CMD }}'
if [ -z "$TEST_CMD" ]; then TEST_CMD='go test ./...'; fi
echo "Running suite at rc: $TEST_CMD"
eval "$TEST_CMD"
- name: Smoke test the live RC instance
run: |
set -e
sleep 5
SMOKE_CMD='${{ vars.SMOKE_CMD }}'
if [ -z "$SMOKE_CMD" ]; then SMOKE_CMD='curl -sf "${{ vars.RC_HEALTH_URL }}"'; fi
echo "Smoke: $SMOKE_CMD"
eval "$SMOKE_CMD" && echo " RC smoke OK"
- name: Mark rc-healthy (promotion pointer prod reads)
env:
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
set -e
RC_REPO="${{ vars.RC_REGISTRY }}/${{ vars.RC_IMAGE }}"
echo "$REGISTRY_TOKEN" | docker login ${{ vars.RC_REGISTRY }} -u ${{ vars.RC_REGISTRY_USER }} --password-stdin
# Only reached when deploy + suite + smoke all passed: this digest is the
# one prod is allowed to promote. Prod gates on the existence of this tag.
docker tag "${RC_REPO}:${{ steps.promote.outputs.tag }}" "${RC_REPO}:rc-healthy"
docker push "${RC_REPO}:rc-healthy"
echo "rc-healthy -> ${RC_REPO}:${{ steps.promote.outputs.tag }}"
- name: Publish rc commit status
if: always()
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
STATE='success'; [ '${{ job.status }}' = 'success' ] || STATE='failure'
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H 'Content-Type: application/json' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}" \
-d "{\"state\":\"${STATE}\",\"context\":\"pipeline/rc\",\"description\":\"rc deploy+suite+smoke\"}" \
|| echo '::warning::could not publish rc commit status'
# ── PROD GATE ──────────────────────────────────────────────────────────────
# Gate on the RC build (NOT dev — replaces the old "verify dev healthy" gate
# removed in MokoGitea-Fork#758). Promote the SAME rc-healthy digest to prod;
# never build or ship an image that did not pass through the RC tier.
prod:
name: "prod · gate on RC → promote same digest"
runs-on: ubuntu-latest
if: >-
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.tier == 'prod')
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Determine prod version
id: ver
run: |
VERSION="$(git describe --tags --always 2>/dev/null || echo "prod-$(git rev-parse --short HEAD)")"
echo "tag=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Prod version: ${VERSION}"
- name: GATE — require a healthy RC build, then promote its digest to prod
env:
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
set -e
RC_REPO="${{ vars.RC_REGISTRY }}/${{ vars.RC_IMAGE }}"
PROD_REPO="${{ vars.PROD_REGISTRY }}/${{ vars.PROD_IMAGE }}"
echo "$REGISTRY_TOKEN" | docker login ${{ vars.RC_REGISTRY }} -u ${{ vars.RC_REGISTRY_USER }} --password-stdin
# FAIL CLOSED: no rc-healthy artifact => the RC tier never certified a
# build => refuse to promote to prod. This IS the gate.
if ! docker pull "${RC_REPO}:rc-healthy"; then
echo '::error::PROD GATE: no rc-healthy artifact — RC build was not certified healthy; refusing prod promotion'
exit 1
fi
# Promote the SAME tested digest — no rebuild — to prod :version + :latest.
if [ "${{ vars.PROD_REGISTRY }}" != "${{ vars.RC_REGISTRY }}" ]; then
echo "$REGISTRY_TOKEN" | docker login ${{ vars.PROD_REGISTRY }} -u ${{ vars.PROD_REGISTRY_USER }} --password-stdin
fi
docker tag "${RC_REPO}:rc-healthy" "${PROD_REPO}:${{ steps.ver.outputs.tag }}"
docker tag "${RC_REPO}:rc-healthy" "${PROD_REPO}:latest"
docker push "${PROD_REPO}:${{ steps.ver.outputs.tag }}"
docker push "${PROD_REPO}:latest"
echo "Promoted rc-healthy -> ${PROD_REPO}:${{ steps.ver.outputs.tag }} (+ :latest)"
- name: Deploy promoted image to prod
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -e
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
# Mirrors deploy-prod.yml but PULLS the promoted digest (no rebuild).
ssh -i ~/.ssh/deploy_key -p ${{ vars.PROD_SSH_PORT }} \
-o ConnectTimeout=30 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
${{ vars.PROD_SSH_USERNAME }}@${{ vars.PROD_SSH_HOST }} \
"TAG='$TAG' REGISTRY_TOKEN='$REGISTRY_TOKEN' bash -s" <<'DEPLOY_EOF'
set -e
echo 'SSH connected to prod environment'
[ -n "$TAG" ] || { echo 'ERROR: empty TAG' >&2; exit 1; }
HEALTH_FMT='{{.State.Health.Status}}'
echo "$REGISTRY_TOKEN" | docker login ${{ vars.PROD_REGISTRY }} -u ${{ vars.PROD_REGISTRY_USER }} --password-stdin
docker pull "${{ vars.PROD_REGISTRY }}/${{ vars.PROD_IMAGE }}:$TAG"
cd '${{ vars.PROD_COMPOSE_DIR }}'
docker rm -f '${{ vars.PROD_CONTAINER }}' 2>/dev/null || true
${{ vars.PROD_TAG_ENV }}="$TAG" docker compose -p '${{ vars.PROD_COMPOSE_PROJECT }}' up -d --force-recreate '${{ vars.PROD_CONTAINER }}'
for i in 1 2 3 4 5 6 7 8; do
sleep 15
if docker inspect --format="$HEALTH_FMT" '${{ vars.PROD_CONTAINER }}' 2>/dev/null | grep -q healthy; then
echo 'Prod container healthy!'; exit 0
fi
echo "Waiting... (attempt $i/8)"
done
echo 'Health check failed'; docker logs '${{ vars.PROD_CONTAINER }}' --tail 20; exit 1
DEPLOY_EOF
- name: Verify prod instance
run: |
sleep 5
curl -sf "${{ vars.PROD_HEALTH_URL }}" && echo " Prod API healthy"
- name: Notify on failure
if: failure()
run: echo "::error::Prod promotion/deploy failed for ${{ steps.ver.outputs.tag }}"