# GitHub Actions Workflow Lockfiles: Why uses@SHA Pinning Is Not Enough

> Canonical: https://vxi.me/en/posts/github-actions-workflow-lockfiles-transitive-dependencies/
> Locale: en-us
> Published: 2026-10-18
> Updated: 2026-10-18
> Markdown: https://vxi.me/en/posts/github-actions-workflow-lockfiles-transitive-dependencies.md

# GitHub Actions Workflow Lockfiles: Why uses@SHA Pinning Is Not Enough

Pinning an Action to a full commit SHA fixes the code at that entry point. Yet a pinned Composite Action can invoke another Action via a moving `@v1` tag. Its own source remains unchanged while the downstream code can change. The GitHub Actions YAML alone does not describe the full execution graph.

GitHub's [public preview announcement](https://github.blog/changelog/2026-10-14-github-actions-workflow-lockfiles-in-public-preview) introduces `.github/workflows/actions.lock` for direct and transitive Action dependencies. Locked workflows use recorded commits and verify repository identity.

**Already using `uses@SHA`? Check the dependency graph before migrating.** The gain is a reviewable record of transitive pins and repository identity, not pinning the same direct Action twice.

| Your CI situation | Recommendation | Reason |
| --- | --- | --- |
| Few direct Actions, all on complete SHAs | Inventory first; postpone a full migration | The added maintenance may not pay off |
| Nested Composite Actions call `@v1` or `@main` | Pilot one noncritical workflow | Lockfiles cover the transitive Action graph |
| Teams share cross-repository reusable workflows | Review both caller and callee responsibilities | The callee owns its internal Action locks |
| Organization-wide Allowed Actions policy is enabled | Audit policy before onboarding | The allowlist may match locked commits, not tag names |

