GitHub Actions Workflow Lockfiles: Why uses@SHA Pinning Is Not Enough
GitHub Actions Workflow Lockfiles: Why uses@SHA Pinning Is Not Enough
Pinning an Action to a full commit SHA fixes the code at that entry point. Yet a pinned Composite Action can invoke another Action via a moving @v1 tag. Its own source remains unchanged while the downstream code can change. The GitHub Actions YAML alone does not describe the full execution graph.
GitHub’s public preview announcement introduces .github/workflows/actions.lock for direct and transitive Action dependencies. Locked workflows use recorded commits and verify repository identity.
Already using uses@SHA? Check the dependency graph before migrating. The gain is a reviewable record of transitive pins and repository identity, not pinning the same direct Action twice.
| Your CI situation | Recommendation | Reason |
|---|---|---|
| Few direct Actions, all on complete SHAs | Inventory first; postpone a full migration | The added maintenance may not pay off |
Nested Composite Actions call @v1 or @main | Pilot one noncritical workflow | Lockfiles cover the transitive Action graph |
| Teams share cross-repository reusable workflows | Review both caller and callee responsibilities | The callee owns its internal Action locks |
| Organization-wide Allowed Actions policy is enabled | Audit policy before onboarding | The allowlist may match locked commits, not tag names |
These cases are not mutually exclusive. The discussion below is anchored to gh-actions-lock v0.2.0.
A pinned entry is not a pinned dependency graph
Consider a hypothetical workflow that pins example-org/build to a full SHA. That Composite Action invokes example-org/upload@v1. The outer commit is fixed, but the inner tag may still move.
| Dependency | Direct SHA pinning | Workflow Lockfile |
|---|---|---|
| Third-party Action used directly | Fixes its entry commit | Records commit and repository identity |
| Actions called by that Composite Action | Does not automatically pin nested tags | Includes transitive Action dependencies |
| External reusable workflow | Pins its call target | Caller records the call; the called repo locks its internal Actions |
Same-repository uses: $/… | Follows running repository commit | Inherently pinned; no separate record |
Downloads inside run: or mutable image tags | Not covered | Still outside the Action Lockfile |
GitHub’s concepts reference defines this boundary: a Lockfile covers workflows defined in its repository. The internal Actions of an externally defined reusable workflow are the responsibility of the other repository. Treating a pinned reusable-workflow call as a review of every Action inside it confuses two trust domains.
Reading actions.lock: commits, IDs, and transitive uses
The workflows mapping identifies the Pins a workflow depends on. dependencies lists ref, commit, numeric owner_id and repo_id, and nested uses where appropriate. This complete v0.0.3 YAML comes from the pinned official benchV003 parser fixture. Repeated a/b digests in it are test placeholders, not real deployment results:
version: v0.0.3
workflows:
.github/workflows/ci.yml:
- actions/checkout@v4
- actions/setup-go@v5
- actions/cache@v4
dependencies:
actions/checkout@v4:
hostname: github.com
ref: v4
commit: sha1-11bd71901bbe5b1630ceea73d27597364c9af683
owner_id: 44036562
repo_id: 197814629
actions/setup-go@v5:
hostname: github.com
ref: v5
commit: sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
owner_id: 44036562
repo_id: 249058325
actions/cache@v4:
hostname: github.com
ref: v4
commit: sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
owner_id: 44036562
repo_id: 251882839
uses:
- actions/checkout@v4
The fixture includes hostname: github.com; a generated lock for the default github.com host may omit that optional field. The nested uses field shows how another Action is associated with a dependency, whereas workflows lists what the workflow requires.
The pinned Go marshal source writes these fields:
a := file.Dependencies[k]
entry := &yaml.Node{Kind: yaml.MappingNode}
// hostname handling omitted
if a.Ref != "" {
addQuotedField(entry, "ref", a.Ref)
}
if a.Commit != "" {
addQuotedField(entry, "commit", a.Commit)
}
addIntField(entry, "owner_id", a.OwnerID)
addIntField(entry, "repo_id", a.RepoID)
The commit identifies the Git object; numeric IDs additionally identify the repository behind a mutable name. GitHub’s concept page still shows a v0.0.2 format example, while the v0.2.0 release and format README use schema v0.0.3. Tool version and file schema version are different; generate the file rather than copying a dated specimen.
Migration changes worth reviewing before enforcement
Choose one reversible workflow on a test branch. Assuming an installed/authenticated GitHub CLI and repository read access, use the extension version inspected here:
# Install the inspected extension version
gh extension install github/gh-actions-lock --pin v0.2.0
gh actions-lock .github/workflows/ci.yml
# Inspect workflow edits, local actions, and new files
git status --short
git diff
cat .github/workflows/actions.lock
# Verify only the pilot workflow
gh actions-lock --verify .github/workflows/ci.yml
The scoped verification form is supported by root.go, verify.go and upstream tests. The shared actions.lock must still parse, but checking one path does not onboard unrelated workflows.
The tool may narrow a version tag or rewrite a valid local Action path from ./ to $/. For example:
steps:
- - uses: ./actions/build
+ - uses: $/actions/build
The $/ syntax binds to the current repository commit. Incorrect ./ paths will not be fixed automatically. Also check git status --short: new, untracked actions.lock files do not appear in ordinary git diff output.
Allowed Actions rules need another review. For workflows using Lockfiles, GitHub checks allowed dependencies using the pinned commit SHA, so a previous tag-based policy such as actions/checkout@v6 may cease to match. Update the reviewed allowlist deliberately rather than disabling it.
Local and online checks have different guarantees:
gh actions-lock --verify-local .github/workflows/ci.yml
gh actions-lock --verify .github/workflows/ci.yml
--verify-local checks coverage offline; it cannot validate repository identity. --verify performs online checks suitable for PR gating. After every relevant workflow has been onboarded, an unscoped gh actions-lock --verify can protect the entire repository. Running it during a one-file trial may flag unregistered workflows or unused pins.
To investigate, add --json=valid,findings. Exit 1 indicates blocking findings and exit 2 indicates command/I/O/network errors. The source-defined categories distinguish not-pinned, stale, local-action, repo-replaced, and unreachable-pin.
A five-step low-risk pilot:
- Inventory direct references, nested Composite Actions, and external Reusable Workflows; agree who maintains the called workflow’s own locks.
- Generate on a throwaway branch and review changed YAML, local Action definitions and the new Lockfile.
- Check organizational policy against pinned commits and inspect all files being added to the PR.
- Verify in two layers: offline coverage first, online trust next. Resolve
not-pinned,stale, orlocal-actionfindings from the diff; investigaterepo-replacedorunreachable-pinwith upstream maintainers before merging. - Keep a rollback: abandon or revert the trial branch if parsing or policy blocks CI, and widen the rollout only after the pilot is stable.
What happens to pins as upstream versions change?
On first onboarding, the chosen commit depends on the original uses ref and the resolver. The v0.2.0 release cooldown is conditional, not an automatic seven-day wait: a positive github-actions cooldown.default-days in .github/dependabot.yml takes precedence over cooldown_days in the user’s config. Without either, version-age filtering is disabled; new releases under three days old produce a nonblocking fresh-tag notice. See cooldown resolution and the notice logic. Review the actual locked ref, commit, and workflow diff, rather than assuming the selected SHA always belongs to the newest tag.
Pins are sticky. Rerunning gh actions-lock does not silently replace an existing pin when an upstream branch or major tag moves. Use gh actions-lock --relock for an intended update and review every changed commit. If the prior commit becomes unreachable following history rewriting, --relock --accept-moved is a separate trust decision, not a convenient cure for a red CI job.
Dependabot can update already onboarded workflows; it does not enroll a new workflow by itself. Put actions.lock under CODEOWNERS review and trial small before enabling organization-level Require lockfile. Check account and policy availability: the pinned README says this preview does not support GitHub Enterprise Server, which is different from Enterprise Cloud.
The Lockfile establishes which Action code should execute, not whether that code is safe. Scripts fetched by run:, mutable images, package dependencies and CI token permissions remain separate controls. A useful review asks which nested Actions were newly pinned, who approved their identities, and who owns the locks inside shared reusable workflows. The related GitHub AI Scan post explores why detecting risky code is distinct from enforcing a merge gate.
Evidence and timing: GitHub announced the public preview on October 14, 2026. Source inspection uses fixed gh-actions-lock v0.2.0 commit d5dca22a06ff8590d50b41612038996800cfb336 and format repository commit ac158834765237ee94c36606cfc7d87316aa49ab, checked on October 18. The YAML is an upstream parser fixture containing synthetic digests. No real Runner, CLI migration, or tag-redirection experiment was executed; preview access depends on plan and settings.