Skip to content

Why You Should Pin GitHub Actions to Commit Hashes

If you have used GitHub Actions, you have almost certainly written something like this:

steps:
  - name: Checkout
    uses: actions/checkout@v6
  - name: Setup Go
    uses: actions/setup-go@v6

It works. It is clean. It is also handing the keys to your CI pipeline to a tag pointer that anyone with push access to those repositories can move at any time, for any reason, without your knowledge.

This post covers what is actually happening when you write @v..., why it matters, and how to fix it in a way that is sustainable long-term. No conspiracy theories required: just a clear-eyed look at how Git references work and what a supply chain attack actually looks like in practice.

What Does the Part After @ Actually Mean?

The @ in a uses: reference is not a version. It is a Git reference: a pointer to a specific commit in the action's repository. The question is what kind of pointer it is, and what can change it.

GitHub Actions supports five forms:

Reference Form Example What Does It Point At? Can It Change?
Major version tag @v6 Latest v6.x.y release Yes, by design
Minor version tag @v6.1 Latest v6.1.x patch release Yes, by design
Full version tag @v6.1.0 A specific tagged commit Rarely, but possible
Branch @main, @develop, @foo The tip of whatever branch is named Every single push
Commit hash @de0fac2e4500... Exactly one commit, forever Never

Major and Minor Version Tags

When actions/checkout ships v6.0.2, the project moves the v6 tag (and sometimes a v6.0 tag) to point at the new commit. The next time your workflow runs, @v6 runs v6.0.2 instead of v6.0.1. You did not change anything. You did not approve anything. This is the intended behavior, and for many cases it is fine.

The problem is that the mechanism that makes @v6 automatically track new releases is identical to the mechanism an attacker would use to silently deliver malicious code to every workflow that references that tag.

Full Semver Tags (@v6.1.0)

A fully-specified semver tag like @v6.1.0 is less likely to be moved after the initial release, but it is still technically mutable. Tags in Git are just pointers, and nothing in the protocol prevents overwriting them. "Unlikely to be moved" and "cannot be moved" are meaningfully different guarantees.

Branch References

Using Branch References

A branch reference points at the current tip of that branch, which changes with every push. There is no release event, no version bump, no changelog. Every single workflow run potentially executes different code. This is not a theoretical risk: it is the most direct path to silently running unreviewed code in your CI pipeline.

If the action you want to use does not publish tagged releases, treat that as a signal to evaluate more carefully before adopting it. The @main, @master, @develop, and @trunk variants are all equally dangerous for the same reason: none of them point at a stable, reviewable commit.

Commit Hashes

A commit hash is a cryptographic digest derived from the commit's content. If the content changes, the digest changes. You cannot reuse a hash to deliver different code. actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd refers to exactly that commit, forever, regardless of what any tag or branch in the repository does.

This is the only form that gives you an immutable reference to exactly the code you reviewed and approved.

Why This Matters: The Anatomy of a Supply Chain Attack

