From 0f4b6848a2d77ce110191156faa6bc468fd6f35f Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 6 Jul 2026 16:17:40 -0500 Subject: [PATCH] =?UTF-8?q?feat(ci):=20layered=20promotion-gated=20test=20?= =?UTF-8?q?pipeline=20(build=20once=20=E2=86=92=20dev=20=E2=86=92=20rc=20?= =?UTF-8?q?=E2=86=92=20prod)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .mokogitea/workflows/test-pipeline.yml | 447 +++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 .mokogitea/workflows/test-pipeline.yml diff --git a/.mokogitea/workflows/test-pipeline.yml b/.mokogitea/workflows/test-pipeline.yml new file mode 100644 index 0000000..e327616 --- /dev/null +++ b/.mokogitea/workflows/test-pipeline.yml @@ -0,0 +1,447 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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-`. +# 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): +# :dev-tested -> digest that passed the dev suite (moved by dev job) +# :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 }}" -- 2.52.0