Lock your workflow dependencies.
Warning
Public preview. gh-actions-lock is pre-1.0 and under active development. The lockfile format, command flags, and behavior may change without notice between releases. Use it, file issues, and expect rough edges.
gh-actions-lock is part of GitHub's Workflow Dependency Pinning effort. It gives repositories a lockfile that pins every workflow dependency to a verified commit, so what runs on the runner is exactly what you locked. Development is ongoing and behavior may still change.
Contributions are welcome. See CONTRIBUTING.md to get started.
GitHub Enterprise Server (GHES) is not supported.
Requires the gh CLI. Install it first, then install the extension:
gh extension install github/gh-actions-lockScan every workflow under .github/workflows/ directory, pin each resolvable action to a SHA, and update the lockfile:
gh actions-lockAfter the initial run to onboard workflows, you will need to run gh actions-lock when:
- A new workflow is created that has
usesdependencies. - An existing workflow adds or removes
usesdependencies.
A full-directory run (gh actions-lock with no path arguments) also prunes lockfile entries for workflows that have been deleted from .github/workflows/, dropping any dependencies left orphaned by the removal. Scoped runs that name specific workflows never prune out-of-scope entries.
Locked pins are sticky: a normal run only re-resolves refs the lockfile does not record yet. Every locked pin, including transitive ones, is re-checked the way the Actions backend resolver does: the repository was not renamed or transferred, the pinned commit still exists, and it is still in the history of the tag or branch the lockfile recorded. A failed check blocks the run.
Writes are all or nothing: if a run finds any error it cannot fix itself, it changes no workflow and no lockfile entry, not even for workflows that are otherwise fine.
To check without writing anything, for example in CI, run:
gh actions-lock --verifyEvery mode checks each locked commit against the lockfile entry's ref, which
is looked up only as an existing tag, then branch, of exactly that name, the
way the Actions backend resolver does. Every pin, whether an exact tag
(v4.2.1), a partial version (v4), a branch (main) or a full SHA (uses: owner/repo@<sha>), must have its commit in that ref's history, so a fork
commit can't pass as upstream. Tags can move: the Actions backend accepts an
exact tag that moved ahead of the commit, so runs allow it and --verify only
warns. A ref the Actions backend resolver would reject makes the lockfile
invalid (exit 2): it must be a short tag or branch name the backend accepts,
never a SHA, a refs/ name, revision syntax such as main~1, or a git describe name, and it must equal the uses: ref unless that is a full SHA. A
uses: ref that is not such a tag or branch, such as pull/1/head or
main~1, is never locked.
Generation and relocking use the same tag-first lookup. If a tag and branch
share a name but point at different commits, the run fails without writing. Choose a distinct,
unambiguous tag or branch name; relocking the same ambiguous name cannot fix it.
Every online run except explicit relocking also reads action manifests at
the locked commits to check that the recorded transitive graph covers their
dependencies, without advancing to current upstream commits.
--verify-local runs offline and never writes. It checks direct refs and all
recorded transitive edges for missing dependency entries, but cannot discover an
edge erased from the lockfile or establish that recorded commits are
trustworthy; use --verify in CI for that.
To verify a repository you have not cloned, pass it with -R or as the first
argument. The repository is read through the API at one commit, so nothing is
cloned or written, and it works from outside any repository:
gh actions-lock --verify octo/api
gh actions-lock --verify -R octo/api --ref v1.2.0
gh actions-lock --verify https://github.com/octo/api/commit/<sha>Without a ref it verifies the default branch. A branch, a tag, a commit, or a
URL ending in /tree/<ref>, /releases/tag/<tag> or /commit/<sha> also
sets the ref. Pass .github/workflows/ci.yml after the repository to check
that workflow and the same-repository reusable workflows it calls. Missing
callees fail verification, including during a full-directory scan. A repository
with no actions.lock fails verification.
External job-level reusable workflow calls are not enforced by the caller's
lockfile. Mutable calls fail with a not-pinned error, even if a caller lock
entry exists; pin jobs.<id>.uses directly to a full SHA. The CLI does not
automatically lock or narrow these calls. Full-SHA calls retain a non-blocking
not-pinned warning in JSON and terminal output because the callee's own
lockfile and dependencies are not verified. This rule also applies to local
runs, including --verify-local.
Remote verification never writes a run log and rejects --profile. Repository
URLs cannot contain credentials, ports, query parameters or fragments. A
directory with 1,000 or more entries fails explicitly because the Contents API
cannot guarantee a complete listing at that size; select workflow files
explicitly instead.
If the repository was renamed or transferred, GitHub's redirect is followed and
a non-blocking repo-renamed finding names the new location. The lockfile does
not record the repository's own identity, so a repository replaced under the
same name cannot be detected; pin a commit to be exact. -R only works with
--verify, which stays read-only.
Pins to branches or partial versions (e.g. main, v4) are not advanced when
upstream moves. To regenerate the lockfile from the current upstream commits,
run:
gh actions-lock --relock--relock re-resolves the complete graph, even when no recorded ref has moved,
and restores missing entries and edges. A normal sticky write rejects dangling
recorded references: restore missing entries from version control to keep the old
commits, or explicitly relock to select current upstream commits.
Refreshes reject conflicting commits for a shared dependency rather than
combining manifests from different commits into one lockfile entry.
A composite action that reaches itself through uses: can't be locked; the run
fails before writing any file.
Suspicious pins whose recorded commit is no longer
reachable upstream are left as errors — use --accept-moved to re-resolve
those as well.
For a full-SHA pin, --accept-moved can repair its recorded ref only if an
accepted tag or branch still contains the same commit.
For a symbolic uses: key, a deleted recorded ref still blocks, even with
--relock --accept-moved. Restore the trusted ref or deliberately select and
review another ref. A branch force-push that removes the locked commit from its
history also blocks a normal run; it is not enough that another ref still
contains that commit.
Configure a minimum adoption age with the github-actions entry's
cooldown.default-days in .github/dependabot.yml (or .yaml), or with
cooldown_days in ~/.config/gh-actions-lock/config.yml. A positive Dependabot
default takes precedence over the user config. A missing or non-positive
Dependabot default falls back to the user config. With neither configured,
there is no age restriction; newly pinned recent releases receive an
informational notice.
The user config also supports per-repository overrides:
cooldown_days: 7
repos:
actions/checkout:
cooldown_days: 14Overrides apply when the user config is the selected policy; zero disables the restriction for that repository. Dependabot's semver-specific days and include/exclude filters are not supported and produce warnings.
For a one-off run or CI, gh actions-lock --cooldown-days 7 overrides the
global default. Default precedence is flag, positive Dependabot
default-days, user config cooldown_days, then none. A positive flag keeps
per-repo overrides from the selected policy: with the user config above,
--cooldown-days 3 still requires 14 days for actions/checkout and three for
other repositories. It does not import user-config overrides when Dependabot
is the selected policy. Explicit --cooldown-days 0 disables all cooldowns
for that run, including per-repo overrides; negative values are rejected.
Omitting the flag never disables a configured policy.
Cooldown applies before writing every newly adopted commit, including
transitive dependencies, branch refs, full SHAs, exact tags, --no-narrow,
--relock, and --accept-moved. A candidate that is too young, or whose date
cannot be established, blocks the entire write with a release-cooldown error.
The CLI does not guess an older target of a floating ref or swap commits behind
an already-resolved dependency graph. Existing approved pins are sticky and are
not re-aged; verification does not apply a new adoption policy retroactively.
Age uses the later of the selected commit's committer timestamp and the GitHub Release published_at dates available for the selected ref and tags pointing at that commit. Release metadata is reused from the recent-release cache, with exact-tag lookups for misses. Tag-to-commit association uses the existing tag listing (up to 100 tags); releases attached to other, unlisted aliases are not discovered. Without a release, the committer timestamp is the fallback. Missing/malformed timestamps and API failures fail closed. A 404 for a release means no visible release, so the commit timestamp is used instead.
These dates are not first-seen, push, branch-movement, or tag-creation times. Git commit timestamps are publisher-controlled: old or backdated commits can have been pushed or tagged recently. This is a CLI adoption check, not runtime age enforcement or proof of how long code has been publicly available.
Action commits do not pin the contents of mutable container image tags. The CLI
warns in terminal output about direct uses: docker://... images and images in
remote action manifests it reads: Docker actions' runs.image and composite
actions' uses: docker://..., including transitive actions and cached manifests.
Warnings name the action responsible and do not change JSON findings or exit
codes. Digest-qualified images do not warn.
--verify inspects the recorded commits' manifests. A sticky run that does not
need those manifests, or offline --verify-local, cannot discover images hidden
behind remote actions. Dockerfiles and their base images are not analyzed.
Authenticate gh to your tenant, then
run the extension from your tenant repository checkout:
gh auth login --hostname octocorp.ghe.com
# From the repository checkout:
gh actions-lockWith no conflicting environment overrides, the CLI infers the host from the
repository remote and uses the credentials stored by gh for that host. You do
not need to export a token or pass --hostname on every run. The account must
have read access to the tenant repositories used by your workflows.
--keep-sha leaves existing full-SHA uses: references unchanged while still
narrowing mutable semantic versions to exact tags. --no-narrow preserves both
SHA references and imprecise version references. Both options still resolve
dependencies and verify that each pinned commit belongs to its recorded ref;
neither bypasses lockfile verification.
uses: $/… references an action or reusable workflow in the same repository as
the defining file, resolved at the running commit of that workflow or action.
This is independent of the workspace populated by actions/checkout. The reference
is inherently pinned and needs no lockfile entry. It is valid anywhere a relative
./… reference is:
steps:
- uses: $/actions/my-action # same-repo action, inherently pinned
jobs:
call:
uses: $/.github/workflows/reusable.yml # same-repo reusable workflowA trailing @ref (e.g. $/actions/my-action@v1) is rejected — the ref is always
the running commit.
For a workflow triggered by pull_request_target, $/… in that workflow uses
the default-branch commit selected for the run (github.sha), regardless of the
PR's base branch (see GitHub's event semantics).
A checkout of the same repository with no ref, or with ref: ${{ github.sha }},
uses that commit too. Checking out ref: ${{ github.event.pull_request.head.sha }}
can make ./… use different code. The CLI never converts ./… references to $/….
Local path actions are allowed. gh actions-lock cannot and will not follow
./…: these paths refer to the runner workspace, which checkouts, downloads,
and scripts can change. Local actions are never locked, and their own uses:
dependencies are not covered by the lockfile.
Locking, sticky runs, --verify, and --verify-local report this limit as a
non-blocking local-action notice, including in JSON. Other directly named
actions and dependencies reached through $/… or remote composites are still
checked and locked normally. A local action does not excuse missing or rejected
pins elsewhere in the workflow.
Local references and action files are left unchanged; automatic migration is
no longer performed. --no-migrate-local-actions remains accepted as a
compatibility no-op. Using $/… is an explicit source choice with different
runtime semantics, not an automatic replacement for ./….
A repo gets a lockfile (located at .github/workflows/actions.lock) and workflows are onboarded to the lockfile on a per-workflow basis.
For actions covered by the lockfile, onboarded workflows enforce that dependencies are present and that the locked commit is executed on the runner. Workspace-local ./… actions and their dependencies are not covered. Lockfiles are also verified for forgeries. The sha must exist in the refs it's stated to exist in. Repository identity is recorded and redirects and mismatches are blocked at runtime.
Finally, locked actions must have a branch that the commit being locked exists within. This is to make impostor commit style attacks harder.
Workspace-local ./… actions are allowed but outside lockfile coverage.
The CLI never follows these paths or their nested dependencies, even when a
matching action file exists in the current checkout. Verification checks the
remaining lockfile coverage; a successful check does not validate local actions.
This project is licensed under the terms of the MIT open source license. See LICENSE for the full terms.
gh-actions-lock is maintained by @github/actions-dispatch-reviewers. See CODEOWNERS.
Support is best-effort and community-based. Please file bugs and feature requests as GitHub issues. See SUPPORT.md for details.
