Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/branch-protection/develop.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
"Coverage",
"Architecture (import-linter)",
"Pre-commit",
"File length",
"Version bump check",
"Action pinning audit",
"Tests required",
"src/ README audit",
"Aspirational ticket cite",
"Frontend Build",
"Frontend Quality",
"Branch-protection contexts sync",
Expand Down
6 changes: 6 additions & 0 deletions .github/branch-protection/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
"Coverage",
"Architecture (import-linter)",
"Pre-commit",
"File length",
"Version bump check",
"Action pinning audit",
"Tests required",
"src/ README audit",
"Aspirational ticket cite",
"Frontend Build",
"Frontend Quality",
"Branch-protection contexts sync",
Expand Down
245 changes: 245 additions & 0 deletions .github/scripts/check_action_pins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""Audit GitHub Actions pin shapes against the project policy.

Policy from `docs/DEVELOPMENT.md#action-pinning-policy`:

- **First-party** (`actions/*`, `github/*`) — pin to **major tag**
(`@v\\d+`). SHA + trailing `# vN.M.P` comment is also accepted (stricter
posture; used in elevated-permissions workflows like `release.yml`).

- **`astral-sh/setup-uv`** — pin to **latest patch tag**
(`@vN.M.P`). The maintainers do not publish a floating major tag for
the v8 series; `@v8` would not resolve. SHA + comment also accepted.

- **Third-party** (anything else, including `aquasecurity/*`, `gitleaks/*`,
`amannn/*`, `release-drafter/*`) — pin to a **40-hex-char SHA** with a
trailing `# vN.M.P` comment naming the resolved tag. A moving branch
or re-tagged release in a supply-chain workflow defeats the point of
the scan/automation.

The script walks every YAML file under `.github/workflows/`, extracts
each `uses:` line with its trailing comment if any, and validates the
pin against the policy bucket the action falls into.

Exit codes:
0 — every action invocation matches policy
1 — one or more violations
2 — script-level error (no workflow files found, parse failure)

Usage (from repo root):

python .github/scripts/check_action_pins.py

Bumping a third-party action: open a focused PR that updates the SHA
*and* the trailing comment. Dependabot's `github-actions` ecosystem
opens those PRs automatically when new SHAs land.
"""

from __future__ import annotations

import re
import sys
from dataclasses import dataclass
from pathlib import Path

WORKFLOWS_DIR = Path(".github/workflows")
# Composite actions live under `.github/actions/<name>/action.yml` and can
# carry their own `uses:` invocations of third-party actions in their
# `runs.steps` block. Walk this dir alongside workflows (#137).
ACTIONS_DIR = Path(".github/actions")

# Captures `uses: <action>@<ref>` lines, optionally with a trailing comment.
# Tolerant of the leading `- ` (list item) or no leading dash, and any
# whitespace indent.
_USES_RE = re.compile(r"^\s*-?\s*uses:\s*(?P<ref>\S+)\s*(?P<comment>#.*)?$")

_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
_MAJOR_TAG_RE = re.compile(r"^v\d+$")
_PATCH_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$")
# A `# vN.M.P` annotation in the trailing comment. We accept partial
# semver (`# v5`, `# v4.2`) because some upstream tags are minor-only,
# but we require at least the leading `v\d+`.
_VERSION_COMMENT_RE = re.compile(r"v\d+(\.\d+)*")


@dataclass(frozen=True)
class ActionRef:
"""One `uses:` invocation: file:line, the @ref, and trailing comment."""

file: Path
line: int
action: str # part before @, e.g. "actions/checkout"
pin: str # part after @, e.g. "v4" or "11bd71..."
comment: str | None # raw trailing comment incl. `#`, or None

def loc(self) -> str:
return f"{self.file}:{self.line}"


def parse_workflow(path: Path) -> list[ActionRef]:
"""Return every action invocation in a workflow or composite-action file.

