Skip to content

We use analytics cookies to see how the site is used. They are only set if you accept. Accepting also turns on session recording (pages viewed, clicks and scrolls) so we can fix problems; what you type stays hidden. It also lets Meta (Facebook) see your visit, so we can measure our ads. Read the privacy policy

A sealed evidence bag with a red tamper seal holding a git tag on a string, labelled moved tag.

Evidence - do not tamperVOID

CaseDeveloper Tools

Exhibit description

actions.lock vs SHA Pins: Replaying the tj-actions Tag Hijack Against Every Workflow Defense

GitHub Actions lockfiles went GA on 28 October 2026. We replay the 2025 tj-actions and reviewdog tag hijack move by move to see what a tag, a SHA pin, the pinning policy, immutable releases and actions.lock would each have done.

Read time8 min read

Exhibit A

What happened, and the short answer

At 16:57 UTC on 14 March 2025, one push moved every version tag of tj-actions/changed-files to the same malicious commit, and every workflow that named the action by a tag ran it on its next trigger. GitHub's advisory counts over 23,000 repositories using the action and puts the exposure window at 14 to 15 March 2025. The payload read secrets out of the runner's memory and printed them, encoded, into the build log, which anyone can read on a public repository.

The short answer, worked out exhibit by exhibit below: a tag reference ran the payload; a full commit SHA pin on the action itself would not have; and the new workflow dependency lockfile (.github/workflows/actions.lock), generally available since 28 October 2026 on the Free, Team and Enterprise Cloud plans, also covers the actions your actions call, which is where this attack actually started. None of them would have made a malicious commit safe once you had locked it on purpose.

Sources: GHSA-mrrh-fwg8-r2c3 (CVE-2025-30066); GitHub changelog, 28 October 2026.

Exhibit B

Chain of custody: who held the tag, and when

The tj-actions tags were not the first thing the attacker moved. Unit 42's reconstruction, updated on 2 April 2025, traces it back four months and two organisations.

Time (UTC)Who held itWhat changed
2024-11-28 09:45spotbugs maintainerAdds their own personal access token (PAT) to a spotbugs/sonar-findbugs workflow. On 6 December 2024 a pull_request_target workflow leaks it to the attacker.
2025-03-11 10:52attacker, with the spotbugs maintainer's PATPushes a workflow to spotbugs/spotbugs that leaks the repository's secrets, a reviewdog maintainer's PAT among them, and deletes the branch a second later.
2025-03-11 18:42attacker, with a reviewdog maintainer's PATPushes the v1 tag of reviewdog/action-setup to b833eecd, a commit that came from a fork.
2025-03-11 20:31sameForce-pushes v1 back to a clean commit. The bad tag lived about two hours.
in that windowtj-actions CItj-actions/changed-files' own CI runs tj-actions/eslint-changed-files, which uses reviewdog/action-setup@v1, and leaks the PAT of the tj-bot-actions account.
2025-03-14 16:57attacker, with the tj-bot PATRepoints the tags of tj-actions/changed-files to 0e58ed8. NVD lists v1 through v45.0.7 as affected.
2025-03-14StepSecurityDetects an unexpected outbound call to gist.githubusercontent.com and reports it.

Two details matter for the replay. The first hijack used a tag inside someone else's action, two hops away from the workflows that leaked. And the commit it pointed at did not live in the action's own history; it came from a fork.

Sources: Unit 42 threat assessment (updated 2 April 2025); Wiz, reviewdog/action-setup (CVE-2025-30154).

Exhibit C

The replay: three moves against five defenses

