Skip to content
Français
CI/CD & Automatisation medium

Pinning actions by SHA

35 min de lecture

Read this page in French

You probably use GitHub actions such as actions/checkout@v4 in your workflows. That @v4 looks harmless, yet it is a security hole that has already enabled real attacks. This guide explains why, and how to protect yourself.

What you will learn

  • Understand the danger of moving tags (@v4, @main, @latest)
  • Recognise a tag-moving attack, the tj-actions/changed-files way
  • Pin an action to its commit SHA, with a version comment
  • Find the SHA of an action through the API, the CLI or the GitHub interface
  • Automate the conversion and the updates with pin-github-action and Dependabot
  • Check how well pinning covers a whole repository

What is wrong with @v4?

The @v4 suffix seen everywhere in workflows names a Git tag. Behind that harmless habit hides a moving reference, a structural weakness this section takes apart, first through an image, then in technical terms.

In technical terms

A Git tag such as @v4 is simply a label stuck on a commit. The problem? That label moves: anyone with access to the repository can shift it to another commit.

SHA versus tag: the difference between a moving label and an immutable fingerprint

❌ Tag (@v4)

  • It is a moving label
  • It can point at any code
  • The maintainer (or an attacker) can change it

✅ SHA (fingerprint)

  • It is a cryptographic fingerprint
  • It identifies one precise, unique piece of code
  • It cannot be changed or forged

How does the attack unfold?

This is not science fiction. In March 2025, the popular action tj-actions/changed-files was compromised in exactly this way.

Timeline of a mutable tag attack

  1. Day 1: you use action/example@v4, which points at commit abc123, legitimate code, tested and reviewed.

  2. Day 30: an attacker compromises the maintainer's account (phishing, weak password, stolen token, and so on).

  3. The attacker moves the tag: @v4 now points at a new commit xyz789 holding malicious code.

  4. Day 31: your workflow runs. It fetches @v4, and runs the malicious code without you noticing anything.

  5. Result: your secrets (GITHUB_TOKEN, API keys, credentials) are exfiltrated to the attacker.

The fix: pin to a SHA

Instead of trusting a moving label, use the cryptographic fingerprint (the SHA) of the commit. It is like handing over the exact fingerprint of the code you want to run.

# ❌ Dangerous: a mutable tag, it can change at any moment
- uses: actions/checkout@v4
# ✅ Safe: an immutable SHA, always the same code
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

Why the # v4.2.2 comment? It records which version that SHA corresponds to. It is purely informative, for you and for Dependabot, but the SHA is what carries the security.

How do you find the SHA of an action?

You now know why to use a SHA. How do you find it? Several methods exist, from the simplest to the most automated.

Method 1: through the GitHub API (fast)

The most direct request queries the GitHub REST API: it returns the commit SHA behind any tag, with nothing to install.

Fenêtre de terminal
# Get the SHA of tag v4.2.2 of the checkout action
curl -s https://api.github.com/repos/actions/checkout/commits/v4.2.2 \
| jq -r .sha

Expected result: 11bd71901bbe5b1630ceea73d27597364c9af683

Method 2: through the GitHub CLI (if installed)

If the gh CLI is installed and authenticated, a single call is enough: it handles authentication and extracts the SHA without curl or jq.

Fenêtre de terminal
gh api repos/actions/checkout/commits/v4.2.2 --jq .sha

Method 3: through the web interface

Without a terminal, the GitHub interface gives you the SHA in a few clicks from the repository's releases page.

  1. Open the action's repository (for example github.com/actions/checkout)
  2. Click the Releases tab in the sidebar
  3. Find the version you want (for example v4.2.2)
  4. Click the link to the associated commit
  5. Copy the full SHA (40 characters) from the URL or the page

Automate the pinning (do not do it by hand)

Converting each action manually would be tedious and error-prone. Fortunately, tools do the work for you.

Option 1: pin-github-action (command line)

This tool parses your workflows and replaces tags with their SHAs automatically:

Fenêtre de terminal
# Install the tool
npm install -g pin-github-action
# Convert a single workflow
pin-github-action .github/workflows/ci.yml
# Convert EVERY workflow at once
find .github/workflows -name "*.yml" -exec pin-github-action {} \;

Before (vulnerable):

- uses: actions/checkout@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

After (secured):

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0

Option 2: StepSecurity (web interface)

