Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 18 min read

GitHub Actions lockfile: pin your workflows safely

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, yo

GitHub Actions lockfile: pin your workflows safely

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/[email protected]'
        - 'actions/[email protected]'
dependencies:
    'actions/[email protected]':
        ref: 'v7.0.1'
        commit: 'sha1-...'
        owner_id: 44036562
        repo_id: 197814629
    'actions/[email protected]':
        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/[email protected]
        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.ts";

const base = parseLockfile(`
version: 'v0.0.2'
workflows:
  '.github/workflows/ci.yml':
    - 'actions/[email protected]'
    - 'bookshop/[email protected]'
dependencies:
  'actions/[email protected]':
    ref: 'v7.0.1'
    commit: 'sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
    owner_id: 44036562
    repo_id: 197814629
  'bookshop/[email protected]':
    ref: 'v2.3.0'
    commit: 'sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
    owner_id: 1001
    repo_id: 2002
`);

test("no change yields an empty list", () => {
  assert.deepEqual(diffLockfiles(base, base), []);
  assert.match(toMarkdown([]), /No changes/);
});

test("new commit is detected as a commit change", () => {
  const head = structuredClone(base);
  head.dependencies["bookshop/[email protected]"].commit = "sha1-cccccccccccccccccccccccccccccccccccccccc";
  const changes = diffLockfiles(base, head);
  assert.equal(changes.length, 1);
  assert.equal(changes[0].kind, "commit");
  assert.equal(changes[0].after, "cccccccccccc");
});

test("different repo_id behind the same name is an identity finding", () => {
  const head = structuredClone(base);
  head.dependencies["bookshop/[email protected]"].repo_id = 9999;
  const changes = diffLockfiles(base, head);
  assert.equal(changes[0].kind, "identity");
  assert.match(toMarkdown(changes), /DIFFERENT REPOSITORY/);
});

test("missing lockfile in target branch: everything is new", () => {
  const changes = diffLockfiles(parseLockfile(""), base);
  assert.deepEqual(changes.map((c) => c.kind), ["added", "added"]);
});

test("incomplete entries are rejected", () => {
  assert.throws(() => parseLockfile("dependencies:\n  'x/y@v1':\n    ref: 'v1'\n"), /incomplete/);
});
npx tsc --noEmit
node --test test/*.test.ts
node src/cli.ts base.lock ../../.github/workflows/actions.lock

Expected result: five green tests and a table like | actions/[email protected] | different commit | aaaaaaaaaaaa | cccccccccccc |. If it says β€œDIFFERENT REPOSITORY”, look into where the action comes from before you merge anything.

Roll out the β€œRequire lockfile” policy

Up to this point, the lockfile is voluntary. Repository, organization or enterprise administrators can make it mandatory with the β€œRequire lockfile” policy. It blocks runs of workflows that use actions but are not onboarded to the repository’s lockfile. You find the setting among the rules that govern workflow execution. I find the order GitHub recommends sensible and would adopt it exactly like this:

  1. Pilot: Apply the policy only to repositories and workflows that already have a lockfile.
  2. Observe: Start in β€œEvaluate” mode and analyze the results under β€œPolicy insights”. According to the docs, this mode is only available in GitHub Enterprise.
  3. Automate: Set up Dependabot, the verify check and the hook so nobody has to run the lock command manually.
  4. Enforce: Only then switch to β€œActive” and include all onboarded repositories.

The verify check stays active even after enforcement. The policy stops runs without a valid lockfile, while the check warns the author of a pull request beforehand. Without it, you only find out after the merge that the pipeline is stuck.

When a tag moves legitimately

If a tag like v7 or a branch like main points to a new commit after a regular release, gh actions-lock --relock brings the pins up to date. If the originally locked commit can no longer be reached upstream, for example after a force push, that fails as well. Only --relock --accept-moved accepts the new commit. This option never belongs in automation, so look at the history of the foreign repository first.

# pick up regular releases
gh actions-lock --relock

# only after a manual review: replace a vanished commit
gh actions-lock --relock --accept-moved

Common mistakes and how to avoid them

Symptom Cause Fix
Workflow is skipped during generation Local action ./… given relative to the workflow folder Write the path from the repository root and regenerate
Actions are rejected after the switch Allowlist contains tags like @v6 Use a pattern with @* or the full SHA
Required check hangs on β€œExpected” Verify workflow has a paths filter Remove the filter or do not mark the check as required
--relock fails Locked commit was removed upstream Check the history, then deliberately use --accept-moved
Rate limit during verify Anonymous requests to github.com (60 per hour) Set GH_TOKEN, as in the workflow above

The whole setup costs very little: the verify job is a short run on a standard runner, and in private repositories it counts towards your normal Actions minutes. In terms of security, the basic rule from above applies: the lockfile protects against surprising changes, while reviewing new versions remains human work.

My take

I consider the GitHub Actions lockfile one of the most useful GitHub features this year, precisely because it is unspectacular. It is the same principle we have long used with npm with a lockfile and a cooldown, finally applied to the pipeline, which often has more permissions than the application itself. Readability and security are no longer played off against each other, and that removes a lot of friction in teams.

I would push back on the feeling that this settles the supply chain topic for workflows. If you lock an action without looking at it, you have merely frozen the problem. Keeping the permissions of the GITHUB_TOKEN small, handing out secrets sparingly and choosing third-party actions consciously remain just as important as before. My advice: start in the repository that holds your shared workflows, and give yourself half an hour for the first diff. It rarely takes more.

FAQ

Do I still need to pin actions by SHA in the workflow?

According to the GitHub docs, SHA pinning in the workflow is no longer necessary once a repository uses the lockfile. You can keep readable tags like @v7.0.1, and the commit lives in the lockfile.

Which GitHub plans include workflow lockfiles?

According to the changelog from October 28, 2026: Free, Team, Enterprise Cloud and Enterprise Cloud with data residency. The β€œEvaluate” mode of the mandatory policy is only available in GitHub Enterprise, and the CLI extension does not support GitHub Enterprise Server.

Does the lockfile also cover reusable workflows?

Only indirectly. The dependencies of a reusable workflow are locked by the lockfile of the repository in which that workflow lives. That is why you should switch central workflow repositories first.

May I edit actions.lock by hand?

No. If an entry looks wrong, fix the workflow files and regenerate the lockfile with gh actions-lock.

Your next step

Pick a single repository today, ideally the one with your shared workflows: install the extension, run gh actions-lock, read the diff calmly, check the allowlist, commit. The verify check and Dependabot follow in the second pull request. You can find more posts on secure pipelines in the English blog archive.

I am curious: do you already pin your actions by SHA today, or do you rely on tags? And what has kept you from changing that so far? Let me know in the comments.

Sources

This post was created with AI assistance.

πŸ‘‰ Next step: Not sure which AWS service fits your use case? My AWS service finder asks a few questions and points you to the right one.

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.