Why You Should Pin GitHub Actions to Commit Hashes¶
If you have used GitHub Actions, you have almost certainly written something like this:
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:
- 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).
- They push new code that exfiltrates secrets, modifies build artifacts, or plants a backdoor in the compiled output.
- They move a mutable version tag (
v2,v3,v6) to point at the malicious commit. - 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
- Navigate to the action's repository, for example
https://github.com/actions/checkout. - Open the Tags or Releases tab and find the release you want.
- Click the short commit SHA shown next to the tag to open the commit page.
- 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:
Copy the commit hash on the left.
Method 3: GitHub CLI
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:
-
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. -
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¶
- GitHub Security Advisory:
tj-actions/changed-files(GHSA-mrrh-fwg8-r2c3) - GitHub: Security hardening for GitHub Actions
- Renovate:
pinDigestsconfiguration option - GitHub: Configuring Dependabot version updates
actionlint: Static checker for GitHub Actions workflow files- Introducing
setup-task: Install Task in GitHub Actions