---
title: "Pin GitHub Actions to a full-length commit SHA"
description: "Referencing actions by @v4 or @main lets a compromised tag run new code in your CI. Pin actions to a full-length commit SHA, with copyable YAML and real SHAs."
url: https://starsling.dev/best-practices/github-actions/pin-action-shas
canonicalUrl: https://starsling.dev/best-practices/github-actions/pin-action-shas
---

# Pin GitHub Actions to commit SHAs

Best practice: Security. Last updated: 2026-07-16

Reference every third-party action by its full 40-character commit SHA (with the version as a trailing comment), not a mutable `@v4` tag or `@main` branch that its maintainer, or an attacker, can re-point.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [Seen in the wild](#seen-in-the-wild)
- [Why it matters](#why-it-matters)
- [When to use](#when-to-use)
- [Verify on your repo](#verify)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

Third-party actions are pinned to an immutable 40-character commit SHA, with the human-readable version kept as a trailing comment so Dependabot and Renovate still track upgrades. A compromised upstream tag can no longer silently change the code that runs under your workflow's secrets and `GITHUB_TOKEN`.

_Pinned to an immutable SHA (real SHAs, copy them)_

```yaml
# The actions people pin most, at the full-length commit SHA of a release, with
# the version kept as a trailing comment. Resolved from upstream on 2026-07-14:
#   gh api repos/actions/checkout/git/ref/tags/v7.0.0 --jq .object.sha
# Every SHA below runs on node24. GitHub's runners have defaulted to Node24 since
# 2026-06-16 and drop node20 in the fall of 2026, so the previous majors
# (checkout v4, setup-node v4, cache v4) warn today and will fail later.
steps:
  - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
  - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
  - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
  - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
  - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
  - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

# A node24 action needs Actions Runner >= 2.327.1, which matters only if you
# host your own runners. Pin the versions you actually run: re-derive any SHA
# with the command above, then preserve the version as a trailing comment.
```

<a id="avoid-this"></a>

## Avoid this

A re-pointed tag runs new code under your secrets and GITHUB_TOKEN on the next CI run.

_Mutable tags and branches_

```yaml
steps:
  - uses: actions/checkout@v7        # tag, mutable, can be re-pointed
  - uses: some-org/some-action@main  # branch, anyone with push can change it
```

<a id="seen-in-the-wild"></a>

## Seen in the wild

Infisical, Trivy, and Immich pin actions to immutable commits so a moving tag cannot change what CI executes.

### Infisical/infisical - .github/workflows/check-be-ts-and-lint.yml

```yaml
      - name: ☁️ Checkout source
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: 🔧 Setup Node 22
        id: setup-node
        uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        with:
```

Key lines: `uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2`, `uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0`.

Every one of the 90 external action references in this repository is pinned to a full commit SHA. The version tag survives as a comment.

Source: [Infisical/infisical `.github/workflows/check-be-ts-and-lint.yml` lines 19-24](https://github.com/Infisical/infisical/blob/931fe8fe6892b79cfbec472c289da93355139048/.github/workflows/check-be-ts-and-lint.yml#L19-L24) at commit `931fe8f`.

### aquasecurity/trivy - .github/workflows/scan.yaml

```yaml
      - name: Checkout code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Run Trivy vulnerability scanner and create GitHub issues
        uses: knqyf263/trivy-issue-action@4466f52d1401b66dd2a2ab9e0c40cddc021829ec # v0.0.6
```

Key lines: `uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2`, `uses: knqyf263/trivy-issue-action@4466f52d1401b66dd2a2ab9e0c40cddc021829ec # v0.0.6`.

All 73 of them, third-party actions included. A security scanner holding itself to the advice it gives everyone else.

Source: [aquasecurity/trivy `.github/workflows/scan.yaml` lines 12-18](https://github.com/aquasecurity/trivy/blob/beb33f07859e459d15eb21e8071ca8caf159eeb3/.github/workflows/scan.yaml#L12-L18) at commit `beb33f0`.

### immich-app/immich - .github/workflows/test.yml

```yaml
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false
          token: ${{ steps.token.outputs.token }}
      - name: Run ShellCheck
        uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
```

Key lines: `- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3`, `uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0`.

Every action reference in this file is pinned to a full SHA. Elsewhere in it, that includes tj-actions/verify-changed-files -- from the org whose changed-files action was hijacked through a moving tag (CVE-2025-30066).

Source: [immich-app/immich `.github/workflows/test.yml` lines 686-691](https://github.com/immich-app/immich/blob/19313e75fd1860f7da56fd23dc9f3dabfe9478b6/.github/workflows/test.yml#L686-L691) at commit `19313e7`.

<a id="why-it-matters"></a>

## Why it matters

A tag like `@v4` is a moving pointer. If the action's maintainer account is compromised, the attacker re-points the tag at malicious code and your next CI run executes it with full access to your secrets. This is not hypothetical, the [tj-actions/changed-files compromise](https://www.wiz.io/blog/github-action-tj-actions-changed-files-supply-chain-attack-cve-2025-30066) (March 2025, CVE-2025-30066) worked exactly this way. Pinning to a SHA makes the code immutable; a branch ref like `@main` is the worst case and should be pinned or vendored. The [recommended docker/build-push-action config](/github-actions/docker-build-push-action) ships SHA-pinned for this reason.

<a id="when-to-use"></a>

## When to use

**Use it when:** Every third-party action reference. First-party `actions/*` are lower-risk but still worth pinning for a consistent policy.

**Be careful when:** Pinning has no real downside beyond upgrade friction, which Dependabot/Renovate handle via the version comment. For an action that only ships a `main` branch, vendor it (fork and pin your own SHA) or push upstream to cut releases.

<a id="verify"></a>

## Verify on your repo

Hand this prompt to your coding agent to audit and fix this practice in your own repo:

Inspect this repo's .github/workflows for third-party actions referenced by a mutable ref: `uses:` lines ending in `@v1` / `@v4` / `@main` / `@master` rather than a full 40-character commit SHA. For each, pin it to the immutable 40-char SHA of the release currently in use and keep the human-readable version as a trailing comment (`@<sha> # v7.0.0`) so Dependabot and Renovate still track upgrades. Show me the diff and open a PR rather than applying it blindly.

Prefer to check by hand:

- Scan for unpinned references: look for `uses:` lines ending in `@v1`/`@v2`/`@main`/`@master` rather than a 40-char hex SHA.
- Resolve the SHA for any tag yourself, so you can check the ones above (or pin a version we don't list): `gh api repos/actions/checkout/git/ref/tags/v7.0.0 --jq .object.sha`. If the tag is annotated, that returns a tag object, so deref it with `gh api repos/<owner>/<action>/git/tags/<sha> --jq .object.sha`.
- Convert them in one shot with a SHA-pinning rewrite tool, or resolve each tag yourself and preserve the version as a trailing comment.
- For stronger enforcement, run a workflow-security audit in CI that catches tag-pinned actions and SHAs that don't exist in the action's history.

<a id="more-best-practices"></a>

## More best practices for GitHub Actions

- [Scope id-token: write to the publishing job](https://starsling.dev/best-practices/github-actions/scope-id-token-per-job)
- [All CI best practices](https://starsling.dev/best-practices/github-actions)

<a id="faq"></a>

## FAQ

### Do I need to pin first-party actions like actions/checkout?

The immediate risk is highest for third-party actions, but pinning everything gives you one consistent, auditable policy. Pinning first-party actions too is the safer default.

### Won't pinning to a SHA break my automated updates?

No, keep the version as a trailing comment (`@<sha> # v7.0.0`). Dependabot and Renovate read that comment and still open upgrade PRs, so you get immutability and updates.

<a id="sources"></a>

## Sources

1. [GitHub - security hardening (pin actions to a full-length SHA)](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions)
