Template
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
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:
@@ -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 }}"
|
||||
Reference in New Issue
Block a user