These cases are not mutually exclusive. The discussion below is anchored to [gh-actions-lock v0.2.0](https://github.com/github/gh-actions-lock/releases/tag/v0.2.0).

## A pinned entry is not a pinned dependency graph

Consider a hypothetical workflow that pins `example-org/build` to a full SHA. That Composite Action invokes `example-org/upload@v1`. The outer commit is fixed, but the inner tag may still move.

| Dependency | Direct SHA pinning | Workflow Lockfile |
| --- | --- | --- |
| Third-party Action used directly | Fixes its entry commit | Records commit and repository identity |
| Actions called by that Composite Action | Does not automatically pin nested tags | Includes transitive Action dependencies |
| External reusable workflow | Pins its call target | Caller records the call; the **called repo** locks its internal Actions |
| Same-repository `uses: $/…` | Follows running repository commit | Inherently pinned; no separate record |
| Downloads inside `run:` or mutable image tags | Not covered | Still outside the Action Lockfile |

[GitHub's concepts reference](https://docs.github.com/en/actions/concepts/security/workflow-dependency-lockfiles) defines this boundary: a Lockfile covers workflows defined in its repository. The internal Actions of an externally defined reusable workflow are the responsibility of the other repository. Treating a pinned reusable-workflow call as a review of every Action inside it confuses two trust domains.

## Reading actions.lock: commits, IDs, and transitive uses

The `workflows` mapping identifies the Pins a workflow depends on. `dependencies` lists `ref`, `commit`, numeric `owner_id` and `repo_id`, and nested `uses` where appropriate. This complete v0.0.3 YAML comes from the pinned [official `benchV003` parser fixture](https://github.com/github/actions-lockfile/blob/ac158834765237ee94c36606cfc7d87316aa49ab/go/pkg/lockfile/bench_test.go#L31-L58). Repeated a/b digests in it are test placeholders, not real deployment results:

```yaml
version: v0.0.3
workflows:
  .github/workflows/ci.yml:
    - actions/checkout@v4
    - actions/setup-go@v5
    - actions/cache@v4
dependencies:
  actions/checkout@v4:
    hostname: github.com
    ref: v4
    commit: sha1-11bd71901bbe5b1630ceea73d27597364c9af683
    owner_id: 44036562
    repo_id: 197814629
  actions/setup-go@v5:
    hostname: github.com
    ref: v5
    commit: sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
    owner_id: 44036562
    repo_id: 249058325
  actions/cache@v4:
    hostname: github.com
    ref: v4
    commit: sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
    owner_id: 44036562
    repo_id: 251882839
    uses:
      - actions/checkout@v4
```

The fixture includes `hostname: github.com`; a generated lock for the default github.com host may omit that optional field. The nested `uses` field shows how another Action is associated with a dependency, whereas `workflows` lists what the workflow requires.

The pinned [Go marshal source](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/internal/lockfile/state_marshal.go) writes these fields:

```go
a := file.Dependencies[k]
entry := &yaml.Node{Kind: yaml.MappingNode}
// hostname handling omitted
if a.Ref != "" {
    addQuotedField(entry, "ref", a.Ref)
}
if a.Commit != "" {
    addQuotedField(entry, "commit", a.Commit)
}
addIntField(entry, "owner_id", a.OwnerID)
addIntField(entry, "repo_id", a.RepoID)
```

The commit identifies the Git object; numeric IDs additionally identify the repository behind a mutable name. GitHub's concept page still shows a v0.0.2 format example, while the [v0.2.0 release](https://github.com/github/gh-actions-lock/releases/tag/v0.2.0) and [format README](https://github.com/github/actions-lockfile/blob/ac158834765237ee94c36606cfc7d87316aa49ab/README.md) use schema **v0.0.3**. Tool version and file schema version are different; generate the file rather than copying a dated specimen.

## Migration changes worth reviewing before enforcement

Choose one reversible workflow on a test branch. Assuming an installed/authenticated GitHub CLI and repository read access, use the extension version inspected here:

```bash
# Install the inspected extension version
gh extension install github/gh-actions-lock --pin v0.2.0
gh actions-lock .github/workflows/ci.yml

# Inspect workflow edits, local actions, and new files
git status --short
git diff
cat .github/workflows/actions.lock

# Verify only the pilot workflow
gh actions-lock --verify .github/workflows/ci.yml
```

The scoped verification form is supported by [root.go](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/cmd/gh-actions-lock/root.go#L64-L70), [verify.go](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/cmd/gh-actions-lock/verify.go#L24-L40) and upstream tests. The shared `actions.lock` must still parse, but checking one path does not onboard unrelated workflows.

The tool may narrow a version tag or rewrite a valid local Action path from `./` to `$/`. For example:

```diff
 steps:
-  - uses: ./actions/build
+  - uses: $/actions/build
```

The `$/` syntax binds to the current repository commit. Incorrect `./` paths will not be fixed automatically. Also check `git status --short`: new, untracked `actions.lock` files do not appear in ordinary `git diff` output.

Allowed Actions rules need another review. For workflows using Lockfiles, GitHub checks allowed dependencies using the pinned commit SHA, so a previous tag-based policy such as `actions/checkout@v6` may cease to match. Update the reviewed allowlist deliberately rather than disabling it.

Local and online checks have different guarantees:

```bash
gh actions-lock --verify-local .github/workflows/ci.yml
gh actions-lock --verify .github/workflows/ci.yml
```

`--verify-local` checks coverage offline; it cannot validate repository identity. `--verify` performs online checks suitable for PR gating. After every relevant workflow has been onboarded, an unscoped `gh actions-lock --verify` can protect the entire repository. Running it during a one-file trial may flag unregistered workflows or unused pins.

To investigate, add `--json=valid,findings`. Exit 1 indicates blocking findings and exit 2 indicates command/I/O/network errors. The [source-defined categories](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/internal/pipeline/checks/category.go) distinguish `not-pinned`, `stale`, `local-action`, `repo-replaced`, and `unreachable-pin`.

**A five-step low-risk pilot:**
1. **Inventory** direct references, nested Composite Actions, and external Reusable Workflows; agree who maintains the called workflow's own locks.
2. **Generate** on a throwaway branch and review changed YAML, local Action definitions and the new Lockfile.
3. **Check organizational policy** against pinned commits and inspect all files being added to the PR.
4. **Verify in two layers**: offline coverage first, online trust next. Resolve `not-pinned`, `stale`, or `local-action` findings from the diff; investigate `repo-replaced` or `unreachable-pin` with upstream maintainers before merging.
5. **Keep a rollback**: abandon or revert the trial branch if parsing or policy blocks CI, and widen the rollout only after the pilot is stable.

## What happens to pins as upstream versions change?

On first onboarding, the chosen commit depends on the original `uses` ref and the resolver. The v0.2.0 **release cooldown is conditional, not an automatic seven-day wait**: a positive `github-actions` `cooldown.default-days` in `.github/dependabot.yml` takes precedence over `cooldown_days` in the user's config. Without either, version-age filtering is disabled; new releases under three days old produce a nonblocking `fresh-tag` notice. See [cooldown resolution](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/cmd/gh-actions-lock/fresh_tag.go#L16-L32) and the [notice logic](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/cmd/gh-actions-lock/fresh_tag.go#L48-L75). Review the actual locked `ref`, `commit`, and workflow diff, rather than assuming the selected SHA always belongs to the newest tag.

Pins are **sticky**. Rerunning `gh actions-lock` does not silently replace an existing pin when an upstream branch or major tag moves. Use `gh actions-lock --relock` for an intended update and review every changed commit. If the prior commit becomes unreachable following history rewriting, `--relock --accept-moved` is a separate trust decision, not a convenient cure for a red CI job.

Dependabot can update already onboarded workflows; it does not enroll a new workflow by itself. Put `actions.lock` under CODEOWNERS review and trial small before enabling organization-level `Require lockfile`. Check account and policy availability: the pinned README says this preview does **not** support GitHub Enterprise Server, which is different from Enterprise Cloud.

The Lockfile establishes **which** Action code should execute, not whether that code is safe. Scripts fetched by `run:`, mutable images, package dependencies and CI token permissions remain separate controls. A useful review asks which nested Actions were newly pinned, who approved their identities, and who owns the locks inside shared reusable workflows. The related [GitHub AI Scan post](/posts/github-ai-scan-pr-security-checks/) explores why detecting risky code is distinct from enforcing a merge gate.

---

*Evidence and timing: GitHub announced the public preview on October 14, 2026. Source inspection uses fixed `gh-actions-lock v0.2.0` commit `d5dca22a06ff8590d50b41612038996800cfb336` and format repository commit `ac158834765237ee94c36606cfc7d87316aa49ab`, checked on October 18. The YAML is an upstream parser fixture containing synthetic digests. No real Runner, CLI migration, or tag-redirection experiment was executed; preview access depends on plan and settings.*