Walks `uses:` lines, including those inside composite-action `runs.steps`
blocks (#137). Skips local-path references (`uses: ./...`) — those point
at repo-internal files (reusable workflows / composite actions in the
same repo) and don't carry a third-party-action pin to validate.
"""
refs: list[ActionRef] = []
for line_num, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), start=1
):
match = _USES_RE.match(line)
if not match:
continue
ref_value = match.group("ref")
# Skip local-path references — these point at repo-internal files
# (reusable workflows or composite actions in this same repo) and
# don't carry an external pin shape we should validate. The path
# may have a `@ref` suffix (reusable workflows) or none (composite
# actions); either is fine.
if ref_value.startswith(("./", "../")):
continue
if "@" not in ref_value:
# Malformed — surface as a special error in validate_ref.
refs.append(
ActionRef(
file=path,
line=line_num,
action=ref_value,
pin="",
comment=match.group("comment"),
)
)
continue
action, pin = ref_value.split("@", 1)
refs.append(
ActionRef(
file=path,
line=line_num,
action=action,
pin=pin,
comment=match.group("comment"),
)
)
return refs


def classify(action: str) -> str:
"""Return the pin-shape policy bucket for a given action.

- "patch-tag" — astral-sh/setup-uv (no floating major tag)
- "major-tag" — actions/* and github/* (first-party)
- "third-party-sha" — everyone else (default for unknown)
"""
if action == "astral-sh/setup-uv":
return "patch-tag"
if action.startswith(("actions/", "github/")):
return "major-tag"
return "third-party-sha"


def validate_ref(ref: ActionRef) -> str | None:
"""Return an error message if `ref` violates policy, else None.

SHA pins are always accepted (most-strict shape) for any action,
provided the trailing comment names the resolved version. Tag pins
are accepted only for the buckets the policy documents.
"""
if not ref.pin:
return f"missing @ref on `uses: {ref.action}`"

bucket = classify(ref.action)

# SHA pins are stricter than tag pins; accept everywhere as long
# as a trailing version comment is present. The comment ties the
# opaque hash back to a human-reviewable changelog.
if _SHA_RE.match(ref.pin):
if not ref.comment or not _VERSION_COMMENT_RE.search(ref.comment):
return (
f"{ref.action}@{ref.pin[:8]}… — SHA pin missing trailing "
"`# vN.M.P` comment"
)
return None

# Tag pins — must match the bucket.
if bucket == "patch-tag":
if not _PATCH_TAG_RE.match(ref.pin):
return (
f"{ref.action}@{ref.pin} — astral-sh/setup-uv has no floating "
"major tag for the v8 series; pin to a patch tag (e.g. "
"`@v8.0.0`) or a SHA + trailing `# vN.M.P` comment"
)
return None

if bucket == "major-tag":
if not _MAJOR_TAG_RE.match(ref.pin):
return (
f"{ref.action}@{ref.pin} — first-party action requires major "
"tag (`@v4`) or SHA + trailing `# vN.M.P` comment"
)
return None

# third-party-sha — only SHA + comment is acceptable.
return (
f"{ref.action}@{ref.pin} — third-party action requires SHA pin "
"(40 hex chars) with trailing `# vN.M.P` comment, not a tag"
)


def _collect_yaml_files() -> list[Path]:
"""Workflow files + composite-action files. Composite dir is optional."""
files: list[Path] = []
files.extend(sorted(WORKFLOWS_DIR.glob("*.yml")))
files.extend(sorted(WORKFLOWS_DIR.glob("*.yaml")))
if ACTIONS_DIR.is_dir():
# `.github/actions/<name>/action.yml` (or `action.yaml`). Recursive
# glob so nested composite actions are picked up.
files.extend(sorted(ACTIONS_DIR.glob("**/action.yml")))
files.extend(sorted(ACTIONS_DIR.glob("**/action.yaml")))
return files


def main() -> int:
if not WORKFLOWS_DIR.is_dir():
print(f"::error::workflows dir not found: {WORKFLOWS_DIR}")
return 2

yml_files = _collect_yaml_files()
if not yml_files:
print(f"::error::no workflow files in {WORKFLOWS_DIR}")
return 2

refs: list[ActionRef] = []
for path in yml_files:
refs.extend(parse_workflow(path))

if not refs:
print(f"::error::no `uses:` lines found across {len(yml_files)} workflow files")
return 2

failed = False
for ref in refs:
problem = validate_ref(ref)
if problem is None:
continue
failed = True
# GitHub Actions error annotation: surfaces inline on the file in PR review.
print(f"::error file={ref.file},line={ref.line}::{problem}")

if failed:
print(
"\nSee docs/DEVELOPMENT.md#action-pinning-policy for the rule. "
"Dependabot's `github-actions` ecosystem opens SHA-bump PRs "
"automatically when new tags ship."
)
return 1

print(
f"Action pins audit OK — {len(refs)} pins, {len(yml_files)} files "
f"(workflows + composite actions)."
)
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading