Pin GitHub Actions to immutable commit SHAs — and notice when a tag you pinned gets repointed.
In March 2025 the popular tj-actions/changed-files action was compromised:
attackers repointed its version tags at a malicious commit that dumped CI
secrets, affecting an estimated 23,000+ repositories that referenced the
action by tag. A tag or branch in a uses: line is a mutable pointer under
someone else's control; a full commit SHA is not. actions-lock audits your
workflows for mutable references, rewrites them to SHAs without touching
anything else in the file, and re-verifies pinned SHAs against their
original tags so a repointed tag is caught instead of silently ignored.
go install github.com/aminyx/actions-lock@latest
Then, in a repository with workflows:
actions-lock check . # audit; exit 1 if anything is unpinned
actions-lock pin --dry-run # preview the rewrite as a diff
actions-lock pin # rewrite tags/branches to commit SHAs
actions-lock update # later: has any pinned tag been repointed?
Scans .github/workflows/*.{yml,yaml} under path (default .) and
reports every uses: reference. Exits 1 if any third-party reference is
not pinned to a full 40-hex commit SHA, which makes it a drop-in CI gate.
$ actions-lock check .
UNPINNED .github/workflows/ci.yml:8 actions/checkout@v4
UNPINNED .github/workflows/ci.yml:9 actions/setup-go@v5
UNPINNED .github/workflows/ci.yml:12 golangci/golangci-lint-action@v6
1 workflow file(s), 3 uses ref(s): 0 pinned, 3 unpinned
run `actions-lock pin .` to pin the unpinned refs
$ echo $?
1
Statuses:
| Status | Meaning |
|---|---|
OK |
already pinned to a full commit SHA |
UNPINNED |
mutable tag/branch (or short SHA) — fails the check |
TRUSTED |
unpinned, but the owner is in trust_owners — warns only |
IGNORED |
listed in ignore (config or --ignore flag) — skipped |
LOCAL |
./path action in the same repo — nothing to pin |
DOCKER |
docker://image:tag — reported with a warning, never rewritten |
Flags: --ignore owner/repo (repeatable), --json for machine output.
Resolves each unpinned tag/branch to its current commit SHA via the GitHub
REST API (annotated tags are dereferenced to the commit they point at;
branches and abbreviated SHAs are handled too) and rewrites the line to
owner/repo@<sha> # <original-ref>. Reusable workflows
(owner/repo/.github/workflows/x.yml@v1) are pinned the same way.
The edit is line-based, not a YAML round-trip: everything outside the
changed ref substring — indentation, quoting, comments, blank lines, CRLF
line endings, a missing final newline — survives byte-for-byte. Running
pin twice changes nothing. Already-pinned lines keep their existing
trailing comments untouched.
--dry-run prints a unified diff instead of writing:
$ actions-lock pin --dry-run .
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,9 +5,9 @@
test:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
- - uses: golangci/golangci-lint-action@v6 # lint
+ - uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6 (lint)
- run: go test ./...
3 ref(s) would be pinned (dry run, nothing written)
Without --dry-run it writes the files:
$ actions-lock pin .
pin .github/workflows/ci.yml:8 actions/checkout@v4 -> 11d5960a3267
pin .github/workflows/ci.yml:9 actions/setup-go@v5 -> 40f1582b2485
pin .github/workflows/ci.yml:12 golangci/golangci-lint-action@v6 -> 55c2c1448f86
pinned 3 ref(s) in 1 file(s)
$ actions-lock check .
OK .github/workflows/ci.yml:8 actions/checkout@11d5960a326750d5838078e36cf38b85af677262
OK .github/workflows/ci.yml:9 actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
OK .github/workflows/ci.yml:12 golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84
1 workflow file(s), 3 uses ref(s): 3 pinned, 0 unpinned
$ echo $?
0
For every reference already pinned as @<sha> # <ref>, update resolves
the commented ref again and compares. If the tag now points at a different
commit — the exact shape of the tj-actions attack — it is reported as
REPOINTED and the command exits 1. A repointed tag can also be a
legitimate patch release, so inspect the new commit before accepting it;
--write then updates the SHA in place (the comment stays).
$ actions-lock update .
current .github/workflows/ci.yml:8 actions/checkout@11d5960a3267 (v4)
current .github/workflows/ci.yml:9 actions/setup-go@40f1582b2485 (v5)
current .github/workflows/ci.yml:12 golangci/golangci-lint-action@55c2c1448f86 (v6)
And when a pinned SHA no longer matches what its tag points at:
$ actions-lock update .
REPOINTED .github/workflows/ci.yml:8 actions/checkout: v4 now points to 11d5960a3267 (pinned: 8f4b7f848644)
current .github/workflows/ci.yml:9 actions/setup-go@40f1582b2485 (v5)
current .github/workflows/ci.yml:12 golangci/golangci-lint-action@55c2c1448f86 (v6)
1 tag(s) have been repointed since pinning — inspect before trusting them
$ echo $?
1
Pinned refs without a ref comment are reported as unverifiable.
Flags: --ignore owner/repo (repeatable), --json, --write.
actions-lock reads .actions-lock.yml (or .actions-lock.yaml) from the
scanned path:
# Skip these actions entirely (e.g. actions in repos you control).
ignore:
- my-org/internal-action
# Unpinned refs from these owners warn instead of failing `check`.
# Empty by default — see below.
trust_owners:
- actions--ignore owner/repo flags merge with the ignore: list. Matching is
case-insensitive, as on GitHub.
The default policy pins everything, including actions/*. Whether an org
is "too big to compromise" is a policy decision your repository should make
explicitly, not a default the tool makes for you: tj-actions was popular
and widely trusted right up until its tags pointed at an attacker's commit,
and first-party orgs have had incidents too (reviewdog, compromised in
the same campaign, was a trusted staple). Pinning actions/checkout costs
one comment on one line; add trust_owners only when you consciously
accept tag-following for an owner.
- Resolution uses the public GitHub REST API, unauthenticated by default. Unauthenticated clients get 60 requests/hour; each unique action@ref costs 1–2 requests (duplicates are cached within a run).
- Set
GITHUB_TOKENorGH_TOKENto authenticate (5,000 requests/hour). On a rate-limit response,actions-locksays exactly that instead of producing a confusing "not found". ACTIONS_LOCK_API_URLoverrides the API base URL (GitHub Enterprise, test servers). The test suite runs entirely against local fake servers — no network.
- No lockfile yet: the pinned SHA and its origin ref live only in the
workflow files themselves (
@<sha> # <ref>). A separate lockfile with provenance metadata may come later. - API rate limits apply when resolving many actions unauthenticated; use a token for large repos.
- Composite actions are pinned as a unit: the inner
uses:steps inside a third-party composite action are not audited — pinningowner/composite@shafreezes which inner refs are named, not what those refs resolve to. docker://references are only warned about; pin container images by digest in the action or job definition itself.- Line-based extraction assumes one
uses:per line (the overwhelmingly common form); exotic YAML (flow mappings, anchors, block scalars that merely contain the textuses:) may be missed or misread.
MIT © 2026 Aminyx