GitHub Actions Supply Chain Post-Mortem: Mutable Tags, Burned Version Numbers, and Five Real Errors
I actually run a technical blog deployed on GitHub Pages. The whole deployment chain is an ordinary workflow: checkout, configure-pages, upload-pages-artifact, deploy-pages. Four steps, four actions, all from the official actions organization. Textbook clean.
Then last week I read that file line by line and noticed something: **all four actions were referenced by tag**. The v4 in actions/checkout@v4 is not "version 4". It is a **runtime name lookup** — on every CI run the runner asks "where does v4 point right now", then downloads that code and executes it.
That "right now" is the whole problem. A tag is a mutable pointer. Anyone who holds write access to the repository — or anyone who steals a maintainer account — can point it at a different commit. Your workflow file does not change by a single character. The diff is spotless. The log looks completely normal.
One disclosure up front: the section near the end called "The 2 a.m. incident desk" contains affiliate links. If you buy through them, this site may earn a commission. It has nothing to do with the technical conclusions above, and it is deliberately kept as its own section so you can skip it.
⏳ TL;DR
🥇 **The one-line conclusion**: uses: owner/action@v4 does not pin a version, it re-asks "who is v4 today" on every run. The only real pin is a full 40-character commit SHA.
🔧 Three things you can finish today:
1. List every unpinned reference: grep -rn "uses:" .github/workflows/ | grep -vE "@[0-9a-f]{40}"
2. Resolve a real SHA (checkout shown): git ls-remote https://github.com/actions/checkout "refs/tags/v7.0.1*"
3. Enforce centrally: Settings → Actions → General → Policies, switch on SHA pinning enforcement so unpinned workflows fail at runtime.
💻 The 2 a.m. incident desk (affiliate links, unrelated to the conclusions above):
- Dell UltraSharp U2723QE 27" 4K USB-C Hub monitor — for laying out workflow logs, diffs and `git log -p` side by side. Roughly $429–529 as of this writing; check the current price.
👉 https://www.amazon.com/dp/B09TRW57WB?tag=techpassive-20
- BenQ ScreenBar Halo 2 monitor light bar — at 2 a.m., light on the screen beats lighting the whole room. Roughly $179–199 as of this writing.
👉 https://www.amazon.com/dp/B0DK59YKRS?tag=techpassive-20
What the attack surface actually is: you run labels, not bytes
GitHub Actions resolves uses: owner/repo@ref by **fetching that Git ref from the repository at run time**, then executing whatever it points to. A tag is one of the mutable kinds of Git ref. A branch is even more mutable.
Three things follow at once:
- Major-version tags like `@v4` are, by convention, **moved** by maintainers to each new minor release. That is the design intent, not a bug.
- Once an attacker has write access, they move the tag to a malicious commit. Your workflow produces no diff and no alert.
- You think you pinned a version. You pinned a **name**.
GitHub's own documentation is blunt about this in "Security hardening for GitHub Actions": pinning an action to a full-length commit SHA is the way to consume it as an immutable release, and the short form of a commit SHA is insecure and should never be used to specify an action's Git reference.
Incident one: tj-actions/changed-files (CVE-2025-30066)
This is where "the tag that moves" stopped being a theoretical concern.
- On **2025-03-14 and 2025-03-15**, an attacker re-pointed tags `v1` through `v45.0.7` of `tj-actions/changed-files` to commit `0e58ed8`, which carried malicious code in a function named `updateFeatures`.
- The payload was crude and effective: **it dumped secrets from the CI environment into workflow logs**. On a public repository, logs are readable by anyone, so the keys were effectively hung on the front door.
- Multiple security vendors put the blast radius at **over 23,000 repositories**. CISA published a supply chain compromise alert on 2025-03-18 and added CVE-2025-30066 to its Known Exploited Vulnerabilities catalog, with a remediation deadline of 2025-04-08.
- The fixed version was `v46.0.1`. In the same window, a second action, `reviewdog/action-setup@v1`, was also compromised and tracked as CVE-2025-30154.
- Root cause: the Personal Access Token of the maintainer bot account `@tj-actions-bot` was stolen.
There is one detail here that matters enormously during incident response: **exposed secrets may appear in the logs as a double base64-encoded payload**. CISA calls this out explicitly. That detail is where the base64: invalid input error in my troubleshooting section comes from.
Incident two: trivy-action (CVE-2026-33634), bigger, and it left two second-order traps
The March 2026 wave used the same playbook, but the blast radius and the cleanup difficulty both went up a level.
- On **2026-03-19**, an attacker used stolen credentials to publish a malicious Trivy binary, `v0.69.4`, force-push **76 of the 77 version tags** in `aquasecurity/trivy-action` to credential-stealing malware, and replace **all 7 tags** in `aquasecurity/setup-trivy`.
- Time windows in UTC: `trivy-action` ran roughly 17:43 on 03-19 to 05:40 on 03-20; `setup-trivy` roughly 17:43 to 21:44; the `trivy` `v0.69.4` binary roughly 18:22 to 21:42. Any pipeline that ran inside those windows should treat its credentials as exposed.
- What the payload did: pull credentials out of `/proc` memory, sweep more than 50 common paths (SSH keys, AWS/GCP/Azure credentials, Kubernetes tokens, Docker configs, `.env` files, database credentials, crypto wallets), encrypt the haul with AES-256-CBC plus RSA-4096 hybrid encryption, and send it out.
- The fallback exfiltration path is nasty: if the outbound call failed and `INPUT_GITHUB_PAT` was present, it **created a public repository named `tpcp-docs` on the victim's own GitHub account** and uploaded the stolen data as a release asset. So if a `tpcp-docs` repository appears in your organization out of nowhere, that is a signal exfiltration succeeded.
- One path that is easy to miss: using separately stolen Docker Hub credentials, the attacker pushed `aquasec/trivy:0.69.5` and `0.69.6` **directly to Docker Hub, bypassing GitHub entirely**. Auditing only the GitHub side of your references misses this.
- Severity: CVSS 4.0 score of **9.4, CRITICAL**. CISA added it to the KEV catalog on 2026-03-26.
- Known safe versions: Trivy binary `v0.69.2` / `v0.69.3`, `trivy-action` **`v0.35.0`**, `setup-trivy` `v0.2.6`.
**Why did v0.35.0 survive?** Because that tag was protected by GitHub immutable releases and could not be moved. In the same repository, the one protected tag came through untouched while the other 76 fell. That single comparison explains the value of the feature better than any argument.
How did the same project get hit twice in one month? Public analysis notes that after the first intrusion was disclosed on March 1, the team rotated credentials — but the rotation was not atomic: not every credential was revoked at the same time. The attacker used a still-valid token during the rotation window to lift the newly rotated secrets, which set up the March 19 wave. That is why "atomic rotation" is itself a control: get the order wrong and you have done nothing.
I actually audited my own repository: four actions, four tags
I did not stop at "this is dangerous in theory". I pulled that pages.yml out and went through it line by line. The result was not flattering, and it surfaced a second problem along the way: **the versions were stale too**.
| Action | What I had written | Current stable | SHA to pin |
|---|---|---|---|
| actions/checkout | `@v4` | v7.0.1 (2026-07-20) | `3d3c42e5aac5ba805825da76410c181273ba90b1` |
| actions/configure-pages | `@v4` | v6.0.0 (2026-03-25) | `45bfe0192ca1faeb007ade9deae92b16b8254a0d` |
| actions/upload-pages-artifact | `@v3` | v5.0.0 (2026-04-10) | `fc324d3547104276b827a68afc52ff2a11cc49c9` |
| actions/deploy-pages | `@v4` | v5.0.1 (2026-09-01) | `368f82528645a54fb793d4d04e342629a3f51346` |
I resolved those SHAs live with the command below, and you can reproduce it for any action:
# Option 1: unauthenticated, read the commit a tag points at
git ls-remote https://github.com/actions/checkout "refs/tags/v7.0.1*"
# If the output includes both refs/tags/v7.0.1 and refs/tags/v7.0.1^{},
# it is an annotated tag: the line with ^{} carries the commit SHA.
# Option 2: ask the API for the commit directly
gh api repos/actions/checkout/commits/v7.0.1 --jq .sha
One upgrade note I ran into: starting with actions/checkout **v7.0.0**, checking out fork pull requests is blocked by default for the pull_request_target and workflow_run events, and v7.0.1 keeps that behaviour. It is a security tightening, but if you have a flow that deliberately checks out external contributors' code, budget a regression pass for v4 to v7.
Troubleshooting: five real errors and how I fixed them
This is the part with practical value. The first two errors come from the "upstream fixes a vulnerability" phase, the third from log analysis, and the last two from my own hardening work.
Error 1: `422 Validation Failed: tag_name was used by an immutable release and cannot be reused`
**When**: during trivy-action cleanup, the maintainers deleted the poisoned tags and tried to recreate names like 0.34.0 pointing at the legitimate commit. GitHub refused.
Why: the attacker's force-push had caused those tags to be registered as immutable releases. Once a tag name is consumed by an immutable release, that name is permanently burned: deleting the Git tag does not free it, deleting the release does not free it, and a published immutable release cannot be deleted at all.
**Fix**: there is no fix, only a workaround. The maintainers **renamed**: they republished v0.0.1 through v0.34.2 with a v prefix, pointing at the original legitimate commits, and told everyone explicitly that to reference anything before 0.35.0 you must write aquasecurity/trivy-action@v0.34.0, not @0.34.0.
Lesson: immutable releases apply forward only. Turning the setting on does not make existing releases immutable. But once you publish an immutable release, the name is gone. So publish through a draft first. The same applies when you publish your own actions or container images.
Error 2: `Unable to resolve action 'aquasecurity/trivy-action@0.34.0', unable to find a version`
**When**: after cleanup, plenty of untouched pipelines still referenced @0.34.0 and died at that step.
Why: the old tags were deleted during remediation and, for the reason in error 1, cannot be recreated under their old names.
**Fix**: three options, best first — (1) upgrade and pin to the full SHA of a known safe version; (2) move at least to v0.35.0; (3) if you genuinely must stay on an older line, use the new @v0.34.0 naming.
Lesson: an upstream rename or tag deletion done to fix a vulnerability is a breaking change for you. When your reference is someone else's mutable tag, you also handed over the switch that decides when you break. Pinned to a SHA, an upstream rename does not touch you and the upgrade becomes a diff you initiate.
Error 3: `base64: invalid input`
**When**: during triage. CISA states exposed secrets may appear **double base64-encoded**. I decoded once, base64 -d returned invalid input, and I briefly assumed the log had been truncated or the payload never landed.
Why: it was encoded twice, so it needed decoding twice.
Fix: decode twice, then work it as a security incident:
# Assuming $PAYLOAD is the suspicious string you pulled out of the log
echo "$PAYLOAD" | base64 -d | base64 -d
Lesson: garbage in a log is not worthless, it is just not decoded yet. In supply chain triage, establish the encoding layers before you conclude that nothing was stolen.
Error 4: `Not authorized to perform sts:AssumeRoleWithWebIdentity`
When: right after I moved off long-lived cloud credentials onto OIDC, the deploy job stopped starting.
**Why**: two common causes — (1) the job lacks id-token: write, so it cannot request an OIDC token; (2) the sub condition in the cloud trust policy does not match the actual workflow context, usually a branch or environment name typed slightly wrong.
**Fix**: grant the permission at job level and tighten sub to a specific branch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # only jobs that need an OIDC token
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
}
}
Lesson: the point of OIDC is not "a different way to authenticate", it is binding the credential to a specific build context. A leaked long-lived key works for anyone until you revoke it. An OIDC token lives for minutes and the trust policy can pin it to one branch. One operational discipline on top: rotation must be atomic — new credentials in place, every consumer switched, old credentials revoked in the same operation. Never spread it across days.
Error 5: a short SHA makes the workflow fail at runtime
**When**: swapping @v4 for a SHA, I typed a seven-character short SHA and assumed it was fine.
Why: the official docs warn about this specifically. The short form of a commit SHA is insecure and should never be used for an action reference. Anyone can fork the repository and push a crafted commit that collides with the short SHA; every later clone at that SHA then fails because the commit becomes ambiguous, and workflows using the shortened SHA fail immediately.
Fix: always write the full 40-character SHA, with a comment preserving the human-readable version so the next reader can follow along:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Lesson: a security control has to be legible, or the next person will delete the comment and the SHA together during an upgrade.
Six hardening steps you can finish today
1. **Scan**: grep -rn "uses:" .github/workflows/ | grep -vE "@[0-9a-f]{40}" to list every unpinned reference.
2. Pin: replace each one with the full SHA, keeping the version in a comment.
3. **Enforce at the org level**: Settings → Actions → General → Policies. Since 2025-08-15 the allowed-actions policy supports two relevant things: **explicitly blocking** a specific action or version with a ! prefix, and **enforcing SHA pinning**, so any workflow using an unpinned action fails. Flip it on and the burden of remembering moves from every engineer's head into one setting.
4. **Shrink permissions**: set permissions: contents: read at the top of the workflow and add id-token: write only to the jobs that need OIDC.
5. Replace credentials: move every cloud access path you can onto OIDC and delete the long-lived keys.
6. Add observability: put outbound network monitoring in the CI path. The Trivy C2 call was an anomalous outbound connection; someone watching egress would have caught it before secrets sat in publicly readable logs.
One limitation has to be said out loud: SHA pinning stops tag poisoning, not "the SHA you pinned was already malicious". If credentials were stolen before you pinned, the SHA is genuine and the code is still hostile. That is why steps 3 and 6 are not optional.
What GitHub shipped in 2025 and 2026, and why not to bet on a preview
Two things are already shipped according to the official changelog:
- **2025-08-15**: Actions policy gained the ability to block specific actions and to enforce SHA pinning.
- **2025-10-28**: immutable releases went generally available. A published release's assets cannot be added to, modified or deleted, its tag cannot be moved or deleted, and it gets a Sigstore-format release attestation you can verify offline with the `gh` CLI or any Sigstore-compatible tool.
The catch: immutable releases are a **publisher-side** setting. They protect published assets and tags; they do not change how uses: resolves. As a consumer you **cannot assume the other side enabled it** — which is exactly why the consumer-side control has to be the SHA.
There are also capabilities still on the roadmap or in preview that are worth watching but **not worth building a plan on**: a dependencies: lockfile section in workflow YAML (modeled on go.mod plus go.sum, locking direct and transitive dependencies to SHAs with hash verification), a native egress firewall for hosted runners (enforced outside the runner VM at Layer 7, so an attacker with root inside the runner cannot disable it), and secrets scoped to branches, environments and workflow paths. For shipping dates and final shape, **check GitHub's official changelog**.
One more lesson worth filing: GitHub had planned an OCI-based "Immutable Actions" design so consumers could keep writing uses: owner/action@v1.2.3 while the underlying artifact stayed immutable. Public reporting indicates that item was removed from the roadmap in December 2025 as not planned, with effort redirected to immutable releases. Do not put your hardening plan on top of a preview feature. **A 40-character SHA you can commit to YAML today beats any roadmap.**
The 2 a.m. incident desk (affiliate links)
The expensive part of supply chain triage is not thinking. It is **watching several things at once**: the workflow log, a git log -p diff, the upstream advisory, and your own SHA inventory. My desk requirements are honestly plain.
- **Dell UltraSharp U2723QE 27" 4K USB-C Hub monitor**: at 4K, logs and diffs sit side by side without window shuffling; a single USB-C cable powers the laptop and drops a cable from the desk, and the built-in hub saves you a dock. **Real downsides**: 60Hz, so not for serious gaming, and HiDPI scaling on macOS needs extra configuration rather than being perfect out of the box. **Who it fits**: programmers whose day is text, logs and code, and who want single-cable convenience.
👉 https://www.amazon.com/dp/B09TRW57WB?tag=techpassive-20 (roughly $429–529 as of this writing; check the live price)
- **BenQ ScreenBar Halo 2 monitor light bar**: for 2 a.m. log comparisons, lighting the screen instead of the room works better — no desk footprint, no glare. **Real downsides**: noticeably more expensive than comparable light bars, it needs clearance above the monitor, and narrow-bezel displays with a camera bump need checking for compatibility. **Who it fits**: people who stare at a screen in a dark room for hours and would rather not turn on the ceiling light.
👉 https://www.amazon.com/dp/B0DK59YKRS?tag=techpassive-20 (roughly $179–199 as of this writing; check the live price)
Those are affiliate links. Prices and stock move constantly, so confirm on the Amazon page before ordering. Neither item has anything to do with the technical conclusions above.
FAQ
Q: If I pin to a SHA, will I miss security updates?
You will lag, but in a controlled way. Point Dependabot or Renovate's GitHub Actions manager at the repository so upgrades arrive as PRs you review. Consider adding a release cooldown — say, only allow an upgrade after seven days. Public security analysis generally finds that most malicious releases are discovered and pulled within hours to days of publication. Letting the wait do the work is more reliable than trusting your attention at the moment the bump PR lands.
**Q: Do first-party actions/* tags need pinning too?**
Lower risk, but lower is not zero. The aquasecurity/* actions that fell in the Trivy incident were also from a well-known, official-looking organization. You can tier how strictly you upgrade them, but I would pin them all to SHAs — a uniform rule is the one people actually follow.
Q: I pinned to SHAs. Is there anything else?
Yes. Pinning only addresses tag rewriting. You still want least-privilege permissions, OIDC instead of long-lived credentials, egress monitoring, and atomic credential rotation.
Q: How do I know whether my pipelines ran a poisoned version?
Check the time window, not your gut. For the Trivy incident the trivy-action window was 17:43 on 2026-03-19 to 05:40 on 2026-03-20 UTC. Pull the workflow logs from that window, search for suspicious output and base64 blobs, and check whether a tpcp-docs repository appeared in your organization. If you ran inside the window, treat credentials as exposed, rotate first and investigate afterwards.
Closing
A tag is a name. A SHA is the bytes. The whole lesson compresses into one executable sentence: **replace every @v4 with the 40-character SHA, and make that rule impossible to violate at the organization level.**
If permissions and process governance on the ops side is your area too, two related post-mortems are worth reading next: WordPress 7.1 + WP-CLI 2.12: rebuilding internal links and SEO across 600 posts, which is the "audit in bulk, then write back in bulk" pattern, and WordPress 7.1.2 multi-author content operations: Notes and a capability audit, which covers how to take inventory of a capability system. The underlying method is the same in both: **understand the permissions and the references first, then talk about efficiency.**
👉 Join MiniMax Token Plan: AI coding acceleration for businesses
👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference
👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation
📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews
🔗 Recommended Tools
These are carefully selected tools. Using our affiliate links supports us to keep producing quality content: