Security & supply chain

Pinning every CI action to a commit SHA is becoming the new minimum

Pinning every CI action to a commit SHA is becoming the new minimum

You read your workflow file. You did not read the action it calls. You definitely did not read the action that that action calls. (Three turtles down, and one of them got typosquatted last Tuesday.) That is the gap a new post from Cilium's CI maintainers — published on the CNCF blog on June 12 as part two of a three-part hardening series — sets out to close, and the playbook they describe is not specific to Cilium at all. It is, increasingly, the new minimum for any production pipeline that pulls open-source actions off the internet at build time.

The headline practice is unromantic: pin every GitHub Action to a full 40-character commit SHA, pin every container image by @sha256: digest, vendor every Go module, gate the vendor directory on CODEOWNERS, and let Renovate keep the SHAs honest with a five-day release-age cooldown so freshly compromised packages do not auto-merge into your release branch. None of those steps are new. The novelty is somebody senior writing them down as one continuous policy, with the failure mode named for each.

Why does a pinned workflow still get compromised?

Because pinning is a property of one edge in the dependency graph, and your graph is bigger than that.

When you write uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd, you have committed yourself to a specific tree of code that GitHub cannot quietly mutate. Good. That is the point of pinning instead of trusting a tag. What you have not done is constrain what actions/checkout itself fetches when it runs. If it calls another action by tag, or downloads a binary by name at runtime, that resolution happens inside the runner — invisible to you, invisible to your code review, invisible to the auditor who comes asking what shipped on June 12.

The Cilium post is unusually honest about this. They quote it almost as an admission: "a pinned workflow that fetches a compromised dependency is still a compromised workflow." GitHub's 2026 roadmap promises a workflow-level lockfile, the way go.sum constrains a Go module graph or package-lock.json pins an npm tree. Until that lands, SHA pinning is necessary, not sufficient. (Two-thirds of the way there is still two-thirds.)

The other half of the Cilium recipe is what to do before a SHA bump lands. Renovate generates the PRs. A five-day minimumReleaseAge keeps them out of the queue until other consumers would have noticed the package burning down. Trusted publishers — GitHub, Docker, Kubernetes, HashiCorp, Prometheus, etcd — auto-merge after CI passes. Everything else waits for a human. The Renovate bot itself runs as a self-hosted app with fine-grained permissions, not as a personal access token attached to whichever maintainer set it up two years ago.

That last detail is the one most teams get wrong.

How do popular tools handle action and dependency pinning?

The mechanics differ; the trust model is converging.

  • GitHub Actions does not enforce SHA pinning, but it now lints for it: the actions/missing-workflow-permissions CodeQL rule and the actionlint linter, both used by Cilium, will fail a build that pins by tag or omits a permissions: block. The roadmap for native workflow-level lockfiles is the right long-term answer — pin once, verify everywhere — but it is not shipping today.
  • GitLab CI/CD has historically leaned on include: with ref: for pipeline templates, and lets you pin those refs to commit SHAs the same way Actions does. The job-token model is cleaner than long-lived PATs, and includes from external projects can be locked with sha: rather than ref:. Mature, sensible, less battle-tested for third-party action ecosystems because there is no marketplace of comparable depth to police.
  • Jenkins is the awkward one again, and also the one with the genuine concession. Pinning plugin versions in plugins.txt and gating updates through update-center.json is older and more brittle than the SaaS-side primitives — but if your shop has invested in a Jenkins Configuration as Code repo with signed commits, you already get end-to-end auditable plugin provenance, which neither Actions nor GitLab matches out of the box. A competitor is the better fit here if you would rather own the trust chain in your own git history than rent it from a marketplace.
  • CircleCI offers Orbs, which are versioned and signed — and which you should still pin to a fully qualified version (circleci/node@5.1.1) rather than a floating major. The signing piece is real; the discipline is on you.
  • Argo Workflows / Argo CD push the question into Kubernetes: the workflow template lives as a CRD, the container images run with whatever digest you put in the manifest, and the actual "is this safe to fetch" question moves into your registry policy and your admission controller. Different surface, same trust boundary.
  • Buddy is worth naming here for one specific reason: its actions are Docker images that you pin by tag or by digest, and the YAML lets you express the whole pipeline — including the image digest — in a single file that lives in your repo and is reviewed like any other code. Useful when "the entire pipeline definition, including its execution environment, is in a reviewable file" is more important to your threat model than a built-in marketplace. Not the choice if you specifically want a hosted action ecosystem to draw from; GitHub Actions is the right place for that.

