← Back to Home

GitHub Actions Supply Chain Post-Mortem: Mutable Tags, Burned Version Numbers, and Five Real Errors

GitHub Actionssupply chain securityCI/CDDocker

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):

👉 https://www.amazon.com/dp/B09TRW57WB?tag=techpassive-20

👉 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:

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.

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.

**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**.

ActionWhat I had writtenCurrent stableSHA 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:


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:

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.

👉 https://www.amazon.com/dp/B09TRW57WB?tag=techpassive-20 (roughly $429–529 as of this writing; check the live price)

👉 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:

☁️ DigitalOcean Cloud ⚡ Vultr VPS ⭐ MiniMax Token Plan 🤖 QoderWork CN (Refer & Earn) ☁️ Aliyun AI Products 📚 WordPress Books 🔍 WordPress SEO Books 🌐 Web Hosting Books 🐳 Docker Books 🐧 Linux Books 🐍 Python Books 💰 Affiliate Marketing 💵 Passive Income Books 🖥️ Server Books ☁️ Cloud Computing Books 🚀 DevOps Books 🤖 Xiaomi MiMo Platform
GitHub Actions, 供应链安全
Langfuse, observability
Langfuse, 可观测性
WP-CLI, Internal Links
← Back to Home