Skip to content

Commit 61127f1

Browse files
authored
fix(release): qualify immutable Main Guard candidates (#10665)
1 parent f6a5b38 commit 61127f1

5 files changed

Lines changed: 211 additions & 18 deletions

File tree

.github/workflows/audit-debt.yml

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@
5151

5252
name: Main Guard
5353

54+
# A dispatched qualification is an immutable release proof; a push is only a
55+
# latest-tip health signal. The run name is the stable lookup key used by
56+
# release.yml when a dispatch starts after main has moved again.
57+
run-name: ${{ inputs.qualification_sha && format('Release Qualification {0}', inputs.qualification_sha) || 'Main Guard' }}
58+
5459
on:
5560
push:
5661
branches: [main]
@@ -63,6 +68,10 @@ on:
6368
description: 'Audit profile to run (full = all discovery detectors).'
6469
required: false
6570
default: 'full'
71+
qualification_sha:
72+
description: 'Immutable commit SHA to qualify for a release.'
73+
required: false
74+
type: string
6675

6776
# Latest merge wins. This gate answers exactly one question — "is main green
6877
# RIGHT NOW?" — and that answer is a pure function of the current tip, so a
@@ -86,8 +95,10 @@ on:
8695
# Push and sweep get separate groups so a merge train never cancels the weekly
8796
# audit sweep (and vice versa).
8897
concurrency:
89-
group: main-guard-${{ github.event_name == 'push' && 'post-merge' || 'sweep' }}
90-
cancel-in-progress: true
98+
group: main-guard-${{ inputs.qualification_sha && format('qualification-{0}', inputs.qualification_sha) || (github.event_name == 'push' && 'post-merge' || 'sweep') }}
99+
# A qualification proves one immutable release candidate and must survive
100+
# newer merges. Push monitoring remains latest-wins.
101+
cancel-in-progress: ${{ inputs.qualification_sha == '' }}
91102

92103
permissions:
93104
contents: read
@@ -103,14 +114,16 @@ jobs:
103114
# every merge. One release build now feeds all three.
104115
gate-build:
105116
name: Build
106-
if: github.event_name == 'push'
117+
if: github.event_name == 'push' || inputs.qualification_sha != ''
107118
runs-on: ubuntu-latest
108119
# Least privilege: this job compiles and uploads, it never files issues.
109120
permissions:
110121
contents: read
111122
actions: read
112123
steps:
113124
- uses: actions/checkout@v6
125+
with:
126+
ref: ${{ inputs.qualification_sha || github.sha }}
114127
- uses: dtolnay/rust-toolchain@stable
115128

116129
# Deliberately the same cache key as release.yml's gate-build: both run
@@ -180,7 +193,7 @@ jobs:
180193
# `continue-on-error` from the audit step below.
181194
full-audit-gate:
182195
name: Full-tree audit gate
183-
if: github.event_name == 'push'
196+
if: github.event_name == 'push' || inputs.qualification_sha != ''
184197
needs: gate-build
185198
runs-on: ubuntu-latest
186199
# Pure pass/fail gate — deliberately cannot file issues, unlike the sweep.
@@ -190,6 +203,7 @@ jobs:
190203
steps:
191204
- uses: actions/checkout@v6
192205
with:
206+
ref: ${{ inputs.qualification_sha || github.sha }}
193207
fetch-depth: 0
194208

195209
- name: Download homeboy binary
@@ -286,12 +300,13 @@ jobs:
286300
# autofix/auto-push path to disable. This gate never mutates the repo.
287301
full-lint-gate:
288302
name: Full-suite lint gate
289-
if: github.event_name == 'push'
303+
if: github.event_name == 'push' || inputs.qualification_sha != ''
290304
needs: gate-build
291305
runs-on: ubuntu-latest
292306
steps:
293307
- uses: actions/checkout@v6
294308
with:
309+
ref: ${{ inputs.qualification_sha || github.sha }}
295310
fetch-depth: 0
296311

297312
- name: Download homeboy binary
@@ -338,12 +353,13 @@ jobs:
338353

339354
full-test-gate:
340355
name: Full-suite test gate
341-
if: github.event_name == 'push'
356+
if: github.event_name == 'push' || inputs.qualification_sha != ''
342357
needs: gate-build
343358
runs-on: ubuntu-latest
344359
steps:
345360
- uses: actions/checkout@v6
346361
with:
362+
ref: ${{ inputs.qualification_sha || github.sha }}
347363
fetch-depth: 0
348364

349365
- name: Download homeboy binary
@@ -390,7 +406,7 @@ jobs:
390406

391407
full-audit:
392408
name: Full-tree audit → tracking issues
393-
if: github.event_name != 'push'
409+
if: github.event_name != 'push' && inputs.qualification_sha == ''
394410
runs-on: ubuntu-latest
395411
steps:
396412
- uses: actions/checkout@v6

.github/workflows/release.yml

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -231,13 +231,108 @@ jobs:
231231
echo "::notice::Release dry-run predicts v${RELEASE_VERSION} (${BUMP_TYPE})"
232232
fi
233233
234-
# ── Step 2: Build once ──
234+
# ── Step 2: Exact-SHA release qualification ──
235+
#
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+
release-qualification:
241+
name: Release qualification
242+
needs: check
243+
if: needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true'
244+
runs-on: ubuntu-latest
245+
timeout-minutes: 45
246+
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 }}
252+
steps:
253+
- name: Reuse or await exact Main Guard proof
254+
id: proof
255+
env:
256+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
257+
CANDIDATE_SHA: ${{ github.sha }}
258+
DRY_RUN: ${{ inputs.dry-run || 'false' }}
259+
run: |
260+
set -euo pipefail
261+
262+
title="Release Qualification ${CANDIDATE_SHA}"
263+
run_json="$(gh run list --workflow audit-debt.yml --limit 100 --json databaseId,displayTitle,status,conclusion,url | jq -c --arg title "$title" 'map(select(.displayTitle == $title)) | first // empty')"
264+
action="reused exact terminal proof"
265+
266+
# A push-triggered Main Guard has the candidate as its workflow SHA,
267+
# so it is equally valid evidence and avoids an unnecessary dispatch.
268+
if [ -z "$run_json" ]; then
269+
run_json="$(gh run list --workflow audit-debt.yml --commit "$CANDIDATE_SHA" --status completed --limit 100 --json databaseId,status,conclusion,url | jq -c 'map(select(.conclusion == "success")) | first // empty')"
270+
fi
271+
272+
if [ -z "$run_json" ]; then
273+
gh workflow run audit-debt.yml --ref main -f qualification_sha="$CANDIDATE_SHA"
274+
action="started immutable qualification"
275+
for _ in $(seq 1 30); do
276+
run_json="$(gh run list --workflow audit-debt.yml --limit 100 --json databaseId,displayTitle,status,conclusion,url | jq -c --arg title "$title" 'map(select(.displayTitle == $title)) | first // empty')"
277+
[ -n "$run_json" ] && break
278+
sleep 2
279+
done
280+
fi
281+
282+
if [ -z "$run_json" ]; then
283+
echo "::error::Main Guard qualification did not appear for ${CANDIDATE_SHA}"
284+
exit 1
285+
fi
286+
287+
run_id="$(jq -r '.databaseId' <<<"$run_json")"
288+
while :; do
289+
run_json="$(gh run view "$run_id" --json status,conclusion,url)"
290+
status="$(jq -r '.status' <<<"$run_json")"
291+
[ "$status" = "completed" ] && break
292+
sleep 30
293+
done
294+
295+
conclusion="$(jq -r '.conclusion // "cancelled"' <<<"$run_json")"
296+
url="$(jq -r '.url' <<<"$run_json")"
297+
if [ "$conclusion" = "success" ]; then
298+
next_action="release immutable candidate"
299+
else
300+
next_action="repair or rerun the exact qualification"
301+
fi
302+
303+
{
304+
echo "candidate-sha=${CANDIDATE_SHA}"
305+
echo "proof-sha=${CANDIDATE_SHA}"
306+
echo "proof-run=${url}"
307+
echo "proof-status=${conclusion}"
308+
echo "next-action=${next_action}"
309+
} >> "$GITHUB_OUTPUT"
310+
311+
{
312+
echo "### Release qualification"
313+
echo
314+
echo "- Selected SHA: \`${CANDIDATE_SHA}\`"
315+
echo "- Exact gate proof SHA: \`${CANDIDATE_SHA}\`"
316+
echo "- Exact gate proof run: ${url}"
317+
echo "- Proof status: \`${conclusion}\`"
318+
echo "- Next action: ${next_action}"
319+
echo "- Proof source: ${action}"
320+
if [ "$DRY_RUN" = "true" ]; then
321+
echo "- Dry run: release mutation remains disabled"
322+
fi
323+
} >> "$GITHUB_STEP_SUMMARY"
324+
325+
[ "$conclusion" = "success" ]
326+
327+
# ── Step 3: Build once ──
235328
# Compile homeboy from source once and share the binary with all
236329
# quality gate jobs. Eliminates 3× redundant cargo builds.
237330
gate-build:
238331
name: Build
239-
needs: check
240-
if: needs.check.outputs.should-release == 'true'
332+
needs:
333+
- check
334+
- release-qualification
335+
if: needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true' && needs.release-qualification.outputs.proof-status != 'success'
241336
# This binary is executed by the ubuntu-22.04 publication jobs. Build on
242337
# the oldest consumer runtime so recovery finalizers cannot require a newer GLIBC.
243338
runs-on: ubuntu-22.04
@@ -280,8 +375,9 @@ jobs:
280375
name: Audit
281376
needs:
282377
- check
378+
- release-qualification
283379
- gate-build
284-
if: needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true'
380+
if: needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true' && needs.release-qualification.outputs.proof-status != 'success'
285381
runs-on: ubuntu-latest
286382
outputs:
287383
audit-result: ${{ steps.audit.outcome }}
@@ -329,8 +425,9 @@ jobs:
329425
name: Lint
330426
needs:
331427
- check
428+
- release-qualification
332429
- gate-build
333-
if: needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true'
430+
if: needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true' && needs.release-qualification.outputs.proof-status != 'success'
334431
runs-on: ubuntu-latest
335432
steps:
336433
- uses: actions/checkout@v4
@@ -373,8 +470,9 @@ jobs:
373470
name: Test
374471
needs:
375472
- check
473+
- release-qualification
376474
- gate-build
377-
if: needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true'
475+
if: needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true' && needs.release-qualification.outputs.proof-status != 'success'
378476
runs-on: ubuntu-latest
379477
steps:
380478
- uses: actions/checkout@v4
@@ -430,11 +528,12 @@ jobs:
430528
name: Release Quality Policy
431529
needs:
432530
- check
531+
- release-qualification
433532
- gate-build
434533
- gate-audit
435534
- gate-lint
436535
- gate-test
437-
if: ${{ always() && needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true' && needs.gate-build.result == 'success' }}
536+
if: ${{ always() && needs.check.outputs.should-release == 'true' && needs.check.outputs.recovery-release != 'true' && needs.release-qualification.result == 'success' }}
438537
runs-on: ubuntu-latest
439538
steps:
440539
- name: Checkout workflow event commit
@@ -445,20 +544,26 @@ jobs:
445544
- name: Enforce release-blocking commands
446545
env:
447546
BLOCKING_COMMANDS: ${{ env.RELEASE_BLOCKING_COMMANDS }}
547+
QUALIFICATION_RESULT: ${{ needs.release-qualification.outputs.proof-status }}
448548
AUDIT_RESULT: ${{ needs.gate-audit.outputs.audit-result || needs.gate-audit.result }}
449549
LINT_RESULT: ${{ needs.gate-lint.result }}
450550
TEST_RESULT: ${{ needs.gate-test.result }}
451551
run: |
552+
if [ "$QUALIFICATION_RESULT" != "success" ]; then
553+
echo "::error::Exact Main Guard qualification finished with result: $QUALIFICATION_RESULT"
554+
exit 1
555+
fi
452556
bash .github/release-quality-policy.sh
453557
454558
# ── Step 4: Version bump + changelog + tag ──
455559
prepare:
456560
name: Prepare Release
457561
needs:
458562
- check
563+
- release-qualification
459564
- gate-build
460565
- release-quality-policy
461-
if: ${{ always() && needs.check.outputs.should-release == 'true' && needs.gate-build.result == 'success' && (needs.check.outputs.recovery-release == 'true' || inputs.release_tag != '' || needs.release-quality-policy.result == 'success') }}
566+
if: ${{ always() && needs.check.outputs.should-release == 'true' && (needs.release-qualification.outputs.proof-status == 'success' || needs.check.outputs.recovery-release == 'true' || inputs.release_tag != '') && (needs.check.outputs.recovery-release == 'true' || inputs.release_tag != '' || needs.release-quality-policy.result == 'success') }}
462567
runs-on: ubuntu-latest
463568
outputs:
464569
release-version: ${{ steps.outputs.outputs['release-version'] }}

docs/architecture/release-pipeline.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,18 @@ Release execution:
9797
4. Stops on failure and returns structured step results.
9898
5. Leaves plan-only steps visible for review without executing them.
9999

100+
## CI Qualification
101+
102+
The release workflow selects its triggering commit as an immutable candidate. It
103+
reuses a terminal green Main Guard run for that exact SHA when one exists. Without
104+
one, it dispatches a SHA-pinned Main Guard qualification and waits for its terminal
105+
result. This qualification has its own non-cancellable concurrency key, while push
106+
triggered Main Guard monitoring remains latest-wins.
107+
108+
Workflow dry runs report the selected SHA, exact proof SHA and run, proof status,
109+
and one next action in the job summary. The selected candidate remains valid when
110+
new commits arrive; releasing only the latest tip is not inferred from PR state.
111+
100112
Release requires a clean working tree except for files the release process owns,
101113
such as version targets and changelog targets.
102114

tests/audit_debt_workflow_test.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,13 +287,37 @@ fn post_merge_gates_never_mutate_the_repository() {
287287
#[test]
288288
fn superseded_post_merge_runs_are_cancelled() {
289289
let workflow = main_guard_workflow();
290-
// Opposite of release.yml's `cancel-in-progress: false`: this gate reports
291-
// on the current tip and strands nothing, so a superseded run is worthless.
292-
assert!(workflow.contains("cancel-in-progress: true"));
290+
// Push monitoring reports on the current tip and strands nothing, so a
291+
// superseded run is worthless. An explicit qualification is different: it
292+
// proves an immutable release candidate and must not be cancelled by a
293+
// later merge.
294+
assert!(workflow.contains("cancel-in-progress: ${{ inputs.qualification_sha == '' }}"));
293295
// Push and sweep are separate groups so a merge train cannot cancel the
294296
// weekly audit sweep.
295297
assert!(workflow.contains("group: main-guard-"));
296298
assert!(workflow.contains("github.event_name == 'push' && 'post-merge' || 'sweep'"));
299+
assert!(workflow.contains("format('qualification-{0}', inputs.qualification_sha)"));
300+
}
301+
302+
#[test]
303+
fn immutable_qualification_checks_out_and_gates_the_requested_sha() {
304+
let workflow = main_guard_workflow();
305+
306+
assert!(workflow.contains("qualification_sha:"));
307+
assert!(workflow.contains("Release Qualification {0}"));
308+
assert!(workflow.contains("ref: ${{ inputs.qualification_sha || github.sha }}"));
309+
for name in [
310+
"gate-build",
311+
"full-audit-gate",
312+
"full-lint-gate",
313+
"full-test-gate",
314+
] {
315+
let gate = job(name);
316+
assert!(
317+
gate.contains("inputs.qualification_sha != ''"),
318+
"{name} must run for an immutable qualification"
319+
);
320+
}
297321
}
298322

299323
/// The audit gate must be able to SEE the tree it claims to gate.

tests/release_workflow_test.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,42 @@ fn release_concurrency_scopes_recovery_runs_to_the_requested_tag() {
213213
));
214214
}
215215

216+
#[test]
217+
fn release_reuses_or_awaits_an_exact_main_guard_qualification() {
218+
let workflow = release_workflow();
219+
let qualification = job_section(workflow, "release-qualification");
220+
221+
assert!(qualification.contains("CANDIDATE_SHA: ${{ github.sha }}"));
222+
assert!(qualification.contains("gh run list --workflow audit-debt.yml"));
223+
assert!(qualification.contains("--commit \"$CANDIDATE_SHA\""));
224+
assert!(qualification.contains(
225+
"gh workflow run audit-debt.yml --ref main -f qualification_sha=\"$CANDIDATE_SHA\""
226+
));
227+
assert!(qualification.contains("proof-status=${conclusion}"));
228+
assert!(qualification.contains("Selected SHA:"));
229+
assert!(qualification.contains("Exact gate proof run:"));
230+
assert!(qualification.contains("Proof source: ${action}"));
231+
assert!(qualification.contains("Dry run: release mutation remains disabled"));
232+
}
233+
234+
#[test]
235+
fn terminal_green_qualification_skips_duplicate_release_gates() {
236+
let workflow = release_workflow();
237+
238+
for job in ["gate-build", "gate-audit", "gate-lint", "gate-test"] {
239+
let section = job_section(workflow, job);
240+
assert!(
241+
section.contains("needs.release-qualification.outputs.proof-status != 'success'"),
242+
"{job} must reuse exact terminal green Main Guard evidence"
243+
);
244+
}
245+
246+
let policy = job_section(workflow, "release-quality-policy");
247+
assert!(policy
248+
.contains("QUALIFICATION_RESULT: ${{ needs.release-qualification.outputs.proof-status }}"));
249+
assert!(policy.contains("Exact Main Guard qualification finished with result"));
250+
}
251+
216252
#[test]
217253
fn release_test_gate_does_not_repeat_separate_lint_gate() {
218254
let gate_test = job_section(release_workflow(), "gate-test");

0 commit comments

Comments
 (0)