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.
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
doneThen 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.
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
doneExpected 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.
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.
Related
- Pipeline Security & Supply Chain Hardening β the parent guide, including the permissions model that limits what a compromised action could do.
- Authenticating to AWS from GitHub Actions with OIDC β removes the standing credential an unpinned action would otherwise reach.
- CI/CD Pipeline Architecture & Fundamentals β the section overview.
- Securing & Invalidating Build Caches β the other place untrusted input enters a build.
β Back to Pipeline Security & Supply Chain Hardening