Skip to content

How to pin GitHub Actions by SHA for Python projects

Tag-based pins like @v7 are mutable references that the action’s maintainer (or anyone who compromises their account) can repoint at malicious code, which then runs with access to your CI secrets. The March 2025 tj-actions/changed-files compromise worked exactly this way: an action used by 23,000+ repositories dumped CI secrets into public build logs. Pinning every third-party action to a full 40-character commit SHA makes the pin immutable and cuts off that attack path. This is the same principle as pinning Python dependencies with hashes, applied one layer up to your CI.

Pinning an action to a SHA

Replace the tag in every uses: line with the full commit SHA, and keep the human-readable version as a trailing comment:

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d  # v10.0.1
- run: uv sync --locked

The SHA is what GitHub Actions resolves, so the comment has no runtime effect. Write the full version in it (# v10.0.1, never # v10): Dependabot, pinact run --verify, and zizmor all read the comment to decide which release the SHA is supposed to match. See the setup-uv tutorial for the broader uv-in-CI workflow this slots into.

astral-sh/setup-uv publishes only full-version tags such as v10.0.1, with no moving @v10, so a workflow still on @v7 never upgrades on its own. How to upgrade setup-uv from v7 to the current release covers that move.

Finding the SHA for a tag

Use git ls-remote against the action’s repository to resolve a tag to its commit SHA without cloning:

git ls-remote https://github.com/astral-sh/setup-uv refs/tags/v10.0.1
$ git ls-remote https://github.com/astral-sh/setup-uv refs/tags/v10.0.1
20cfd1bf945f4377ade1205e4dbc17946fc9a30d  refs/tags/v10.0.1

The 40-character hash is the SHA to paste into uses:. The GitHub UI also exposes it: open the repository’s tags page, click the tag, and copy the commit hash from the URL or the commit header.

Converting a whole workflow with pinact

Rewriting dozens of uses: lines by hand invites mistakes. pinact scans .github/workflows/ and replaces every tag pin with the corresponding SHA and trailing tag comment.

Install it with Homebrew or Scoop:

brew install pinact

On any platform with Go installed, go install github.com/suzuki-shunsuke/pinact/v4/cmd/pinact@latest builds the same binary.

Run it from the repository root:

pinact run
$ pinact run
.github/workflows/ci.yml:7
-       - uses: actions/checkout@v7.0.1
+       - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
.github/workflows/ci.yml:8
-       - uses: astral-sh/setup-uv@v10.0.1
+       - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1

pinact edits every workflow file in place and prints one hunk per rewritten line. Review the diff, commit, and the repository is fully SHA-pinned. pinact run --check prints the same diff without editing and exits 1 while any uses: line is still unpinned, which makes it a CI gate against new tag pins.

Keeping pinned actions updated with Dependabot

A SHA pin that nobody updates rots into an unpatched dependency. Dependabot parses the # v10.0.1 trailing comment, checks upstream for newer releases, and opens pull requests that bump both the SHA and the comment together.

Create .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Dependabot then opens one PR per action whenever a new release ships. Each PR’s diff shows the SHA change alongside the release notes, so reviewing an action update takes seconds.

These version-update PRs are the only feed a SHA-pinned workflow gets. GitHub generates Dependabot alerts only for actions referenced by a semantic-version tag, not by SHA, so an advisory against a pinned action never appears on the alerts page. Keep the schedule running rather than waiting for an alert.

Verifying the SHA matches the tag

pinact trusts whatever SHA GitHub returns for a tag at conversion time. Two checks catch drift afterward.

pinact run --verify resolves each comment’s tag and rewrites any comment whose tag no longer points at the pinned SHA. Given a line pinned to the v10.0.1 commit but labeled # v9.0.0, it logs a mismatch warning and prints the correction:

$ pinact run --verify
.github/workflows/ci.yml:9
-       - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v9.0.0
+       - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1

zizmor audits the whole workflow for supply-chain and misconfiguration issues. Its unpinned-uses audit fails any uses: line that is not a hash:

uvx zizmor .github/workflows
$ uvx zizmor .github/workflows
error[unpinned-uses]: unpinned action reference
 --> .github/workflows/ci.yml:9:15
  |
9 |       - uses: astral-sh/setup-uv@v10.0.1
  |               ^^^^^^^^^^^^^^^^^^^^^^^^^^ action is not pinned to a hash (required by blanket policy)
  |
  = note: audit confidence → High
  = help: audit documentation → https://docs.zizmor.sh/audits/#unpinned-uses

Two further audits need GitHub API access, so pass --gh-token or set GH_TOKEN: ref-version-mismatch flags a pin whose comment names a tag that resolves to a different commit, and impostor-commit flags a SHA that exists only in a fork of the action’s repository, the trick an attacker uses to make a malicious commit look like it belongs upstream.

When a moving ref is fine

Internal actions inside the same organization have the same trust boundary as the calling repository: a compromise of my-org/internal-action is already a compromise of my-org. Pinning my-org/internal-action@main is reasonable for these, as is pinning actions published from the same repository as the workflow. Reserve SHA pinning for every third-party action outside that trust boundary.

The repository setting Require actions to be pinned to a full-length commit SHA (Settings → Actions → General) removes that exception. With it on, actions from your own organization and actions authored by GitHub must be SHA-pinned too; only reusable workflows may still be referenced by tag.

Last updated on