The argument for SHA pinning GitHub Actions looks compelling on paper. Instead of writing uses: actions/checkout@v4, you write uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 and now you are safe from tag mutation attacks. Nobody can change what your workflow runs by silently redirecting v4 to malicious code. The logic is sound as far as it goes, but the threat model most teams are defending against is much narrower than the threats they actually face.
What SHA Pinning Actually Protects
It is worth being precise about the protection, because it is real in one specific scenario.
When you pin to a full commit SHA, you are guaranteed to run the code at that exact commit object as stored in GitHub’s object store. Git’s content-addressable storage means the SHA is a cryptographic hash of the entire tree it represents; if the content changes, the SHA changes. Tag mutation, where a maintainer or attacker with repository write access quietly moves a version tag like v4 to a different commit, cannot affect a pinned workflow.
The March 2025 compromise of tj-actions/changed-files followed this exact pattern. Attackers who gained access to the repository modified the action’s code and updated multiple version tags to point to the malicious commit. Workflows pinned to specific SHAs were unaffected. Workflows using mutable tags ran attacker-controlled code with access to the runner environment, GITHUB_TOKEN, and any secrets the workflow had mounted. The incident report from StepSecurity documented exfiltration of CI secrets from the affected runners.
SHA pinning defends against this attack class. The problem is that tag-redirect attacks are one entry point in a substantially larger surface.
The Transitive Dependency Problem
GitHub Actions supports action composition. A workflow calls an action, and that action can call other actions internally:
# your workflow
- uses: some-org/composite-action@abc123def456
# some-org/composite-action/action.yml
runs:
using: composite
steps:
- uses: actions/setup-node@v4 # not pinned
- uses: another-org/some-tool@main # definitely not pinned
Your SHA pin for composite-action guarantees you run that action’s code exactly as it was when you pinned it. That action itself, however, calls actions/setup-node@v4 without a SHA pin. Your pipeline’s supply chain is only as pinned as its least-pinned transitive dependency, and you have no visibility into that chain unless you manually audit every action.yml reachable from your workflow.
There is no native GitHub mechanism that enforces or even surfaces transitive pinning requirements. The StepSecurity action-advisor tooling attempts to surface unpinned transitive calls by scanning the dependency tree, but it is an external tool that most teams do not run routinely. The problem is structural: the GitHub Actions execution model makes transitive dependencies invisible at the workflow level.
Runtime Fetches Break the Model Entirely
Many actions are thin wrappers around package installation or external downloads. A simplified version of what many setup actions do internally:
// inside an action's index.js, which you have pinned to a SHA
const toolUrl = `https://releases.example.com/tool/v${inputs.version}/linux-amd64.tar.gz`;
await exec.exec('curl', ['-fsSL', toolUrl, '-o', 'tool.tar.gz']);
await exec.exec('tar', ['-xzf', 'tool.tar.gz']);
The SHA you pinned covers the JavaScript wrapper. It covers nothing about what that wrapper fetches at runtime. The download URL might resolve to a CDN bucket with no content verification. If the publisher’s storage is compromised, or if the URL is not version-locked and resolves to “latest,” your SHA pin provides zero protection against what actually executes on the runner.
This class of attack is genuinely difficult to close without runtime monitoring. The npm install, pip install, and curl | sh patterns that appear inside actions execute code that was never part of the commit you pinned to. A malicious package published to a registry with a name that shadows a legitimate dependency can end up running in your CI environment entirely independently of your SHA pinning discipline.
Docker Images and Mutable Tags
A large category of GitHub Actions run in containers, specified in action.yml like this:
runs:
using: docker
image: docker://ghcr.io/my-org/my-action:v2
Pinning the action’s git SHA does nothing about the Docker image tag. v2 and latest are exactly as mutable as any git tag. An attacker who compromises the container registry can substitute the image without touching the git repository at all, and the image pulled during your workflow run will be attacker-controlled regardless of your SHA pin.
The correct approach requires pinning by content digest:
runs:
using: docker
image: docker://ghcr.io/my-org/my-action@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Keeping that digest current requires automation, since digests change on every rebuild even when the behavior is identical. Renovate supports Docker digest pinning and can generate PRs when a digest changes, but it is another configuration layer that most teams have not applied to their action containers.
The Update Opacity Problem
There is a practical problem sitting underneath all of this: commit SHAs are opaque to human reviewers.
When Renovate or Dependabot proposes updating a pin, the PR looks like this:
# Before
- uses: actions/setup-python@0a5c61591373683505ea898e09424b01f8d0e4d7 # v5.0.0
# After
- uses: actions/setup-python@42375524b85f4b97fa3b8cd236375e4a6a5e9c7 # v5.1.0
Approving this change correctly requires either fully trusting the upstream project’s release process, which puts you in substantially the same position as using mutable tags, or auditing the diff between those two commits. The security-correct behavior is the latter, and under normal engineering workloads it rarely happens. Teams merge Dependabot PRs on green CI without reviewing what changed, because the alternative is spending hours per week reviewing upstream action diffs across a large repository.
SHA pinning applied without code review on updates shifts when you trust third parties, not whether you trust them.
What a More Complete Defense Looks Like
None of this means SHA pinning should be abandoned. It is a necessary baseline that closes a real attack vector. The point is treating it as a baseline rather than a destination.
SLSA (Supply chain Levels for Software Artifacts) formalizes a more complete threat model. SLSA Level 3 requires isolated build environments, unforgeable provenance attestations, and end-to-end artifact verifiability. SHA pinning on direct dependencies, on its own, corresponds to nothing in the SLSA framework, because SLSA is about the provenance of the artifact you are running, not merely the identifier you used to fetch it.
Sigstore and cosign allow signing and verifying both container images and arbitrary artifacts against short-lived certificates that require no key management. A workflow can verify provenance before executing a tool:
cosign verify-blob \
--certificate tool.pem \
--signature tool.sig \
--certificate-identity \
"https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main" \
--certificate-oidc-issuer \
"https://token.actions.githubusercontent.com" \
tool.tar.gz
This ties the artifact to a known build provenance rather than just a known content hash. It does not help with runtime fetches inside actions, but it addresses the “action downloads a binary” pattern more rigorously than SHA pinning does.
StepSecurity’s Harden-Runner takes a runtime approach, monitoring outbound network calls from the runner and allowing you to allowlist expected destinations. In enforcement mode it blocks unexpected outbound connections, which catches the “action fetches an unverified URL” class of attack before the payload executes. This is a complementary layer to SHA pinning, not a replacement.
For actions your organization depends on with broad access to secrets, the most robust option is an internal fork. You control the code, you apply upstream changes as explicit PRs that go through your own review process, and the transitive dependency chain starts inside your trust boundary. This is operationally expensive across a large portfolio of dependencies, but for a handful of high-privilege actions it gives the clearest trust model.
Where the Trust Boundary Actually Is
The supply chain attack surface for a GitHub Actions workflow includes the actions you call directly, the actions those actions call transitively, the packages those actions install at runtime, the container images they pull, the build infrastructure upstream of all of those, and GitHub’s own infrastructure. SHA pinning on direct dependencies closes one of those layers. The rest require a combination of tooling, process, and an honest accounting of where you are trusting third parties.
That trust is not fully avoidable. At some point you trust git’s object model, you trust GitHub’s servers, you trust the package registries your dependencies pull from. The goal is to be precise about where your trust boundary is. The original article’s framing of SHA pinning as a comforting lie is pointed but fair: the comfort comes from a visible practice that generates diffs and Dependabot PRs, something that feels like rigor. The lie is in treating that visible thing as though it addresses a threat model larger than the specific and narrow one it actually addresses.