Skip to content

Commit 98c1f8c

Browse files
committed
test: gate-chain contract pins the explicit-result conditions
The contract required the bare '!cancelled() && !failure()' idiom on build/smoke/soak — the exact form the v0.10.7 incident proved fail-open (failure() does not cover a CANCELLED needed job). It now requires each gate's explicit accepted results, the sanctioned skip_tests clause on build, and the preflight version guard wired before lint. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1 parent 9427dd0 commit 98c1f8c

1 file changed

Lines changed: 36 additions & 9 deletions

File tree

‎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)