Climbing the SLSA Ladder with GitHub Actions
)
One application, four rungs, and then the part that needs a plan
October 19, 2026
Chain of custody for software means being able to prove what was built, from what source, by which workflow. That is what SLSA measures. It is a ladder rather than a certification, and on GitHub Actions each rung is a small, specific change to a build workflow. So instead of describing the rungs in the abstract, this post climbs them with one application, showing the workflow at every step. Then it looks at what a single application hides: across a few hundred repositories the YAML is the easy part and the plan is the work. And it makes one claim worth carrying with you. The top rung of the ladder is a reusable workflow, so if you are already modernizing how your builds are written, you are already climbing.
The application
Meet orders-api. It lives at
acme/orders-api, builds a container image on every push to main, pushes it to ghcr.io/acme/orders-api, and deploys to the orders namespace on a Kubernetes cluster. Its build workflow is about what you would expect:name: buildon: push: branches: [main]jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: docker/build-push-action@v6 with: push: true tags: ghcr.io/acme/orders-api:${{ github.sha }}This works. It has worked for years. It is also SLSA Build L0: there is no record of how the image was produced beyond the workflow logs, and nothing downstream checks.
What is the ladder?
SLSA v1.2 defines a Build track with four levels. L0 is nothing. L1 means provenance exists: a document describing how the artifact was built. L2 means that provenance is signed and was generated by a hosted build platform, so you can trust where it came from. L3 means the build platform is hardened: the build and the provenance generation run isolated from the workflow that called them, so nobody who can edit the application's workflow can tamper with what gets attested.
Each rung is cumulative, and on GitHub each is a concrete change to the workflow above. Let's make them.
Rung 1 and 2: provenance that is signed
On GitHub, L1 and L2 arrive together, because the mechanism that produces provenance also signs it. Artifact attestations generate a SLSA provenance statement, sign it with a short-lived Sigstore certificate bound to the workflow run's OIDC identity, and store it where it can be verified later. There is no key material to manage; the workflow run is the signer.
Two changes to
orders-api's workflow. Permissions first:permissions: id-token: write contents: read packages: write attestations: writeThen, after the build step, an attestation step that takes the image's digest from the build output and pushes the attestation to the registry alongside the image:
- uses: docker/build-push-action@v6 id: build with: push: true tags: ghcr.io/acme/orders-api:${{ github.sha }} - uses: actions/attest@v4 with: subject-name: ghcr.io/acme/orders-api subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: trueThat is the entire change. The next push to main produces an image and a signed provenance statement that names the repository, the commit, the workflow file, and the run that built it. Anyone can check it:
gh attestation verify oci://ghcr.io/acme/orders-api:<sha> -R acme/orders-apiorders-api is at Build L2. Two caveats before moving on. Artifact attestations for private and internal repositories require GitHub Enterprise Cloud; on Free, Pro, and Team plans they are available for public repositories only. And L2 assumes the build platform is trustworthy, which is true of GitHub-hosted runners by construction and true of self-hosted runners only if you make it so; ephemeral runners through Actions Runner Controller keep the claim defensible, long-lived ones do not.
Rung 3: isolation
Here is the problem with
orders-api at L2. The attest step lives in the same workflow file as the build. Anyone who can push to .github/workflows/build.yml can add a step before it that swaps the artifact, changes a build flag, or pulls a dependency from somewhere it should not. The provenance will faithfully sign whatever happened. It is trustworthy about origin, not about integrity of the build steps themselves.L3 closes that by moving the build and the attestation somewhere the application's workflow cannot reach into. On GitHub Actions, that is a reusable workflow owned by a platform team. The build steps and the attest step move to
acme/building-blocks; orders-api calls it and passes inputs. The caller cannot inject steps into the reusable workflow, and the signing identity becomes the reusable workflow rather than the caller.In
acme/building-blocks/.github/workflows/container-build.yml:on: workflow_call: inputs: image: { type: string, required: true }permissions: id-token: write contents: read packages: write attestations: writejobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: docker/build-push-action@v6 id: build with: push: true tags: ${{ inputs.image }}:${{ github.sha }} - uses: actions/attest@v4 with: subject-name: ${{ inputs.image }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: trueAnd
orders-api's workflow, which is now shorter than it was at L0:name: buildon: push: branches: [main]jobs: build: uses: acme/building-blocks/.github/workflows/container-build.yml@main permissions: id-token: write contents: read packages: write attestations: write with: image: ghcr.io/acme/orders-apiBoth sides declare the permissions. Verification now asserts not just that acme built the image, but that the golden path built it:
gh attestation verify oci://ghcr.io/acme/orders-api:<sha> \ -R acme/orders-api \ --signer-repo acme/building-blocks \ --signer-workflow acme/building-blocks/.github/workflows/container-build.ymlAn image built by any other workflow, even one in the right organization, fails that check.
orders-api is at Build L3, and the orders-api team's workflow is now a call and a parameter. A reusable workflow that every repository calls is also what platform teams call a golden path, and that is not a coincidence: L3 and the golden path are the same engineering decision seen from two directions. Read it the other way and it is the most useful fact in this post. If acme is already consolidating its hand-copied build workflows into shared, versioned ones in building-blocks, which is what modernizing an Actions estate mostly consists of, then L3 is one attest step added to a workflow that was going to exist anyway. The standardization is the security control. Nothing was bolted on.Off the ladder: making it a gate
Everything so far produces provenance and lets someone check it. Nothing yet stops an unverified image from running. That is a deploy-time decision, and there are two places to make it: in the workflow that deploys, and in the platform that runs the result. They are not alternatives. The first is the one you control from GitHub; the second is the one that holds even when someone deploys around GitHub.
In the CD workflow
orders-api deploys through a separate workflow that promotes a specific image digest to production. The gate is a verify step before the rollout, using the same command as above, with the signer flags. If verification fails, the job fails and nothing ships:name: deployon: workflow_dispatch: inputs: digest: { type: string, required: true }jobs: deploy: runs-on: ubuntu-latest environment: production permissions: contents: read packages: read steps: - run: echo "${{ github.token }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - name: Verify provenance before rollout env: GH_TOKEN: ${{ github.token }} run: | gh attestation verify oci://ghcr.io/acme/orders-api@${{ inputs.digest }} \ -R acme/orders-api \ --signer-repo acme/building-blocks \ --signer-workflow acme/building-blocks/.github/workflows/container-build.yml - run: kubectl set image deployment/orders-api api=ghcr.io/acme/orders-api@${{ inputs.digest }}Two things make this worth more than it looks. Deploying by digest rather than tag means the thing verified is exactly the thing rolled out; a tag can be moved after the check, a digest cannot. And because the deploy workflow is itself a candidate for
acme/building-blocks, the verify step ends up written once and inherited by every service that deploys through the shared workflow, rather than pasted into each one and quietly dropped by the third team that copies it. Same pattern as the build: standardize the workflow, and the gate comes with it.The limit of this gate is that it only guards deployments that go through the workflow. A
kubectl apply from a laptop, a Helm release from another pipeline, an image referenced by a manifest someone edited by hand: none of those pass through the verify step.In the cluster
An admission controller closes that gap. It runs inside Kubernetes, every time, regardless of how the pod got there. GitHub publishes a Helm chart that installs its trust root and a policy into the Sigstore Policy Controller. Two installs:
helm upgrade policy-controller --install --atomic \ --create-namespace --namespace artifact-attestations \ oci://ghcr.io/sigstore/helm-charts/policy-controller --version 0.10.5
helm upgrade trust-policies --install --atomic \ --namespace artifact-attestations \ oci://ghcr.io/github/artifact-attestations-helm-charts/trust-policies \ --version v0.7.0 \ --set policy.enabled=true --set policy.organization=acmeNothing is enforced until a namespace opts in:
kubectl label namespace orders policy.sigstore.dev/include=trueFrom that point, a pod in
orders whose image does not carry a valid attestation from acme does not start. The push-to-registry: true in the attest step is what makes this possible; the controller verifies against the attestation stored next to the image. The chart's policy.images and policy.exemptImages values take glob patterns so you can enforce on ghcr.io/acme/ while admitting a base image you do not build, and the policy can be tightened from "built by acme" to "built by the golden path," which is the deploy-time version of the --signer-workflow check above.That is the whole climb for one application: four small edits to one workflow, one workflow moved to a shared repository, one verify step in the deploy workflow, two Helm installs, one label. An afternoon.
Now do it four hundred times
Here is what the single-application walkthrough hides. Every step above was easy because someone had already decided the things that are actually hard.
Someone decided that
acme/building-blocks exists, which team owns it, who reviews changes to it, and that application teams are expected to call it rather than copy it. Someone decided that container builds get one reusable workflow and binary builds get another, and what inputs each takes, which means someone had first looked at every build workflow in the organization and found the patterns. Someone knew that orders-api runs on GitHub-hosted runners and deploys to a namespace the platform team controls, and that the eighteen services that build on self-hosted runners or deploy through a different pipeline need a different plan. Someone decided the admission controller goes into orders before it goes into payments, and what happens to the four images that get rejected on day one.None of that is YAML, and all of it is the same work as standardizing the Actions estate in the first place. An organization with four hundred hand-written build workflows cannot reach L3 by adding a step to each of them. L3 is only reachable once those four hundred workflows have become four hundred calls to a handful of shared ones.
So the SLSA climb across an estate is not a security project with a modernization prerequisite. It is the modernization, with provenance as one of its outputs. Across four hundred repositories, the project is:
- An inventory. Which repositories build what, on which runners, deploying where.
- A golden-path design. Shared workflows that cover the real build patterns in the estate, not the ideal one.
- A migration order. Which repositories move onto the shared workflows in which wave, respecting dependencies between services and the calendars of the teams that own them.
- An enforcement rollout. The verify step added to the shared deploy workflows, and the admission controller turned on namespace by namespace as the repositories feeding each one finish migrating.
- Visibility. At any point, how many repositories are on the golden path, how many are not, and which wave they are in.
That is planning and coordination work, and it is the same planning and coordination work the migration or modernization the organization is already running needs. Which is the point of treating it as a stop on a Journey rather than a project of its own. In a CodeCargo Journey, the SLSA climb is planned alongside the workflow standardization it depends on: the golden path is designed once, the repositories move onto it in waves, and enforcement follows the waves, so the admission controller's rejection list is a progress report rather than an outage. The YAML for orders-api is the same either way. What changes is whether the four hundredth repository gets there.
Key Takeaways
- SLSA Build levels are cumulative and concrete. L1 is provenance, L2 is signed provenance from a hosted platform, L3 is provenance generated in isolation from the workflow that could tamper with it.
- L2 is a permissions block and one attest step. No keys to manage; the workflow run's OIDC identity does the signing.
- L3 is a reusable workflow. Move the build and the attest step into a platform-owned workflow that application repositories call. The application's workflow gets shorter, and the signing identity becomes the golden path.
- Standardizing GitHub Actions is the road to L3. Consolidating hand-copied workflows into shared, versioned ones is exactly the isolation L3 requires. If that modernization is already underway, provenance is one added step in a workflow that was going to exist anyway; if it is not, L3 is out of reach until it is. For how that modernization fits alongside the rest of a secure SDLC on GitHub, and why it belongs inside a migration rather than after one, see Secure SDLC on GitHub: Why It Belongs Inside Your Migration, Not After It (https://codecargo.com/blog/secure-sdlc-on-github).
- Verify by signer, not just by org.
gh attestation verify with --signer-repoand--signer-workflowrejects builds that bypassed the golden path. - Gate twice: in the deploy workflow and in the cluster. A gh attestation verify step before rollout stops unverified images from shipping through CD; Sigstore Policy Controller with GitHub's trust-policies chart stops them from running no matter how they arrived.
- One application is an afternoon. Four hundred is a plan. Inventory, golden-path design, migration order, enforcement rollout, and progress visibility are the actual work, and they belong on the same Journey as the migration or modernization already underway.
C
CodeCargo Team
The CodeCargo team writes about GitHub workflow automation, developer productivity, and DevOps best practices.
)