Yesterday I pinned 31 GitHub Actions to commit SHAs for a set of workflows I was packaging. Every SHA was resolved from the upstream repository rather than copied from a README or a tutorial, and the process turned up three things worth writing down — including one action whose v3 major tag is two full major versions behind its newest release.
Here is how to pin correctly, without the API and without trusting anyone's blog post, and what the exercise found.
Why the SHA, not the tag
uses: actions/checkout@v7 runs whatever commit the v7 tag points to at the moment your job starts. The maintainer moves that tag for every patch release — which is the feature — and anyone who takes over the repository or a maintainer's account can move it too — which is the problem. A commit SHA cannot be moved. Pinning to it means the code that runs is the code you reviewed, until you change the pin.
The convention is the SHA plus the release in a comment, so a human can still read the file:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Dependabot's github-actions ecosystem updates both halves together, so pinning does not cost you updates.
Resolving a SHA without the API
The GitHub REST API is rate-limited to 60 unauthenticated requests an hour, and resolving one pin can take three calls (release → tag → annotated tag object). git ls-remote over HTTPS has no such limit and needs no token:
git ls-remote --tags https://github.com/actions/checkout | grep -E 'refs/tags/v7(\.0\.1)?(\^\{\})?$'
3d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7
3d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7.0.1
Two facts in two lines: the v7 major tag and the v7.0.1 release point at the same commit, so v7 currently means 7.0.1, and that is the SHA to pin.
One subtlety. Some projects use annotated tags, which are objects of their own; ls-remote then prints two lines for one tag — the tag object and, with a ^{} suffix, the commit it points to. You want the ^{} one:
006b7ce8314066bdf1765b4500370d40fa6917a3 refs/tags/v0.24.2
3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 refs/tags/v0.24.2^{}
Pin 3ad7283…, not 006b7ce…. A tag-object SHA in a uses: line fails with "unable to resolve action" — a confusing error for a correct-looking pin.
I wrapped this in a short script: for each repository, take the highest vN tag, its peeled commit, and whichever vX.Y.Z tag shares that commit. Thirty-one actions in about a minute.
What the exercise found
1. A major tag that is two majors stale. actions/dependency-review-action@v3 resolves to a commit with no v3.x.y release tag at all — the newest release is v5.0.0. The v3 tag was never advanced to anything after v3's last patch, so anyone still on @v3 is on an unmaintained line and does not know it. Pin v5.0.0 (a1d282b36b6f3519aa1f3fc636f609c47dddb294).
2. A major tag that lags its own releases. sigstore/cosign-installer@v3 points at v3.9.1; the newest release is v4.1.2. Not wrong, but "I'm on the latest major" is a belief that a major tag does not support. Only the release list does.
3. No major tag at all. aquasecurity/trivy-action publishes only exact versions (0.35.0), no floating v0. A workflow written as @master — and there are many — is pinned to a branch, the one thing worse than a tag.
None of this is visible from inside a workflow file. It is visible in about a second from ls-remote.
The rest of the checklist that goes with pins
Pinning is one line of a security posture, and on its own it is the least important line. The workflows I was packaging apply the same six things everywhere, and if you copy nothing else from this post, copy this block:
permissions:
contents: read # read-only by default; widen per job, never here
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }} # cancel CI, never a deploy
jobs:
test:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # the token is not written to .git/config
- name: Use a branch name safely
env:
REF: ${{ github.head_ref }} # context values go through env…
run: echo "building $REF" # …never interpolated into the script
And the two rules that are about events, not syntax: nothing that publishes or deploys runs on pull_request, and pull_request_target is not used at all. A fork's pull request gets a read-only token and no secrets — GitHub enforces that — but only if you never reach for the trigger that gives it your secrets back.
Each of those has a free lesson behind it — pinning, least-privilege permissions, workflow security — and a lab where you find all of them missing from a workflow that looks fine: secure an unsafe workflow.
Where the 31 pins ended up
The workflows were for a product I released yesterday: the GitHub Actions Production Workflow Vault — twenty workflows (CI for Python, Node, Go, Java and .NET; Docker build-and-test, GHCR and multi-arch publishing; Terraform PR validation; Ansible with Molecule; CodeQL, dependency review, gitleaks, SBOM and attestations; release, Pages, Kubernetes; AWS, Azure and GCP via OIDC), three reusable workflows and a composite action, with every one of those six rules applied and the full pin table in the docs, verification method included. It is $19.99 once, no subscription, and it comes with the honest caveat that no workflow works unmodified — every file marks what to change.
If you would rather do it yourself, the ls-remote command above and the six lines of YAML are the whole method. Pin today; let Dependabot keep it current.
Top comments (0)