Skip to content

Commit 97b1d81

Browse files
authored
fix(release): hand off successful Main Guard proof (#10677)
1 parent c4335bb commit 97b1d81

3 files changed

Lines changed: 118 additions & 59 deletions

File tree

.github/workflows/release.yml

Lines changed: 72 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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

2222
on:
23-
push:
23+
workflow_run:
24+
workflows: [Main Guard]
25+
types: [completed]
2426
branches: [main]
2527
workflow_dispatch:
2628
inputs:
@@ -41,11 +43,12 @@ env:
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.
4952
concurrency:
5053
group: release-${{ inputs.release_tag || github.ref }}
5154
cancel-in-progress: false
@@ -55,6 +58,7 @@ jobs:
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 }}
@@ -66,7 +70,7 @@ jobs:
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
@@ -75,7 +79,7 @@ jobs:
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

tests/release_expected_assets_test.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,11 @@ fn the_plan_job_still_fails_closed_when_cargo_dist_plans_no_artifacts() {
179179
fn recovery_records_control_binary_and_release_target_provenance_separately() {
180180
// Issue #10519: recovery used to execute the stranded tag's own binary, so
181181
// a publisher fix merged after the tag could never repair it. The control
182-
// binary now comes from `gate-build` at `github.sha` while the release
182+
// binary now comes from `gate-build` at the proven candidate while the release
183183
// target stays pinned to the tag; the run has to state both.
184184
let workflow = release_workflow();
185185
assert!(
186-
workflow.contains("CONTROL_SHA: ${{ github.sha }}"),
186+
workflow.contains("CONTROL_SHA: ${{ env.RELEASE_CANDIDATE_SHA }}"),
187187
"the finalizer must record which commit built the control binary"
188188
);
189189
assert!(workflow.contains("| control binary | "));

tests/release_workflow_test.rs

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,8 @@ fn release_quality_policy_checks_out_event_commit_before_running_script() {
156156
);
157157

158158
assert!(
159-
policy.contains("ref: ${{ github.sha }}"),
160-
"release-quality-policy must check out the exact workflow/event commit (github.sha)"
159+
policy.contains("ref: ${{ env.RELEASE_CANDIDATE_SHA }}"),
160+
"release-quality-policy must check out the proven release candidate"
161161
);
162162
}
163163

@@ -214,22 +214,48 @@ fn release_concurrency_scopes_recovery_runs_to_the_requested_tag() {
214214
}
215215

216216
#[test]
217-
fn release_reuses_or_awaits_an_exact_main_guard_qualification() {
217+
fn automatic_release_consumes_the_triggering_main_guard_without_polling() {
218218
let workflow = release_workflow();
219219
let qualification = job_section(workflow, "release-qualification");
220+
let proof = release_step_block(qualification, "name: Consume triggering Main Guard proof");
221+
let check = job_section(workflow, "check");
220222

221-
assert!(qualification.contains("CANDIDATE_SHA: ${{ github.sha }}"));
222-
assert!(qualification.contains("GH_REPO: ${{ github.repository }}"));
223-
assert!(qualification.contains("gh run list --workflow audit-debt.yml"));
224-
assert!(qualification.contains("--commit \"$CANDIDATE_SHA\""));
225-
assert!(qualification.contains(
223+
assert!(workflow.contains(
224+
"workflow_run:\n workflows: [Main Guard]\n types: [completed]\n branches: [main]"
225+
));
226+
assert!(!workflow.contains("push:\n branches: [main]"));
227+
assert!(workflow.contains(
228+
"RELEASE_CANDIDATE_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}"
229+
));
230+
assert!(check.contains("github.event.workflow_run.event == 'push'"));
231+
assert!(check.contains("github.event.workflow_run.conclusion == 'success'"));
232+
assert!(proof.contains("CANDIDATE_SHA: ${{ github.event.workflow_run.head_sha }}"));
233+
assert!(proof.contains("github.event.workflow_run.id"));
234+
assert!(proof.contains("proof-status=success"));
235+
assert!(!proof.contains("gh run list"));
236+
assert!(!proof.contains("gh workflow run"));
237+
assert!(!proof.contains("sleep 30"));
238+
}
239+
240+
#[test]
241+
fn manual_release_reuses_or_awaits_an_exact_main_guard_qualification() {
242+
let workflow = release_workflow();
243+
let qualification = job_section(workflow, "release-qualification");
244+
let proof = release_step_block(qualification, "name: Qualify manually selected candidate");
245+
246+
assert!(proof.contains("if: github.event_name == 'workflow_dispatch'"));
247+
assert!(proof.contains("CANDIDATE_SHA: ${{ env.RELEASE_CANDIDATE_SHA }}"));
248+
assert!(proof.contains("GH_REPO: ${{ github.repository }}"));
249+
assert!(proof.contains("gh run list --workflow audit-debt.yml"));
250+
assert!(proof.contains("--commit \"$CANDIDATE_SHA\""));
251+
assert!(proof.contains(
226252
"gh workflow run audit-debt.yml --ref main -f qualification_sha=\"$CANDIDATE_SHA\""
227253
));
228-
assert!(qualification.contains("proof-status=${conclusion}"));
229-
assert!(qualification.contains("Selected SHA:"));
230-
assert!(qualification.contains("Exact gate proof run:"));
231-
assert!(qualification.contains("Proof source: ${action}"));
232-
assert!(qualification.contains("Dry run: release mutation remains disabled"));
254+
assert!(proof.contains("proof-status=${conclusion}"));
255+
assert!(proof.contains("Selected SHA:"));
256+
assert!(proof.contains("Exact gate proof run:"));
257+
assert!(proof.contains("Proof source: ${action}"));
258+
assert!(proof.contains("Dry run: release mutation remains disabled"));
233259
}
234260

235261
#[test]
@@ -893,16 +919,16 @@ fn release_fast_path_keeps_every_publication_verification() {
893919
);
894920

895921
// The control binary that performs adoption is still the one built from the
896-
// dispatching commit, not from the stranded tag (#10519's headline defect,
922+
// proven candidate, not from the stranded tag (#10519's headline defect,
897923
// fixed in #10560 — keep it fixed).
898924
let host = job_section(workflow, "host");
899925
assert!(
900926
host.contains("binary-path: ${{ needs.prepare.outputs.recovery-release == 'true' && '.homeboy-bin/homeboy' || '' }}"),
901927
"recovery must execute the freshly built control binary, never the stranded tag's"
902928
);
903929
assert!(
904-
job_section(workflow, "gate-build").contains("ref: ${{ github.sha }}"),
905-
"the control binary must be built from the dispatching commit"
930+
job_section(workflow, "gate-build").contains("ref: ${{ env.RELEASE_CANDIDATE_SHA }}"),
931+
"the control binary must be built from the proven release candidate"
906932
);
907933
assert!(
908934
host.contains("ref: ${{ needs.prepare.outputs['release-tag'] }}"),
@@ -916,7 +942,7 @@ fn release_fast_path_keeps_every_publication_verification() {
916942

917943
/// Recovery is allowed — required — to run a control binary NEWER than the tag
918944
/// it repairs; that is the bootstrap #10519 asks for, and #10560 built it by
919-
/// pinning `gate-build` to `github.sha`. The inverse was never bounded: a
945+
/// pinning `gate-build` to the release candidate SHA. The inverse was never bounded: a
920946
/// control binary from a tree that never contained the tag cannot be shown to
921947
/// carry the publisher fix recovery exists to apply, and would impose a release
922948
/// contract the tag was never planned under.
@@ -931,7 +957,7 @@ fn release_recovery_records_control_binary_lineage_against_the_release_target()
931957
let adoption = release_step_block(host, "name: Create remote draft adoption manifest");
932958

933959
assert!(
934-
adoption.contains("CONTROL_SHA: ${{ github.sha }}"),
960+
adoption.contains("CONTROL_SHA: ${{ env.RELEASE_CANDIDATE_SHA }}"),
935961
"the adoption manifest must record which commit the control binary was built from"
936962
);
937963
assert!(

0 commit comments

Comments
 (0)