A GitHub Actions lockfile is something I have missed for a long time. With npm, Maven or Go it has been normal for years that exactly the version someone reviewed is the one that runs. In workflows, on the other hand, you would find uses: someone/something@v3, and which code sat behind that tag was only decided when the job started. Since October 28, 2026, workflow lockfiles are generally available, and with them you can finally close this gap without unreadable walls of SHAs.

In this article we set up the lockfile step by step in a sample repository (a fictional online bookshop), build a check for pull requests that highlights suspicious changes, and look at how to introduce the mandatory policy in your team without seeing every pipeline turn red on Monday morning.

In short

The new lockfile .github/workflows/actions.lock records the commit and the repository ID for every action and its transitive dependencies, so moved tags can no longer bring unknown code into your pipeline. You create and verify it with the CLI extension gh actions-lock, and Dependabot keeps it up to date. The biggest pitfall are tag-based allowlists for actions: they stop matching after the switch.

What you will learn in this article

  • Which problem the GitHub Actions lockfile solves and which it does not
  • How to install gh actions-lock and generate your first lockfile
  • How CI, Dependabot and CODEOWNERS keep the lockfile current and reviewed
  • How a small TypeScript tool flags suspicious lockfile changes in a PR
  • How to roll out the “Require lockfile” policy without outages

Why tags in workflows are a risk

A Git tag is a pointer. Anyone with write access to a repository can move it to a different commit. That is exactly what makes @v3 convenient, because maintainers move the major tag forward with every patch release. At the same time it is the weak spot: if an attacker takes over a maintainer account or a token with write access, v3 may point tomorrow to code nobody on your team has ever seen. Your workflow loads it anyway, with access to secrets, tokens and build artifacts.

The classic countermeasure is SHA pinning directly in the workflow, i.e. uses: actions/checkout@<40-character hash>. It works, but it is tedious to read and only protects the first level. If a composite action calls further actions by tag, you are unprotected again at that point. In addition, a hash does not answer whether the repository behind the name is still the same one, for example after a rename when someone registers the old name again.

What a lockfile changes here

The lockfile separates readability from security. Your workflow keeps the descriptive tag, and the lockfile records which commit it resolved to when it was locked. GitHub also stores the numeric IDs of the owner and the repository. When a run starts, Actions takes the stored commit and checks whether the repository behind the name is still the locked one. A moved tag is not even resolved again during a normal run.

One thing matters for the big picture: the lockfile does not judge whether an action is good or bad. It makes sure that what you locked is what runs. If you lock a compromised version, you reliably get the compromised version. Reviewing before locking therefore remains mandatory.

Diagram: workflow reference @v3 is checked via actions.lock against commit and repository ID, moved tags are ignored

At start, Actions takes the locked commit and checks the repository ID; a moved tag does not matter.

How the GitHub Actions lockfile is structured

The file always lives at .github/workflows/actions.lock, it is YAML and it is generated by the CLI extension. You should not edit it by hand. An excerpt for our bookshop looks roughly like this (the hashes are shortened):

# .github/workflows/actions.lock # 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/ci.yml': - 'actions/checkout@v7.0.1' - 'actions/setup-node@v6.0.0' dependencies: 'actions/checkout@v7.0.1': ref: 'v7.0.1' commit: 'sha1-...' owner_id: 44036562 repo_id: 197814629  'actions/setup-node@v6.0.0': ref: 'v6.0.0' commit: 'sha1-...' owner_id: 44036562 repo_id: 123456789 

The workflows section lists which workflow file uses which dependencies. Under dependencies you find, for each reference, the tag (ref), the resolved commit (commit) and both IDs. The IDs of setup-node in this excerpt are placeholders, your file contains the real values. A workflow counts as “onboarded” as soon as it is listed in the lockfile. From then on, every one of its action dependencies must appear there, and Actions runs the stored commit.

What the lockfile covers

