Pinning GitHub Actions to Commit SHAs

uses: some-org/setup-thing@v3 looks like a version. It is a pointer, and whoever controls the upstream repository can move it β€” so the code your pipeline runs tomorrow may not be the code you reviewed today, with no diff, no notification, and full access to whatever the job can reach.

When to use this pattern

  • Your workflows reference third-party actions by tag or branch.
  • Those workflows hold deploy credentials, publish artifacts, or can write to the repository.
  • You want upstream changes to arrive as a reviewable pull request rather than silently.

Prerequisites

What a mutable tag actually resolves to

An action reference is resolved at run time, on the runner, against whatever the upstream repository says the ref means at that moment. Tags are ordinary Git refs: they can be deleted and recreated pointing anywhere. The pipeline has no memory of what the tag meant during review.

Tag Reference vs SHA Reference at Resolution Time The top path shows a workflow referencing a v3 tag: during review it resolves to commit a1b2c3, after an upstream force-move it resolves to commit deadbe, and the workflow runs unreviewed code. The bottom path shows the same workflow pinned to a1b2c3, which resolves to the same commit on both runs regardless of where the tag moved. uses: org/action@v3 run on Monday reviewed resolves commit a1b2c3d run on Friday same workflow file commit deadbee tag was moved unreviewed code runs with the job's full token uses: org/action@a1b2c3d… (40 chars) any run, any day commit a1b2c3d upstream cannot change it

A commit SHA is content-addressed: it names a specific tree of files and cannot be repointed by anyone, including the repository owner. That single property is what converts an action from a trust relationship into a dependency.

It is worth being clear about how much access is at stake, because the mitigation looks disproportionate until you count it. An action runs as a step inside your job, on your runner, with the job’s environment, the workflow token, any secrets that step can see, and network egress. It can read the checkout, write to it, and exfiltrate anything it finds. Nothing sandboxes it β€” the trust boundary is the reference you typed. That is why the industry advice is not β€œpin the risky ones” but β€œpin all of them”: there is no way to look at a uses: line and tell which category it is in.

Complete working example

#!/usr/bin/env bash
# scripts/pin-actions.sh β€” rewrite every tag reference to its current commit SHA.
set -euo pipefail

# Collect "owner/repo@ref" from every workflow, ignoring already-pinned entries and local actions.
refs=$(grep -rhoP '^\s*(-\s+)?uses:\s*\K[\w.-]+/[\w.-]+@[^\s#]+' .github/workflows/ \
       | grep -vP '@[0-9a-f]{40}$' | sort -u)

for ref in $refs; do
  repo="${ref%@*}"; tag="${ref#*@}"

  # Resolve the tag to the commit it points at RIGHT NOW. Annotated tags need the
  # dereferenced object, which is why we prefer ^{} when it exists.
  sha=$(git ls-remote "https://github.com/${repo}.git" "refs/tags/${tag}^{}" "refs/tags/${tag}" \
        | head -1 | cut -f1)
  [ -n "$sha" ] || { echo "could not resolve ${ref}" >&2; exit 1; }

  echo "pinning ${repo}: ${tag} -> ${sha}"
  # Replace the reference and append the human-readable version as a trailing comment,
  # which is also what Dependabot reads when it later bumps the pin.
  grep -rl "uses: ${repo}@${tag}" .github/workflows/ | while read -r f; do
    sed -i "s|uses: ${repo}@${tag}.*|uses: ${repo}@${sha} # ${tag}|" "$f"
  done
done

Then make the state enforceable, so nobody reintroduces a tag by copy-pasting a snippet:

# .github/workflows/pin-check.yml β€” required status check
name: Action Pin Check
on: [pull_request]

permissions:
  contents: read

jobs:
  pinned:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683   # v4.2.2
      - name: Fail on unpinned third-party actions
        run: |
          # Any `uses:` whose ref is not a 40-hex SHA, excluding local (./) and docker:// refs.
          bad=$(grep -rnP '^\s*(-\s+)?uses:\s*(?!\./|docker://)[\w.-]+/[\w.-]+@(?![0-9a-f]{40}\b)' \
                .github/workflows/ || true)
          if [ -n "$bad" ]; then
            echo "::error::Unpinned action reference(s) found:"; echo "$bad"; exit 1
          fi
          echo "all action references are SHA-pinned"

