On Wednesday 14 October GitHub put workflow dependency lockfiles into public preview for Free, Team and Enterprise Cloud. A gh actions-lock run writes .github/workflows/actions.lock, every action in an onboarded workflow resolves to the commit recorded there, and the docs promise that "the code you reviewed is the code that runs." For JavaScript and composite actions, that holds. For a Docker container action, the commit is locked and the container is not: the runner reads runs.image from the action's own manifest at job start and pulls whatever that tag points to today.
The extension warns about a mutable docker:// tag only when it sits directly in your workflow, and only on an online run. An issue filed in the extension's repository on launch day, after a scan of 1,802 public repositories, found that case in 1.2% of them, and an image reached silently through a locked action in 9.8%, or 21.2% once Docker actions built from an unpinned FROM line are counted. The maintainer's position, posted Thursday: "there is no reasonable path to runtime enforcement" for images.
By Friday the same tracker held three more holes. Job-level calls to reusable workflows in other repositories are not locked, and the maintainers do not plan to lock them. Actions inside the parallel: step groups GitHub shipped in June are left out. And a hand-edited lockfile pointing at a commit that exists only in a fork of actions/checkout passed --verify and ran on a hosted runner under an active Require lockfile policy. I built v0.2.0 from source on Saturday and ran the offline check the docs recommend for pre-commit hooks against a workflow with all of this in it. It reported complete coverage.
What the lockfile locks
The design is better than SHA pinning, which is why the gaps matter. SHA pinning puts a 40-character hash in your YAML and covers only the actions you can see. The lockfile keeps uses: actions/checkout@v6 readable, narrows it to the highest matching full tag on first lock, and records the commit in a separate file along with the numeric owner_id and repo_id of the source repository. At run time GitHub uses the recorded commit, checks that the repository behind the name is still the one you locked, and requires the commit to exist on a branch of that repository, the defence against impostor commits served under an upstream name. It also walks composite actions and records what they use, so the transitive graph that the tj-actions incident exploited shows up in the diff.
Onboarding is per workflow, and an onboarded workflow must have an entry for every action it uses or the run does not start. A "Require lockfile" policy, also in preview, blocks runs of targeted workflows whose uses: dependencies are not in the lockfile. Dependabot regenerates the entry when it bumps an onboarded action. The first run also rewrites your files: @v3 becomes @v3.1.0, in-repo ./ actions become $/, and any allowlist entry pinned to a tag stops matching because the allowlist is now checked against the locked SHA. The changelog adds that locking does not determine whether a dependency is safe. The launch reached 39 points and two comments on Hacker News.
What a Docker action does at job start
A JavaScript action is a directory of code at a commit. A Docker container action is a manifest at a commit whose runs.image names the thing that executes. GitHub's metadata reference says the value "can be the Docker base image name, a local Dockerfile in your repository, or a public image in Docker Hub or another registry." When the job starts, the runner pulls that image or builds that Dockerfile. Nothing in the lockfile describes either.
The extension's source draws the boundary exactly. The transitive walk in internal/resolve/discovery.go descends only into actions whose runs.using is composite; for a Docker action it stops, and the library it uses to parse action metadata does not decode runs.image at all. The one warning that exists, added in July as "Warn when workflows use mutable Docker image tags", fires for a uses: docker://image:tag step in your own workflow or in an in-repo composite, is suppressed when the reference carries an @sha256: digest, and is attached to the report only by the online run. A docker:// step inside a remote composite gets no warning. A Docker action's own image gets no warning.
Issue 168, filed on launch day, gives the example that matters: ossf/scorecard-action pinned to a full SHA in your workflow and locked to that SHA, whose action.yaml says image: "docker://ghcr.io/ossf/scorecard-action:v2.4.4". The job runs whatever that tag points to when it starts, and a moved tag produces no lockfile diff. I checked the manifest on Saturday: it now says v2.5.0, released Monday.
The same pattern holds for actions/jekyll-build-pages (v1.0.13), EnricoMi/publish-unit-test-result-action (v2.25.0), super-linter/super-linter (v9.0.0, 10,600 stars) and bridgecrewio/checkov-action (3.3.29). getsentry/action-release is composite, so the walk does enter it, and finds uses: docker://ghcr.io/getsentry/action-release-image:3.7.1, the remote-composite case that gets no warning. Two counterexamples show what a pinned image looks like: hadolint/hadolint-action builds from a Dockerfile whose FROM carries a digest, and mikefarah/yq names its image with @sha256: in action.yml. For those the lockfile's promise holds all the way down.
How common
The issue's author scanned .github/workflows in 1,802 active public repositories on 14 October, the top search results across ten languages pushed in the previous 30 days, and followed each remote action's manifest and composite steps. 177 repositories, 9.8%, use an action that pulls a prebuilt image by tag, led by scorecard-action in 81, publish-unit-test-result-action in 21, action-release in 11 and jekyll-build-pages in 10. Counting Docker actions built from a Dockerfile whose FROM is not digest-pinned, the figure is 382 repositories, 21.2%, and 220 of those reach the image through a uses: already pinned to a full SHA. The 21 repositories, 1.2%, with a direct docker:// tag are the only ones the extension would have told. The author notes that 358 of 4,383 manifest fetches returned 404, so these are lower bounds, and that the sample is not random.
The maintainer answered the general question in a related thread on Thursday: "The challenge with pinning docker actions is that there is no reasonable path to runtime enforcement," because the lockfile is enforced by GitHub's resolver and "we trust however the runner is configured to resolve a docker image to do the right thing." He added that there is "some middle ground" to find and that the docs should say "where the trust stops." As of Saturday the three docs pages on lockfiles do not contain the word Docker.
Three more by Friday
The concept page says the lockfile pins "every action (including transitive dependencies) and reusable workflow your workflows depend on." The CLI does not lock a job-level uses: owner/repo/.github/workflows/file.yml@ref. The parser returns nothing for a path under .github/workflows/, the call is dropped without a finding, a workflow whose only external dependency is a reusable workflow is recorded as [], and --verify passes. Issue 179, filed Friday, asked for this to change and quoted the maintainer closing an earlier report on Thursday: "we don't plan to pin job-level uses at this time." Keep those refs on full SHAs in YAML.
parallel: step groups shipped on 25 June. The extension reads uses: only from steps directly under steps:, so a workflow with actions/checkout@v7.0.1 followed by a parallel: group containing setup-java and setup-node gets a lockfile entry listing only checkout. Issue 175 was filed Friday morning; Jesse Houwing, one of the four community members the changelog thanks, had a fix open an hour later, and the maintainer expects no backend change and will review it Monday. Until it lands, a group that an onboarded workflow runs is a group the lockfile does not know about, and the run proceeds.
Issue 174, filed Thursday evening by François Proulx, another of the four, is the serious one. Change actions/checkout@v6.1.0 to a 40-character SHA that exists only in a fork, then hand-edit actions.lock to carry that SHA under the genuine upstream owner_id and repo_id with ref: 'v6.1.0'. A fresh generation refuses it: "commit 373d742f768e is not on any tag or branch of actions/checkout: it may be a fork or impostor commit served under the upstream name." But --verify reported "All 1 workflow valid", and a hosted runner under an active Require lockfile policy logged "Using locked action versions from the workflow's lockfile" and ran the fork's marker commit. The maintainer replied four and a half hours later that the backend defect was fixed and the CLI gap would follow next week.
What the offline check passes
The enforcement docs recommend gh actions-lock --verify-local --no-fix as a pre-commit hook: offline, no authentication, it "confirms that every dependency has a lockfile entry." My gh token had expired, so the online path told me "authentication token not found for host github.com" and the offline check was what I could run. I built v0.2.0 from the tagged source and pointed it at a repository with two workflows. The first used actions/checkout@v6, ossf/scorecard-action@v2.5.0, three docker:// steps (an Alpine tag, the super-linter tag and an Alpine digest), a parallel: group with setup-node@v4 and setup-java@v6, and a job calling Google's OSV scanner reusable workflow at v2.6.0. The second called an in-repo $/ composite that uses setup-python@v5 and a BusyBox tag.
With no lockfile it reported "2 of 2 workflows failed: 4 not-pinned": checkout twice, scorecard and setup-python. Not in the list: setup-node, setup-java, the OSV scanner call, and the four container images. Nothing was printed about any of them, and the source says why: the offline check builds its report without the parse warnings the online run carries. I then wrote a lockfile by hand covering exactly those four entries and ran the check again. "All 2 workflows have complete lockfile coverage", valid: true, exit 0. That is the hook the docs describe, passing a commit in which two actions, a reusable workflow and four images are unlocked.
One more variant: the same first workflow with a ./.github/actions/hello composite instead of $/. The check printed that the workflow was skipped because "local path actions are not yet supported", then "All 1 workflow have complete lockfile coverage", valid, exit 0. The README does say local-path workflows are skipped for onboarding until a fix run migrates them to $/. A skipped workflow counting as covered is still what the hook tells the developer.
What to do
Lock anyway. The lockfile closes the moved-tag attack on every JavaScript and composite action, records transitive dependencies you could not see, and binds names to repository identities, and none of that existed before Wednesday. Then draw the line the docs have not drawn. For every locked action, read its action.yml: if runs.using is docker, the image is outside the lock. Prefer the composite or JavaScript variant where one exists, or an action whose image carries a digest, and write your own docker:// steps in the @sha256: form, which the extension already treats as pinned.
Keep reusable workflow calls on full SHAs. Until the parallel fix ships, check every parallel: step's action against the lockfile by hand. Leave "Require actions to be pinned to a full-length commit SHA" on, since GitHub evaluates it against the locked commit for covered dependencies and against the YAML for everything else. Pin the extension with --pin v0.2.0, because the release notes say the format and flags change without notice. And run --verify in CI, not only --verify-local in a hook, because only one of them talks to GitHub.