Pin convention for GitHub Actions¶
Every uses: reference to a third-party GitHub Action in this repo
is pinned by full 40-hex commit SHA. The trailing comment names a
versioned tag (# vX.Y[.Z] — at least two numeric components) whose
SHA matches the pin, not the floating major tag; naming the patch tag is
the convention.
# Required
- uses: some-org/some-action@0123456789abcdef0123456789abcdef01234567 # v3.36.0
# Forbidden
- uses: some-org/some-action@0123456789abcdef0123456789abcdef01234567 # v3
Why patch tags, not major tags¶
Major tags are designed to be force-moved on every release, so a
deliberately-moving tag cannot be judged by tag-vs-pin equality, and
a # vN ref is left out of the audit's comparison (see the runtime check under
Enforcement). Its integrity rests on the immutable
digest pin, Renovate currency, and the PR-time digest-provenance gate
alone.
Per-patch tags (e.g. v3.36.0) are immutable by publisher
convention. Anchoring the comment to a patch tag means a future audit
fire is a real signal: the publisher force-moved an immutable tag,
which is a compromise or a maintainer error and warrants escalation
per the ratchet-pin-audit runbook.
Exceptions¶
A ref that cannot name a versioned tag carries an inline marker on the
same line: a publisher that tags only majors leaves no versioned tag for
the comment to name, so the comment may remain # v<major>; this repo's
own self-referenced composite action names no upstream tag at all (see
Enforcement below). A pin that names the SHA of an annotated tag object
rather than the release commit is a different case: the inventory
script's SHA-equality lookup against gh api repos/<owner>/<repo>/tags
returns no match for it, but if its comment names a versioned tag it
needs no marker — the hook's patch-tag regex passes it and the audit
accepts either SHA:
The marker reason must be non-empty (the half that the lint checks) and, by convention, say why this ref cannot name a versioned tag.
Enforcement¶
-
Lint:
scripts/check-patch-tag-pins.sh, wired as thepatch-tag-pinspre-commit hook (seenix/hooks/workflow-security.nix). Fails the commit locally when a SHA pin lacks both a versioned-tag comment and an exception marker — whether the comment is missing entirely, names no version, or names only a major tag. The hook is the whole enforcement surface: the rule is in no lint group and gates no merge, andharness-groupruns its fixture tests without a live-repo probe, so a bypassed hook lets a malformed comment reachmain. -
Runtime check: ratchet-pin-audit (daily). Patch-tag immutability means the audit stays quiet under routine publisher releases; a fire is a real upstream tag-move. The audit globs
.github/workflows/only, so a pin inside a composite action under.github/actions/rests on the PR-time gates alone — those do scan both roots.The audit compares each pin against the tag it names, accepting a pin that equals either the annotated-tag-object SHA or the commit that tag dereferences to — so a tag-object pin whose comment names a versioned tag (the marker-free case under Exceptions above) is not mistaken for drift, while a genuine force-move (which changes both SHAs) still is. Refs whose comment names a floating major (
vN) are excluded from the comparison and logged: a deliberately-moving tag cannot be judged by tag-vs-pin equality, so their integrity rests on the fallback trio under Why patch tags; the PR-time gate there,scripts/check-pin-digest-provenance.sh, requires floating-major digest moves to be reachable from the upstream default branch.Self-reference pins — a
uses:whose owner/repo is this repo's own — are excluded for a different reason: they name no upstream tag at all, since Renovate'spinDigestsrule tracks this repo's ownmainHEAD. The digest-provenance gate excludes them too. Both self-reference forms are live. The./-relative form (uses: ./.github/actions/…) is content-addressed by the checkout itself and skipped byscripts/check-uses-sha-pinned.shas well. Theowner/repo@shaform is what these exclusions actually act on: it is used where apull_requestevent must not run a PR branch's copy of the composite, andscripts/check-uses-sha-pinned.shstill holds it to a full 40-hex SHA at PR time. Thepatch-tag-pinshook reads that line too, and since this repo publishes no release tags on its composite actions the comment can name no version — so the pin carries a# patch-tag-exception:marker. -
Bulk remediation: when comment drift is tree-wide,
scripts/inventory-action-pin-tags.shbuilds a TSV of each pin's resolved tag andscripts/apply-patch-tag-pin-rewrite.shapplies the recorded rewrites in place — all-or-nothing, aborting on anyAPI_FAILURErow or a line whose content no longer matches the inventory. -
Bump path: Renovate's
helpers:pinGitHubActionDigestspreset. Renovate's github-actions manager parses the trailing comment ascurrentValueand rewrites both the SHA and the comment on each bump, so per-patch comments naturally roll forward to the next patch release.