Pinning GitHub Actions to a Commit SHA
Every uses: line in a workflow is an instruction to download and execute code from someone else’s repository on a machine that holds your credentials. When that reference is a tag, the content it resolves to is under the control of whoever owns the repository — and a tag can be moved silently, which is exactly what happens when a maintainer account is compromised.
Pinning to a commit digest converts the reference from a label into content. This guide covers the migration: inventorying references, resolving each tag to its commit, keeping the version legible, automating the bump so pinning does not become staleness, and gating the regression in CI. It is part of the Build Pipeline & CI Runner Hardening guide within Supply Chain & Dependency Security.
Prerequisites
- Workflow files in version control, with review required to change them
- A token able to read public repository references (for resolution)
- Update tooling configured for the workflow ecosystem, not only for application dependencies
- A CI job able to fail the build on a pattern match
Expected Outcomes
- Every third-party action referenced by a full commit digest with the version in a comment
- Container images referenced by digest as well
- Automated pull requests that move the digest and the comment together
- A gate that fails on any reintroduced tag or branch reference
Step 1: Inventory Every Reference
# All third-party references, with the file and line for each.
grep -REn '^\s*(- )?uses:\s*[^ ]+' .github/workflows/ \
| grep -v 'uses: \./' \
| sed -E 's/\s+/ /g' \
| sort -u
# Container images referenced in run steps and job containers.
grep -REn 'image:\s*|docker run [^ ]+' .github/workflows/ | sort -u
Expect the list to be longer than anyone guesses — a mid-sized repository typically has thirty to fifty third-party references once reusable workflows are counted. Record whether each is first-party, widely used, or obscure, because the obscure ones deserve a look at their source before you pin anything at all.
Step 2: Resolve Tags to Commits and Keep the Version Readable
#!/usr/bin/env bash
# pin.sh — resolve owner/repo@tag to its commit and emit a pinned reference.
set -euo pipefail
ref="$1" # e.g. actions/[email protected]
repo="${ref%@*}"; tag="${ref#*@}"
sha=$(gh api "repos/$repo/git/ref/tags/$tag" --jq '
if .object.type == "tag" then .object.sha else .object.sha end')
# Annotated tags point at a tag object; dereference to the commit.
type=$(gh api "repos/$repo/git/tags/$sha" --jq .object.sha 2>/dev/null || echo "$sha")
echo "$repo@$type # $tag"
# The result, in the workflow:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
The trailing comment is not decoration. Without it, a reviewer cannot tell whether a digest is current or three years old, and the pull request that bumps it becomes an opaque hex change nobody can evaluate.
Step 3: Automate the Bump
Pinning without automation produces a repository frozen on two-year-old actions, which is its own risk. Configure the update tool to treat workflows as a first-class ecosystem.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule: { interval: weekly }
groups:
actions-minor-patch:
update-types: [minor, patch] # batch the noise, review the majors alone
commit-message:
prefix: "ci(actions)"
The tooling moves the digest and rewrites the version comment together, so the pull request reads as “v4.2.2 → v4.2.3” rather than as an unreviewable hex diff. Group patches to keep the queue manageable, and leave major bumps ungrouped because those are the ones that deserve a changelog read.
Step 4: Gate the Regression
name: workflow-hygiene
on: [pull_request]
permissions: { contents: read }
jobs:
check:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Every third-party step must be pinned to a 40-character commit
run: |
bad=$(grep -REn '^\s*(- )?uses:\s*[^ ]+@' .github/workflows/ \
| grep -v 'uses: \./' \
| grep -vE '@[0-9a-f]{40}(\s|$)' || true)
if [ -n "$bad" ]; then
echo "$bad"
echo "::error::Pin these to a commit digest (keep the version in a trailing comment)."
exit 1
fi
- name: Container images must be referenced by digest
run: |
bad=$(grep -REn 'image:\s*[^ ]+:[^@ ]+\s*$' .github/workflows/ || true)
[ -z "$bad" ] || { echo "$bad"; echo "::error::Reference images by digest."; exit 1; }
Run this on pull requests so a hurried workflow edit that reverts a pin is caught in review rather than discovered during an incident.
Verification
# 1. Nothing floats: no tags, branches or short digests remain.
grep -REn '^\s*(- )?uses:' .github/workflows/ | grep -v 'uses: \./' | grep -vE '@[0-9a-f]{40}'
# Expected: no output.
# 2. Every pinned digest still exists upstream (catches a typo or a force-push).
grep -RhoE '[^ ]+@[0-9a-f]{40}' .github/workflows/ | sort -u | while read -r ref; do
repo="${ref%@*}"; sha="${ref#*@}"
gh api "repos/$repo/commits/$sha" --jq '.sha' >/dev/null \
&& echo "ok $ref" || echo "MISS $ref"
done
# 3. The version comment matches the digest for a sample reference.
gh api repos/actions/checkout/git/ref/tags/v4.2.2 --jq .object.sha
The second check is worth running on a schedule: a digest that no longer resolves usually means the upstream repository was rewritten or removed, which is worth knowing before a build fails at an awkward moment.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Resolution returns a tag object, not a commit | Annotated tag needs dereferencing | Follow the tag object to its target commit before pinning |
| Update tool never proposes action bumps | Workflow ecosystem not configured | Add the actions ecosystem entry to the update configuration |
| Reviewers cannot judge a bump | Version comment missing or stale | Require the comment; have tooling rewrite it with the digest |
| Gate fires on local composite actions | Pattern does not exclude relative references | Exclude references beginning with a dot-slash path |
| A pinned digest stops resolving | Upstream history rewritten or repository removed | Re-pin to a current release, and review whether to keep the dependency |
| Workflow still runs unpinned code | A pinned composite action references others by tag | Resolve the full graph; consider vendoring for sensitive workflows |
Common Implementation Mistakes
Frequently Asked Questions
Does pinning mean we stop getting security fixes?
Only if you pin and then walk away. With update tooling configured for the workflow ecosystem, a new upstream release becomes a pull request that moves the digest and the version comment together, so the fix arrives through review rather than silently. That is strictly better than a floating tag, where you receive the fix and anything else that changed — including whatever an attacker added — with no review at all.
Do actions published by the platform vendor need pinning too?
Yes. First-party actions are widely trusted, which makes them a high-value target rather than an exception, and pinning them costs exactly the same as pinning anything else. A uniform rule is also far easier to enforce: “everything is pinned” is a single grep, whereas “everything except this list” becomes a list nobody maintains and an argument in every review.
What about actions referenced inside other actions?
A composite action can reference further actions by tag within its own definition, so your pin covers the entry point but not the whole graph. Prefer actions whose sources you can read, resolve the full graph periodically with a script, and for the most sensitive workflows consider vendoring a small action into your own repository so that every link in the chain is one you control.
Related
- Build Pipeline & CI Runner Hardening — the parent guide covering the whole pipeline threat model
- OIDC Federation Instead of Long-Lived Cloud Keys — removing the credential a compromised action would look for
- Verifying Container Image Signatures With Cosign — extending immutability to what you deploy
- Dependabot Auto-Merge as Policy-as-Code — the merge policy that governs these bumps