Coverage for .github / scripts / verify_gate_needs.py: 100.00%

121 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1#!/usr/bin/env python3 

2"""Fail a workflow's gate job unless every job it needs succeeded. 

3 

4Every PR-triggered workflow ends in one ``gate:<workflow>`` job that ``needs`` 

5each other job in the file and is scheduled with ``always()``. Branch 

6protection requires only those gates, so the merge rule is "every job in 

7every PR workflow passed" without naming the jobs — matrix legs, renamed jobs 

8and new jobs included — and a ruleset entry can never drift from a job name. 

9 

10The gate step hands this script the ``needs`` context (``toJSON(needs)``):: 

11 

12 {"job-id": {"result": "success|failure|cancelled|skipped", "outputs": {...}}} 

13 

14Every job must have ``result == "success"``. A job may be ``skipped`` only when 

15named by ``--allow-skipped``, and the ``JOB=DEP.OUTPUT=VALUE`` form additionally 

16requires the named output of another needed job to carry that value — the way 

17a path-filtered job proves its skip was the filter's decision rather than a 

18broken ``if:``. Anything else (failure, cancellation, an unexplained skip, a 

19result this script does not recognise) fails the gate. 

20 

21Exit codes: ``0`` every job passed, ``1`` at least one job did not, ``2`` the 

22input or the skip policy is malformed — so "the gate was misconfigured" never 

23reads as "a job failed" or, worse, as a pass. Stdlib only: the gate job needs 

24no dependency install. 

25""" 

26 

27from __future__ import annotations 

28 

29import argparse 

30import json 

31import os 

32import sys 

33from dataclasses import dataclass 

34from pathlib import Path 

35from typing import Any 

36 

37#: The only result that satisfies the gate without a skip allowance. 

38SUCCESS = "success" 

39#: The only other result an allowance can excuse. 

40SKIPPED = "skipped" 

41 

42 

43class PolicyError(ValueError): 

44 """The needs document or the skip policy is malformed (exit 2).""" 

45 

46 

47@dataclass(frozen=True) 

48class OutputCondition: 

49 """``DEP.OUTPUT=VALUE``: the skip is legitimate only while this output holds.""" 

50 

51 dependency: str 

52 output: str 

53 value: str 

54 

55 def describe(self) -> str: 

56 return f"{self.dependency}.outputs.{self.output} == {self.value!r}" 

57 

58 

59@dataclass(frozen=True) 

60class SkipAllowance: 

61 """``JOB`` or ``JOB=DEP.OUTPUT=VALUE`` from ``--allow-skipped``.""" 

62 

63 job: str 

64 condition: OutputCondition | None = None 

65 

66 def describe(self) -> str: 

67 if self.condition is None: 

68 return "skip allowed unconditionally" 

69 return f"skip allowed while {self.condition.describe()}" 

70 

71 

72def parse_allowance(spec: str) -> SkipAllowance: 

73 """Parse one ``--allow-skipped`` value; malformed specs are a PolicyError.""" 

74 job, separator, condition = spec.partition("=") 

75 if not job: 

76 raise PolicyError(f"--allow-skipped {spec!r}: empty job id") 

77 if not separator: 

78 return SkipAllowance(job=job) 

79 dependency, dot, rest = condition.partition(".") 

80 output, equals, value = rest.partition("=") 

81 if not (dependency and dot and output and equals and value): 

82 raise PolicyError( 

83 f"--allow-skipped {spec!r}: expected JOB or JOB=DEP.OUTPUT=VALUE " 

84 "(DEP is another needed job id, OUTPUT one of its outputs)" 

85 ) 

86 return SkipAllowance(job=job, condition=OutputCondition(dependency, output, value)) 

87 

88 

89def parse_needs(document: str) -> dict[str, dict[str, Any]]: 

90 """Decode ``toJSON(needs)``; anything but a non-empty object of objects is a PolicyError.""" 

91 try: 

92 needs = json.loads(document) 

93 except json.JSONDecodeError as exc: 

94 raise PolicyError(f"--needs is not valid JSON: {exc}") from exc 

95 if not isinstance(needs, dict) or not needs: 

96 raise PolicyError( 

97 "--needs must be the non-empty `needs` context object; a gate that " 

98 "needs nothing would pass vacuously" 

99 ) 

100 for job_id, entry in needs.items(): 

101 if not isinstance(entry, dict) or not isinstance(entry.get("result"), str): 

102 raise PolicyError(f"needs[{job_id!r}] is missing a string `result`") 

103 return needs 

104 

105 

106def _index_allowances( 

107 needs: dict[str, dict[str, Any]], allowances: list[SkipAllowance] 

108) -> dict[str, SkipAllowance]: 

109 """Key allowances by job; a stale or duplicate allowance is a policy bug.""" 

110 by_job: dict[str, SkipAllowance] = {} 

111 for allowance in allowances: 

112 if allowance.job not in needs: 

113 raise PolicyError( 

114 f"--allow-skipped names {allowance.job!r}, which this gate does not need" 

115 ) 

