Pinning GitHub Actions to a Commit SHA

Every uses: line in a workflow is an instruction to download and execute code from someone else’s repository on a machine that holds your credentials. When that reference is a tag, the content it resolves to is under the control of whoever owns the repository — and a tag can be moved silently, which is exactly what happens when a maintainer account is compromised.

Pinning to a commit digest converts the reference from a label into content. This guide covers the migration: inventorying references, resolving each tag to its commit, keeping the version legible, automating the bump so pinning does not become staleness, and gating the regression in CI. It is part of the Build Pipeline & CI Runner Hardening guide within Supply Chain & Dependency Security.

Prerequisites

  • Workflow files in version control, with review required to change them
  • A token able to read public repository references (for resolution)
  • Update tooling configured for the workflow ecosystem, not only for application dependencies
  • A CI job able to fail the build on a pattern match

Expected Outcomes

  • Every third-party action referenced by a full commit digest with the version in a comment
  • Container images referenced by digest as well
  • Automated pull requests that move the digest and the comment together
  • A gate that fails on any reintroduced tag or branch reference

Step 1: Inventory Every Reference

# All third-party references, with the file and line for each.
grep -REn '^\s*(- )?uses:\s*[^ ]+' .github/workflows/ \
  | grep -v 'uses: \./' \
  | sed -E 's/\s+/ /g' \
  | sort -u

# Container images referenced in run steps and job containers.
grep -REn 'image:\s*|docker run [^ ]+' .github/workflows/ | sort -u

Expect the list to be longer than anyone guesses — a mid-sized repository typically has thirty to fifty third-party references once reusable workflows are counted. Record whether each is first-party, widely used, or obscure, because the obscure ones deserve a look at their source before you pin anything at all.


Step 2: Resolve Tags to Commits and Keep the Version Readable

#!/usr/bin/env bash
# pin.sh — resolve owner/repo@tag to its commit and emit a pinned reference.
set -euo pipefail
ref="$1"                                  # e.g. actions/[email protected]
repo="${ref%@*}"; tag="${ref#*@}"

