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
« 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.
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.
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.
16Three rules, over `.github/workflows/*.yml` and any composite action beside them:
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.
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.
28Usage:
29 uv run --python 3.12 --no-project python \
30 scripts/check_workflow_pins.py [--workflows .github/workflows]
32Exit codes:
33 0 every pin agrees
34 1 a pin is unpinned, unannotated, or disagrees with another call site
35"""
37from __future__ import annotations
39import argparse
40import re
41import sys
42from pathlib import Path
44WORKFLOWS_DIR = ".github/workflows"
45"""Default root to scan, relative to the repository root."""
47UV_ACTION = "astral-sh/setup-uv"
48"""The one action whose version *input* is checked, not just its own pin."""
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#]+)")
64class Pin:
65 """One ``uses:`` line: which action, at which SHA, annotated with which version.
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 """
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
80 @property
81 def repository(self) -> str:
82 """The action *repository*, dropping any sub-path.
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.
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]}"
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*.
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
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
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
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}"]
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
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)
186def check_workflows(root: Path) -> list[str]:
187 """Return every pin violation under the workflows directory *root*.
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)
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)
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
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
228if __name__ == "__main__":
229 raise SystemExit(main())