Coverage for plugin/scripts/check_workflow_pins.py: 100%

91 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2"""Assert the workflows agree about the versions they pin. 

3 

4A SHA pin is two halves: the SHA, which binds, and the `# v10.0.0` comment, which is the 

5only half a human reading the diff can act on. Nothing checked that the halves agreed, and 

6they stopped agreeing — one `setup-uv` call site kept a `# v7.1.1` comment through a bump 

7to v10.0.0, so an auditor asking "which release is this" got two answers from one SHA. 

8 

9The uv *version input* is the same failure one level down, and it matters more. Everything 

10this repo pins flows through uv — `UV_CONSTRAINT` and `UV_PYTHON` are exported for it to 

11consume, so 14 pinned tools resolve however uv decides to resolve them. That input is 

12duplicated at every call site, and one workflow simply did not have it: the job that 

13decided whether a release tag is created floated its uv. Dependabot watches the action pin 

14above the input and cannot see the input, so nothing would have said so. 

15 

16Three rules, over `.github/workflows/*.yml` and any composite action beside them: 

17 

181. every remote ``uses:`` is pinned to a full 40-character SHA and annotated with a 

19 ``# <version>`` comment; 

202. all call sites of one action repository agree — same SHA, same comment. Sub-path uses 

21 (``actions/cache/save``) are the same repository as their parent and are held to it; 

223. every ``astral-sh/setup-uv`` step passes a ``version:`` input, and all of them agree. 

23 

24Rule 3 is the narrow, tool-specific one, and it is deliberately not generalised to "every 

25action input agrees": `python-version` legitimately differs per job, and a rule that 

26guessed which inputs were pins would either miss this one or forbid that. 

27 

28Usage: 

29 uv run --python 3.12 --no-project python \ 

30 scripts/check_workflow_pins.py [--workflows .github/workflows] 

31 

32Exit codes: 

33 0 every pin agrees 

34 1 a pin is unpinned, unannotated, or disagrees with another call site 

35""" 

36 

37from __future__ import annotations 

38 

39import argparse 

40import re 

41import sys 

42from pathlib import Path 

43 

44WORKFLOWS_DIR = ".github/workflows" 

45"""Default root to scan, relative to the repository root.""" 

46 

47UV_ACTION = "astral-sh/setup-uv" 

48"""The one action whose version *input* is checked, not just its own pin.""" 

49 

50# `uses: owner/repo[/subpath]@<sha> # comment`, matched one line at a time. The comment 

51# group is optional so an unannotated pin is reported by rule 1 rather than skipped as 

52# "not a pin", and `indent` counts the whitespace before an optional `- ` because the rest 

53# of the step is indented relative to it. A local action (`uses: ./x`) has no `owner/repo` 

54# and so never matches — nothing SHA-pins it, and nothing here should ask it to. 

55_USES = re.compile( 

56 r"^(?P<indent>\s*)-?\s*uses:\s*" 

57 r"(?P<action>[A-Za-z0-9._-]+/[A-Za-z0-9._/-]+)@(?P<ref>\S+)" 

58 r"(?:\s*#\s*(?P<comment>\S+))?\s*$" 

59) 

60_SHA = re.compile(r"^[0-9a-f]{40}$") 

61_VERSION_INPUT = re.compile(r"^\s*version:\s*['\"]?(?P<value>[^'\"\s#]+)") 

62 

63 

64class Pin: 

65 """One ``uses:`` line: which action, at which SHA, annotated with which version. 

66 

67 *where* is the ``file:line`` the violation messages quote, and *version_input* is the 

68 ``version:`` value from the step's ``with:`` block — ``None`` when the step has none, 

69 which is what rule 3 reports for :data:`UV_ACTION`. 

70 """ 

71 

72 def __init__(self, action: str, ref: str, comment: str | None, where: str) -> None: 

73 """Record one pin and the step input that came with it.""" 

74 self.action = action 

75 self.ref = ref 

76 self.comment = comment 

77 self.where = where 

78 self.version_input: str | None = None 

79 

80 @property 

81 def repository(self) -> str: 

82 """The action *repository*, dropping any sub-path. 

83 

84 Sub-path actions ship from their parent's tree, so they share its SHA and must 

85 share its annotation — grouping by the full ``uses:`` value would let 

86 ``actions/cache`` and ``actions/cache/save`` drift apart unnoticed. 

87 

88 >>> Pin("actions/cache/save", "abc", "v6.1.0", "ci.yml:1").repository 

89 'actions/cache' 

90 >>> Pin("astral-sh/setup-uv", "abc", "v10.0.0", "ci.yml:1").repository 

91 'astral-sh/setup-uv' 

92 """ 

93 owner, _, rest = self.action.partition("/") 

94 return f"{owner}/{rest.split('/')[0]}" 

95 

96 

97def _step_version_input(lines: list[str], start: int, indent: int) -> str | None: 

98 """Return the ``version:`` input of the step whose ``uses:`` line is at *start*. 

99 

100 Reads forward to the end of that step. ``with:`` is a sibling key of ``uses:`` rather 

101 than a child, so the block continues at the *same* indentation and ends where a new 

102 list item begins or the indentation drops — not, as a first cut had it, at the first 

103 line no deeper than ``uses:``, which found the pin in none of six real call sites. 

104 Comment and blank lines are skipped rather than ending the step; a ``with:`` block 

105 introduced by a comment is the usual shape here. 

106 """ 

