GitHub Actions pinned to SHA

Checks whether third-party GitHub Actions used in the workflows are pinned to a full commit SHA instead of a tag or branch.
category
security
weight
8
applies to
GitHub repositories only

How Plumb checks this

What is inspected, and how the result is decided.

A GitHub Actions workflow runs code from other repositories through uses: lines such as actions/checkout@v4. A tag like v4 or a branch name is a moving target: whoever controls that repository can point it at different code at any time, and every workflow using it will run the new code on its next run, with access to the repository's secrets. In March 2025 the popular tj-actions/changed-files action was compromised exactly this way and leaked CI secrets from thousands of repositories. A full 40-character commit SHA cannot be moved, so pinning to one guarantees the same code runs every time.

Plumb reads every workflow file in .github/workflows and collects each uses: reference from jobs and steps. References to the repository's own actions (paths starting with ./ and the repository's own reusable workflows) are skipped, because they are already protected by the repository's own review process. Each remaining reference counts as pinned when the part after @ is a 40-character hexadecimal SHA.

The check passes when every third-party reference is pinned. If more than 80 percent are pinned it warns and awards half credit. Otherwise it fails. The unpinned references are listed in the evidence so they can be fixed. The check is not applicable when the repository has no workflows or the workflows use no third-party actions, and it only runs for repositories hosted on GitHub.

Doing it well

Why this matters

When a workflow runs uses: some-org/some-action@v4, GitHub downloads whatever code the v4 tag points to at that moment. Tags and branches can be moved. If the action's repository is compromised, the attacker can point v4 at malicious code, and every workflow using it runs that code on its next trigger, with access to your secrets and your repository.

This is not a theoretical problem. In March 2025 the widely used tj-actions/changed-files action was compromised this way. Its version tags were rewritten to point at code that dumped CI secrets into build logs, and thousands of repositories were affected before the tags were fixed.

A commit SHA is different. It identifies one exact snapshot of code and cannot be changed to point anywhere else. Pinning to a SHA means the code you reviewed is the code that runs, every time.

What good looks like

Every third-party action and reusable workflow is referenced by its full 40-character commit SHA, with the human-readable version kept in a comment:

steps:
  - uses: actions/checkout@34e114876b0b11c390a56381ad2be3d6a4ef3b74 # v4.2.2
  - uses: shivammathur/setup-php@ccf2c627fe61b1b4d924adfcbd19d661a18133a0 # v2.32.0

Actions that live in your own repository, referenced as ./.github/actions/..., do not need pinning. They are already protected by your own branch rules.

How to do it

Pin by hand

  1. Open the action's repository on GitHub and go to its releases or tags.
  2. Click the tag you use, for example v4.2.2, and copy the full commit SHA it points to.
  3. Replace the tag in your workflow with the SHA and add the version as a trailing comment, as in the example above.

Pin with a tool

Doing this by hand across many workflows is tedious. Tools can rewrite every reference for you:

  • pinact pins every action in a repository in one run and keeps the version comment.
  • zizmor audits workflows for unpinned actions and other risky patterns, and can run in CI to stop new ones being added.
  • StepSecurity Secure Repo pins actions through a web interface and opens a pull request.

Keep pins up to date

A pinned SHA does not update itself, so pair pinning with an update bot. Both Dependabot and Renovate understand SHA pins with a version comment and will open pull requests that move the SHA and the comment together.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly

Renovate handles pinned actions by default, and its helpers:pinGitHubActionDigests preset will pin any remaining tags for you.

Things to watch for

  • Short SHAs. A 7-character abbreviation is not a pin. Use the full 40-character SHA.
  • Reusable workflows. uses: other-org/repo/.github/workflows/ci.yml@main carries the same risk as an action and needs the same treatment.
  • Pinning without an update bot. Pins stop silent changes, but they also stop silent updates, including security fixes. The update bot brings those back as reviewable pull requests.
  • Losing the version comment. Without it, nobody can tell what version a SHA is. Keep the comment; the bots rely on it too.

Further reading