DEV Community

Cover image for GitHub Actions Workflow Lockfile: Pin Transitive Deps
Mudassir Khan
Mudassir Khan

Posted on

GitHub Actions Workflow Lockfile: Pin Transitive Deps

GitHub just made workflow lockfiles generally available, and the gh actions-lock command finally answers a question that SHA pinning never could: what about the actions your actions call? A GitHub Actions workflow lockfile records exact commits for actions and their transitive dependencies, while your YAML keeps its readable @v4 style references. That's the release. The harder part is rolling it out without freezing CI, and knowing what it still doesn't prove.

TL;DR

  • A lockfile fixes which commit runs, including the nested actions you never see in your YAML. It's a real upgrade over hand pinning the top level only.
  • Roll it out one workflow at a time, and add a guard that fails a PR when a uses: line changes without the lockfile changing.
  • It says nothing about how an action was built or whether your repo is configured safely. SLSA provenance and OpenSSF Scorecard cover those, and you still need both questions answered.

Dependency risk in workflow YAML

Here's the workflow most of us have in a repo right now:

# .github/workflows/ci.yml
name: ci
on: [pull_request]
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: your-org/setup-toolchain@v2
      - run: npm test
Enter fullscreen mode Exit fullscreen mode

Two uses: lines, and they look harmless. But @v2 is a Git tag, and a tag is a pointer that someone with write access can move. If setup-toolchain is a composite action, it has its own uses: lines inside its action.yml, and those have their own tags. You reviewed two lines. You're running a tree.

The old fix was replacing every tag with a 40 character SHA. It works for the top level, it makes the YAML unreadable, and it doesn't reach the nested layer unless the action's author pinned too. You can't pin someone else's action.yml from your repo. That gap is the footgun, and it's the specific thing the lockfile closes.

This matters more once your pipelines do real work on their own. If you're moving toward production AI agent workflows, CI is where those agents get tokens, and an action you never reviewed runs with the same job permissions as the ones you did.

A chain builds left to right: your workflow, a tagged action, a nested action, then the code that runs, with a note that tags can move and an emphasis on the nested layer you never reviewed

gh actions-lock migration

Before you generate anything, find out what you have. This script needs only the Python standard library. It reads your workflows and any local composite actions, then sorts each reference by how it's pinned:

#!/usr/bin/env python3
# audit_uses.py: run from the repo root
import collections
import pathlib
import re

USES = re.compile(r"""^\s*(?:-\s*)?uses:\s*['"]?([^'"\s#]+)""")
SHA = re.compile(r"^[0-9a-f]{40}$")


def classify(ref: str) -> str:
    if ref.startswith("./"):
        return "local"
    if ref.startswith("docker://"):
        return "docker"
    if "@" not in ref:
        return "unknown"
    return "sha" if SHA.match(ref.rsplit("@", 1)[1]) else "mutable"


root = pathlib.Path(".github")
files = [p for p in root.rglob("*") if p.suffix in {".yml", ".yaml"}]
found = collections.defaultdict(set)

for path in files:
    for line in path.read_text(encoding="utf-8").splitlines():
        match = USES.match(line)
        if match:
            found[classify(match[1])].add(f"{match[1]}  ({path})")

for kind in sorted(found):
    print(f"\n{kind}: {len(found[kind])}")
    for item in sorted(found[kind]):
        print(f"  {item}")
Enter fullscreen mode Exit fullscreen mode

The mutable bucket is your migration list. Now resist doing the whole org in one PR. Here's the order I'd use:

  1. Audit with the script above and save the output, so you've got a before picture.
  2. Lock one workflow that's low stakes but runs often. A docs build is ideal. Run gh actions-lock, commit what it generates, and read the result before you merge. I'm leaving flags out on purpose: the changelog entry and gh actions-lock --help are the source of truth for a tool this new.
  3. Review the transitive entries. This first read is where you'll learn what your actions actually depend on.
  4. Expand to the workflows that hold deploy credentials once a few normal PRs have passed through.

Four cards build in order: audit every uses line, lock one low stakes workflow, review the transitive entries, expand to deploy workflows

Transitive pins and review process

A lockfile only helps if it can't quietly drift from the YAML. So add a guard. This job fails a PR when a uses: line changes and the lockfile doesn't. Set ACTIONS_LOCKFILE_PATH as a repo variable pointing at the file your generator wrote:

# .github/workflows/lockfile-guard.yml
name: lockfile-guard
on:
  pull_request:
    paths:
      - ".github/**"
