Share some knowledge, skills and others — security research, pentesting notes and more.

View on GitHub
29 August 2026

Pin GitHub Actions to a Commit SHA

by allencharp

Introduction

Supply-chain security usually starts with packages — the poisoned tarball, the base image that was never what its tag claimed. There is a quieter intake path that gets far less attention: the third-party actions referenced by uses: at the top of every workflow.

# .github/workflows/ci.yml
name: CI
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4      # someone else's code
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci                    # your own command
      - run: npm test

Four steps: two run code you wrote, two fetch and run code someone else wrote. The uses: lines look like pinned versions. They are not — they are mutable pointers, re-resolved on every run, and a typical workflow contains ten to thirty of them, each one executing on your runner with your secrets.

Version tag vs commit SHA

What uses: Actually Does

A step comes in two forms: run: executes a command you wrote, uses: fetches and executes a program someone else wrote.

Reaching a uses: line, the runner asks GitHub what v4 currently points at, downloads that commit, and runs whatever action.yml defines — JavaScript, a container, or a shell entrypoint. Tags resolve at run time, every run.

That code is not sandboxed from your build. It is your build, on a machine holding your source, your GITHUB_TOKEN, and your cloud credentials. Worth asking: of the twenty-odd third-party actions your workflows execute, how many have you read?

Tags Are Pointers, SHAs Are Fingerprints

A commit SHA is derived from content — change one byte and the hash is entirely different. It cannot be forged, and it cannot be repointed.

A tag is an alias pointing at one commit. Aliases can be deleted and recreated against a different commit. The name stays v4; the content behind it does not have to.

# what does v4 point at right now?
curl -s https://api.github.com/repos/actions/checkout/commits/v4 | jq -r .sha

# anyone with push access to that repo can do this:
git tag -f v4 <their-commit> && git push -f origin v4

This is the same property that makes app:latest useless as an image identifier. The fix is the same too: reference content, not a name.

The 2023 tj-actions/changed-files Incident

An action used by tens of thousands of repositories was compromised; the attacker moved several version tags to a commit that dumped CI secrets into build logs.

The technique is unremarkable. The observability failure is the lesson:

Pinning does not stop a maintainer’s account from being compromised. It makes the change impossible to deliver silently — an attacker can move a tag, but cannot make an existing SHA mean something else. New code can only enter through a commit to your workflow, which is a reviewable event.

Pinning

Resolve the specific version, not the rolling major tag:

curl -s https://api.github.com/repos/actions/checkout/commits/v4.1.1 | jq -r .sha
# or: gh api repos/actions/checkout/commits/v4.1.1 --jq .sha
# before
- uses: actions/checkout@v4

# after
- uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v4.1.1

Keep the version as a trailing comment — the SHA is for the machine, the comment is for whoever reads the diff in six months.

Audit what you already have:

grep -rEn 'uses: [^@]+@(v[0-9]+(\.[0-9]+)*|main|master|latest)[[:space:]]*$' .github/workflows/

Staying Current

Pinning does not mean never updating — it means updates become a pull request instead of a silent background event.

How Dependabot keeps pinned actions current

The file that starts this is .github/dependabot.yml — two settings and nothing else: package-ecosystem: "github-actions", directory: "/" (mandatory, unused for actions), and interval: weekly. Dependabot rewrites both the SHA and the comment, and opens a PR titled with the new version.

Beyond Pinning

Pinning stops silent substitution. It does not stop you from voluntarily running a malicious action on day one:

Azure DevOps

Azure Pipelines does not use uses:, and the risk model genuinely differs — Marketplace tasks (task: NodeTool@0, task: Docker@2) are installed into the organisation at a fixed version, so an upstream compromise does not silently change what your pipeline runs.

One place has exactly the same weakness — referencing a template repository:

resources:
  repositories:
    - repository: securityTemplates
      type: git
      name: 'Platform/security-templates'
      ref: 'refs/tags/v1.4.0'   # deletable and re-pointable

Pin it to a commit instead:

      ref: '8f4b7f84864484a7bf31766abe9204da3cbe65b3'

This matters more than it looks: the extends pattern used to enforce pipeline security baselines depends entirely on that reference resolving to code you actually reviewed.

Checklist

A name is a promise, a hash is a fact. Substitute names for hashes wherever the cost of being wrong is someone else’s code running on your infrastructure.

tags: supply-chain-security - github-actions - cicd - devsecops - security-design