It covers the actions in your workflows and their transitive dependencies, including what a composite action calls internally. Reusable workflows are an exception: their dependencies are covered by the lockfile of the repository in which the reusable workflow is defined. If your team maintains shared workflows in a central repository, that is where you should start.

Set up the GitHub Actions lockfile step by step

Our starting point is a plain CI workflow for the bookshop. It checks out the code, installs Node.js and runs the tests. The action versions here are only examples, use the ones you already rely on today.

# .github/workflows/ci.yml name: ci on: pull_request: push: branches: [main] permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-node@v6 with: node-version: 22 - run: npm ci - run: npm test 

Prerequisites

  • The GitHub CLI gh, signed in via gh auth login, and write access to the repository.
  • A repository on github.com or in GitHub Enterprise Cloud with data residency. According to its README, the extension does not support GitHub Enterprise Server.
  • According to the changelog, lockfiles are available on the Free, Team and Enterprise Cloud plans. At the time of writing, some docs pages still carry the preview notice, so details may still change.
  • Recommended: Dependabot version updates for GitHub Actions are already enabled (more on that shortly).

Step 1: Install the extension

gh extension install github/gh-actions-lock gh actions-lock --help 

Later, gh extension upgrade gh-actions-lock fetches new versions. If you work with an AI coding agent, gh skill install github/gh-actions-lock teaches it to regenerate the lockfile itself after workflow changes. That is a convenience, not a control.

Step 2: Generate the lockfile

In the root directory of the repository, a single command generates the lockfile for all workflows under .github/workflows/. If you want to start small, pass individual files.

# all workflows at once gh actions-lock # or just one workflow to begin with gh actions-lock .github/workflows/ci.yml 

If the extension reports that it skips a workflow because of local actions, a reference of the form uses: ./… could not be resolved. Local paths always start at the repository root, not at the folder of the workflow file. For special cases there are the options --migrate-local-actions and --ignore-local-actions.

Step 3: Actually read the diff

Now comes the part many people skip. The extension changes more than just the lockfile. For example, it rewrites partial versions like @v7 to the full tag, i.e. @v7.0.1, while the commit stays the same. According to the docs, local references uses: ./… become uses: $/…, because they always belong to the commit that is currently running and need no lockfile entry. Use git diff to look at every changed file and then commit everything together.

git diff -- .github/ git add .github/workflows/ git commit -m "ci: lock GitHub Actions dependencies" 

Step 4: Check your allowlists

This is where the most common pitfall hides. If your repository, organization or enterprise restricts the allowed actions, GitHub matches that list against the locked commit SHAs after the switch. An entry like actions/checkout@v6 will then no longer match. Replace such entries with a pattern like actions/checkout@* or with the full SHA before you push the commit.

After that, the CI workflow runs as before, just with pinned code. Expected result: a green run, and a new file actions.lock sitting next to your workflows in the repository.

Keep the lockfile current

A lockfile that is generated only once goes stale quickly. As soon as someone adds or removes an action, gh actions-lock has to run again. Four building blocks make sure this does not depend on anyone’s memory.

Dependabot updates the lockfile too

When Dependabot bumps the version of an action in an already onboarded workflow, it regenerates the matching lockfile entry in the same pull request, without extra configuration. However, Dependabot does not onboard new workflows by itself, it only reports that onboarding is missing. I like to combine this with a cooldown so that fresh releases can mature for a few days before they reach us.

# .github/dependabot.yml version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" cooldown: default-days: 7 

Check pull requests automatically

The command gh actions-lock --verify writes nothing, but runs the same checks as a normal run and exits with an error if the lockfile no longer matches the workflows. That makes it suitable as a required check. The extension’s repository ships a template; my variant adds a step that writes lockfile changes neatly into the job summary (we build the tool for that in the next section).

