uses: some-org/some-action@v3 is a dependency declaration, and an unusual one: it resolves at run
time, to whatever v3 points at then, and the resulting code runs in your job with your token.
A git tag is a mutable pointer. The publisher can move it, and nothing in your repository changes.
What each reference form actually promises
Section titled “What each reference form actually promises”| Form | Resolves to | Immutable? |
|---|---|---|
@v3 | Wherever the v3 tag points now | No |
@v3.1.4 | Wherever that tag points now | No — tags can be moved or deleted |
@main | The branch head right now | No |
@a1b2c3… (full SHA) | Exactly that commit | Yes |
Even @v3.1.4 is not immutable. Tags are just refs; a publisher can retag or delete and recreate one.
The convention that a version tag is permanent is a convention, not an enforcement.
And by design, publishers do move major tags: the standard release process for an action is to
force-push v3 onto each new v3.x release. That is what makes @v3 pick up fixes automatically —
and it is the same mechanism that would distribute a compromise.
Pinning
Section titled “Pinning”- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1What it doesReferences an action by full 40-character commit SHA, with the human-readable version in a comment.
Why we run itA commit SHA is content-addressed — it cannot be repointed. Combined with the comment, the reference is both immutable and reviewable.
Expected resultExactly the same action code on every run, until you deliberately change the pin.
The trailing comment matters. Without it the workflow becomes unreadable, and nobody can tell whether a pin is current without looking it up.
Use the full 40-character SHA. Short SHAs are rejected.
Resolve a tag to its SHA:
gh api repos/actions/checkout/git/ref/tags/v7.0.1 --jq '.object.sha'Note the annotated-tag subtlety: for an annotated tag this returns the tag object’s SHA rather than
the commit’s, and pinning the wrong one fails at run time. git ls-remote makes both visible without
needing API authentication or a rate-limit budget:
git ls-remote --tags https://github.com/actions/checkout.git 'v7.0.1' 'v7.0.1^{}'A ^{} line, if present, is the commit the annotated tag points at — that is the one to pin. If only
one line comes back, the tag is lightweight and already names the commit.
What to pin, and what not to
Section titled “What to pin, and what not to”Pinning everything creates real maintenance work, so spend it where the risk is:
Always pin:
- Any action from an individual or small project.
- Any action in a workflow that holds deployment credentials or
id-token: write. - Any action in a reusable workflow or shared composite action — many repositories are downstream of it.
Reasonable to use a major tag:
- First-party
actions/*in low-privilege jobs, where the maintainer is GitHub itself and the job holds nothing worth stealing.
Never:
@mainor any branch, anywhere.
Keeping pins current
Section titled “Keeping pins current”Pinned actions do not update, which means an unmaintained pin is a stale dependency with unpatched bugs. Dependabot understands SHA pins and raises pull requests that update both the SHA and the comment:
{/* .github/dependabot.yml */}version: 2updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly groups: actions: patterns: ["*"]groups batches the updates into one pull request instead of fifteen, which is the difference between
a review that happens and a queue nobody reads.
Dependabot only scans .github/workflows/ at the configured directory by default. Composite actions
elsewhere in the repository need their own directory: entries — a common gap.
Organisation-level controls
Section titled “Organisation-level controls”Pinning is per-workflow, so it depends on everyone doing it. Two settings apply repository-wide and are stronger:
Actions permissions — Settings → Actions → General can restrict workflows to actions authored by GitHub, by verified creators, or to an explicit allowlist:
actions/*,docker/*,aws-actions/configure-aws-credentials@*An allowlist is the highest-leverage control here, because it applies to workflows nobody has reviewed yet, including ones added tomorrow.
Require approval for fork pull request workflows, so no outside contributor’s code runs without a human looking first.
Auditing existing references
Section titled “Auditing existing references”{/* Unpinned references across the repository */}grep -rn "uses:" .github/workflows/ \ | grep -v "uses: \./" \ | grep -vE "@[0-9a-f]{40}" \ | grep -vE "uses: actions/"{/* Anything referencing a branch — always worth fixing */}grep -rnE "uses: .+@(main|master|develop)" .github/workflows/The site you are reading maintains a registry of every action version it publishes
(src/lib/action-versions.ts) with an automated drift check, because a documentation site has the same
problem in a slightly different shape: a published example pinned to a version three majors old is
teaching something that no longer works.
The limits of pinning
Section titled “The limits of pinning”Pinning bounds one risk and leaves others:
- Transitive actions. A composite action you pinned may itself use unpinned actions. Your pin does not extend inside it.
- Runtime downloads. An action that fetches a binary at run time is not pinned by its SHA — the download is a moving target.
- Docker actions by tag.
image: docker://org/image:latestis a mutable reference inside a pinned action. Pin the image by digest. - The publisher’s account. Pinning protects against a tag being moved, not against a maliciously crafted release you then adopt.
Pinning is one layer. The others are the allowlist above, scoped permissions, and not giving low-trust jobs credentials at all.
Exercise
Section titled “Exercise”-
Run the audit commands and count unpinned third-party references.
-
Pin the action in your most privileged workflow — the one holding deployment credentials — to a full SHA with a version comment.
-
Confirm the workflow still runs.
-
Add the Dependabot config with grouping. Confirm the first pull request updates both the SHA and the comment.
-
Set an actions allowlist for a test repository. Add a workflow using an action outside it and read the failure.
-
Find an action in your workflows that downloads a binary at run time. Note that pinning the action did not pin that download.