If you prefer a graphical interface, app.stepsecurity.io offers a simple path:

  1. Paste your workflow YAML into the interface
  2. The tool converts every tag into a SHA automatically
  3. Copy the secured result
  4. Replace your workflow file

Bonus: StepSecurity also reviews other security aspects of your workflows (permissions, exposed secrets, and so on).

Keeping the SHAs current (without effort)

A pinned SHA is good. But actions receive security fixes and new features. How do you stay current without going back to mutable tags?

Dependabot watches your dependencies and opens pull requests automatically when new versions land:

.github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly" # Checks every week
commit-message:
prefix: "ci" # Commit prefix: "ci: ..."

How it works: Dependabot reads the # v4.2.2 comment in your workflows. When v4.2.3 ships, it opens a pull request updating both the SHA and the comment. All you have to do is review and merge.

Solution 2: Renovate (an alternative)

Renovate is an alternative to Dependabot with more configuration options:

renovate.json
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"github-actions": {
"enabled": true,
"pinDigests": true
}
}

Checking that everything is pinned

How do you know whether your workflows are properly secured? The OpenSSF Scorecard tool analyses your repository and grades your security practices:

Fenêtre de terminal
scorecard --local . --checks Pinned-Dependencies --show-details

Reading the score:

ScoreMeaning
10/10Perfect. Every action is pinned by SHA
5-9/10Some actions still use tags
0/10Danger. Mutable tags everywhere (@v1, @latest, @main)

Do we pin the official GitHub actions too?

Yes. Even though the actions GitHub maintains (actions/*) are more trustworthy than third-party ones, pinning remains good practice:

ActionUseWhy pin it
actions/checkoutClone the repositoryIt has access to your source code
actions/setup-nodeInstall Node.jsIt runs scripts on the runner
actions/setup-pythonInstall PythonSame
actions/cacheCache dependenciesIt manipulates files
actions/upload-artifactSave artefactsIt has access to the produced files
actions/download-artifactRetrieve artefactsIt can inject files

The principle: any code running in your CI deserves to be verified and pinned, whatever its source.

Third-party actions: how do you assess the risk?

Not all actions are equal. Before adding a third-party action to your workflows, ask yourself these questions:

✅ Positive signals

  • Many stars and users
  • Regular commits, issues handled
  • Maintained by a known organisation
  • Readable, documented source code
  • Releases with a changelog

⚠️ Warning signs

  • Few stars, no maintenance
  • Asks for permissions: write-all
  • No tagged releases
  • Obfuscated or minified code
  • Maintained by an anonymous account

A complete example: a secured workflow

Here is a workflow applying every good practice:

.github/workflows/ci.yml
name: CI
# No permission by default: each job asks for what it needs
permissions: {}
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
# Every action is pinned by SHA
- name: Check out the code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Install Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '20'
cache: 'npm'
- name: Install the dependencies
run: npm ci
- name: Run the tests
run: npm test
- name: Publish the coverage report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage
path: coverage/

What makes this workflow secure:

  1. permissions: {} at the workflow level, contents: read granted only to the job that needs it
  2. Every action pinned by SHA, with a version comment
  3. persist-credentials: false on actions/checkout, so the GITHUB_TOKEN does not stay in the git configuration
  4. Version comments that Dependabot can act on
  5. No dubious third-party action

Recap: three steps to secure your workflows

Three concrete actions are enough to move from a vulnerable repository to pinned and maintained workflows.

  1. Convert every workflow with pin-github-action or StepSecurity

  2. Configure Dependabot to keep the SHAs current automatically

  3. Check regularly with Scorecard that nothing was forgotten

Key points

  • A Git tag (@v4, @main, @latest) is a moving reference: the maintainer, or an attacker, can shift it to any code.
  • The tj-actions/changed-files attack of March 2025 exploited exactly that mechanism to exfiltrate the secrets of thousands of pipelines.
  • Pinning an action to its commit SHA (40 characters) guarantees the pipeline always runs the same verified code.
  • The # v4.2.2 comment after the SHA is informative: it lets Dependabot offer version updates.
  • You do not pin by hand: pin-github-action or StepSecurity convert the workflows, Dependabot or Renovate keep the SHAs current.
  • The official actions/* actions get pinned too; any code running in CI deserves to be frozen.

Next steps

  • Auditing with zizmor: automatically spotting the actions still sitting on a mutable tag across all your workflows.
  • Scanning with poutine: extending the pinning check to a whole organisation, repository by repository.
  • Security checklist: placing pinning among the other checks to pass before merging.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn