@@ -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.
81108draft = cond("release-draft")
0 commit comments