Pinning GitHub Actions to a commit SHA Jump to heading
Every third-party action in your pipeline runs code you did not write, on a machine holding your credentials, with access to your source. Referencing it by tag means you are trusting not only the code at that tag today but anyone who can move the tag tomorrow — and tags are mutable by design. Pinning to a commit id replaces that trust with a hash. This recipe pins an existing pipeline without freezing it in time, extending automated dependency updates.
When to use this approach Jump to heading
- Your pipeline uses actions maintained outside your organisation.
- Workflows have access to secrets, a package registry, or deployment credentials.
- You need a defensible answer to “what code ran in this build?”.
- A supply-chain review or compliance requirement has asked about third-party code.
- If every action you use is published from your own organisation and you control the tags, the risk is lower — but the pin still makes builds reproducible.
Step 1 — Inventory what your workflows currently trust Jump to heading
# Every external action reference, with the ref it resolves to
grep -rhoE 'uses:\s*[^ ]+' .github/workflows/ \
| sed 's/uses:[[:space:]]*//' | sort -u # Split them: already pinned, tag-referenced, or branch-referenced
grep -rhoE 'uses:\s*[^ ]+' .github/workflows/ | sed 's/uses:[[:space:]]*//' | sort -u | \
while read -r ref; do
case "${ref##*@}" in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*) echo "pinned $ref" ;;
v*) echo "tag $ref" ;;
*) echo "BRANCH $ref" ;;
esac
done Branch references are the urgent ones: they resolve to whatever was pushed most recently, so the code running in your pipeline can change between two runs of the same workflow.
Step 2 — Resolve each tag to its commit id Jump to heading
# Resolve a tag to the commit it currently points at
gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha'
# Annotated tags need one more hop to reach the commit
gh api repos/actions/checkout/git/tags/<tag-object-sha> --jq '.object.sha' 2>/dev/null # Do the whole file at once, keeping the human-readable version in a comment
python3 - <<'PY'
import re, subprocess, pathlib
for wf in pathlib.Path('.github/workflows').glob('*.yml'):
text = wf.read_text()
def pin(m):
repo, ref = m.group(1), m.group(2)
if re.fullmatch(r'[0-9a-f]{40}', ref):
return m.group(0)
sha = subprocess.run(
['gh', 'api', f'repos/{repo}/commits/{ref}', '--jq', '.sha'],
capture_output=True, text=True).stdout.strip()
return f'uses: {repo}@{sha} # {ref}' if sha else m.group(0)
wf.write_text(re.sub(r'uses:\s*([\w.-]+/[\w.-]+)@([\w.-]+)', pin, text))
print('pinned', wf)
PY # Verification: no tag or branch references remain
grep -rhoE 'uses:\s*[\w.-]+/[\w.-]+@[\w.-]+' .github/workflows/ \
| grep -vE '@[0-9a-f]{40}' || echo "all external actions are pinned" The trailing comment is not decoration. Without it, nobody reading the workflow can tell whether a pin is six weeks or three years old, and the file becomes unreviewable.
Step 3 — Let the update bot move the pins Jump to heading
Pinning does not mean freezing. The bot understands the pin-plus-comment convention and will propose a new commit id with an updated comment.
{
"packageRules": [
{
"matchManagers": ["github-actions"],
"pinDigests": true,
"groupName": "pipeline actions",
"schedule": ["before 6am on monday"],
"automerge": false
}
]
} # Verification: the bot proposes a digest change, not a tag change
gh pr list --label dependencies --json title --jq '.[] | select(.title | test("action"))' Step 4 — Enforce the rule so it does not decay Jump to heading
One person adding @v4 a month from now undoes the work silently.
# .github/workflows/policy.yml
name: policy
on: [pull_request]
jobs:
pinned-actions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: External actions must be pinned by commit id
run: |
bad=$(grep -rhoE 'uses:[[:space:]]*[A-Za-z0-9._-]+/[A-Za-z0-9._-]+@[A-Za-z0-9._-]+' \
.github/workflows/ \
| grep -vE '@[0-9a-f]{40}' \
| grep -v '^uses:[[:space:]]*\./' || true)
[ -z "$bad" ] || { echo "::error::unpinned actions:"; echo "$bad"; exit 1; } # Verification: introduce a tag reference and confirm the job fails
printf ' - uses: actions/setup-go@v5\n' >> .github/workflows/ci.yml
# ... push, observe the failure, then revert
git checkout -- .github/workflows/ci.yml SAFETY WARNING — a pin protects the action’s own code, not the code it downloads at runtime. An action that fetches a binary from a URL at execution time is as mutable as the URL, whatever its commit id. Read what an action does before trusting it with a job that holds credentials, and prefer actions that vendor what they need.
Step 5 — Reduce the number of actions you have to trust Jump to heading
The cheapest pin is the one you do not need. Many one-line actions wrap a command you could run directly.
# Before: a third-party action, its dependencies, and its permissions
- uses: some-org/setup-thing@<sha> # v2.1.0
# After: the command it runs, visible in the workflow
- run: curl -fsSL https://example.com/thing.tar.gz | tar -xz -C /usr/local/bin Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Does pinning mean we stop getting security fixes? Jump to heading
Only if nothing updates the pins, which is why Step 3 exists. Pinning changes when you adopt a change from “silently, at upstream’s convenience” to “in a pull request somebody reviews”. The fix still arrives; it arrives with a diff.
Are actions from the forge’s own organisation safe to tag-reference? Jump to heading
They are lower risk, not no risk, and mixing conventions makes the policy check harder to enforce. Pinning everything is simpler to reason about and costs nothing extra once the bot maintains the pins.
What about reusable workflows we own? Jump to heading
Reference them by a ref you control — a tag in your own repository is fine, because moving it is your decision and is itself governed by branch protection. The rule is about trust boundaries, not about syntax.
Related Jump to heading
- Automated Dependency Updates — the parent topic and the bot that maintains these pins.
- Auto-Merging Patch Updates Safely — why pipeline dependencies stay on the human-review side of the boundary.
- Limiting Workflow Permissions Per Job — shrinking what a compromised action could reach.