In March 2025, the tj-actions/changed-files action was compromised. An attacker moved every tag, from v1 to v45.0.7, onto a malicious commit that copied the runner's secrets straight into the pipeline's public logs. More than 23,000 repositories used that action (CVE-2025-30066). The part that stuck with me: none of the victims had changed a single line of their workflow. The swapped code arrived on its own, because a Git tag is nothing more than a sticky note you can peel off and stick somewhere else.
I wanted to understand this family of attacks from the inside, not from a post-mortem. So I rebuilt it on my own repositories, from the legitimate commit all the way to secret exfiltration. Here is what it looks like, step by step, and why the obvious fix (SHA pinning) does not fully close the door.
The setup is two repositories: Kylian14/demo-actions, the reusable action, and Kylian14/demo-app, the project that consumes it in CI.
Note for readers: the demo output is in French.
Code LÉGITIME exécutémeans "legitimate code ran" andCode MALVEILLANT exécutémeans "malicious code ran". Everything else maps to the GitHub variable names you already know.
The trust chain nobody looks at
A GitHub workflow calls a third party action with one line:
- uses: Kylian14/demo-actions@v1
@v1 is a reference. By default, almost everyone pins to a version tag like that one, for readability and stability. The catch is that the tag is mutable. On the action side, the owner (or anyone who pushed with a stolen token) can repoint v1 to any commit they like. On the consuming side, nothing moves and nothing warns you. The tag is resolved at the moment the job runs, and it resolves to whatever it points at right then.
Put differently: you run third party code, with access to your CI/CD secrets, based on a promise that can change while you sleep.
Attack 1: tag hijacking
At the start, everything is normal. The v1 tag points to commit dae8c12, which holds the real action code. The victim's workflow runs and the job proudly prints its legitimate output.

Then the attacker moves the tag. Two commands are enough:
git tag -f v1 03d2b04 # 03d2b04 = malicious commit
git push --force origin v1
Commit 03d2b04 is a child of the legitimate commit with an added payload. The v1 tag no longer points to dae8c12, it points to that one. The victim's workflow is byte for byte identical: still uses: Kylian14/demo-actions@v1.

On the next run, the same @v1 executes the attacker's code. No PR, no notification, no diff to review on the victim's side. The secrets go into the logs.

The job prints GITHUB_TOKEN, RUNNER_OS, GITHUB_REPOSITORY and GITHUB_ACTOR. This is exactly what hit the 23,000 tj-actions repositories. The moment my own pipeline spat my variables back out without me touching anything, it got real.
The fix: pin to a SHA
GitHub's official recommendation, echoed by Dependabot, StepSecurity and zizmor, is to pin to a full commit SHA instead of a tag:
# Before: mutable reference
- uses: Kylian14/demo-actions@v1
# After: immutable reference
- uses: Kylian14/demo-actions@dae8c1244426330fedc8b2990e3b2acbac1980ba

A commit SHA is a hash of the content. You cannot move it, and you cannot rewrite it without changing the SHA itself. If the attacker force-points v1 at their commit, my workflow pinned to dae8c12... keeps running the right code, because it no longer asks "give me v1", it asks "give me this exact commit".

That shuts down tag hijacking. Cleanly. But it moves the problem rather than removing it, and this is where a lot of teams stop too early.
Attack 2: the impostor commit
The SHA guarantees you always run the same commit. It does not guarantee that commit is clean. If you pin to a malicious SHA, the SHA does its job perfectly: it serves you the malicious code, the exact same code, on every single run.
What remains is getting that SHA into the victim's workflow. This is where the attack turns human again.

The play: the attacker starts from the legitimate code (through a fork, for example), adds a child commit dab001a that looks like a harmless update but carries the payload, then opens a pull request against the victim project. Not a shady PR. A PR that looks like a maintenance chore.

Look at the title: fix(ci): update checkout pinning. The kind of PR you skim and approve on a Friday evening. The diff changes a single line:
- uses: Kylian14/demo-actions@dae8c1244426330fedc8b2990e3b2acbac1980ba # v1
+ uses: Kylian14/demo-actions@dab001addf2662895f42e5e6aea4b7d879c4d43a # v1.01
The nasty detail is in the comment. # v1.01 makes it read like a simple version bump. Except that comment is free text: it proves nothing. The SHA dab001a... matches no official v1.01 tag, it points to the malicious commit. A reviewer who trusts the SHA because it is "fixed and precise", without reading the content of the target commit, approves the exfiltration.

Same result as the tag hijacking: GITHUB_TOKEN, RUNNER_OS, GITHUB_REPOSITORY (Kylian14/demo-app) and GITHUB_ACTOR in the logs. Except this time the workflow is pinned to a SHA, following best practice to the letter. That is the impostor commit: a perfectly valid SHA pointing to code nobody read.
What protects you, and what does not

SHA pinning is not useless, it is partial. It neutralizes tag hijacking, which is already huge given how common that attack is. But it does not excuse you from checking what the commit contains. My takeaways after breaking everything myself:
- Audit the content, not just the reference. On every SHA update, read the diff of the target commit, not only the line that changes in your workflow. Be wary of scripts loaded dynamically at run time. Tools like zizmor and StepSecurity Harden-Runner automate part of that check.
- Protect
.github/workflows/with CODEOWNERS. A SHA change is a change to code that runs with your secrets. It deserves a required review by someone who knows the directory, plus branch protection rules onmain. - Fork and self-host critical actions. For sensitive dependencies, the strongest option is to fork the action into your own organization and point at your controlled fork. No external dependency you do not own.
None of these is magic on its own. Together, they turn a silent attack into one that has to clear several locked doors.

What I took away
CI supply chain security is transitive trust: you trust an action, which trusts its dependencies, which trust their maintainers. Tag hijacking exploits the mutable link. SHA pinning hardens that link, but the impostor commit shows that an attacker can still get you to point, of your own free will, at booby-trapped code.
The real defense is not a line of YAML. It is a habit: treat every CI dependency update as code to review, because that is what it is. Reproducing the attack myself taught me that better than any article could. This one included.
References
- CVE-2025-30066,
tj-actions/changed-filescompromise, GitHub Advisory - CISA alert on the
tj-actions/changed-filesandreviewdog/action-setupcompromise - Wiz analysis of the attack
- Tools: zizmor, StepSecurity Harden-Runner, OpenSSF Scorecard, Dependabot
- Demo repositories:
Kylian14/demo-actions,Kylian14/demo-app