DEV Community

Cover image for A GitHub Actions tag is a promise, not a fact: pinning by SHA the right way
Wahib EL KHADIRI
Wahib EL KHADIRI

Posted on

A GitHub Actions tag is a promise, not a fact: pinning by SHA the right way

When you write uses: actions/checkout@v4 in a GitHub Actions workflow, you are not pinning anything. You are trusting a promise.

A tag is a movable pointer. Whoever owns that repository can repoint v4 at a different commit tomorrow, after you reviewed it, and every run of your pipeline will silently pull the new code. In a job that holds a registry login, a PyPI token, or a signing key, that is exactly the place you do not want a mutable reference.

I recently went through a project's workflows and pinned the remaining actions to commit SHAs. Here is what I actually learned doing it — beyond "just use the SHA".

A tag is a promise; a SHA is a fact

Compare these two lines:

# a promise: "v4 will keep meaning what you reviewed"
- uses: docker/login-action@v4

# a fact: this exact tree, forever
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
Enter fullscreen mode Exit fullscreen mode

The second form is content-addressed. dbcb813… can only ever refer to one tree of code. If upstream is compromised and someone force-pushes v4 to a malicious commit, your pinned SHA keeps running the code you actually audited. That is the whole security argument for pinning, and it is a real one — tag-repointing has been used in real supply-chain incidents.

Re-resolve the SHAs yourself

The interesting part is how you get the SHA. It is tempting to copy it from a table someone handed you. Don't. The point of pinning is to pin to code you verified, so resolve each ref yourself against the source of truth:

gh api repos/docker/login-action/commits/v4 --jq .sha
Enter fullscreen mode Exit fullscreen mode

This dereferences the ref to the commit it currently points at. Do it for every action, and you have both verified the value and produced the exact string to paste. When I did this across seven uses: lines, they matched the values I'd been given — but the trust now comes from having checked, not from having copied.

Keep the version comment, or you break Dependabot

Here is the subtlety that turns a security win into a maintenance problem. Once you pin to a SHA, the human readability is gone — nobody knows dbcb813 is v4 at a glance, and neither does your update tooling unless you tell it:

- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
Enter fullscreen mode Exit fullscreen mode

Dependabot parses that trailing # v4 comment. Keep it, and Dependabot keeps offering version bumps as PRs — you get the security of a pin and a maintained update path. Drop the comment, and Dependabot goes quiet: your pin quietly rots, and a stale pin nobody notices is its own risk. The comment is not decoration; it is the contract with your update bot.

The one pin you have to think about

Not every ref is a version tag. Some actions publish from a moving branch on purpose:

- uses: pypa/gh-action-pypi-publish@release/v1
Enter fullscreen mode Exit fullscreen mode

release/v1 is a branch the maintainers push fixes to. Pinning it to a SHA is still correct — but now you own the decision of when to move it forward, and this one holds the PyPI publish. That is a real trade: you have removed the silent-update risk, but you have taken on the duty to bump it deliberately when upstream ships a fix. Pin it, keep the # release/v1 comment so Dependabot still nudges you, and write down that you understand the trade. The judgement is the point, not the edit.

The takeaway

Pinning actions by SHA is a one-line-per-uses: change, and the diff looks trivial. The value isn't in the edit — it's in three habits:

  1. Resolve every SHA yourself against the upstream repo.
  2. Keep the # vX comment so Dependabot can still bump it.
  3. Name the moving-branch pins and accept that you now own their updates.

A tag says "trust me." A SHA says "here is exactly what runs." In the job that holds your credentials, prefer the fact.


This came out of a real contribution — pinning the remaining actions across a project's workflows in thingctx#127. If you maintain workflows with credentialed jobs, it's an afternoon's audit worth doing.

Top comments (1)

Collapse
 
circuit profile image
Rahul S

Pinning your direct actions by SHA is the easy half — the part that bites is that a pinned composite action can still do uses: foo/bar@v2 inside its own action.yml, so you've frozen the top layer and left the layer that actually executes on a mutable tag. Resolving the SHA yourself with gh api is good, but it only covers what you can see in your own workflow, not the transitive uses one hop down. Worth saying out loud too: a SHA pin doesn't make a compromised action safe, it just means the compromise has to arrive through a tag you later choose to bump to — which is exactly why keeping the # v4 comment for Dependabot matters. The pin freezes you, and the bump is where a human should actually be looking.