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. A workflow or action kept in a different repository is treated like any other third-party reference, even when that repository belongs to the same owner, because it has its own access and branch settings. 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.

A workflow or action kept in a different repository is referenced the same way as a third-party one, by SHA with a version comment. That includes another repository of your own.

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.

Sharing a workflow across your own repositories

Many maintainers keep one reusable workflow in a hub repository and call it from every package, so a CI change is made once. That is a good pattern, and the caller line still needs a SHA.

Only ./ paths and references into the same repository are covered by the calling repository's branch rules. A separate repository under the same account has its own collaborators, branch and tag settings, and tokens, and a tag in it can be moved like any other tag. A shared workflow that every package calls, with access to every package's secrets, is the single most valuable target in the account. That is the reason to pin it, not a reason to skip it.

jobs:
  ci:
    uses: your-name/.github/.github/workflows/ci.yml@6c0c0d9a8f9e5f1b2a3c4d5e6f708192a3b4c5d6 # v1.4.3

The pin does not have to be edited by hand. Set the packages up so the update rolls out on its own:

  1. Change the shared workflow and cut a tag in the hub repository.
  2. The Dependabot configuration shown above opens a pull request in each package that moves the SHA and the version comment together. Dependabot follows reusable workflow references under jobs.<id>.uses as well as step actions. Do not list the hub repository under ignore in dependabot.yml, or nothing will update.
  3. Each package's own CI runs against the new workflow before the pull request merges, so a bad change to the shared workflow is caught in one pull request instead of reaching every package at once.
  4. Because the change is your own, let the pull request merge itself. Enable auto-merge in the package repository's settings and add a small workflow:
# .github/workflows/dependabot-auto-merge.yml
name: Dependabot auto-merge
on: pull_request
permissions:
  contents: write
  pull-requests: write
jobs:
  auto-merge:
    if: github.actor == 'dependabot[bot]'
    runs-on: ubuntu-latest
    steps:
      - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
        id: metadata
      - if: startsWith(steps.metadata.outputs.dependency-names, 'your-name/.github')
        run: gh pr merge --auto --squash "$PR_URL"
        env:
          PR_URL: ${{ github.event.pull_request.html_url }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Renovate covers the same flow. It updates SHA-pinned references by default, and a packageRules entry can merge updates from the hub repository automatically. Set automergeType to branch to skip the pull request entirely. The configuration can also live in the hub repository as a preset that each package extends with github>your-name/.github, so the update policy is shared the same way the workflow is.

{
  "extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
  "packageRules": [
    { "matchPackageNames": ["your-name/.github"], "automerge": true }
  ]
}

Pinning the caller freezes the workflow file only. Actions that the shared workflow itself calls by tag still float, so pin those inside the hub repository too.

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.
  • Your own other repositories. The ./ exemption covers only the calling repository. A uses: line pointing at another repository you own needs a SHA like any other, and the section above shows how to keep it current without editing every package.
  • 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