11# Continuous release pipeline for Homeboy.
22#
3- # Triggers on push to main (and manual dispatch). Checks for releasable
4- # conventional commits since the last tag. If found:
3+ # Triggers after Main Guard validates main (or by manual dispatch). Checks for
4+ # releasable conventional commits since the last tag. If found:
55# 1. Quality gate (audit, lint, test)
66# 2. Version bump + changelog generation from conventional commits
77# 3. Cross-platform binary builds via cargo-dist
@@ -20,7 +20,9 @@ permissions:
2020 pull-requests : write
2121
2222on :
23- push :
23+ workflow_run :
24+ workflows : [Main Guard]
25+ types : [completed]
2426 branches : [main]
2527 workflow_dispatch :
2628 inputs :
4143 RELEASE_BLOCKING_COMMANDS : ${{ inputs.release_blocking_commands || 'review lint,review test' }}
4244 HOMEBOY_NO_UPDATE_CHECK : ' 1'
4345 RELEASE_MIN_FREE_KB : ' 5242880'
46+ RELEASE_CANDIDATE_SHA : ${{ github.event.workflow_run.head_sha || github.sha }}
4447
45- # Only one release pipeline at a time. If a push arrives while a release
46- # is already running, it queues (never cancels). The queued run starts
47- # after the first finishes and its check job exits in seconds because
48- # HEAD is already tagged — zero wasted work .
48+ # Only one release pipeline at a time. If Main Guard validates another SHA
49+ # while a release is already running, it queues (never cancels). The queued run
50+ # starts after the first finishes and its check job exits in seconds if the
51+ # candidate is already tagged.
4952concurrency :
5053 group : release-${{ inputs.release_tag || github.ref }}
5154 cancel-in-progress : false
5558 # Fast exit if nothing to release (e.g. chore-only commits).
5659 check :
5760 name : Check for releasable commits
61+ if : github.event_name == 'workflow_dispatch' || (github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success')
5862 runs-on : ubuntu-latest
5963 outputs :
6064 should-release : ${{ steps.check.outputs.should-release }}
6670 steps :
6771 - uses : actions/checkout@v4
6872 with :
69- ref : ${{ inputs.release_tag || github.ref }}
73+ ref : ${{ inputs.release_tag || env.RELEASE_CANDIDATE_SHA }}
7074 fetch-depth : 0
7175
7276 - name : Restore failed release marker
7579 uses : actions/cache/restore@v4
7680 with :
7781 path : ${{ runner.temp }}/homeboy-release-last-failed
78- key : release-last-failed-${{ github.ref_name }}-${{ github.sha }}
82+ key : release-last-failed-${{ github.ref_name }}-${{ env.RELEASE_CANDIDATE_SHA }}
7983 restore-keys : |
8084 release-last-failed-${{ github.ref_name }}-
8185
@@ -196,8 +200,8 @@ jobs:
196200 echo "::notice::Release recovery will publish ${RECOVERY_TAG}"
197201 elif [ -n "${STRANDED_TAG}" ]; then
198202 # Recovery PREEMPTS a fresh release, and preempts the failed-SHA
199- # marker too. The marker is keyed to github.sha and only means
200- # "do not re-prepare this HEAD"; finishing an already-prepared tag
203+ # marker too. The marker is keyed to the release candidate SHA and
204+ # only means "do not re-prepare this HEAD"; finishing an already-prepared tag
201205 # is a different, HEAD-independent action. Preemption is the whole
202206 # point: a fresh release prepared on top of a stranded tag buries
203207 # it under a newer published release, where the contiguous-window
@@ -231,31 +235,57 @@ jobs:
231235 echo "::notice::Release dry-run predicts v${RELEASE_VERSION} (${BUMP_TYPE})"
232236 fi
233237
234- # ── Step 2: Exact-SHA release qualification ──
238+ # ── Step 2: Exact-SHA release proof ──
235239 #
236- # A release owns an immutable candidate (github.sha), unlike Main Guard's
237- # push monitoring, which is intentionally latest-wins. Reuse terminal green
238- # Main Guard evidence for this SHA when it exists; otherwise dispatch a
239- # non-cancellable qualification and await that exact proof.
240+ # Automatic releases are downstream of a successful Main Guard and consume
241+ # that run's immutable SHA directly. Manual releases have no triggering proof,
242+ # so they retain the explicit lookup/dispatch qualification path.
240243 release-qualification :
241- name : Release qualification
244+ name : Bind release proof
242245 needs : check
243246 if : needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true'
244247 runs-on : ubuntu-latest
245248 timeout-minutes : 45
246249 outputs :
247- candidate-sha : ${{ steps.proof .outputs. candidate-sha }}
248- proof-sha : ${{ steps.proof .outputs.proof-sha }}
249- proof-run : ${{ steps.proof .outputs.proof-run }}
250- proof-status : ${{ steps.proof .outputs.proof-status }}
251- next-action : ${{ steps.proof .outputs. next-action }}
250+ candidate-sha : ${{ steps.main_guard .outputs['candidate-sha'] || steps.proof.outputs[' candidate-sha'] }}
251+ proof-sha : ${{ steps.main_guard .outputs['proof-sha'] || steps .proof.outputs['proof -sha'] }}
252+ proof-run : ${{ steps.main_guard .outputs['proof-run'] || steps .proof.outputs['proof -run'] }}
253+ proof-status : ${{ steps.main_guard .outputs['proof-status'] || steps .proof.outputs['proof -status'] }}
254+ next-action : ${{ steps.main_guard .outputs['next-action'] || steps.proof.outputs[' next-action'] }}
252255 steps :
253- - name : Reuse or await exact Main Guard proof
256+ - name : Consume triggering Main Guard proof
257+ id : main_guard
258+ if : github.event_name == 'workflow_run'
259+ env :
260+ CANDIDATE_SHA : ${{ github.event.workflow_run.head_sha }}
261+ PROOF_RUN : ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}
262+ run : |
263+ {
264+ echo "candidate-sha=${CANDIDATE_SHA}"
265+ echo "proof-sha=${CANDIDATE_SHA}"
266+ echo "proof-run=${PROOF_RUN}"
267+ echo "proof-status=success"
268+ echo "next-action=release immutable candidate"
269+ } >> "$GITHUB_OUTPUT"
270+
271+ {
272+ echo "### Release qualification"
273+ echo
274+ echo "- Selected SHA: \`${CANDIDATE_SHA}\`"
275+ echo "- Exact gate proof SHA: \`${CANDIDATE_SHA}\`"
276+ echo "- Exact gate proof run: ${PROOF_RUN}"
277+ echo "- Proof status: \`success\`"
278+ echo "- Next action: release immutable candidate"
279+ echo "- Proof source: triggering Main Guard workflow"
280+ } >> "$GITHUB_STEP_SUMMARY"
281+
282+ - name : Qualify manually selected candidate
254283 id : proof
284+ if : github.event_name == 'workflow_dispatch'
255285 env :
256286 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
257287 GH_REPO : ${{ github.repository }}
258- CANDIDATE_SHA : ${{ github.sha }}
288+ CANDIDATE_SHA : ${{ env.RELEASE_CANDIDATE_SHA }}
259289 DRY_RUN : ${{ inputs.dry-run || 'false' }}
260290 run : |
261291 set -euo pipefail
@@ -342,7 +372,7 @@ jobs:
342372 with :
343373 # Recovery checks out an older release tag downstream, but the
344374 # finalizer must include the current recovery contract from main.
345- ref : ${{ github.sha }}
375+ ref : ${{ env.RELEASE_CANDIDATE_SHA }}
346376
347377 - name : Install Rust toolchain
348378 uses : dtolnay/rust-toolchain@stable
@@ -385,7 +415,7 @@ jobs:
385415 steps :
386416 - uses : actions/checkout@v4
387417 with :
388- ref : ${{ inputs.release_tag || github.ref }}
418+ ref : ${{ inputs.release_tag || env.RELEASE_CANDIDATE_SHA }}
389419 fetch-depth : 0
390420
391421 - name : Download homeboy binary
@@ -433,7 +463,7 @@ jobs:
433463 steps :
434464 - uses : actions/checkout@v4
435465 with :
436- ref : ${{ inputs.release_tag || github.ref }}
466+ ref : ${{ inputs.release_tag || env.RELEASE_CANDIDATE_SHA }}
437467 fetch-depth : 0
438468
439469 - name : Download homeboy binary
@@ -478,7 +508,7 @@ jobs:
478508 steps :
479509 - uses : actions/checkout@v4
480510 with :
481- ref : ${{ inputs.release_tag || github.ref }}
511+ ref : ${{ inputs.release_tag || env.RELEASE_CANDIDATE_SHA }}
482512 fetch-depth : 0
483513
484514 - name : Download homeboy binary
@@ -540,7 +570,7 @@ jobs:
540570 - name : Checkout workflow event commit
541571 uses : actions/checkout@v4
542572 with :
543- ref : ${{ github.sha }}
573+ ref : ${{ env.RELEASE_CANDIDATE_SHA }}
544574
545575 - name : Enforce release-blocking commands
546576 env :
@@ -581,12 +611,12 @@ jobs:
581611 app-id : ${{ secrets.HOMEBOY_APP_ID }}
582612 private-key : ${{ secrets.HOMEBOY_APP_PRIVATE_KEY }}
583613
584- # A push-triggered self-recovery has no `inputs.release_tag`, and main has
614+ # An automatic self-recovery has no `inputs.release_tag`, and main has
585615 # normally moved well past the stranded tag by now. Check out the tag
586616 # being recovered so every job in the publish chain agrees on the tree.
587617 - uses : actions/checkout@v4
588618 with :
589- ref : ${{ inputs.release_tag || (needs.check.outputs.recovery-release == 'true' && needs.check.outputs['release-tag']) || github.ref }}
619+ ref : ${{ inputs.release_tag || (needs.check.outputs.recovery-release == 'true' && needs.check.outputs['release-tag']) || env.RELEASE_CANDIDATE_SHA }}
590620 fetch-depth : 0
591621 persist-credentials : true
592622 token : ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
@@ -1092,7 +1122,7 @@ jobs:
10921122 RELEASE_TAG : ${{ needs.prepare.outputs['release-tag'] }}
10931123 RELEASE_VERSION : ${{ needs.prepare.outputs['release-version'] }}
10941124 EXPECTED_ASSETS : ${{ needs.plan.outputs.expected-assets }}
1095- CONTROL_SHA : ${{ github.sha }}
1125+ CONTROL_SHA : ${{ env.RELEASE_CANDIDATE_SHA }}
10961126 run : |
10971127 set -euo pipefail
10981128 mkdir -p draft-adoption
@@ -1156,7 +1186,7 @@ jobs:
11561186 if : needs.prepare.outputs.recovery-release == 'true'
11571187 env :
11581188 RELEASE_TAG : ${{ needs.prepare.outputs['release-tag'] }}
1159- CONTROL_SHA : ${{ github.sha }}
1189+ CONTROL_SHA : ${{ env.RELEASE_CANDIDATE_SHA }}
11601190 ADOPTION_SOURCE : ${{ needs.prepare.outputs.recovery-release == 'true' && 'draft-adoption' || 'artifacts' }}
11611191 ARTIFACT_ORIGIN : ${{ needs.plan.outputs.draft-complete == 'true' && 'pre-existing draft assets (rebuild skipped)' || 'rebuilt and re-uploaded by this run' }}
11621192 run : |
@@ -1257,6 +1287,7 @@ jobs:
12571287 steps :
12581288 - uses : actions/checkout@v4
12591289 with :
1290+ ref : ${{ env.RELEASE_CANDIDATE_SHA }}
12601291 persist-credentials : false
12611292 submodules : recursive
12621293
@@ -1276,10 +1307,12 @@ jobs:
12761307 - host
12771308 - verify-published
12781309 - announce
1279- if : ${{ always() && github.event_name == 'push ' && needs.check.outputs.should-release == 'true' && contains(toJson(needs), '"result":"failure"') }}
1310+ if : ${{ always() && github.event_name == 'workflow_run ' && needs.check.outputs.should-release == 'true' && contains(toJson(needs), '"result":"failure"') }}
12801311 runs-on : ubuntu-latest
12811312 steps :
12821313 - uses : actions/checkout@v4
1314+ with :
1315+ ref : ${{ env.RELEASE_CANDIDATE_SHA }}
12831316
12841317 # The SHA marker means "do not re-PREPARE this HEAD". A recovery run
12851318 # never prepares anything — it finishes an already-prepared tag — so
@@ -1294,12 +1327,12 @@ jobs:
12941327 uses : actions/cache/save@v4
12951328 with :
12961329 path : ${{ runner.temp }}/homeboy-release-last-failed
1297- key : release-last-failed-${{ github.ref_name }}-${{ github.sha }}
1330+ key : release-last-failed-${{ github.ref_name }}-${{ env.RELEASE_CANDIDATE_SHA }}
12981331
12991332 # A failed recovery is charged to the TAG, so a stranded tag that cannot
1300- # be published gets a bounded retry budget instead of re-firing on every
1301- # push forever . `detect-stranded-release.sh` refuses a tag once it hits
1302- # MAX_RECOVERY_ATTEMPTS and falls through to a normal fresh release.
1333+ # be published gets a bounded retry budget instead of re-firing after
1334+ # every successful Main Guard . `detect-stranded-release.sh` refuses a tag
1335+ # once it hits MAX_RECOVERY_ATTEMPTS and falls through to a fresh release.
13031336 - name : Restore recovery attempts
13041337 if : needs.check.outputs.recovery-release == 'true' && needs.check.outputs['release-tag'] != ''
13051338 uses : actions/cache/restore@v4
@@ -1347,7 +1380,7 @@ jobs:
13471380 - host
13481381 - verify-published
13491382 - announce
1350- if : ${{ always() && github.event_name == 'push ' && needs.check.outputs.should-release == 'true' && !contains(toJson(needs), '"result":"failure"') && !contains(toJson(needs), '"result":"cancelled"') }}
1383+ if : ${{ always() && github.event_name == 'workflow_run ' && needs.check.outputs.should-release == 'true' && !contains(toJson(needs), '"result":"failure"') && !contains(toJson(needs), '"result":"cancelled"') }}
13511384 runs-on : ubuntu-latest
13521385 steps :
13531386 - name : Clear failed SHA cache
0 commit comments