116 if allowance.condition is not None and allowance.condition.dependency not in needs: 

117 raise PolicyError( 

118 f"--allow-skipped for {allowance.job!r} reads " 

119 f"{allowance.condition.dependency!r}, which this gate does not need" 

120 ) 

121 if allowance.job in by_job: 

122 raise PolicyError(f"--allow-skipped names {allowance.job!r} twice") 

123 by_job[allowance.job] = allowance 

124 return by_job 

125 

126 

127def _judge_skip(needs: dict[str, dict[str, Any]], allowance: SkipAllowance) -> tuple[str, bool]: 

128 """Note for a skipped job with an allowance, and whether the skip is excused.""" 

129 condition = allowance.condition 

130 if condition is None: 

131 return allowance.describe(), True 

132 outputs = needs[condition.dependency].get("outputs") 

133 actual = outputs.get(condition.output) if isinstance(outputs, dict) else None 

134 if actual == condition.value: 

135 return allowance.describe(), True 

136 return ( 

137 f"skipped, but {condition.dependency}.outputs.{condition.output} is " 

138 f"{actual!r}, not {condition.value!r}", 

139 False, 

140 ) 

141 

142 

143def evaluate( 

144 needs: dict[str, dict[str, Any]], allowances: list[SkipAllowance] 

145) -> tuple[list[tuple[str, str, str]], list[str]]: 

146 """Judge every needed job. 

147 

148 Returns ``(rows, violations)``: one ``(job, result, note)`` row per needed 

149 job in sorted order, and the human-readable violations. 

150 """ 

151 by_job = _index_allowances(needs, allowances) 

152 rows: list[tuple[str, str, str]] = [] 

153 violations: list[str] = [] 

154 for job_id in sorted(needs): 

155 result = needs[job_id]["result"] 

156 if result == SUCCESS: 

157 rows.append((job_id, result, "")) 

158 continue 

159 allowance = by_job.get(job_id) 

160 if result == SKIPPED and allowance is not None: 

161 note, excused = _judge_skip(needs, allowance) 

162 else: 

163 note = "skipped without an allowance" if result == SKIPPED else f"result was {result!r}" 

164 excused = False 

165 rows.append((job_id, result, note)) 

166 if not excused: 

167 violations.append(f"{job_id}: {note}") 

168 return rows, violations 

169 

170 

171def render_table(rows: list[tuple[str, str, str]]) -> str: 

172 """Markdown table of every needed job — the step summary and the log share it.""" 

173 lines = ["| Job | Result | Note |", "|-----|--------|------|"] 

174 for job_id, result, note in rows: 

175 marker = "✅" if result == SUCCESS or note.startswith("skip allowed") else "❌" 

176 lines.append(f"| `{job_id}` | {marker} `{result}` | {note} |") 

177 return "\n".join(lines) 

178 

179 

180def write_step_summary(table: str, verdict: str) -> None: 

181 """Append the verdict to the job summary when GitHub provides one.""" 

182 summary_path = os.environ.get("GITHUB_STEP_SUMMARY") 

183 if not summary_path: 

184 return 

185 with Path(summary_path).open("a", encoding="utf-8") as handle: 

186 handle.write(f"### Gate: {verdict}\n\n{table}\n\n") 

187 

188 

189def main(argv: list[str] | None = None) -> int: 

190 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) 

191 parser.add_argument( 

192 "--needs", 

193 required=True, 

194 help="the gate job's `needs` context as JSON (pass `${{ toJSON(needs) }}` through env)", 

195 ) 

196 parser.add_argument( 

197 "--allow-skipped", 

198 action="append", 

199 default=[], 

200 metavar="JOB[=DEP.OUTPUT=VALUE]", 

201 help=( 

202 "a needed job whose `skipped` result is acceptable; with the " 

203 "DEP.OUTPUT=VALUE suffix, only while that output of another needed " 

204 "job carries that value (repeatable)" 

205 ), 

206 ) 

207 args = parser.parse_args(argv) 

208 

209 try: 

210 needs = parse_needs(args.needs) 

211 allowances = [parse_allowance(spec) for spec in args.allow_skipped] 

212 rows, violations = evaluate(needs, allowances) 

213 except PolicyError as exc: 

214 print(f"::error::gate misconfigured: {exc}", file=sys.stderr) 

215 return 2 

216 

217 table = render_table(rows) 

218 verdict = ( 

219 "every needed job succeeded" 

220 if not violations 

221 else f"{len(violations)} job(s) did not succeed" 

222 ) 

223 print(table) 

224 write_step_summary(table, verdict) 

225 for violation in violations: 

226 print(f"::error::{violation}", file=sys.stderr) 

227 if violations: 

228 print(f"::error::refusing a successful gate: {verdict}", file=sys.stderr) 

229 return 1 

230 print(f"Gate satisfied: {len(rows)} job(s) succeeded or were skipped by policy.") 

231 return 0 

232 

233 

234if __name__ == "__main__": 

235 raise SystemExit(main())