Skip to content

Commit caf93d3

Browse files
authored
chore: three CI gates — version bump, action pinning, tests required (#122, #123, #125) (#70)
1 parent ec96aec commit caf93d3

13 files changed

Lines changed: 1597 additions & 3 deletions

.github/branch-protection/develop.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
"Architecture (import-linter)",
1010
"Pre-commit",
1111
"File length",
12+
"Version bump check",
13+
"Action pinning audit",
14+
"Tests required",
1215
"Frontend Build",
1316
"Frontend Quality",
1417
"Branch-protection contexts sync",

.github/branch-protection/main.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
"Architecture (import-linter)",
1010
"Pre-commit",
1111
"File length",
12+
"Version bump check",
13+
"Action pinning audit",
14+
"Tests required",
1215
"Frontend Build",
1316
"Frontend Quality",
1417
"Branch-protection contexts sync",
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
#!/usr/bin/env python3
2+
"""Audit GitHub Actions pin shapes against the project policy.
3+
4+
Policy from `docs/DEVELOPMENT.md#action-pinning-policy`:
5+
6+
- **First-party** (`actions/*`, `github/*`) — pin to **major tag**
7+
(`@v\\d+`). SHA + trailing `# vN.M.P` comment is also accepted (stricter
8+
posture; used in elevated-permissions workflows like `release.yml`).
9+
10+
- **`astral-sh/setup-uv`** — pin to **latest patch tag**
11+
(`@vN.M.P`). The maintainers do not publish a floating major tag for
12+
the v8 series; `@v8` would not resolve. SHA + comment also accepted.
13+
14+
- **Third-party** (anything else, including `aquasecurity/*`, `gitleaks/*`,
15+
`amannn/*`, `release-drafter/*`) — pin to a **40-hex-char SHA** with a
16+
trailing `# vN.M.P` comment naming the resolved tag. A moving branch
17+
or re-tagged release in a supply-chain workflow defeats the point of
18+
the scan/automation.
19+
20+
The script walks every YAML file under `.github/workflows/`, extracts
21+
each `uses:` line with its trailing comment if any, and validates the
22+
pin against the policy bucket the action falls into.
23+
24+
Exit codes:
25+
0 — every action invocation matches policy
26+
1 — one or more violations
27+
2 — script-level error (no workflow files found, parse failure)
28+
29+
Usage (from repo root):
30+
31+
python .github/scripts/check_action_pins.py
32+
33+
Bumping a third-party action: open a focused PR that updates the SHA
34+
*and* the trailing comment. Dependabot's `github-actions` ecosystem
35+
opens those PRs automatically when new SHAs land.
36+
"""
37+
38+
from __future__ import annotations
39+
40+
import re
41+
import sys
42+
from dataclasses import dataclass
43+
from pathlib import Path
44+
45+
WORKFLOWS_DIR = Path(".github/workflows")
46+
# Composite actions live under `.github/actions/<name>/action.yml` and can
47+
# carry their own `uses:` invocations of third-party actions in their
48+
# `runs.steps` block. Walk this dir alongside workflows (#137).
49+
ACTIONS_DIR = Path(".github/actions")
50+
51+
# Captures `uses: <action>@<ref>` lines, optionally with a trailing comment.
52+
# Tolerant of the leading `- ` (list item) or no leading dash, and any
53+
# whitespace indent.
54+
_USES_RE = re.compile(r"^\s*-?\s*uses:\s*(?P<ref>\S+)\s*(?P<comment>#.*)?$")
55+
56+
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
57+
_MAJOR_TAG_RE = re.compile(r"^v\d+$")
58+
_PATCH_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$")
59+
# A `# vN.M.P` annotation in the trailing comment. We accept partial
60+
# semver (`# v5`, `# v4.2`) because some upstream tags are minor-only,
61+
# but we require at least the leading `v\d+`.
62+
_VERSION_COMMENT_RE = re.compile(r"v\d+(\.\d+)*")
63+
64+
65+
@dataclass(frozen=True)
66+
class ActionRef:
67+
"""One `uses:` invocation: file:line, the @ref, and trailing comment."""
68+
69+
file: Path
70+
line: int
71+
action: str # part before @, e.g. "actions/checkout"
72+
pin: str # part after @, e.g. "v4" or "11bd71..."
73+
comment: str | None # raw trailing comment incl. `#`, or None
74+
75+
def loc(self) -> str:
76+
return f"{self.file}:{self.line}"
77+
78+
79+
def parse_workflow(path: Path) -> list[ActionRef]:
80+
"""Return every action invocation in a workflow or composite-action file.
81+
82+
Walks `uses:` lines, including those inside composite-action `runs.steps`
83+
blocks (#137). Skips local-path references (`uses: ./...`) — those point
84+
at repo-internal files (reusable workflows / composite actions in the
85+
same repo) and don't carry a third-party-action pin to validate.
86+
"""
87+
refs: list[ActionRef] = []
88+
for line_num, line in enumerate(
89+
path.read_text(encoding="utf-8").splitlines(), start=1
90+
):
91+
match = _USES_RE.match(line)
92+
if not match:
93+
continue
94+
ref_value = match.group("ref")
95+
# Skip local-path references — these point at repo-internal files
96+
# (reusable workflows or composite actions in this same repo) and
97+
# don't carry an external pin shape we should validate. The path
98+
# may have a `@ref` suffix (reusable workflows) or none (composite
99+
# actions); either is fine.
100+
if ref_value.startswith(("./", "../")):
101+
continue
102+
if "@" not in ref_value:
103+
# Malformed — surface as a special error in validate_ref.
104+
refs.append(
105+
ActionRef(
106+
file=path,
107+
line=line_num,
108+
action=ref_value,
109+
pin="",
110+
comment=match.group("comment"),
111+
)
112+
)
113+
continue
114+
action, pin = ref_value.split("@", 1)
115+
refs.append(
116+
ActionRef(
117+
file=path,
118+
line=line_num,
119+
action=action,
120+
pin=pin,
121+
comment=match.group("comment"),
122+
)
123+
)
124+
return refs
125+
126+
127+
def classify(action: str) -> str:
128+
"""Return the pin-shape policy bucket for a given action.
129+
130+
- "patch-tag" — astral-sh/setup-uv (no floating major tag)
131+
- "major-tag" — actions/* and github/* (first-party)
132+
- "third-party-sha" — everyone else (default for unknown)
133+
"""
134+
if action == "astral-sh/setup-uv":
135+
return "patch-tag"
136+
if action.startswith(("actions/", "github/")):
137+
return "major-tag"
138+
return "third-party-sha"
139+
140+
141+
def validate_ref(ref: ActionRef) -> str | None:
142+
"""Return an error message if `ref` violates policy, else None.
143+
144+
SHA pins are always accepted (most-strict shape) for any action,
145+
provided the trailing comment names the resolved version. Tag pins
146+
are accepted only for the buckets the policy documents.
147+
"""
148+
if not ref.pin:
149+
return f"missing @ref on `uses: {ref.action}`"
150+
151+
bucket = classify(ref.action)
152+
153+
# SHA pins are stricter than tag pins; accept everywhere as long
154+
# as a trailing version comment is present. The comment ties the
155+
# opaque hash back to a human-reviewable changelog.
156+
if _SHA_RE.match(ref.pin):
157+
if not ref.comment or not _VERSION_COMMENT_RE.search(ref.comment):
158+
return (
159+
f"{ref.action}@{ref.pin[:8]}… — SHA pin missing trailing "
160+
"`# vN.M.P` comment"
161+
)
162+
return None
163+
164+
# Tag pins — must match the bucket.
165+
if bucket == "patch-tag":
166+
if not _PATCH_TAG_RE.match(ref.pin):
167+
return (
168+
f"{ref.action}@{ref.pin} — astral-sh/setup-uv has no floating "
169+
"major tag for the v8 series; pin to a patch tag (e.g. "
170+
"`@v8.0.0`) or a SHA + trailing `# vN.M.P` comment"
171+
)
172+
return None
173+
174+
if bucket == "major-tag":
175+
if not _MAJOR_TAG_RE.match(ref.pin):
176+
return (
177+
f"{ref.action}@{ref.pin} — first-party action requires major "
178+
"tag (`@v4`) or SHA + trailing `# vN.M.P` comment"
179+
)
180+
return None
181+
182+
# third-party-sha — only SHA + comment is acceptable.
183+
return (
184+
f"{ref.action}@{ref.pin} — third-party action requires SHA pin "
185+
"(40 hex chars) with trailing `# vN.M.P` comment, not a tag"
186+
)
187+
188+
189+
def _collect_yaml_files() -> list[Path]:
190+
"""Workflow files + composite-action files. Composite dir is optional."""
191+
files: list[Path] = []
192+
files.extend(sorted(WORKFLOWS_DIR.glob("*.yml")))
193+
files.extend(sorted(WORKFLOWS_DIR.glob("*.yaml")))
194+
if ACTIONS_DIR.is_dir():
195+
# `.github/actions/<name>/action.yml` (or `action.yaml`). Recursive
196+
# glob so nested composite actions are picked up.
197+
files.extend(sorted(ACTIONS_DIR.glob("**/action.yml")))
198+
files.extend(sorted(ACTIONS_DIR.glob("**/action.yaml")))
199+
return files
200+
201+
202+
def main() -> int:
203+
if not WORKFLOWS_DIR.is_dir():
204+
print(f"::error::workflows dir not found: {WORKFLOWS_DIR}")
205+
return 2
206+
207+
yml_files = _collect_yaml_files()
208+
if not yml_files:
209+
print(f"::error::no workflow files in {WORKFLOWS_DIR}")
210+
return 2
211+
212+
refs: list[ActionRef] = []
213+
for path in yml_files:
214+
refs.extend(parse_workflow(path))
215+
216+
if not refs:
217+
print(f"::error::no `uses:` lines found across {len(yml_files)} workflow files")
218+
return 2
219+
220+
failed = False
221+
for ref in refs:
222+
problem = validate_ref(ref)
223+
if problem is None:
224+
continue
225+
failed = True
226+
# GitHub Actions error annotation: surfaces inline on the file in PR review.
227+
print(f"::error file={ref.file},line={ref.line}::{problem}")
228+
229+
if failed:
230+
print(
231+
"\nSee docs/DEVELOPMENT.md#action-pinning-policy for the rule. "
232+
"Dependabot's `github-actions` ecosystem opens SHA-bump PRs "
233+
"automatically when new tags ship."
234+
)
235+
return 1
236+
237+
print(
238+
f"Action pins audit OK — {len(refs)} pins, {len(yml_files)} files "
239+
f"(workflows + composite actions)."
240+
)
241+
return 0
242+
243+
244+
if __name__ == "__main__":
245+
sys.exit(main())

0 commit comments

Comments
 (0)