Pinning the Unpinnable: Taking GitHub Actions Lockfiles Apart
TL;DR: GitHub Actions workflow lockfiles entered public preview on October 14, 2026, moving dependency pins into a lockfile and extending them to previously “unpinnable” actions: dependencies nested inside composite actions that you couldn’t pin from your own workflow. We adopted them in poutine’s CI/CD and tested both nested dependency pinning and fork pull request behavior. Locked dependencies stayed fixed when upstream branches moved, and Require lockfile blocked workflows without lockfile coverage. We also found and reported a validation defect that allowed a fork-only commit to pass as an upstream action. GitHub deployed a backend fix within hours, and our retest confirmed that the bypass was closed while a legitimate locked workflow continued to run. A CLI verification fix is still pending.
Forty random characters and a misleading comment
For years, the advice has been straightforward, but a maintenance nightmare: pin your GitHub Actions to full commit SHAs, they all said.
Then add a comment so a human can tell what the hell those forty characters mean.
Then remember to update the comment.
In our own poutine repository, the lockfile migration surfaced this little souvenir:
# Before
- uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v3.29.5
# After
- uses: github/codeql-action/init@v4.31.2
That looks like a major version upgrade. It wasn’t. The lockfile recorded the same commit we were already running. The comment had been lying to us. You can see it in the migration diff.
SHA pinning did its job: it identified specific code. The version tracking system we’d built out of YAML comments and wishful thinking was less successful.
And that was the easy problem. The harder one was the action you pinned calling another action you couldn’t pin from your own workflow.
On October 14, 2026, GitHub opened workflow lockfiles to public preview. Thanks to Steve, Gregory, Jeff, and the wider GitHub team for involving us in the security design discussions along the way. I’ve spent enough time breaking CI/CD trust assumptions to appreciate seeing the platform take more responsibility for dependency integrity.
This is still a public preview, and real repositories will expose cases that tests missed. Our poutine migration went smoothly. Then we started poking at the edges.
Research snapshot, October 15, 2026: We tested CLI commit
f43e069against hosted runner2.337.0in Boost’s public sandbox. The experiments used harmless markers, read-only tokens, and no secrets. Results below describe the build we tested, followed by our retest after GitHub deployed a backend fix.
Finally pinning the action inside the action
We migrated poutine’s CI/CD workflows in PR #462. The workflow uses version tags such as actions/checkout@v6.0.2; the corresponding commit pins live in .github/workflows/actions.lock. Here is a shortened excerpt from the committed file:
version: 'v0.0.3'
workflows:
'.github/workflows/golangci-lint.yml':
- 'actions/checkout@v6.0.2'
# Other dependencies omitted.
dependencies:
'actions/checkout@v6.0.2':
ref: 'v6.0.2'
commit: 'sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd'
owner_id: 44036562
repo_id: 197814629
The file associates workflows with dependencies, then records the selected commits and repository identities. Composite entries also record their child dependencies. It is a graph: the CLI’s resolver walks nested action metadata to discover those edges.
One repository can appear at several versions. In poutine, actions/upload-pages-artifact brought its own actions/upload-artifact dependency while another workflow used a newer release directly. Both fit in the same file. Reviewing the graph means checking those individual selections, not assuming every reference to an action shares one version.
Now consider a composite action pinned to a full SHA. Inside that fixed commit, it says:
runs:
using: composite
steps:
- uses: another-maintainer/helper@main
You pinned the instruction to fetch main. You did not pin what main will contain tomorrow. Pinning harder in your own workflow cannot edit somebody else’s action.yml.
The lockfile gives you somewhere to record that nested resolution. The parent can keep declaring helper@main, while your lock records a specific commit for it.
We tested this with a composite calling helper@moving. After locking both references, we advanced only the helper branch to a new commit that printed V2 instead of V1. The parent and consumer stayed unchanged. Running the CLI again preserved the pins, verification passed, and the next workflow run still printed V1.
The parent asked for a moving branch; the consumer decided which commit that request meant. That is the improvement I care about most.
It changes what an action update looks like in review. A parent can stay fixed while a child receives its own reviewed update. Conversely, approving a parent action does not mean accepting whatever its nested branch points to next Tuesday. The lockfile makes that selection explicit in your repository, without requiring you to fork the composite just to change its internal uses: lines.
A locked commit can still depend on its upstream reference existing. When we deleted the helper’s moving branch but kept its commit on main, online verification failed, and the workflow run failed during resolution. The server error did not explain why, but the practical limit was clear: keeping the pinned commit available was not enough to keep this workflow runnable.
The lockfile pins repository identity through repo_id. Changing that ID in our test lockfile caused both CLI verification and the workflow run to reject it. Changing only owner_id passed both checks. The CLI implementation allows a repository rename or transfer with a warning, provided the repository ID still matches. We changed metadata rather than performing a live transfer, but the distinction matters: the repository’s identity is more durable than its name or owner.
Our lint workflow run printed Using locked action versions from the workflow's lockfile, then downloaded actions/checkout at the commit recorded in the lockfile. The other actions also matched their locked commits.
Locked versions are selected during job setup, before checkout executes. In the runner’s source code, we can see it passing dependency information received with the job to GitHub’s resolution service. That code shows the handoff; the backend validation happens on GitHub’s side. You don’t install a “security action” halfway through the job and hope it gets there before the other actions do.
What happens when a fork messes with the lock?
A lockfile sitting in a repository does not guarantee every workflow uses it. GitHub Actions policies govern workflow execution. We tested eleven controlled fork pull requests, first without a Require lockfile policy, then with that policy active for all workflows.
We asked two questions: can a contributor make a workflow run without a lockfile, and which revision of the lockfile does a pull request run use?
| Fork change | Without Require lockfile policy | With Require lockfile policy |
|---|---|---|
| Delete the lockfile | Ran unlocked | Stopped at startup |
| Supply malformed YAML or remove workflow coverage | Ran unlocked | Stopped: malformed YAML, missing coverage |
| Add an action-using workflow without onboarding it | Ran unlocked | Stopped at startup |
| Change an action reference without a matching pin | Stopped at startup | Stopped at startup |
| Change the reference and supply a matching lock | Ran locked | Ran locked |
Require lockfile blocked workflows without valid lockfile coverage while allowing changes backed by matching pins. Generating a lockfile and enforcing its use are separate steps: the CLI onboards selected workflows, while the policy requires coverage across its configured scope.
To isolate which revision supplied the lockfile, we changed no workflow YAML. In PR #8, the fork changed only the lockfile’s @main pin to an older, valid upstream commit. Under the same active policy:
| Event | Configuration source | Downloaded commit |
|---|---|---|
pull_request | PR merge revision | 18161c1..., from the fork’s lock |
pull_request_target | Base branch | adfe176..., from the base lock |
Both workflow runs reported using locked versions. The event determined which revision supplied the lockfile. Across all eleven cases, our pull_request_target controls retained the base workflow and pins.
Our fork pull_request workflow runs had no repository secrets and only read-only tokens. They executed proposed workflow changes, including inline run: commands: pinning dependencies does not turn a workflow into a sandbox.
Contributor approval remains a separate gate. After the baseline run, our repository required approval only for first-time contributors new to GitHub, so the test account’s subsequent runs started without further approval. Lockfiles did not bypass an approval requirement.
There was one useful rollout surprise: a shell-only workflow failed under the Require lockfile policy until the CLI added an empty dependency list for it. Even workflows with no dependencies need explicit onboarding.
Review workflow and lockfile changes together. A base-controlled pull_request_target definition still becomes dangerous if it explicitly checks out and executes untrusted PR code.
A SHA is not a certificate of origin
GitHub’s shared fork network creates a trap: a commit that exists only on a fork can be addressable through the upstream repository’s name. A forty-character SHA identifies code; it does not establish that the upstream project ever accepted it.
Chainguard’s Billy Lynch documented this in the original 2023 “What the fork?” research, coining the term “imposter commits” and showing how a fork-only commit could execute under an upstream action’s name. Our lockfile experiment asked whether the new identity and ancestry checks closed that known trap.
Lockfiles include repository identity and ancestry checks to address that distinction. We forked actions/checkout, added a harmless log marker in commit 373d742, and kept it only on the fork. A workflow referencing actions/checkout@373d742... made the CLI refuse to create a lock:
commit 373d742f768e is not on any tag or branch of actions/checkout
Then we supplied the lock ourselves. In our fork pull request, we changed the workflow to reference the fork-only commit by its full SHA and manually authored a matching lock entry. The entry used the genuine actions organization and actions/checkout repository IDs. Its dependency key was actions/checkout@373d742..., while the separate ref field claimed v6.1.0. The commit remained on a live fork branch and was never proposed to upstream.
gh actions-lock --verify warned about the bare SHA but reported All 1 workflow valid. With the Require lockfile policy active for all workflows, the original workflow run succeeded too. It downloaded the fork-only commit through the actions/checkout name and printed our marker. Using @main instead of the full SHA in both the workflow reference and lockfile key, while keeping the same commit pinned, caused both CLI verification and workflow setup to reject the lock.
Generating a new lockfile rejected the commit, but verifying and executing with a manually authored entry accepted it. The workflow run had no secrets and a read-only token. This required visible edits to both the workflow and lockfile; it did not replace an unchanged, reviewed pin. Getting those changes into a trusted branch would still require persuading someone to accept them.
The CLI’s verification code checked upstream branch history only when the lockfile key used something other than a full commit SHA. That explained the CLI result. We reported the reproduction in issue #174.
GitHub confirmed the backend defect: validation treated the full SHA as the reference against which that same commit’s ancestry was checked. A commit is a valid ancestor of itself. Repository identity could be correct while that check proved nothing about upstream history.
GitHub deployed a backend fix later that day. We reran the exact same pull request commit under the same active policy. This time, the workflow run failed during action preparation: the fork-only commit did not match the claimed upstream v6.1.0 ref, and the action never executed. A fresh run using the legitimate locked action still succeeded. The backend bypass we reported was closed; GitHub said the corresponding CLI verification fix would follow.
That is what public-preview research should look like: test real trust boundaries, report what breaks, collaborate on the details, and verify the fix before GA.
Three boundaries worth keeping in your head
Reusable workflows carry their own locks. For a composite action, the consumer’s lock covers its nested action dependencies. For a reusable workflow, the caller locks the workflow reference, while its internal action dependencies belong to the lockfile in the defining repository. GitHub documents this boundary. Onboard your central workflow repository alongside the applications that call it. Otherwise, a tidy application lockfile can conceal an unlocked dependency graph one workflow call away.
The dollar sign changes where an action comes from. Onboarding can rewrite local references:
- uses: ./.github/actions/build # From the runner's workspace
- uses: $/.github/actions/build # From the defining repository's commit
We tested this by putting a conflicting helper in the caller’s workspace, then invoking a remote composite that called $/helper. The helper from the composite’s own repository executed, without checkout. Review these rewrites as behavior changes, especially if your workflow generates or modifies local actions before invoking them.
Runtime downloads still need their own integrity controls. Our locked poutine lint action subsequently downloaded the golangci-lint binary from a release. The action commit and the binary are separate inputs. Likewise, a locked composite can execute curl | bash, install packages with npm or pip, or pull a mutable container image. Its action dependency graph can be fully locked while those downloads change underneath it.
Use reviewed package lockfiles and reproducible installs, verify downloaded artifacts, and pin container images by digest where you control the reference. The container warning gaps reported during preview make this particularly easy to miss: a locked Docker action’s Git commit does not freeze a tagged runs.image.
These boundaries matter because scanners and reviewers need to follow what actually executes. A tag can now resolve through a lock to fixed code. A full SHA can still lead to mutable runtime downloads. Counting hexadecimal characters was never a complete security review, and it is an even less useful shortcut now.
Nor does a lockfile fix shell injection, excessive token permissions, or a compromised runner. And a malicious version selected during initial locking is still malicious. The tool can freeze your mistake very reliably.
Give the hashes a better home
Start with one workflow:
gh extension install github/gh-actions-lock
gh actions-lock .github/workflows/golangci-lint.yml
git diff
gh actions-lock --verify
Review the generated workflow changes and lockfile together. Preserve good existing pins and investigate apparent version changes, including the ones caused by lying comments. Running the CLI again preserves existing pins; --relock requests updates. Check the resulting diff rather than assuming a successful command advanced every dependency you intended to update. The CLI documentation covers the flags.
Resist the temptation to delete and regenerate the file whenever verification complains. The old lock records what you previously selected; throwing it away can discard useful evidence of changed upstream history. An update should explain which code changed and why you want it, including changes buried inside composites.
Two adoption details deserve attention. Action allowlists evaluate locked SHAs, so tag-only allowances may need updating. Dependabot’s preview integration updates matching lock entries when bumping actions in onboarded workflows; it does not onboard your whole repository for you.
After reviewing and merging the migration, enable the Require lockfile Actions policy for the intended scope. Include reusable workflow repositories and shell-only workflows. A verification job provides feedback; the policy blocks workflows without valid lockfile coverage.
My enthusiasm comes from finally giving consumers control over dependencies they could not pin from their own workflow. Our experiments showed that control working, clarified which lockfile a fork pull request run uses, and uncovered a reproducible defect. We reported it, GitHub deployed a backend fix within hours, and our retest confirmed that the fork-only commit no longer ran. The feature became safer before GA because the public preview gave researchers and GitHub room to find, fix, and verify the edge case.
The forty-character hashes are still there. They have a better home now.