Each move below is one thing the attacker did. Under it, every defense gets a verdict for a consumer workflow on the day of the attack.

  1. Move 1: a moved tag two hops down

    reviewdog/action-setup@v1 is repointed to a fork commit, and runs inside an action that another action uses.

    • Tag, @v1Runs itGitHub resolves the tag at run time, so the new commit runs.
    • SHA pin on your own uses: linesDependsPins the action you name. If that action calls another by a tag, the inner tag still floats.
    • Require SHA pinning policyStops itFails any workflow that uses an action not pinned to a full SHA, and the check reaches inside pinned actions: Semgrep's org-wide rollout lists "transitively unpinned actions" among what it breaks. The job fails instead of running the moved tag, and keeps failing until that action's maintainer pins too.
    • Immutable releasesDependsTags of new immutable releases cannot be moved, if the maintainer switched it on. It is their switch, not yours.
    • actions.lockStops itPins transitive dependencies too, and rejects a commit that exists only in a fork or is not reachable from the recorded tag.
  2. Move 2: every version tag repointed at once

    tj-actions/changed-files tags, full versions such as v44.5.1 included, now point at 0e58ed8.

    • Tag, @v45 or @v44.5.1Runs itA full version tag is just as movable as a major one.
    • SHA pin on your own uses: linesStops itA pinned commit cannot change without a SHA-1 collision, so yesterday's code runs.
    • Require SHA pinning policyStops itSame as a pin, enforced across the organisation so nobody slips a tag in.
    • Immutable releasesDependsOnly if these releases had been published as immutable. The feature went GA in October 2025, after the attack.
    • actions.lockStops itThe locked commit runs. On the next gh actions-lock run, a moved full version tag is reported as an error until you relock on purpose.
  3. Move 3: the payload runs

    Code you already run dumps the runner's memory and prints the secrets to the log.

    • Every pin and lockRuns itLocking freezes code, it does not judge it. GitHub's own changelog: lockfiles "don't determine whether a dependency is safe".
    • Least privilegeDependsWhat the secure use guide asks for: a read-only default GITHUB_TOKEN, raised per job. It limits what a leaked token can do, which is the move that let this attack hop from one organisation to the next.

Sources: GitHub Docs, secure use reference and workflow dependency lockfiles; GitHub changelog, 15 August 2025 and 28 October 2025; Semgrep, 12 August 2026.

Exhibit D

Bag it: the commands for a repository

Dependabot first, because GitHub recommends enabling it before you lock, and because it then bumps the workflow and its lockfile entry in the same pull request.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Then the lockfile, from the repository root:

gh extension install github/gh-actions-lock
gh actions-lock                              # all workflows
gh actions-lock .github/workflows/ci.yml     # or one at a time

Read the whole diff before you commit. The extension also rewrites references to the exact version it resolved, so @v3 can become @v3.1.0 with the same commit. If your organisation allows only listed actions, entries pinned to a tag such as actions/checkout@v6 stop matching once a workflow is onboarded; use a wildcard or the full SHA.

No lockfile yet? A plain SHA pin still beats a tag. Write the version in a comment so a reviewer can read it:

- uses: actions/checkout@<full 40-character commit SHA> # v6.x.y

Sources: GitHub Docs, locking action dependencies and keeping actions up to date with Dependabot.

Exhibit E

Reading a lockfile change in review

The lockfile records, for each dependency, the ref it came from, the commit that will run, and the numeric owner_id and repo_id of the source repository. That gives a reviewer three things to check:

  • A changed commit is a version bump. Read it like one.
  • A changed owner_id or repo_id means the repository behind the name is not the one you locked. Stop and investigate.
  • A ref-moved warning on a branch or major tag means upstream moved. Look at their history before you run gh actions-lock --relock, and keep --accept-moved out of automation.

Two gaps stay open. A lockfile does not pin reusable workflows you call with jobs.<id>.uses: pin those by full SHA, or rely on the lockfile in the repository that defines them. And a uses: ./path local action runs whatever is checked out, so the extension cannot lock it. If that local action uses other actions, gh actions-lock stops with a local-action error and writes nothing until you pass --migrate-local-actions, which rewrites ./ to a $/ reference that can be locked. GitHub recommends that over --ignore-local-actions, which keeps the ./ path and leaves the actions inside it without lockfile entries.

Source: GitHub Docs, workflow dependency lockfiles.

Exhibit F

Questions from the evidence desk

Do I still need SHA pins if I adopt actions.lock?

Not in onboarded workflows. GitHub says SHA pinning there is no longer necessary, and the lockfile satisfies the "require full-length commit SHA" policy for them.

Which GitHub plans have lockfiles?

General availability covers Free, Team, Enterprise Cloud and Enterprise Cloud with data residency, per the 28 October 2026 changelog.

Can admins force every repository to use one?

Yes. Enterprise, organisation and repository admins can turn on a "Require lockfile" policy.

Would a lockfile have helped a repository already running the bad commit?

No. If you locked 0e58ed8 during the window, it would keep running. Rotate any secret a workflow could read in that window, as the advisory asks.

Sources: GitHub Docs and changelog, read on 3 November 2026.

Sources