# 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](https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised) 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](https://pydevtools.com/handbook/how-to/how-to-pin-dependencies-with-hashes-in-uv.md), 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:

```yaml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4  # v10.1.0
- 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.1.0`, 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](https://pydevtools.com/handbook/tutorial/setting-up-github-actions-with-uv.md) for the broader uv-in-CI workflow this slots into.

`astral-sh/setup-uv` publishes only full-version tags such as `v10.1.0`, with no moving `@v10`, so a workflow still on `@v7` never upgrades on its own. [How to upgrade setup-uv in GitHub Actions](https://pydevtools.com/handbook/how-to/how-to-upgrade-setup-uv-in-github-actions.md) 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:

```bash
git ls-remote https://github.com/astral-sh/setup-uv refs/tags/v10.1.0
```

```console
$ git ls-remote https://github.com/astral-sh/setup-uv refs/tags/v10.1.0
bec219d24cd3e171d82865faccec33120bb574f4  refs/tags/v10.1.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](https://github.com/suzuki-shunsuke/pinact) scans `.github/workflows/` and replaces every tag pin with the corresponding SHA and trailing tag comment.

Install it with Homebrew or Scoop:

```bash
brew install pinact
```
```bash
scoop bucket add suzuki-shunsuke https://github.com/suzuki-shunsuke/scoop-bucket
scoop 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:

```bash
pinact run
```

```console
$ 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.1.0
+       - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
```

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.1.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`:

```yaml
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.1.0 commit but labeled `# v9.0.0`, it logs a mismatch warning and prints the correction:

```console
$ pinact run --verify
.github/workflows/ci.yml:9
-       - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v9.0.0
+       - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
```

[zizmor](https://github.com/zizmorcore/zizmor) audits the whole workflow for supply-chain and misconfiguration issues. Its `unpinned-uses` audit fails any `uses:` line that is not a hash:

```bash
uvx zizmor .github/workflows
```

```console
$ uvx zizmor .github/workflows
error[unpinned-uses]: unpinned action reference
 --> .github/workflows/ci.yml:9:15
  |
9 |       - uses: astral-sh/setup-uv@v10.1.0
  |               ^^^^^^^^^^^^^^^^^^^^^^^^^^ 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.