The attack pattern is straightforward:

  1. An attacker gains write access to an action repository (compromised credential, malicious maintainer, stolen token, or a dependency compromise in the action's own build pipeline).
  2. They push new code that exfiltrates secrets, modifies build artifacts, or plants a backdoor in the compiled output.
  3. They move a mutable version tag (v2, v3, v6) to point at the malicious commit.
  4. Every workflow that references that tag executes the malicious code on its next run, silently and automatically.

The attack works because it exploits a trusted relationship. The action was reviewed when you first adopted it. Your colleagues use it. Nothing looks different. The code that runs is just not the code you reviewed.

The tj-actions/changed-files Incident

In March 2025, the tj-actions/changed-files action, used in tens of thousands of repositories, was compromised. Malicious code was pushed that extracted CI runner secrets and printed them to workflow logs. The version tags were moved to point at the malicious commit. Every workflow using a floating tag reference executed the malicious code automatically on its next run.

Repositories that had pinned to specific commit hashes were unaffected. Their workflows ran the exact commit they had previously reviewed, and nothing changed without an explicit, reviewable pull request.

This was not a theoretical risk. It happened at scale. See the GitHub Security Advisory (GHSA-mrrh-fwg8-r2c3) for the full details.

The Convenience Trap

It is worth acknowledging that floating version tags are not a design flaw, they are a deliberate convenience feature. Action authors move major version tags so that consumers automatically receive patch releases without updating their workflow files. The GitHub Actions documentation encourages this pattern. The intent is benign.

The problem is that the mechanism is indistinguishable from the attack. Any process that automatically runs unreviewed code in your CI pipeline is exploitable, regardless of whether the intent behind it is convenience or compromise.

The Fix: Pin to a Commit Hash With a Version Comment

Replace the tag reference with the full commit hash and add an inline comment to preserve the human-readable version. The comment is for you. The hash is for security.

# Before
- name: Checkout
  uses: actions/checkout@v6

# After
- name: Checkout
  uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

The workflow behavior is identical. The hash guarantees that no matter what happens to the v6.0.2 tag after this moment, your workflow runs exactly the code you pinned.

How to Find the Commit Hash for a Tag

Method 1: GitHub Web Interface

  1. Navigate to the action's repository, for example https://github.com/actions/checkout.
  2. Open the Tags or Releases tab and find the release you want.
  3. Click the short commit SHA shown next to the tag to open the commit page.
  4. The URL becomes https://github.com/actions/checkout/commit/<full-hash>. Copy the full commit hash.

Method 2: git ls-remote

The ^{} suffix dereferences annotated tags to their underlying commit:

git ls-remote https://github.com/actions/checkout 'refs/tags/v6.0.2^{}'
de0fac2e4500dabe0009e67214ff5f5447ce83dd    refs/tags/v6.0.2^{}

Copy the commit hash on the left.

Method 3: GitHub CLI

gh api repos/actions/checkout/git/ref/tags/v6.0.2 --jq '.object.sha'

For annotated tags, the first call returns the tag object SHA. Dereference it to get the underlying commit hash:

TAG_SHA=$(gh api repos/actions/checkout/git/ref/tags/v6.0.2 --jq '.object.sha')
gh api repos/actions/checkout/git/tags/"$TAG_SHA" --jq '.object.sha' 2>/dev/null \
  || echo "$TAG_SHA"

Before and After: A Real-World Example

Here is a realistic CI workflow for a Go project, before and after pinning. The "After" version uses the same structure and hashes as the tenthirtyam/go-vnc project.

Before:

name: Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - name: Setup Go
        uses: actions/setup-go@v6
        with:
          go-version-file: go.mod
      - name: Setup Task
        uses: tenthirtyam/setup-task@v1
        with:
          version: latest
      - name: Run Tests
        run: task test
      - name: Run Tests with Race Detection
        run: task test-race

  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - name: Setup Go
        uses: actions/setup-go@v6
        with:
          go-version-file: go.mod
      - name: Run Linters
        uses: golangci/golangci-lint-action@v9
        with:
          version: latest

After:

name: Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: Setup Go
        uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
        with:
          go-version-file: go.mod
      - name: Setup Task
        uses: tenthirtyam/setup-task@c8f6450c3d1f72ea6424cf5398f5c8a4f149c774 # v1.0.3
        with:
          version: latest
      - name: Run Tests
        run: task test
      - name: Run Tests with Race Detection
        run: task test-race

  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: Setup Go
        uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
        with:
          go-version-file: go.mod
      - name: Run Linters
        uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
        with:
          version: latest

The workflows are functionally identical. Every action in the "After" version runs the exact code that was reviewed when the hash was recorded.

Keeping Pins Updated: Dependabot and Renovate

Pinning to commit hashes solves the security problem. It does create a legitimate maintenance question: how do you find out when new versions are released, and how do you update the hashes without doing it by hand?

Both Dependabot and Renovate understand commit-hash-pinned GitHub Actions references. When a new version is released, they compute the new hash, open a pull request updating both the hash and the version comment, and leave the review and merge decision to you. You still have to say yes, which is the whole point.

Dependabot is built into GitHub and requires no additional infrastructure. Add or update .github/dependabot.yml in your repository:

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    groups:
      github-actions:
        patterns:
          - "*"

When a pinned action has a new release, Dependabot opens a pull request that updates both the hash and the inline version comment:

# Before (in your workflow)
- name: Checkout
  uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

# After (in the pull request by Dependabot)
- name: Checkout
  uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.1.0

Review the diff, check the release notes, merge. That is the entire workflow.

Dependabot Does Not Auto-Convert Floating Tags

Dependabot keeps existing pinned hashes current, but it does not convert floating tag references like @v6 to commit hashes. The initial pinning has to be done manually using the lookup methods described above. Once everything is pinned, Dependabot handles the ongoing updates.

A more complete configuration with scheduling, labels, and reviewers:

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
      time: "09:00"
      timezone: "America/New_York"
    open-pull-requests-limit: 10
    labels:
      - "dependencies"
      - "github-actions"
    reviewers:
      - "peter-gibbons"
    assignees:
      - "peter-gibbons"
    commit-message:
      prefix: "chore"
      include: "scope"
    groups:
      github-actions:
        patterns:
          - "*"

Renovate is a more configurable alternative that runs either as a GitHub App (hosted by Mend) or self-hosted. The key option for this workflow is pinDigests, which tells Renovate to pin action references to their commit hash digests.

A minimal renovate.json at the repository root:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended"],
  "packageRules": [
    {
      "matchManagers": ["github-actions"],
      "pinDigests": true
    }
  ]
}