# .github/workflows/verify-lockfile.yml name: verify-lockfile on: pull_request: push: branches: [main] permissions: contents: read jobs: verify: runs-on: ubuntu-latest env: GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v7.0.1 with: persist-credentials: false fetch-depth: 0 - run: gh extension install github/gh-actions-lock --pin v0.2.0 # Fails if actions.lock does not match the workflows - run: gh actions-lock --verify - uses: actions/setup-node@v6 with: node-version: 22 - name: Summarize lockfile changes if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} run: | git show "origin/$BASE_REF:.github/workflows/actions.lock" > "$RUNNER_TEMP/base.lock" 2>/dev/null || true npm ci --prefix tools/actions-lock-review node tools/actions-lock-review/src/cli.ts "$RUNNER_TEMP/base.lock" .github/workflows/actions.lock >> "$GITHUB_STEP_SUMMARY" 

Two details matter to me here. The workflow deliberately has no paths filter, because a required check that does not even start for some PRs blocks merging. And the name of the target branch reaches the script through an environment variable instead of directly via ${{ }}, so no expression slips into the shell unchecked. After adding this workflow, run gh actions-lock once, because it uses actions too and has to be in the lockfile.

Pre-commit hook and CODEOWNERS

Locally, a hook helps that checks before every commit whether each dependency has an entry. It works offline and needs no authentication, but it does not verify the pins against GitHub. That remains the job of the CI check.

# .git/hooks/pre-commit (make it executable with chmod +x) #!/bin/sh gh actions-lock --verify-local --no-fix 

So that lockfile changes do not slip through unnoticed, the file belongs in CODEOWNERS. Combined with a branch rule that requires code owner review, someone responsible for the pipeline always looks at new commits.

# .github/CODEOWNERS /.github/workflows/actions.lock @bookshop/platform-team 

🎬 Pull request with a changed action – this animated diagram only runs on my site: watch it on sascha-ms.de

Make lockfile changes visible in reviews

Nobody enjoys reading a YAML diff full of 40-character hashes. The docs name two signals that matter: a changed commit is new code that will run, so treat it like a version bump. A changed owner_id or repo_id should almost never happen and means that a different repository is behind the name. A small TypeScript tool that I keep in the folder tools/actions-lock-review/ highlights exactly these two cases.

Create the project

The tool runs directly on Node.js 22.18 or newer, which executes TypeScript without a build step as long as only “erasable” syntax such as type annotations is used. The only runtime dependency we need is the YAML parser yaml.