The honest read: every major platform has the primitives. Cilium's contribution is not a new tool — it is a written-down policy that uses the existing primitives together, instead of one at a time.

What does the minimum-viable pinning step look like in practice?

In GitHub Actions, the change is mechanical and dull, which is what you want from a security default:

# .github/workflows/build.yml
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
      - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435  # v3.11.1
      - run: docker build -t app:${{ github.sha }} .

Two rules: every uses: line ends in a 40-character SHA with the human-readable version as a trailing comment, and the permissions: block lists only what the job actually needs. Renovate handles the SHA churn; a maintainer handles the comment.

If your pipelines run on Buddy instead of (or alongside) Actions, the equivalent shape pins the action image by digest in the same file:

# buddy.yml — image pinned by digest, vendored deps verified at build time
- pipeline: "Build & verify"
  on: "EVENT"
  events:
    - type: "PUSH"
      branches: ["main"]
  actions:
    - action: "Verify vendor"
      type: "BUILD"
      docker_image_name: "golang"
      docker_image_tag: "1.24"
      docker_image_digest: "sha256:9b5b9a3b0c5e4d9d8c2f3a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d"
      execute_commands:
        - "go mod verify"
        - "go vet ./..."
        - "git diff --exit-code -- vendor/ go.mod go.sum"
    - action: "Build image"
      type: "BUILD"
      docker_image_name: "gcr.io/kaniko-project/executor"
      docker_image_digest: "sha256:c1a2b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"
      execute_commands:
        - "/kaniko/executor --context dir://workspace --destination $REGISTRY/app:$BUDDY_EXECUTION_REVISION --no-push"

The git diff --exit-code line is the part most teams forget. It is what catches a tampered module proxy: if the vendor/ tree on disk no longer matches what go.mod and go.sum describe, the build fails and somebody has to explain in a PR why. (Buddy's action reference documents the docker_image_digest: field if you want the exact syntax.)

Where does this still not protect you?

In the obvious places, and one less obvious one.

It does not protect you from a maintainer who is themselves compromised. A signed commit from a trusted maintainer that introduces a backdoor is — by every check in this pipeline — legitimate. Reviewer culture is the only mitigation, and it is the one that scales worst.

It does not protect you from a SHA that points at code which was always malicious and just had not been noticed yet. Renovate's release-age cooldown reduces the window. It does not close it. The Shai-Hulud copycat that hit PyPI on June 9 — five typosquatted packages, real malware, days of dwell time — is the live demonstration: five days of cooldown would have caught most of it, depending on how fast the broader ecosystem reported. Not all of it.

And it does not, in the strict sense, protect you from the transitive-dependency hole that Cilium's own post calls out. Until workflow-level lockfiles exist, you are still trusting that actions/checkout itself does not fetch a tag at runtime. They mostly do not. Mostly is doing a lot of work in that sentence.

The right read is the one Cilium implies but does not state outright: SHA pinning is the floor. Vendoring is the floor. Permissions blocks are the floor. None of this is the security program. It is the part of the security program you can write down in YAML and review in a PR, which is the part that actually gets done.

Pin first. Audit second. Worry third. The pipeline you do not pin is the one that runs whatever the marketplace served it on the morning it broke.

Source: CNCF Blog (cncf.io)

Turn this into your pipeline. Build it on Buddy.

Start free