Renovate's pinDigests handles the initial conversion automatically

Unlike Dependabot, Renovate with pinDigests: true will convert all floating tag references to commit hashes on its first run. No manual lookup required for the initial setup. Enable Renovate, add the configuration, and the pinning PR appears automatically. After that, it opens update PRs for new releases just like Dependabot.

A more complete configuration with scheduling and grouping:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended"],
  "schedule": ["before 9am on monday"],
  "labels": ["dependencies", "github-actions"],
  "packageRules": [
    {
      "matchManagers": ["github-actions"],
      "pinDigests": true,
      "groupName": "github actions",
      "automerge": false
    }
  ],
  "commitMessagePrefix": "chore(deps):"
}

Setting automerge: false means all updates require a review decision, which is the right default for CI security. If you want to auto-merge patch-level updates while requiring review for major version bumps, Renovate's matchUpdateTypes rule gives you that control per package rule.

First-Party vs. Third-Party Actions

Not all actions carry the same risk. Actions published by GitHub itself (actions/checkout, actions/setup-go, actions/setup-node) have a lower inherent risk profile, because GitHub controls the repository and the publishing process.

"Lower risk" is not the same as "no risk." GitHub employee accounts can be compromised. Supply chain attacks against first-party tooling have happened in other ecosystems. The cost of pinning a first-party action to a commit hash is zero.

Third-party actions deserve extra scrutiny before initial adoption:

  • Review the action's source code before adding it to your workflow.
  • Look at the action's own dependencies (actions can use other actions and npm packages).
  • Check commit frequency and whether security disclosures are actively handled.
  • Prefer actions with a large install base, active maintenance, and a clear security disclosure process.

After doing that evaluation, pin to the hash regardless of the outcome. The evaluation reduces risk at adoption time. The hash prevents the risk from changing after you've approved it.

Putting It All Together

The complete practice is two steps:

  1. Pin all action references to commit hashes. Use the GitHub web interface, git ls-remote, or the GitHub CLI to look up the hash for each tag. Add the version as an inline comment. This is a one-time change per repository, and it takes about ten minutes for a repository with a few workflow files.

  2. Enable Dependabot or Renovate for GitHub Actions. Configure a weekly schedule and group updates to minimize PR noise. From that point on, every new action release shows up as a pull request. You review, approve, and merge. Nothing runs without your sign-off.

Bonus: Enforce Pinning With actionlint

The actionlint static checker can flag unpinned action references. Running it as a required check on pull requests means no unpinned reference can be merged without a deliberate override. It is a lightweight safety net that keeps accidental tag references from slipping through during code review.

With these two things in place, every action that runs in your CI pipeline is exactly the code you reviewed. Any attempt to change it requires an explicit, reviewable pull request. The ongoing cost is a weekly set of dependency update PRs.

A mutable tag reference is a quiet transfer of trust from your reviewed snapshot to whoever controls that tag next. Commit hashes are how you take that trust back.

References