How GitHub Actions Workflow Lockfiles Work
Most workflows pin actions to a version tag: uses: actions/checkout@v4. It reads like a fixed version. It isn't. A tag is a movable label, and whoever controls the action's repository can move it. Usually that means you get a patch release without noticing. In the bad case, it means your CI runs code you never reviewed.
That's the gap workflow lockfiles close. GitHub made them generally available on October 28 for Free, Team, and Enterprise Cloud plans, and the idea is simple: a lockfile records the exact version of every action your workflow uses, so the same workflow always runs the same code.
The problem with tags
When your workflow says @v4, GitHub resolves that tag to a commit at run time. The tag v4 today and the tag v4 next month can point at different commits. Action maintainers move major-version tags forward with every release, which is convenient — it's also the reason a compromised or careless upstream change lands in your pipeline with no review step in between.
The old advice was to pin actions to full commit SHAs instead of tags. That works, and it's still good advice. But a workflow full of 40-character SHAs is unreadable. Lockfiles are GitHub's answer: human-readable tags in the workflow, exact versions recorded elsewhere.
What a lockfile actually pins
A lockfile sits alongside your workflow — at .github/workflows/actions.lock — and records, for each action the workflow references, the exact resolved version: the commit SHA that the tag pointed to when the lockfile was generated. Your workflow file keeps saying @v4. The lockfile says which commit @v4 meant, and the runner uses that. (One gap to know: calls to reusable workflows — jobs.<job_id>.uses — are not pinned; only action steps are.)
Think of it like the lockfile you already know from npm or pip. Your package.json says ^2.1.0; package-lock.json says exactly which version got installed. The workflow is the manifest; the lockfile is the record. Same principle, applied to the actions your CI runs.
What happens when an action updates
Nothing, until you decide otherwise. That's the point. The upstream repo can release v4.3.0, move the tag, and your next run still uses the locked commit. Your build stays reproducible: the same workflow file plus the same lockfile produces the same run, today and next month.
When you do want the update, you regenerate the lockfile — the new resolution gets recorded, and the diff shows exactly which commits changed. A pull request that bumps an action from one SHA to another is a concrete change a human can look at; a tag silently pointing somewhere new is invisible.
The attack it closes
The scenario is straightforward: an attacker gets write access to a popular action's repository — a compromised maintainer account, a hijacked tag — and pushes malicious code under an existing version tag. Every workflow referencing that tag picks up the new code on its next run, with no pull request, no review, no diff anywhere in your repo. CI runners often hold broad credentials: cloud keys, deployment tokens, package publish rights. That's the prize.
A lockfile doesn't stop the upstream compromise. It stops the compromise from reaching you silently. Your runs keep using the locked commit until someone on your team deliberately updates the lockfile, and that update shows up as a visible change in version control.
Adopting it on an existing workflow
The practical path, for a repo with workflows already running:
- Start with one workflow, not all of them. Pick the one with the most permissions — the one that deploys, publishes, or touches production secrets. That's where a silent upstream change hurts most.
- Generate the lockfile and commit it next to the workflow. It is a machine-generated file at
.github/workflows/actions.lock— never edit it by hand. Install the GitHub CLI extension and run it from the repo root:
gh extension install github/gh-actions-lock
gh actions-lock .github/workflows/ci.yml
Run with no file argument to onboard every workflow at once; start with one. gh actions-lock --verify checks the lockfile is current without writing anything, which is what you want as a required check on pull requests. The lockfile only works if it's in the repo, because the runner reads it from there. 3. Review the first lockfile like a dependency update. It should record the commits your tags currently resolve to. If anything in it surprises you, that's worth five minutes of investigation before you merge. 4. Make lockfile updates a normal pull request. When an action releases something you want, regenerate and review the diff the same way you'd review a Dependabot bump. 5. Then roll out to the rest. Once one workflow is locked and the team is used to the rhythm, the rest are the same motion repeated.
What it doesn't do
A lockfile is not a security scanner. It won't tell you an action is malicious; it tells you exactly which version you're running, so a malicious version can't arrive unannounced. It also doesn't replace least-privilege permissions on your workflows. A locked action running with a broad token is still a broad token.
The npm world learned this lesson years ago — nobody serious ships without a lockfile anymore. CI is catching up.
P.S. Everything I write stays free — that part doesn't change. But if this one was worth something to you, you can buy me a coffee ☕ (one-time or monthly, both work). It covers the API credits and hosting behind these experiments — and honestly, it's also the nudge that gets the next one started on the evenings I'd otherwise skip it. — Salam
Comments
Join the discussionNo comments yet. Be the first to comment.

