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 leaked secrets from thousands of repositories precisely this way. 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@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57  # v8.0.0
- run: uv sync --locked

The SHA is what GitHub Actions resolves, so the comment has no runtime effect. It tells humans, Dependabot, and scanners which release the SHA corresponds to. See the setup-uv tutorial for the broader uv-in-CI workflow this slots into.

Note

astral-sh/setup-uv stopped publishing moving v8 and v8.0 tags in its v8.0.0 release (March 2026). Only immutable full-version tags like v8.0.0 are published, which nudges consumers toward SHA pinning or explicit full-version tags.

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/v8.0.0
$ git ls-remote https://github.com/astral-sh/setup-uv refs/tags/v8.0.0
cec208311dfd045dd5311c1add060b2062131d57  refs/tags/v8.0.0

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 go install:

brew install suzuki-shunsuke/pinact/pinact

Run it from the repository root:

pinact run

pinact edits every workflow file in place. Review the diff, commit, and the repository is fully SHA-pinned. Run pinact run --check in CI to fail builds that introduce an unpinned uses: line.

Keeping pinned actions updated with Dependabot

A SHA pin that nobody updates rots into an unpatched dependency. Dependabot parses the # v8.0.0 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.

Verifying the SHA matches the tag

Pinact trusts whatever SHA GitHub currently returns for a tag, which is fine when the tag has not been force-moved. For a stronger check, run zizmor against the workflows directory. zizmor audits GitHub Actions workflows for common security issues, including unpinned actions and suspicious patterns, and is a useful companion to pinact in CI.

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.

Learn More

Last updated on

Please submit corrections and feedback...