DEV Community

Cover image for Pin GitHub Actions to a commit SHA so a moved tag cannot change your build
Kay
Kay

Posted on Originally published at latchkey.dev

Pin GitHub Actions to a commit SHA so a moved tag cannot change your build

To pin GitHub Actions to a commit SHA, replace the tag after the @ with the full 40-character commit SHA it points to, and keep the version in a trailing comment: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1. A tag can be moved to different code at any time, as the tj-actions/changed-files compromise showed in March 2025; a full SHA always names the same commit.

Attack path: release tags repointed to one malicious commit; tag users run it, SHA pins do not
The tj-actions/changed-files compromise as an attack path, with the timeline from the advisory, CISA and StepSecurity. All times UTC.

Every uses: line in a workflow is a dependency that resolves when the job starts. When the reference is a tag, the code that runs is whatever the tag points at in that moment, which need not be what you reviewed when you added it. That is the gap the tj-actions/changed-files attack used: nobody edited the victims' workflows, the attacker changed what the tags meant.

The March 2025 incident is the worked case. Around it: why a tag moves, how to pin and keep pins current, and the controls GitHub has shipped to make pinning a rule rather than a habit, including workflow lockfiles, generally available since October 28, 2026.

Why a tag is not a version

A git tag is a name for a commit. A lightweight tag is nothing more than a ref holding a SHA, and anyone who can push to the action's repository can delete it and create it again pointing somewhere else. GitHub's secure use reference warns that there is risk in pinning to a tag "even if you trust the author, because a tag can be moved or deleted if a bad actor gains access to the repository storing the action."

Maintainers move tags on purpose. actions/checkout@v7 is meant to follow the newest v7 release, so the commit it names changes with every patch. On 2026-11-12, v7 and v7.0.1 named the same commit and v7.0.0 named another: a workflow that said @v7 before the 7.0.1 release ran different code after it, with no change to the file. That is useful when the maintainer moves the tag and dangerous when somebody else does.

git ls-remote https://github.com/actions/checkout 'refs/tags/v7*'
3d3c42e5aac5ba805825da76410c181273ba90b1    refs/tags/v7
9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0    refs/tags/v7.0.0
3d3c42e5aac5ba805825da76410c181273ba90b1    refs/tags/v7.0.1
Enter fullscreen mode Exit fullscreen mode

Output of git ls-remote against github.com/actions/checkout, run on 2026-11-12. For an annotated tag a second line ending in ^{} gives the commit; these are lightweight tags, so each line is already the commit.

The worked case: tj-actions/changed-files, March 2025

tj-actions/changed-files lists the files a change touched, and the GitHub advisory describes the attack as impacting over 23,000 repositories. On March 14, 2025 an attacker added a malicious commit, 0e58ed8671d6b60d0890c21b07f8835ace038e67, and retroactively repointed existing version tags to it. The advisory names v1.0.0, v35.7.7-sec and v44.5.1; StepSecurity, which detected it, found that most release tags had been moved.

The payload targeted the Runner Worker process, scanned its memory for secrets and printed them to the build log, base64 encoded twice. On a public repository the log is public, so every secret the job could see became readable by anyone who opened the run. It is CVE-2025-30066, patched in v46.0.1, and CISA connected it to an earlier compromise of reviewdog/action-setup@v1.

A workflow pinned to an earlier full SHA kept running that commit, because moving a tag does not change what a SHA names. Anyone who had pinned to 0e58ed86 itself had to update: a pin protects the code you reviewed, not code you never looked at.

When (UTC) What happened Source
March 11, 18:42 to 20:31 reviewdog/action-setup@v1 compromised CISA
March 14, about 16:00 Incident starts StepSecurity
March 14, 17:00 Most versions of the action confirmed compromised StepSecurity
March 14, 20:00 Public repositories found leaking secrets in build logs StepSecurity
March 15, 14:00 GitHub removes the action, so workflows cannot use it StepSecurity
March 15, 22:00 GitHub restores the repository with every version cleaned StepSecurity

Read 2026-11-12. CISA gives the window in which affected versions were used as March 12, 00:00 UTC to March 15, 12:00 UTC; the GitHub advisory says March 14 to 15. Both say to rotate any secret a job in that window could reach.

How to pin: the full SHA and a version comment

Resolve the tag to its commit from the action's own repository, not a fork (the secure use reference asks you to check exactly that), and paste all 40 characters in place of the tag. GitHub's enforcement policy checks for a full commit SHA, so a short one fails it. Keep the version as a comment at the end of the line: reviewers read it, and Dependabot and Renovate use it to know which release to move to.

Pin every action you do not own, GitHub's included. The setting that enforces pinning covers actions authored by GitHub and by your own organization, so actions/checkout@v7 fails under it like any other tag. Reusable workflows can still be called by tag.

# Before: three tags, resolved again at the start of every job
steps:
  - uses: actions/checkout@v7
  - uses: actions/setup-node@v7
    with:
      node-version: 22
  - uses: tj-actions/changed-files@v47

# After: three commits, each version kept as a comment
steps:
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
    with:
      node-version: 22
  - uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
Enter fullscreen mode Exit fullscreen mode

SHAs from git ls-remote on 2026-11-12. That day the v47 tag of tj-actions/changed-files and its v47.0.6 tag named different commits, which is why the comment should carry the full version you resolved, not the major.

