Skip to content

Latest commit

 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

actions-lock

ci

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.

Quickstart

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?

Commands

actions-lock check [path]

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.

actions-lock pin [path]

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

actions-lock update [path]

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.

Configuration

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.

Why trust_owners is empty by default

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.

API access, tokens and rate limits

  • 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_TOKEN or GH_TOKEN to authenticate (5,000 requests/hour). On a rate-limit response, actions-lock says exactly that instead of producing a confusing "not found".
  • ACTIONS_LOCK_API_URL overrides the API base URL (GitHub Enterprise, test servers). The test suite runs entirely against local fake servers — no network.

Limitations

  • 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 — pinning owner/composite@sha freezes 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 text uses:) may be missed or misread.

License

MIT © 2026 Aminyx

About

CLI that hardens GitHub Actions workflows: audits uses: refs, pins tags to commit SHAs, and detects repointed tags — the tj-actions attack vector. Single binary, zero trust by default

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages