Most of us write uses: some-org/some-action@v4 and move on. It reads like a version pin. It isn't. A tag is just a pointer, and whoever controls the upstream repository can move it to a different commit tomorrow without your workflow file changing at all.
On October 14, GitHub put workflow dependency lockfiles into public preview for GitHub Enterprise Cloud (including data residency), Team, and Free plans. It's the package-lock.json idea applied to CI: a file that records the exact commit every action resolves to, which the runner then enforces.
I spent some time with the docs and the gh actions-lock extension. Here's what it actually does, and the rollout I'd use so it doesn't turn into a week of red builds.
Why a tag was never a pin
If you need a reminder of why this matters, look at March 2025. Attackers retroactively repointed version tags of tj-actions/changed-files to a malicious commit that dumped CI secrets into workflow logs. The GitHub advisory says it affected over 23,000 repositories, and CISA tracked it as CVE-2025-30066. Nobody affected had to change a single line of YAML to get hit.
The standard advice since then has been to pin every third-party action to a full 40-character SHA. That works, but it has two real problems:
- Workflow files become unreadable walls of hex with version comments that drift out of date.
- It only pins what you reference. If a composite action you use calls another action by tag, your SHA pin does nothing about that transitive dependency.
The lockfile addresses both.
What the lockfile actually is
The file lives at .github/workflows/actions.lock. It's generated by a GitHub CLI extension and, per the docs, should never be edited by hand. It has two sections: workflows, listing which dependencies each onboarded workflow uses, and dependencies, recording what each one resolved to. Here's a trimmed excerpt from the lockfile in GitHub's own gh-actions-lock repo:
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
version: 'v0.0.2'
workflows:
'.github/workflows/test.yml':
- 'actions/checkout@v5.0.1'
- 'actions/setup-go@v6.4.0'
dependencies:
'actions/checkout@v5.0.1':
ref: 'v5.0.1'
commit: 'sha1-93cb6efe18208431cddfb8368fd83d5badbf9bfd'
owner_id: 44036562
repo_id: 197814629
Three details are worth understanding:
-
commitis the code that will run. Once a workflow is onboarded, GitHub Actions runs the recorded commit even if the tag moves later. -
owner_idandrepo_idare numeric identities. If someone deletes a repo and re-creates one under the same name, or the repo gets transferred, the IDs won't match and the run is blocked. -
Transitive dependencies get their own entries, with a
useslist on the parent. That's the part SHA pinning in your YAML could never cover.
There's also a forgery check. The README says a locked commit must exist on a branch of the source repository. That matters because in the tj-actions incident, commenters on the incident issue pointed out that the malicious commit belonged to a fork, not the original repo. A lockfile that rejects commits not reachable from a real branch makes that trick much harder.
Generate it for one workflow first
Install the extension (you need the GitHub CLI first). I'd pin the extension version too. It's pre-1.0, and the README warns that flags and behavior can change between releases. v0.2.0 is the current release as I write this:
gh extension install github/gh-actions-lock --pin v0.2.0
Running gh actions-lock with no arguments onboards every workflow under .github/workflows/. I wouldn't start there. Onboard your main CI workflow alone and see what changes:
gh actions-lock .github/workflows/ci.yml
git diff
Read the whole diff, not just the new file. The extension also rewrites your workflow files in two ways:
-
Partial tags become full versions.
octo-org/deploy-action@v3can becomeocto-org/deploy-action@v3.1.0. It resolves to the same commit, but it's a bigger diff than you might expect. -
Local actions change syntax.
uses: ./.github/actions/setupbecomesuses: $/.github/actions/setup, in your workflows and in your in-repoaction.ymlfiles. A$/reference always resolves to the commit that's running, so it's pinned by definition and needs no lockfile entry. If you'd rather keep./for now, there's a--no-migrate-local-actionsflag.
One gotcha from the docs: local paths resolve from the repository root, not from the folder the file sits in. If the extension says it skipped a workflow because of a local path action, that's usually why.
Writes are also all or nothing. If the extension hits an error it can't fix, it changes no workflow and no lockfile entry, so you won't end up with a half-onboarded repo.
Check your allowed-actions list before you merge
This is the change most likely to break things quietly. If your repo, organization, or enterprise only allows specific actions, that allowlist is checked against the locked commit SHA once a workflow is onboarded. An allowlist entry pinned to a tag, such as actions/checkout@v6, stops matching. You'll need a wildcard entry or the full commit SHA instead.
If you merge first and find out later, the first sign is your onboarded workflows failing to start. Look at the allowlist before you merge.
Add a verify check on pull requests
A lockfile only helps if it stays in sync with your workflows. The CLI has a read-only mode made for CI: gh actions-lock --verify checks that the lockfile is current without writing anything, and it also checks that commits locked under a branch or partial version like v4 are still in that ref's history. As of v0.2.0, it also fails on lockfile entries no workflow uses and on workflows with no lockfile entry, so in a partly onboarded repo, expect it to flag the workflows you haven't locked yet. That's one more reason to finish onboarding before you make the check required.
Here's a minimal verify workflow. Treat it as a starting point and adjust it for your repo:
name: actions-lock-verify
on:
pull_request:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Verify workflow lockfile
env:
GH_TOKEN: ${{ github.token }}
run: |
gh extension install github/gh-actions-lock --pin v0.2.0
gh actions-lock --verify
I left out a paths: filter on purpose. If you make this a required check, a path-filtered workflow that gets skipped leaves the required check pending, and unrelated PRs get stuck. If you don't make it required, filtering on .github/workflows/** and **/action.yml is fine. Note that this workflow uses actions/checkout, so it needs a lockfile entry too. Run gh actions-lock again after adding it.
The enforcement guide also describes a second job that regenerates and commits the lockfile on push. That needs contents: write, and your rulesets have to let Actions push to that branch. I'd start with verify only and let people run the command themselves.
Catch it before the commit
For faster feedback, the docs suggest gh actions-lock --verify-local --no-fix in a pre-commit hook. It runs offline, needs no authentication, and only checks that every reference has a lockfile entry. It doesn't re-verify pins against GitHub, so it complements the CI check rather than replacing it.
#!/bin/sh
# .git/hooks/pre-commit
if git diff --cached --name-only | grep -qE '^\.github/workflows/|(^|/)action\.ya?ml$'; then
gh actions-lock --verify-local --no-fix || {
echo "Workflow lockfile is stale. Run: gh actions-lock"
exit 1
}
fi
Tell your coding agent about it
AI agents edit a lot of workflow files now, and an agent that adds a step and doesn't relock will fail your verify check every time. GitHub's guide recommends a Copilot skill for this. I'd put the same rule wherever your agent reads its instructions, whether that's AGENTS.md, CLAUDE.md, or a Cursor rule:
## GitHub Actions
- After editing anything in .github/workflows/ or any action.yml,
run `gh actions-lock` and commit .github/workflows/actions.lock
in the same change.
- Never edit .github/workflows/actions.lock by hand.
As GitHub's docs put it, this only covers edits the agent makes, so treat it as a convenience. The CI check is the actual control.
Make someone read the lockfile diff
A lockfile nobody reviews is just a slower way to run the same code. The docs suggest adding it to CODEOWNERS and requiring code-owner approval:
/.github/workflows/actions.lock @your-org/build-security
When reviewing, a changed commit means the code your CI runs has changed, so treat it like any dependency bump. A changed owner_id or repo_id should almost never happen. It means the repository behind that name isn't the one you locked, so stop and investigate.
Dependabot helps here too. When it opens a version update for an action in an onboarded workflow, it regenerates the matching lockfile entry in the same PR. It won't onboard new workflows for you, though. It just reports that onboarding is required.
When a tag moves
Pins are sticky. A normal run doesn't follow main or v4 when they move upstream. If the extension reports that a tag now points somewhere else, check the upstream history first. If the move was legitimate, relock:
gh actions-lock --relock
If the commit you originally locked is gone entirely, for example because upstream force-pushed it away, --relock still errors. Only gh actions-lock --relock --accept-moved accepts the new commit. That's the moment to slow down, because a vanished commit is exactly what a repointed-tag attack looks like.
Limits to know before you commit
- It's a preview. The lockfile schema and CLI flags may change before 1.0.
- GitHub Enterprise Server isn't supported.
- Reusable workflows from other repos are covered by their own repo's lockfile, not yours. If your org has a shared-workflows repo, onboard that one first.
- The "Require lockfile" policy can block runs of workflows that aren't onboarded. Its Evaluate mode, which lets you see what would be blocked first, is only on GitHub Enterprise. Don't turn it on as Active until the verify check has been running for a while.
- A lockfile doesn't make an action safe. It guarantees you run the code you locked, which is only as good as your review of it.
My rollout would be: lock your main CI workflow, check the allowlist, add the verify job, tell your agents, then onboard the rest. Have you tried it yet, or are you sticking with SHA pinning for now?
Top comments (1)
I'd add one line to your limits list: the lockfile pins the commit of an action, not the container image that commit points to. A Docker container action names its image in action.yml, for example
image: docker://ghcr.io/some-org/scanner:v2underruns. Locking the commit freezes that text, but the:v2tag is still resolved against the registry at run time, so anyone who can push to that registry can move it the same way the tj-actions tags were moved. I couldn't find anything in the lockfile docs or the gh-actions-lock README about recording an image digest.The check is cheap. For each locked action, read the
runsblock in its action.yml at the locked commit, and grep your own workflows fordocker://. Any image there that isn't written with@sha256:is still a moving pointer, and the lockfile diff your code owners review won't show it changing.