When you write uses: tj-actions/changed-files@v45 in a workflow, you’re not pinning to a version. You’re trusting that a stranger won’t move the tag. That’s not a pin. That’s a prayer.

On March 14-15, 2025, the prayer failed. CVE-2025-30066. An attacker retroactively redirected multiple version tags in tj-actions/changed-files to a malicious commit. The action runs in ~23,000 repositories. Every one of them that executed a workflow between March 12 00:00 UTC and March 15 12:00 UTC ran attacker-controlled code with full access to the runner environment.

The attack wasn’t clever. Git tags are mutable by design. A tag is a named pointer — v45 points to a commit hash, and whoever controls the repository can point it somewhere else, retroactively, silently, without any warning in your workflow file. GitHub does nothing to prevent this. The workflow YAML in your repo stays unchanged. The commit it executes has been swapped.

What the Malicious Commit Did

The payload was a Node.js function carrying base64-encoded instructions. At runtime it decoded and executed Python code that scanned the runner’s memory for credentials. Not subtle — but it didn’t need to be subtle. The runner had already loaded everything worth stealing.

What went into the public build logs: GitHub PATs. npm tokens. Private RSA keys. AWS access keys. Anything the workflow had loaded into environment variables, anything passed as a secret via ${{ secrets.* }}, anything a prior step had exported. CI runners are credential intersections — they hold deploy keys, registry tokens, cloud credentials, signing keys, all at once. That’s the entire value proposition of CI: one place that touches everything. It’s also the attack surface.

The Upstream Chain

The compromise didn’t start with tj-actions. It traced back through reviewdog/action-setup@v1 (CVE-2025-30154). The chain was: reviewdog compromised → tj-actions downstream → 23,000 repositories downstream of that. Supply chain attacks compound. Each layer of trust amplification means the blast radius multiplies. You audit your own code. You probably don’t audit every action you pull in. You definitely don’t audit every action that your actions pull in.

The Fix That’s Been Available Since Day One

SHA pinning.

# This is a lie:
uses: tj-actions/changed-files@v45

# This is a pin:
uses: tj-actions/changed-files@a538f65f2c3e28a5d4c9a2e5d9e3e4f1a2b3c4d5

A SHA is immutable. You cannot retroactively move a commit hash. If you pin to a full SHA, the attacker would need to find a preimage collision against SHA-1 to serve you different code — and GitHub’s object store uses SHA-1 with collision detection on top. The tag approach offers zero cryptographic guarantees. The SHA approach offers near-absolute ones.

This has been true since GitHub Actions launched. The documentation mentions it. Security tooling like step-security/harden-runner and pin-github-action automate it. None of that matters if nobody does it.

Nobody does it because it’s ugly. Tag-pinned workflows are readable — @v45 tells you something. SHA-pinned workflows are opaque — forty hex characters tell you nothing at a glance. Updating a SHA pin requires a deliberate lookup rather than bumping a version number. It’s friction. Small friction, but friction.

That friction just cost 23,000 repositories their CI secrets.

The Structural Problem

This isn’t a bug in tj-actions. The maintainer didn’t write malware. This is how tags work, and the GitHub Actions ecosystem was built on the assumption that action authors are trustworthy and accounts don’t get compromised. Both assumptions fail routinely.

Every third-party action in your workflow is an unconditional code execution grant. uses: is curl | bash, but with version theater instead of versioning. The tag gives you the illusion of control. The SHA gives you the reality.

The mental model most teams operate with — “I’m using a pinned version, so I’m reproducible” — was always wrong. Version tags communicate intent, not immutability. Semver is a convention upheld by social contract. The contract breaks when an account is compromised, when a maintainer goes malicious, when someone sells a popular package to a bad actor. It breaks quietly, with no indication in your workflow file.

What to Do

Pin every action to a full commit SHA. Not a tag, not a branch, a SHA. Add a comment with the human-readable version for when you need to update:

# tj-actions/changed-files@v46.0.1
uses: tj-actions/changed-files@4edd678ac3f81e2dc578756871e4d00c19191daf

Use tooling to automate the maintenance: step-security/harden-runner, Dependabot’s actions ecosystem updates, or pin-github-action as a pre-commit hook. These all exist. The problem isn’t tooling availability, it’s that the default is insecure and the secure option requires work.

The GitHub Actions marketplace has no code review, no security audit, and no cryptographic signing requirement. It’s a package registry where the install command is trust. The tj-actions incident won’t be the last one. It wasn’t even the first.

Pin to SHAs. The inconvenience is real. So was the credential dump.


PGP signature: tj-actions-mutable-tags-were-always-a-lie.md.asc — Key fingerprint: 5FD2 1B4F E7E4 A3CA 7971 CB09 DE66 3978 8E09 1026