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

Maintaining SHA-pinned actions at scale

30 min de lecture

Read this page in French

Pinning every action to its commit SHA closes the door on moved tags. But a SHA also freezes the fixes: with no update mechanism, the good practice turns into debt. This page shows how to keep a pinned estate healthy over time, with Dependabot or Renovate, and why the critical point is not the tool but the triage of the queue.

What you will learn

  • Measure the debt an unmaintained pinning creates
  • Configure Dependabot for actions, images and dependencies
  • Reduce the noise through grouping and a quarantine window
  • Triage the queue without letting it pile up, and decide what to auto-merge
  • Replay the digests of base images, which Dependabot does not always cover

The pinning debt, measured

Abstract reasoning convinces nobody. Here is the state of a reference repository that is exemplary in every other respect (minimal permissions, scanners in CI, SLSA provenance, Cosign signature, a carefully tended OpenSSF Scorecard), audited in September 2026, whose Dependabot queue had not been touched since 16 July, that is seven weeks.

Pinned itemVersion in placeCurrent versionGap
actions/checkoutv7.0.0v7.0.11 fix
docker/login-actionv4.4.0v4.5.01 minor version
getplumber/plumberv0.4.6v0.4.6054 versions
python:3.11-slima July digestthe 2 September digestan unpatched image

Three consequences, all of them observed:

  • The CI fell over by itself. The image scan job failed on every pull request, including the ones touching neither the Dockerfile nor the dependencies. Since the pinned digest receives no fix, it only took a CVE fixed upstream being published for the HIGH,CRITICAL threshold to be crossed.
  • The documentation drifted from the code. The guides quoted pinnings the repository no longer carried, while claiming to comment on its real files.
  • Ten pull requests had piled up, to the point where the queue itself was discouraging to work through.

Configuring Dependabot

Dependabot reads the version comment placed after the SHA to know where you stand. That is why the comment is not decoration: without it, the tool cannot compare.

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

The minimal configuration covers the three ecosystems of a containerised repository:

.github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"

Note the docker declaration: without it, the base image stays on its digest indefinitely, and that is exactly the scenario that brought down the CI measured above.

Reducing the noise before it discourages

A queue of ten pull requests does not get worked through, it gets ignored. Two settings cut the volume without losing anything.

Grouping gathers several updates into a single pull request:

.github/dependabot.yml
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions-patch:
update-types: ["patch"]
actions-minor:
update-types: ["minor"]

The quarantine window (cooldown) delays the proposal of a freshly published version. The point is security, not comfort: a compromised version is usually pulled within hours or days, and waiting is enough never to see it go by.

.github/dependabot.yml
cooldown:
default-days: 7

The Renovate alternative

Renovate offers one setting Dependabot lacks, and it matters for a pinned estate: pinDigests, which converts and then maintains references by digest, images included.

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

The choice between the two comes down to this: Dependabot is built in, with no third-party service to authorise, and it is enough when your actions already carry their version comments. Renovate configures more finely, knows how to pin what is not pinned yet, and groups better. On a single repository Dependabot is enough; across dozens of repositories Renovate starts paying for itself.

Triaging the queue: the part that actually fails

The tooling is never the problem. In the case measured above, Dependabot worked perfectly: it had proposed every missing update, on time. Nobody had looked at them.

A triage rule fits in three lines, and the difficulty is sticking to it.

Kind of updateDecisionCondition
Patch on an action or an imageMerge, possibly automaticallyGreen CI, workflow scans included
MinorMerge after reading the changelogGreen CI
MajorAn explicit review, never in bulkGreen CI and changelog read

Automating the patches, under conditions

Auto-merging patches is only defensible when your CI is genuinely blocking. A repository running actionlint, zizmor, poutine and a strict posture gate can afford it: the door is guarded by the scanners, not by human attention.

.github/workflows/dependabot-automerge.yml
name: Dependabot auto-merge
on: pull_request_target
permissions: {}
jobs:
automerge:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- name: Fetch the update metadata
id: meta
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
- name: Enable auto-merge for patches
if: steps.meta.outputs.update-type == 'version-update:semver-patch'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: gh pr merge --auto --squash "$PR_URL"

Two points of vigilance on that example. The trigger is pull_request_target, which is required for the token to hold write rights on a Dependabot pull request; the workflow never checks out the branch code, which keeps it inside the safe use case described in Securing pull_request_target. And gh pr merge --auto overrides nothing: the merge only happens once every branch protection is satisfied.

Replaying image digests

Base images deserve a section of their own, because their failure mode is the most disconcerting: the CI falls over without a single line of the repository changing.

FROM python:3.11-slim@sha256:9534e5a8e315485d4061ed659af0fd78a284c015f9b73661b41d6bab25604534

That digest is immutable, therefore reproducible, therefore never patched. Meanwhile, your scanner's vulnerability database is updated daily. A job configured like this will eventually fail:

with:
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: "1"

And yet ignore-unfixed: true is the right setting: it only reports vulnerabilities for which a fix exists. In other words, when that job turns red, it is telling you something exact and actionable: a fix is available upstream and you have not taken it.

The remediation is replaying the pinning:

Fenêtre de terminal
# Read the current digest of the tag you follow
docker buildx imagetools inspect python:3.11-slim --format '{{.Manifest.Digest}}'
  1. Read the current digest of the tag you follow.

  2. Replace the digest in every stage of the Dockerfile, the build stage included: one forgotten FROM leaves the vulnerability in the intermediate image.

  3. Let the CI decide. If the scan turns green again, the CVE did come from the base layer. If it stays red, it comes from your application dependencies, and that is a different job.

Cadence, the only thing that lasts

The mechanism matters less than the appointment. Three formats work, in increasing order of cost:

  • Auto-merging patches costs no human time, provided the CI is a real guardrail.
  • A weekly fifteen-minute slot is enough to empty a grouped queue. It is the format that fails most often, because it rests on discipline.
  • A monthly review catches the majors and the deeper changes, with the changelogs read.

The warning signal is simple to watch, and it is objective:

Fenêtre de terminal
# The number of Dependabot pull requests waiting
gh pr list --repo OWNER/REPO --author "app/dependabot" --json number --jq 'length'

Past five, the queue is no longer being worked, it is being endured. That is the moment to group more, to automate the patches, or to accept that some updates do not need proposing every week.

Key points

  • A pinned SHA receives no fix: without an update mechanism, pinning turns a protection into debt.
  • The version comment after the SHA is not decoration, it is what Dependabot reads to compare.
  • Declare the docker ecosystem alongside github-actions: otherwise the base image stays frozen and eventually fails your scan.
  • Grouping and the quarantine window make the queue workable, and quarantine keeps you out of the first adoption wave of a compromised package.
  • Renovate brings pinDigests, useful to pin and maintain what is not pinned yet; Dependabot is enough on a single repository.
  • Auto-merging patches is only defensible when the CI genuinely blocks, and gh pr merge --auto overrides no protection.
  • A Dependabot pull request closed by hand does not come back: it takes @dependabot reopen.
  • An image scan turning red with no change in the repository signals a fix available upstream, not a false positive: replay the digest.

Next steps

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