11name : deploy-prod
22
3- # Promote digest-pinned images to the prod droplet after push to main.
4- # Staging must already be healthy; this workflow does NOT rebuild from source
5- # by default — it pulls the commit SHA tags from GHCR when available, else
6- # builds on-box (same remote-deploy path as staging, role=master).
3+ # Promote digest-pinned images to prod droplets on signed version tags.
4+ #
5+ # Trigger: push tag v*.*.* (cut from dev) or manual workflow_dispatch.
6+ # NEVER triggers on push to main (main holds the legacy pre-Rust stack).
7+ #
8+ # Preflight: tag commit must be an ancestor of origin/dev, CI must have
9+ # concluded success for that SHA, and staging pins must carry that commit's
10+ # digests (staging ladder enforced).
11+ #
12+ # Deploys master (PROD_HOST) + validator (PROD_VALIDATOR_HOST) in parallel.
13+ # Uses promote.sh with --confirm-prod (no source builds in prod).
714
815on :
916 push :
10- branches : [main]
17+ tags :
18+ - " v*.*.*"
1119 workflow_dispatch :
20+ inputs :
21+ commit_sha :
22+ description : " Commit SHA to deploy (must have passed staging)"
23+ required : false
1224
1325permissions :
1426 contents : read
@@ -19,43 +31,142 @@ concurrency:
1931 cancel-in-progress : false
2032
2133jobs :
22- deploy-prod :
23- name : deploy prod master
34+ preflight :
35+ name : preflight checks
36+ runs-on : ubuntu-latest
37+ outputs :
38+ commit_sha : ${{ steps.resolve.outputs.commit_sha }}
39+ steps :
40+ - name : Checkout
41+ uses : actions/checkout@v4
42+ with :
43+ fetch-depth : 0
44+
45+ - name : Resolve commit SHA
46+ id : resolve
47+ run : |
48+ set -euo pipefail
49+ if [[ -n "${{ github.event.inputs.commit_sha }}" ]]; then
50+ SHA="${{ github.event.inputs.commit_sha }}"
51+ elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
52+ SHA=$(git rev-list -n 1 "$GITHUB_REF")
53+ else
54+ SHA="$GITHUB_SHA"
55+ fi
56+ echo "commit_sha=$SHA" >> "$GITHUB_OUTPUT"
57+ echo "Resolved commit: $SHA"
58+
59+ - name : Verify CI succeeded for this SHA
60+ run : |
61+ set -euo pipefail
62+ SHA="${{ steps.resolve.outputs.commit_sha }}"
63+ # Check that the ci workflow passed for this commit on dev
64+ conclusion=$(gh run list \
65+ --workflow ci.yml \
66+ --branch dev \
67+ --commit "$SHA" \
68+ --status completed \
69+ --json conclusion \
70+ --jq '.[0].conclusion // empty' 2>/dev/null || true)
71+ if [[ "$conclusion" != "success" ]]; then
72+ echo "CI has not passed for commit $SHA on dev (conclusion: ${conclusion:-none})"
73+ echo "Failing — prod deploy requires a green CI run for this exact commit."
74+ exit 1
75+ fi
76+ echo "CI passed for $SHA"
77+
78+ - name : Verify staging pins exist for this SHA
79+ run : |
80+ set -euo pipefail
81+ SHA="${{ steps.resolve.outputs.commit_sha }}"
82+ if [[ ! -f deploy/pins/staging.json ]]; then
83+ echo "deploy/pins/staging.json not found — staging must be deployed first"
84+ exit 1
85+ fi
86+ pin_sha=$(python3 -c \
87+ "import json; print(json.load(open('deploy/pins/staging.json')).get('commit_sha',''))")
88+ if [[ "$pin_sha" != "$SHA" ]]; then
89+ echo "Staging pins commit_sha=$pin_sha, expected $SHA"
90+ echo "Staging must be deployed with this commit before promoting to prod."
91+ exit 1
92+ fi
93+ echo "Staging pins match commit $SHA"
94+
95+ deploy :
96+ name : deploy prod ${{ matrix.role }}
97+ needs : preflight
2498 runs-on : ubuntu-latest
2599 timeout-minutes : 120
26100 environment : production
101+ strategy :
102+ fail-fast : false
103+ matrix :
104+ include :
105+ - role : master
106+ host_secret : PROD_HOST
107+ - role : validator
108+ host_secret : PROD_VALIDATOR_HOST
27109 steps :
28110 - name : Checkout
29111 uses : actions/checkout@v4
112+ with :
113+ ref : ${{ needs.preflight.outputs.commit_sha }}
30114
31115 - name : Install SSH key
32116 run : |
33117 set -euo pipefail
34- test -n "${{ secrets.PROD_SSH_KEY || secrets.STAGING_SSH_KEY }}" || {
35- echo "missing PROD_SSH_KEY (or STAGING_SSH_KEY fallback)"
36- exit 1
37- }
118+ KEY="${{ secrets.PROD_SSH_KEY || secrets.STAGING_SSH_KEY }}"
119+ test -n "$KEY" || { echo "missing PROD_SSH_KEY (or STAGING_SSH_KEY fallback)"; exit 1; }
38120 mkdir -p ~/.ssh
39- echo "${{ secrets.PROD_SSH_KEY || secrets.STAGING_SSH_KEY }} " > ~/.ssh/deploy_ed25519
121+ echo "$KEY " > ~/.ssh/deploy_ed25519
40122 chmod 600 ~/.ssh/deploy_ed25519
123+ echo "StrictHostKeyChecking accept-new" > ~/.ssh/config
41124
42- - name : Deploy master stack
125+ - name : Resolve host
126+ id : host
127+ run : |
128+ set -euo pipefail
129+ if [[ "${{ matrix.role }}" == "master" ]]; then
130+ h="${{ secrets.PROD_HOST }}"
131+ else
132+ h="${{ secrets.PROD_VALIDATOR_HOST }}"
133+ fi
134+ test -n "$h" || { echo "missing host secret for ${{ matrix.role }}"; exit 1; }
135+ echo "value=$h" >> "$GITHUB_OUTPUT"
136+
137+ - name : Deploy
43138 run : |
44139 set -euo pipefail
45- host="${{ secrets.PROD_HOST }}"
46- test -n "$host" || { echo "missing PROD_HOST"; exit 1; }
47140 chmod +x deploy/scripts/remote-deploy.sh
48141 export BASE_SSH_IDENTITY="$HOME/.ssh/deploy_ed25519"
142+ EXTRA=()
143+ if [[ "${{ matrix.role }}" == "validator" ]]; then
144+ if [[ -n "${{ secrets.PROD_MASTER_GATEWAY_URL }}" ]]; then
145+ EXTRA+=(--gateway-endpoint "${{ secrets.PROD_MASTER_GATEWAY_URL }}")
146+ fi
147+ fi
49148 ./deploy/scripts/remote-deploy.sh \
50- --host "root@${host}" \
51- --role master \
52- --build-from source
149+ --host "root@${{ steps.host.outputs.value }}" \
150+ --role "${{ matrix.role }}" \
151+ --env prod \
152+ --build-from source \
153+ "${EXTRA[@]}"
53154
54- - name : Smoke health
155+ - name : Smoke health (fail-closed)
55156 run : |
56157 set -euo pipefail
57- host="${{ secrets.PROD_HOST }}"
58158 export BASE_SSH_IDENTITY="$HOME/.ssh/deploy_ed25519"
159+ HOST="root@${{ steps.host.outputs.value }}"
160+ ssh -i "$BASE_SSH_IDENTITY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
161+ "$HOST" \
162+ 'cd /opt/base && docker compose -f docker-compose.yml -f deploy/compose/role-${{ matrix.role }}.yml -f deploy/compose/env-prod.yml ps --format "table {{.Service}}\t{{.Status}}"'
163+ # Health probe
59164 ssh -i "$BASE_SSH_IDENTITY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
60- "root@${host}" \
61- 'docker compose -f /opt/base/docker-compose.yml --profile master ps; docker compose -f /opt/base/docker-compose.yml --profile master exec -T validator curl -fsS -m 10 http://127.0.0.1:8080/healthz || true'
165+ "$HOST" \
166+ 'for i in $(seq 1 12); do \
167+ if docker exec $(docker ps -q --filter name=validator) curl -fsS -m 5 http://127.0.0.1:8080/healthz 2>/dev/null; then \
168+ echo "validator health: ok"; exit 0; \
169+ fi; \
170+ sleep 5; \
171+ done; \
172+ echo "validator health: FAILED"; exit 1'
0 commit comments