permissions:
  contents: read
jobs:
  guard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Require a lockfile update when uses lines change
        env:
          LOCKFILE: ${{ vars.ACTIONS_LOCKFILE_PATH }}
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          if git diff "$BASE_SHA"...HEAD -- .github | grep -E '^[-+][^-+].*uses:'; then
            if git diff --quiet "$BASE_SHA"...HEAD -- "$LOCKFILE"; then
              echo "uses lines changed but $LOCKFILE did not. Regenerate it."
              exit 1
            fi
          fi
Enter fullscreen mode Exit fullscreen mode

Then decide how humans read that diff, because a bump to one action can change many transitive commits at once. My opinion: treat a lockfile change like a package-lock.json change, with one difference. A new top level action needs a named reviewer. A new transitive source gets the same scrutiny, because it runs with the same permissions, even though nobody chose it on purpose.

Three questions I'd put in the PR template for any lockfile diff:

  • Which owners are new in this diff, at any depth?
  • Does this bump change anything inside a job that holds a write token or a deploy secret?
  • Does the release behind the new commit exist, and does it match what the tag claims?

The trap on the other side is letting nobody update the lockfile because review feels expensive. Stale pins age badly too. Schedule the bumps so they arrive in small diffs someone will actually read. If you're running this kind of change across a whole team's process, the design questions look a lot like the ones in workflow design for AI automation: who owns the step, what's the failure path, and who gets paged.

What SLSA provenance does separately

A pin tells you the bytes are the same bytes you saw last time. It doesn't tell you those bytes were any good, or where they came from. Don't let a green lockfile check stand in for those answers.

SLSA is the framework for the build question. Its v1.1 spec defines Build L1 as provenance existing, Build L2 as adding hosted build requirements, and Build L3 as hardened builds. One housekeeping note: the v1.1 page is now marked retired in favor of v1.2, so check the current version before you write it into a policy.

OpenSSF Scorecard covers a third area: repository hygiene. It documents independent checks such as dangerous workflows, token permissions and pinned dependencies, with remediation advice. It's a quick way to see whether the lockfile is sitting inside a repo that's otherwise wide open:

scorecard --repo=github.com/your-org/your-repo \
  --checks=Pinned-Dependencies,Token-Permissions,Dangerous-Workflow
Enter fullscreen mode Exit fullscreen mode

Three columns highlighted in turn: the lockfile answers which commit ran, SLSA provenance answers how it was built, Scorecard answers whether the repo is configured safely

Control Question it answers What it won't tell you
Workflow lockfile Which exact commit runs, at every depth? Whether that commit is safe
SLSA provenance How and where was this built? Whether your workflow picked the right artifact
OpenSSF Scorecard Is this repo configured with good hygiene? Whether a specific run was tampered with

Reach for all three when a workflow can publish, deploy or spend money. For an internal docs build, the lockfile plus tight permissions is enough.

CI rollout checklist

Rollout checklist with six items: audit uses lines, lock one workflow first, guard the lockfile in CI, name owners for transitive changes, set least privilege token permissions, schedule small bumps

  • [ ] Run the audit and save the mutable list
  • [ ] Lock one low stakes workflow and read the output
  • [ ] Add the guard job on pull_request
  • [ ] Name a reviewer for new transitive owners
  • [ ] Set permissions explicitly on every workflow
  • [ ] Schedule small, regular lockfile bumps

FAQ

How does a GitHub Actions workflow lockfile work?
gh actions-lock records the exact commits for your actions and their transitive dependencies. Your workflow keeps readable references, while the recorded commits say what's actually meant to run.

What changes for teams adopting a GitHub Actions workflow lockfile?
The review surface moves. You gain a diff that shows nested dependency changes you couldn't see before, so someone needs to own reading it, and CI should stop a uses: change that arrives without a matching lockfile update.

Does a lockfile replace SHA pinning?
For the goal of fixing what runs, that's the promise, and you keep readable tags in the YAML. Test it on one workflow and confirm the behavior yourself before you strip existing pins from anything that deploys.

Does it replace SLSA or Scorecard?
No. They answer different questions, as the table above shows.


Related reading: why AI code review leaderboards disagree and Next.js security hardening patterns that hold up.

If you want agent pipelines and CI wired up so this kind of control is the default, that's the work I do in agentic AI consulting.

What's in your mutable bucket? I'm curious how deep the transitive layer goes in real repos, so drop your count in the comments.

Top comments (0)