When you write uses: actions/checkout@v4 in a workflow, you are trusting that the code behind that tag today is the same code that will be there tomorrow. It won’t necessarily be. Git tags are mutable references. A malicious actor with push access to an actions repository - or a compromised maintainer account - can move v4 to point to a different commit. Your pipeline picks it up on the next run with no warning.
SHA pinning removes this attack surface. uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 will always resolve to exactly that commit, regardless of what happens to the tags.
The Threat Model
The attack pattern is straightforward:
- Attacker compromises a popular GitHub Action’s maintainer account
- Attacker pushes a new commit that exfiltrates
GITHUB_TOKEN, secrets, or build artifacts - Attacker moves the
v3orv4tag to point to the malicious commit - Every pipeline using that version is now running malicious code
This happened. The tj-actions/changed-files action was compromised in March 2025 (CVE-2025-30066) - the v46 tag was moved to a commit that printed secrets from the runner environment to the workflow log. Within hours, thousands of repositories had leaked their secrets.
The reviewdog/action-setup action was compromised in the same wave. Both used in CI pipelines across hundreds of organisations.
SHA pins would have completely prevented the impact. Pipelines pinned to the known-good SHA would have continued running the safe version.
Finding the Correct SHA
For any action, you need the full 40-character commit SHA of the version you want to pin to.
# Method 1: GitHub CLI
gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha'
# If the tag points to a tag object (annotated tag), dereference it:
gh api repos/actions/checkout/git/ref/tags/v4 \
--jq '.object | if .type == "tag" then .sha else .sha end'
# Method 2: git ls-remote
git ls-remote https://github.com/actions/checkout.git refs/tags/v4
# For annotated tags you may need to dereference (^{} syntax):
git ls-remote https://github.com/actions/checkout.git 'refs/tags/v4^{}'
The SHA to use is the dereferenced commit SHA - the one ending in ^{} in ls-remote output.
Add the version tag as a comment so you know what you pinned:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Automating SHA Updates with Dependabot
Pinned SHAs don’t update themselves. You need Dependabot or a similar tool to propose updates when a new version is released.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: ["*"]
commit-message:
prefix: "ci"
With this config, Dependabot opens a PR each week updating action SHAs to their latest versions, with the new tag in the comment. You review the diff, check the changelog, and merge.
This is the correct workflow: pin for security, automate updates for freshness.
Scanning Your Existing Workflows
To find all unpinned actions in your repository:
# Find actions using tag references instead of SHAs
grep -rn "uses:" .github/workflows/ | \
grep -v "@[0-9a-f]\{40\}" | \
grep -v "^#"
Or use zizmor - a static analyser for GitHub Actions workflows:
pip install zizmor
zizmor .github/workflows/
zizmor flags unpinned actions, excessive permissions, script injection vulnerabilities, and other common workflow issues.
The Exceptions
Not everything can or should be pinned by SHA:
Local actions (uses: ./.github/actions/my-action) reference your own repository and are covered by your normal code review process.
Docker actions with docker://image:tag should use image digests instead:
- uses: docker://node@sha256:abc123... # instead of docker://node:20
Reusable workflows (uses: org/repo/.github/workflows/deploy.yml@main) within your own org are lower risk but should still be pinned for external repos.
Building a Policy
For organisations, codify SHA pinning as a requirement:
# .github/workflows/lint-workflows.yml
name: Lint Workflows
on:
pull_request:
paths: ['.github/workflows/**']
jobs:
zizmor:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Run zizmor
uses: woodruffw/zizmor-action@v1
with:
config: zizmor.yml
This gates every PR that touches workflow files - unpinned actions fail the check before merge.
The Bigger Picture
SHA pinning is one layer of supply chain security. The full posture includes:
| Control | What it prevents |
|---|---|
| SHA pinning | Tag mutation attacks |
Minimal permissions | GITHUB_TOKEN abuse if action is compromised |
pull_request_target avoidance | Fork-based privilege escalation |
| Secret review | Unnecessary secrets in environment |
| OIDC instead of stored keys | Credential theft from runner |
SHA pinning is the cheapest of these to implement and among the most effective against the real attack patterns we’ve seen in the wild. There is no good reason not to do it.