sha=$(gh api "repos/$repo/git/ref/tags/$tag" --jq '
  if .object.type == "tag" then .object.sha else .object.sha end')

# Annotated tags point at a tag object; dereference to the commit.
type=$(gh api "repos/$repo/git/tags/$sha" --jq .object.sha 2>/dev/null || echo "$sha")
echo "$repo@$type # $tag"
# The result, in the workflow:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683   # v4.2.2
- uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0

The trailing comment is not decoration. Without it, a reviewer cannot tell whether a digest is current or three years old, and the pull request that bumps it becomes an opaque hex change nobody can evaluate.

A Tag Is a Promise Someone Else Can Break A tag reference resolves at run time to whatever content the tag currently points at, which the upstream owner can change at any moment without any signal to you. A digest reference resolves to fixed content, so the code that was reviewed is the code that executes, and an upstream change becomes a pull request instead of a silent substitution. uses: owner/action@v4 resolved at run time, every run upstream may repoint the tag at any moment no signal reaches you when it happens Reviewing the action once tells you nothing about what will execute tomorrow. One compromised account, every build. uses: owner/action@11bd719… # v4.2.2 resolves to fixed content, always upstream changes require a pull request here the comment keeps the version legible What you reviewed is what runs, until you deliberately decide otherwise. Updates arrive through review, not silently.

Step 3: Automate the Bump

Pinning without automation produces a repository frozen on two-year-old actions, which is its own risk. Configure the update tool to treat workflows as a first-class ecosystem.

Pinned and Automated Is the Only Stable State Floating tags stay current but execute whatever upstream decides. Pinned with automated updates stays current through reviewed pull requests. Pinned and abandoned is immutable but accumulates known bugs and missing fixes, which eventually pressures someone into unpinning everything at once. Floating tags — current, and out of your control you get fixes automatically, and everything else the tag now points at, with no review Pinned and automated — current, and reviewed a weekly pull request moves the digest and the version comment together; a human approves Pinned and abandoned — immutable, and increasingly broken the state that eventually convinces someone to unpin everything in one afternoon
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: "/"
    schedule: { interval: weekly }
    groups:
      actions-minor-patch:
        update-types: [minor, patch]        # batch the noise, review the majors alone
    commit-message:
      prefix: "ci(actions)"

The tooling moves the digest and rewrites the version comment together, so the pull request reads as “v4.2.2 → v4.2.3” rather than as an unreviewable hex diff. Group patches to keep the queue manageable, and leave major bumps ungrouped because those are the ones that deserve a changelog read.


Step 4: Gate the Regression

name: workflow-hygiene
on: [pull_request]
permissions: { contents: read }
jobs:
  check:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683   # v4.2.2
      - name: Every third-party step must be pinned to a 40-character commit
        run: |
          bad=$(grep -REn '^\s*(- )?uses:\s*[^ ]+@' .github/workflows/ \
                 | grep -v 'uses: \./' \
                 | grep -vE '@[0-9a-f]{40}(\s|$)' || true)
          if [ -n "$bad" ]; then
            echo "$bad"
            echo "::error::Pin these to a commit digest (keep the version in a trailing comment)."
            exit 1
          fi
      - name: Container images must be referenced by digest
        run: |
          bad=$(grep -REn 'image:\s*[^ ]+:[^@ ]+\s*$' .github/workflows/ || true)
          [ -z "$bad" ] || { echo "$bad"; echo "::error::Reference images by digest."; exit 1; }

Run this on pull requests so a hurried workflow edit that reverts a pin is caught in review rather than discovered during an incident.

Pinning Is a Loop, Not a One-Off Migration Five stages that repeat. Inventory finds every third-party reference. Resolution converts each tag to a commit. Annotation keeps the version readable for reviewers. Automated updates propose digest bumps on a schedule. The CI gate ensures nothing slips back to a tag between cycles. 1 · Inventory every reference 2 · Resolve tag to commit 3 · Annotate version comment 4 · Automate weekly bumps 5 · Gate no tags return Skipping stage 4 produces a repository pinned to ancient actions; skipping stage 5 produces one that drifts back to tags within a quarter. Both stages are what make the migration hold.

Verification

# 1. Nothing floats: no tags, branches or short digests remain.
grep -REn '^\s*(- )?uses:' .github/workflows/ | grep -v 'uses: \./' | grep -vE '@[0-9a-f]{40}'
# Expected: no output.

# 2. Every pinned digest still exists upstream (catches a typo or a force-push).
grep -RhoE '[^ ]+@[0-9a-f]{40}' .github/workflows/ | sort -u | while read -r ref; do
  repo="${ref%@*}"; sha="${ref#*@}"
  gh api "repos/$repo/commits/$sha" --jq '.sha' >/dev/null \
    && echo "ok   $ref" || echo "MISS $ref"
done

# 3. The version comment matches the digest for a sample reference.
gh api repos/actions/checkout/git/ref/tags/v4.2.2 --jq .object.sha

The second check is worth running on a schedule: a digest that no longer resolves usually means the upstream repository was rewritten or removed, which is worth knowing before a build fails at an awkward moment.


Troubleshooting

Symptom Likely cause Fix
Resolution returns a tag object, not a commit Annotated tag needs dereferencing Follow the tag object to its target commit before pinning
Update tool never proposes action bumps Workflow ecosystem not configured Add the actions ecosystem entry to the update configuration
Reviewers cannot judge a bump Version comment missing or stale Require the comment; have tooling rewrite it with the digest
Gate fires on local composite actions Pattern does not exclude relative references Exclude references beginning with a dot-slash path
A pinned digest stops resolving Upstream history rewritten or repository removed Re-pin to a current release, and review whether to keep the dependency
Workflow still runs unpinned code A pinned composite action references others by tag Resolve the full graph; consider vendoring for sensitive workflows

Common Implementation Mistakes


Frequently Asked Questions

Does pinning mean we stop getting security fixes?

Only if you pin and then walk away. With update tooling configured for the workflow ecosystem, a new upstream release becomes a pull request that moves the digest and the version comment together, so the fix arrives through review rather than silently. That is strictly better than a floating tag, where you receive the fix and anything else that changed — including whatever an attacker added — with no review at all.

Do actions published by the platform vendor need pinning too?

Yes. First-party actions are widely trusted, which makes them a high-value target rather than an exception, and pinning them costs exactly the same as pinning anything else. A uniform rule is also far easier to enforce: “everything is pinned” is a single grep, whereas “everything except this list” becomes a list nobody maintains and an argument in every review.

What about actions referenced inside other actions?

A composite action can reference further actions by tag within its own definition, so your pin covers the entry point but not the whole graph. Prefer actions whose sources you can read, resolve the full graph periodically with a script, and for the most sensitive workflows consider vendoring a small action into your own repository so that every link in the chain is one you control.