107 for line in lines[start + 1 :]: 

108 if not line.strip() or line.lstrip().startswith("#"): 

109 continue 

110 if len(line) - len(line.lstrip()) < indent or line.lstrip().startswith("- "): 

111 return None 

112 match = _VERSION_INPUT.match(line) 

113 if match: 

114 return match.group("value") 

115 return None 

116 

117 

118def collect_pins(path: Path, rel: str) -> list[Pin]: 

119 """Return every action pin declared in the workflow at *path*.""" 

120 lines = path.read_text(encoding="utf-8").splitlines() 

121 pins: list[Pin] = [] 

122 for index, line in enumerate(lines): 

123 match = _USES.match(line) 

124 if match is None: 

125 continue 

126 pin = Pin( 

127 match.group("action"), 

128 match.group("ref"), 

129 match.group("comment"), 

130 f"{rel}:{index + 1}", 

131 ) 

132 pin.version_input = _step_version_input(lines, index, len(match.group("indent"))) 

133 pins.append(pin) 

134 return pins 

135 

136 

137def _unpinned_violations(pins: list[Pin]) -> list[str]: 

138 """Rule 1: every pin is a full SHA carrying a version comment.""" 

139 violations: list[str] = [] 

140 for pin in pins: 

141 if not _SHA.match(pin.ref): 

142 violations.append(f"{pin.where}: {pin.action} is pinned to {pin.ref!r}, not a SHA") 

143 elif pin.comment is None: 

144 violations.append( 

145 f"{pin.where}: {pin.action} has no '# <version>' comment — the SHA is the " 

146 "half that binds, the comment is the half a reviewer can read" 

147 ) 

148 return violations 

149 

150 

151def _disagreement(label: str, values: dict[str, str]) -> list[str]: 

152 """Report *values* (call site -> value) when they do not all agree on one *label*.""" 

153 if len(set(values.values())) <= 1: 

154 return [] 

155 sites = ", ".join(f"{where}={value!r}" for where, value in sorted(values.items())) 

156 return [f"{label} disagrees across call sites: {sites}"] 

157 

158 

159def _parity_violations(pins: list[Pin]) -> list[str]: 

160 """Rule 2: all call sites of one action repository share a SHA and a comment.""" 

161 violations: list[str] = [] 

162 repositories = sorted({pin.repository for pin in pins}) 

163 for repository in repositories: 

164 sites = [pin for pin in pins if pin.repository == repository] 

165 violations += _disagreement(f"{repository} SHA", {p.where: p.ref for p in sites}) 

166 violations += _disagreement( 

167 f"{repository} version comment", 

168 {p.where: p.comment for p in sites if p.comment is not None}, 

169 ) 

170 return violations 

171 

172 

173def _uv_input_violations(pins: list[Pin]) -> list[str]: 

174 """Rule 3: every :data:`UV_ACTION` step pins uv, and all of them pin the same uv.""" 

175 sites = [pin for pin in pins if pin.repository == UV_ACTION] 

176 violations = [ 

177 f"{pin.where}: {UV_ACTION} passes no 'version:' input, so this job resolves " 

178 "whatever uv release is current at run time" 

179 for pin in sites 

180 if pin.version_input is None 

181 ] 

182 pinned = {p.where: p.version_input for p in sites if p.version_input is not None} 

183 return violations + _disagreement("uv version input", pinned) 

184 

185 

186def check_workflows(root: Path) -> list[str]: 

187 """Return every pin violation under the workflows directory *root*. 

188 

189 Composite actions beside the workflows are read too: moving a pin into one is a 

190 reasonable way to de-duplicate it, and it must not be a way to leave the gate. 

191 """ 

192 pins: list[Pin] = [] 

193 for path in sorted(root.rglob("*.yml")) + sorted(root.rglob("*.yaml")): 

194 # `as_posix`, not `str`: the relative path goes into the violation messages, and on 

195 # Windows `str` spells it `shared\action.yaml` — a workflow path with a separator no 

196 # workflow file uses. A direct child comes back as its bare name either way. 

197 pins += collect_pins(path, path.relative_to(root).as_posix()) 

198 return _unpinned_violations(pins) + _parity_violations(pins) + _uv_input_violations(pins) 

199 

200 

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

202 """Entry point: report disagreeing pins and return an exit code.""" 

203 parser = argparse.ArgumentParser( 

204 description="Assert the workflows agree about the versions they pin.", 

205 ) 

206 parser.add_argument( 

207 "--workflows", 

208 default=WORKFLOWS_DIR, 

209 help=f"Directory of workflow files to check (default: {WORKFLOWS_DIR}).", 

210 ) 

211 args = parser.parse_args(argv) 

212 

213 root = Path(args.workflows) 

214 if not root.is_dir(): 

215 print(f"No workflows directory at {root}", file=sys.stderr) 

216 return 1 

217 

218 violations = check_workflows(root) 

219 for violation in violations: 

220 print(violation, file=sys.stderr) 

221 if violations: 

222 print(f"{len(violations)} pin problem(s)", file=sys.stderr) 

223 return 1 

224 print("workflow pins agree") 

225 return 0 

226 

227 

228if __name__ == "__main__": 

229 raise SystemExit(main())