Finally, keep the updates flowing:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule: { interval: weekly }
    # Dependabot rewrites the SHA and the trailing version comment together,
    # so the pin stays readable while remaining immutable.

Step-by-step walkthrough

Why the full 40 characters. Short SHAs are ambiguous: Git resolves a prefix, and a sufficiently short prefix can be made to collide deliberately. GitHub rejects references shorter than the full hash for exactly this reason, and the enforcement regex above matches only the full length.

Why refs/tags/<tag>^{} first. Release tags are usually annotated, meaning the tag object is itself a Git object that points at the commit. Resolving without the dereference suffix gives you the tag object’s hash, which is not what the runner will check out. Asking for both and taking the first match handles annotated and lightweight tags in one pass.

Why the trailing comment matters. @11bd719… tells a reviewer nothing. The # v4.2.2 comment is what makes the diff of an update readable β€” and both Dependabot and Renovate parse it, so it stays accurate automatically rather than rotting into a lie.

Why the check is a separate required job. Putting the grep at the end of an existing test job means it is skipped whenever that job is skipped β€” which is precisely when someone is editing workflow files in isolation. A dedicated job that runs on every pull request cannot be routed around.

Together these three pieces form a loop that keeps the pins both immutable and current, without anyone having to remember anything.

The Pin Maintenance Loop A five-stage cycle: upstream publishes a release, the update bot opens a pull request changing the SHA and the version comment, the required pin check validates the format, a human reviews the diff, and the merged workflow runs exactly the reviewed commit β€” then the cycle waits for the next upstream release. Upstream v4.2.3 published Bot opens PR SHA + comment bumped Pin check 40-hex or fail Review a real diff exists Merged runs that commit the loop waits for the next upstream release β€” nothing changes in between Without pinning, stages two through four do not exist: the change simply happens.

Verification

# 1. Nothing unpinned remains.
grep -rhoP '^\s*(-\s+)?uses:\s*\K[^\s#]+' .github/workflows/ | grep -v '@[0-9a-f]\{40\}$' \
  && echo "STILL UNPINNED" || echo "all pinned"

# 2. Every pin resolves to a commit that really exists upstream.
grep -rhoP 'uses:\s*\K[\w.-]+/[\w.-]+@[0-9a-f]{40}' .github/workflows/ | sort -u | while read -r r; do
  repo="${r%@*}"; sha="${r#*@}"
  code=$(curl -s -o /dev/null -w '%{http_code}' \
    -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/${repo}/commits/${sha}")
  printf '%s %s\n' "$code" "$r"     # expect 200 for every line
done

Expected output from the first command is all pinned; the second should print 200 on every row. A 404 means the pin references a commit that is no longer reachable β€” usually copied from a fork, or force-pushed away upstream β€” and that workflow will fail the next time it runs.

Common pitfalls

Pinning to a branch instead of a commit. @main is the most mutable ref there is. The enforcement regex catches it, which is a good reason to add the check in the same pull request as the pins rather than later.

Assuming the pin covers transitive actions. A composite action you pinned may itself call other actions by tag, and those resolve upstream at run time. Pinning your references narrows the surface but does not eliminate it β€” which is why the least-privilege permissions block in pipeline security and supply chain hardening does the rest of the work.

What Pinning Covers, and What It Does Not Your workflow's two direct references are pinned and immutable. One of them is a composite action that internally calls two further actions by tag, which are still resolved upstream at run time. A band underneath marks least-privilege job permissions as the control covering that residual layer. Your workflow β€” pinned, immutable checkout@11bd719… setup-thing@fe02b34… resolution is fixed at review time Inside setup-thing β€” still tag-resolved cache@v4 download-tool@v2 resolved on the runner, outside your review permissions: contents: read β€” caps what anything in either box can do Pinning shrinks the untrusted region; least privilege bounds the damage in what remains.

Letting the version comment drift. Hand-editing a SHA without updating the comment produces a reference that lies about what it runs. If Dependabot manages the file, do not edit pins manually; let the bot own them.

← Back to Pipeline Security & Supply Chain Hardening