Skip to content

Commit 46ae198

Browse files
authored
Break-glass merge PR #1719
2 parents afebf00 + 98c1f8c commit 46ae198

3 files changed

Lines changed: 75 additions & 16 deletions

File tree

‎.github/workflows/_lint.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ permissions:
1111
jobs:
1212
lint:
1313
runs-on: ubuntu-latest
14-
timeout-minutes: 15
14+
# 15 min proved too tight on a slow runner day and a timed-out lint reads
15+
# as "cancelled", which the release graph must treat as a hard stop; keep
16+
# a bound, but one only a genuine hang can hit (normal runtime ~5 min).
17+
timeout-minutes: 30
1518
steps:
1619
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1720

‎.github/workflows/release.yml‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,29 @@ jobs:
4646
uses: ./.github/workflows/_security.yml
4747
secrets: inherit
4848

49+
# ── 0. Preflight: refuse malformed dispatch inputs ──────────────
50+
# The tag is inputs.version VERBATIM. A bare "0.10.7" publishes a release the
51+
# installers can never resolve (they fetch releases/download/v<version>/...),
52+
# and with immutable releases the mis-named tag cannot be retagged, deleted,
53+
# or its name reused — the name is burned permanently (2026-08-18 incident).
54+
preflight:
55+
runs-on: ubuntu-latest
56+
timeout-minutes: 5
57+
steps:
58+
- name: Enforce v-prefixed semver version input
59+
env:
60+
VERSION: ${{ inputs.version }}
61+
run: |
62+
if [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
63+
echo "version OK: $VERSION"
64+
else
65+
echo "::error::version must be v-prefixed semver (vX.Y.Z), got '$VERSION'"
66+
exit 1
67+
fi
68+
4969
# ── 1. Lint (cppcheck + clang-format) ───────────────────────────
5070
lint:
71+
needs: [preflight]
5172
uses: ./.github/workflows/_lint.yml
5273

5374
# ── 2. Tests (all platforms, full suite for release) ────────────
@@ -65,11 +86,19 @@ jobs:
6586
shard_suites: true
6687

6788
# ── 3. Build all platforms ──────────────────────────────────────
68-
# !cancelled() && !failure(): run when `test` is deliberately skipped, but
69-
# never when lint or test actually failed.
89+
# `test` may be skipped ONLY by the skip_tests input. A skipped `test` is
90+
# also what a cancelled/timed-out lint produces (needs-cascade), and the
91+
# bare !cancelled() && !failure() form cannot tell those apart: failure()
92+
# does not cover a needed job that was CANCELLED, so a lint timeout let the
93+
# whole pipeline publish with the test matrix silently skipped
94+
# (v0.10.7 incident, 2026-08-18). Require the explicit results.
7095
build:
71-
if: ${{ !cancelled() && !failure() }}
72-
needs: [test]
96+
if: >-
97+
${{ !cancelled()
98+
&& needs.lint.result == 'success'
99+
&& (needs.test.result == 'success'
100+
|| (inputs.skip_tests && needs.test.result == 'skipped')) }}
101+
needs: [lint, test]
73102
permissions:
74103
contents: read
75104
id-token: write
@@ -92,15 +121,15 @@ jobs:
92121
# an optional phase needs this override, or the phase being optional silently
93122
# makes the phases after it optional as well.
94123
smoke:
95-
if: ${{ !cancelled() && !failure() }}
124+
if: ${{ !cancelled() && needs.build.result == 'success' }}
96125
needs: [build]
97126
uses: ./.github/workflows/_smoke.yml
98127
with:
99128
broad_platforms: true
100129

101130
# ── 5. Soak tests ──────────────────────────────────────────────
102131
soak:
103-
if: ${{ !cancelled() && !failure() }}
132+
if: ${{ !cancelled() && needs.build.result == 'success' }}
104133
needs: [build]
105134
uses: ./.github/workflows/_soak.yml
106135
with:

‎tests/test_release_gate_chain_contract.sh‎

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,46 @@ def cond(job):
6363
return ""
6464
return " ".join(m.group(2).split())
6565
66-
# 1. Downstream-of-optional jobs must tolerate a deliberately skipped ancestor.
67-
# `test` is the optional phase (if: !inputs.skip_tests); everything after it
68-
# in the chain has to survive that.
69-
TOLERATE = ["build", "smoke", "soak", "release-draft"]
70-
for job in TOLERATE:
66+
# 1. Gate conditions: tolerate ONLY the sanctioned skip, fail closed otherwise.
67+
# `test` is the optional phase (if: !inputs.skip_tests). The old contract
68+
# required the bare `!cancelled() && !failure()` idiom — but failure() does
69+
# NOT cover a needed job that was CANCELLED (e.g. a lint timeout), so that
70+
# idiom let a cancelled gate cascade test into 'skipped' and publish with
71+
# the whole test matrix silently gone (v0.10.7 incident, 2026-08-18).
72+
# Each gate must name the results it accepts explicitly.
73+
GATE_REQUIREMENTS = {
74+
"build": [
75+
"!cancelled()",
76+
"needs.lint.result == 'success'",
77+
"needs.test.result == 'success'",
78+
"inputs.skip_tests && needs.test.result == 'skipped'",
79+
],
80+
"smoke": ["!cancelled()", "needs.build.result == 'success'"],
81+
"soak": ["!cancelled()", "needs.build.result == 'success'"],
82+
"release-draft": ["!cancelled()", "!failure()"],
83+
}
84+
for job, required in GATE_REQUIREMENTS.items():
7185
if job not in blocks:
7286
failures.append(f"{job}: job missing from release.yml — update this contract")
7387
continue
7488
c = cond(job)
75-
if "!cancelled()" not in c or "!failure()" not in c:
76-
failures.append(
77-
f"{job}: `if:` lacks `!cancelled() && !failure()` (got: {c or '<none>'}).\n"
78-
f" With skip_tests=true a skipped ancestor SKIPS this job silently.")
89+
for fragment in required:
90+
if fragment not in c:
91+
failures.append(
92+
f"{job}: `if:` must contain `{fragment}` (got: {c or '<none>'}).\n"
93+
f" Explicit results only: failure() misses CANCELLED needed\n"
94+
f" jobs, and a bare tolerate-skip idiom is fail-open.")
95+
96+
# 1b. The preflight input guard must exist and gate the whole chain: the tag is
97+
# inputs.version verbatim, and a bare (non-v-prefixed) version publishes a
98+
# release installers can never resolve — unrecoverable under immutability.
99+
if "preflight" not in blocks:
100+
failures.append("preflight: job missing — the version-input guard must exist")
101+
lint_needs = re.search(r"^ needs:\s*(.*)$", blocks.get("lint", ""), re.M)
102+
if not lint_needs or "preflight" not in lint_needs.group(1):
103+
failures.append(
104+
"lint: must `needs: [preflight]` so a malformed version stops the\n"
105+
" chain before any gate runs.")
79106
80107
# 2. The draft must require both runtime gates to have genuinely succeeded.
81108
draft = cond("release-draft")

0 commit comments

Comments
 (0)