Keeping pins current with Dependabot or Renovate

A pin that never moves stops receiving fixes, so the other half of pinning is a bot that proposes new pins as pull requests. Dependabot reads .github/workflows when its github-actions entry uses the directory /. The secure use reference notes one behavior worth knowing: if the pinned commit is not associated with any tag, Dependabot updates it to the latest commit, which might not be the latest release. Pin to tagged release commits and that does not come up.

Renovate updates the SHA according to the tag in the comment, and its helpers:pinGitHubActionDigests preset converts tag references into pinned SHAs for you. Its documentation notes that an action pinned to a bare SHA with no version comment is not updated by default, because Renovate cannot tell which tag the SHA belongs to. Either way, read the diff: the bot is changing which code runs.

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
Enter fullscreen mode Exit fullscreen mode

Dependabot behavior from docs.github.com (secure use reference and keeping your actions up to date with Dependabot), Renovate behavior from docs.renovatebot.com (github-actions manager, helper presets), all read 2026-11-12.

Make it a rule: the controls GitHub shipped

Pinning by convention lasts until the next pull request that adds @main. GitHub has shipped three controls that turn it into policy, each set under Actions in the enterprise, organization or repository settings.

Control Shipped What it does
Require actions to be pinned to a full-length commit SHA August 15, 2025 Any workflow that uses an action by tag or branch fails. Reusable workflows can still be referenced by tag.
Blocking with ! in the allowed actions policy August 15, 2025 Blocks a specific action or version. The block list is evaluated last, so it overrides any rule that would allow the action: the fast response to an advisory.
Workflow lockfiles and the Require lockfile policy Public preview October 14, 2026; generally available October 28, 2026 Records the commit behind every action reference, transitive ones included, in .github/workflows/actions.lock. Require lockfile enforces adoption.

Dates from the GitHub changelog entries of 2025-08-15, 2026-10-14 and 2026-10-28; behavior from docs.github.com; all read 2026-11-12. The pinning policy makes an unpinned workflow fail; it does not pin anything for you.

Lockfiles: the pin without the SHA in your YAML

A lockfile moves the pin out of the workflow. You keep readable references such as @v7.0.1, and gh actions-lock, a GitHub CLI extension, writes .github/workflows/actions.lock with the commit each reference resolved to and the numeric owner and repository IDs it came from. Once a workflow is onboarded, every action it uses must have an entry, and the job runs the recorded commit even if the tag has since moved. GitHub's docs say that for a repository that adopts a lockfile, SHA pinning in workflow files is no longer necessary.

It also reaches further than inline pins, because it covers transitive dependencies: the actions your actions call. When Dependabot bumps an action in an onboarded workflow it regenerates the matching entry in the same pull request. The extension's README lists what it does not cover: reusable workflows called from another repository, local ./ actions, and container images, which only draw a warning when they lack an @sha256: digest.

gh extension install github/gh-actions-lock
gh actions-lock            # onboard or fix: pin every resolvable action, write actions.lock
git add .github/workflows/
gh actions-lock --verify   # in CI: the same checks, without writing anything
Enter fullscreen mode Exit fullscreen mode

Commands and limitations from the github/gh-actions-lock README; behavior from the docs.github.com workflow dependency lockfiles page and the general availability changelog; all read 2026-11-12.

What a pin does not do

A pin fixes which code runs. It does not make that code safe, and GitHub says the same of lockfiles: they do not determine whether a dependency is safe. Read an action before you pin it.

A pin also stops at its own line. An inline SHA does not pin what a composite action calls inside itself, or a container image the action pulls by tag; the lockfile reaches the first, and an image digest is the answer to the second. And a pinned action still runs with everything the job holds, so pair this control with scoping the GITHUB_TOKEN: the less a job holds, the less a compromised step can print into a log.

FAQ

Does Dependabot update GitHub Actions pinned to a commit SHA?

Yes. With a github-actions entry in .github/dependabot.yml, Dependabot proposes new pins as pull requests. If the pinned commit is not associated with a tag, GitHub notes that Dependabot moves it to the latest commit rather than the latest release, so pin to tagged release commits.

How do I find the commit SHA for a GitHub Action version?

Run git ls-remote https://github.com/<owner>/<repo> refs/tags/<tag> against the action's own repository. If a second line ends in ^{}, the tag is annotated and that line holds the commit. Check the repository is the original and not a fork before you paste the SHA.

Should I pin GitHub's own actions like actions/checkout to a SHA?

If you enforce pinning, you have to: the repository setting covers actions authored by GitHub as well as third parties. Beyond the policy it is a trust judgment, but pinning everything keeps one rule for reviewers and lets Dependabot or Renovate handle updates the same way for every action.

Was my repository affected by the tj-actions/changed-files compromise?

Only if a workflow ran the action while the tags pointed at the malicious commit: March 14 to 15, 2025 per the advisory, or from March 12 per CISA. Check those runs for unexpected output in the changed-files step, which held secrets base64 encoded twice, and rotate anything the job could reach.

Is a workflow lockfile better than pinning to a SHA?

It covers more: transitive dependencies are locked too, and the workflow keeps readable tags. It does not lock reusable workflows from other repositories, local actions or container images, so those still need a SHA or a digest.

References

Top comments (0)