← All posts
· 4 min read ·
CI/CDSecurityGitHub ActionsSupply ChainDevSecOps

Pinning GitHub Actions to Commit SHAs: Supply Chain Security That Actually Works

Version tags in GitHub Actions are mutable - a compromised action maintainer can push malicious code to v4 without changing the tag. SHA pinning prevents this. Here's how to do it at scale.

Lock icon over a pipeline flow diagram

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:

  1. Attacker compromises a popular GitHub Action’s maintainer account
  2. Attacker pushes a new commit that exfiltrates GITHUB_TOKEN, secrets, or build artifacts
  3. Attacker moves the v3 or v4 tag to point to the malicious commit
  4. 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:

ControlWhat it prevents
SHA pinningTag mutation attacks
Minimal permissionsGITHUB_TOKEN abuse if action is compromised
pull_request_target avoidanceFork-based privilege escalation
Secret reviewUnnecessary secrets in environment
OIDC instead of stored keysCredential 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.

← All posts