mkdir -p tools/actions-lock-review && cd tools/actions-lock-review npm init -y npm pkg set type=module npm install yaml npm install -D typescript @types/node 
{ "compilerOptions": { "target": "ES2024", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noEmit": true, "allowImportingTsExtensions": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true, "types": ["node"] }, "include": ["src", "test"] } 

Read the lockfile

The first file reads the lockfile and checks that the fields we rely on are present. Empty text stands for the first pull request, when there is no lockfile in the target branch yet.

// tools/actions-lock-review/src/lockfile.ts import { parse } from "yaml"; export interface LockedDependency { ref: string; commit: string; owner_id: number; repo_id: number; } export interface ActionsLock { version: string; workflows: Record<string, string[]>; dependencies: Record<string, LockedDependency>; } // Reads the content of an actions.lock and checks the fields we need. // Empty text means "there is no lockfile yet" (e.g. in the first PR). export function parseLockfile(text: string): ActionsLock { if (text.trim() === "") { return { version: "none", workflows: {}, dependencies: {} }; } const data = parse(text) as Partial<ActionsLock> | null; if (!data || typeof data !== "object") { throw new Error("actions.lock is not a valid YAML object"); } const dependencies = data.dependencies ?? {}; for (const [key, dep] of Object.entries(dependencies)) { if (!dep || typeof dep.commit !== "string" || typeof dep.repo_id !== "number" || typeof dep.owner_id !== "number") { throw new Error(`Entry "${key}" is incomplete (expected commit, owner_id, repo_id)`); } } return { version: String(data.version ?? "unknown"), workflows: data.workflows ?? {}, dependencies, }; } 

Find and report differences

The second file compares the lockfile of the target branch with the one from the pull request and produces a Markdown table that GitHub renders nicely in the job summary.

// tools/actions-lock-review/src/diff.ts import type { ActionsLock } from "./lockfile.ts"; export type ChangeKind = "added" | "removed" | "commit" | "identity"; export interface LockChange { key: string; kind: ChangeKind; before?: string; after?: string; } const short = (sha: string) => sha.replace(/^sha1-/, "").slice(0, 12); // Compares the lockfile from the target branch (base) with the one from the PR (head). export function diffLockfiles(base: ActionsLock, head: ActionsLock): LockChange[] { const changes: LockChange[] = []; const keys = new Set([...Object.keys(base.dependencies), ...Object.keys(head.dependencies)]); for (const key of [...keys].sort()) { const oldDep = base.dependencies[key]; const newDep = head.dependencies[key]; if (!oldDep && newDep) { changes.push({ key, kind: "added", after: short(newDep.commit) }); } else if (oldDep && !newDep) { changes.push({ key, kind: "removed", before: short(oldDep.commit) }); } else if (oldDep && newDep) { // Different repository ID behind the same name: this is the dangerous case. if (oldDep.owner_id !== newDep.owner_id || oldDep.repo_id !== newDep.repo_id) { changes.push({ key, kind: "identity", before: `${oldDep.owner_id}/${oldDep.repo_id}`, after: `${newDep.owner_id}/${newDep.repo_id}`, }); } else if (oldDep.commit !== newDep.commit) { changes.push({ key, kind: "commit", before: short(oldDep.commit), after: short(newDep.commit) }); } } } return changes; } const labels: Record<ChangeKind, string> = { added: "new", removed: "removed", commit: "different commit", identity: "DIFFERENT REPOSITORY", }; // Builds a Markdown report you can use as a PR comment or job summary. export function toMarkdown(changes: LockChange[]): string { if (changes.length === 0) { return "No changes to pinned actions."; } const rows = changes.map( (c) => `| ${c.key} | ${labels[c.kind]} | ${c.before ?? "–"} | ${c.after ?? "–"} |`, ); return ["| Action | Change | before | after |", "| --- | --- | --- | --- |", ...rows].join("\n"); } 

The command line connects both. If it finds a change of the repository ID, it exits with code 1 and the check turns red. New or changed commits are only reported, because they are normal with every Dependabot update.

// tools/actions-lock-review/src/cli.ts import { readFile } from "node:fs/promises"; import { parseLockfile } from "./lockfile.ts"; import { diffLockfiles, toMarkdown } from "./diff.ts"; const [basePath, headPath] = process.argv.slice(2); if (!basePath || !headPath) { console.error("Usage: node src/cli.ts <base.lock> <head.lock>"); process.exit(2); } // If the file is missing in the target branch, we treat it as empty. const readOrEmpty = (path: string) => readFile(path, "utf8").catch(() => ""); const base = parseLockfile(await readOrEmpty(basePath)); const head = parseLockfile(await readFile(headPath, "utf8")); const changes = diffLockfiles(base, head); console.log(toMarkdown(changes)); const identity = changes.filter((c) => c.kind === "identity"); if (identity.length > 0) { console.error(`Warning: ${identity.length} action(s) point to a different repository. Please review.`); process.exit(1); } 

Test it

The tests use the built-in Node.js test runner. They cover the normal case, a new commit, a foreign repository ID, the missing lockfile and broken entries.

// tools/actions-lock-review/test/diff.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { parseLockfile } from "../src/lockfile.ts"; import { diffLockfiles, toMarkdown } from "../src/diff.tsSource:https://dev.to/sascha-ms/github-actions-lockfile-pin-your-workflows-safely-3j17

Related posts