NAME
audit-action-pins - resolve every SHA-pinned GitHub Action against its upstream tag and its declared runtime
PURPOSE
Make a workflow's action pins mean what they say. Every uses: line in this repository is pinned to an immutable 40-hex commit and annotated with a # vX.Y.Z comment naming the release that commit belongs to. The SHA is the security control; the comment is the only part a human can read, and it is what the repository's own guardrail test reads its version floors from. This gate is what keeps the two in agreement.
WHY IT EXISTS
Because a comment nobody verifies is trusted and can still be false. Three pins in this repository carried comments written from intent rather than resolved from the tag: actions/checkout was annotated # v5.2.2, a tag that has never existed upstream, over a commit that is really v4.2.2; shogo82148/actions-setup-perl was annotated # v1.32.0 over v1.31.3.
All three were node20 actions. GitHub force-runs node20 actions on the node24 runtime, which actions/checkout survives and actions-setup-perl v1.31.3 does not - it fails with Error: unable to get latest version. The Setup Perl step therefore failed on every CI run for ten days, skipping the test suite, the coverage gate and both dependency audits, while the guardrail test certified the node24 migration as complete because the comments said so. A version floor read from a comment cannot catch a comment that lies, so the check that can - resolving the SHA - lives here.
WHEN TO USE
Run it whenever an action pin is added or changed, and on every CI push. It is wired into the test workflow after the dependency install, because it needs the project's own LWP::UserAgent and JSON::XS.
HOW TO USE
script/audit-action-pins
script/audit-action-pins .github/workflows
GITHUB_TOKEN=... script/audit-action-pins
The audit reports two distinct kinds of problem, and exits non-zero for both:
FAIL- the pin was resolved and is wrong: it declares a runtime below node24, or its version comment names a tag that resolves to a different commit.UNUSABLE- the pin could not be resolved at all, so this run found nothing out. This is never reported as success. An unauthenticated run can hit the GitHub rate limit, and the/repos/.../commits/{sha}endpoint is unreliable for some large repositories, so the manifest is read through the contents API instead.
Exit codes are 0 clean, 1 findings, 2 usage error, 3 could not audit.
WHAT USES IT
.github/workflows/test.yml runs it on every push and pull request. t/34-scorecard-guardrails.t asserts that wiring still exists, and t/142-action-pin-provenance.t exercises this script's own logic.
OFFLINE TESTING
Setting DD_ACTION_PIN_FIXTURE to a JSON file replaces the network with a canned resolver, so the audit logic can be tested hermetically:
{
"contents": { "actions/checkout|action.yml|<sha>": "runs:\n using: node24\n" },
"tags": { "actions/checkout|v7.0.1": "<sha>" }
}
A fixture-backed run prints a banner saying so and does not certify anything - a fixture can assert whatever it likes, which is the point in a test and would be a hole anywhere else.
EXAMPLES
Audit the repository's workflows before pushing a pin change:
script/audit-action-pins; echo "exit=$?"
Audit a single directory of workflows copied elsewhere:
script/audit-action-pins /tmp/candidate-workflows
Confirm the gate fails closed when it cannot reach the API:
GITHUB_TOKEN= script/audit-action-pins /tmp/empty-dir; echo "exit=$?" # 3