How Do I Attach Build Provenance to an Image?

Set provenance mode=max on the build and push step so the builder attaches the attestation, then sign the pushed digest from the workflow with actions/attest.

Enable provenance output on the build itself: set provenance: mode=max on the build and push step and push the image straight to a registry, and the builder produces a SLSA provenance attestation and pushes it into the same image index (Docker provenance attestations, checked on 2026-08-13). Then add a second attestation from the workflow with actions/attest, which binds the pushed digest to the workflow identity, signs it with a short-lived Sigstore certificate, and uploads it to GitHub's attestations API (actions/attest, checked on 2026-08-13).

Answer

Two producers write provenance for one image, and they are complementary.

ProducerWhere it runsWhat it recordsWhere it landsHow it is read
BuildKit, via provenance: mode=maxthe builder that executed the buildmaterials, source revision, build platform, and at max the build stepsthe image index in the registry, as an attestation manifestdocker buildx imagetools inspect IMAGE --format "{{ json .Provenance }}"
GitHub artifact attestation, via actions/attestthe runner, after the pushthe subject digest bound to the workflow identity in the OIDC tokenGitHub's attestations API, plus the registry when push-to-registry is truegh attestation verify oci://IMAGE -R OWNER/REPO

Both fit in one job. This workflow builds on a WarpBuild remote Docker builder profile, pushes to GitHub Container Registry, and attests the digest that comes back.

name: build-attested-image

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write
  id-token: write
  attestations: write

jobs:
  image:
    runs-on: warp-ubuntu-latest-x64-4x
    env:
      REGISTRY: ghcr.io
      IMAGE_NAME: ${{ github.repository }}
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        id: push
        uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          push: true
          provenance: mode=max
          sbom: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          profile-name: "image-builder"

      - name: Attest the pushed digest
        uses: actions/attest@v4
        with:
          subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true

Four details in that file carry weight.

push: true is required. Attestations ride in the image index, and the local image store cannot load an image that has them, so a build with load: true or the docker exporter drops the attestation silently (Docker attestations with GitHub Actions, checked on 2026-08-13).

mode=max changes what the attestation says. In mode=min, the default, BuildKit records build timestamps, the frontend used, build materials, the source repository and revision, the build platform, and reproducibility, while leaving out build argument values, secret identities, and rich layer metadata. In mode=max it adds the LLB definition of the build, the Dockerfile itself as a base64-encoded copy, and source maps tying build steps to image layers. The trade is that mode=max exposes build argument values, so move any credential to a secret mount before you turn it on (Docker provenance attestations). Defaults already differ by repository visibility: docker/build-push-action attaches mode=max on public repositories and mode=min on private ones. Add version=v1 to provenance: mode=max,version=v1 if your consumers want the SLSA v1 schema rather than the v0.2 default.

subject-name takes the fully qualified image name with no tag. The digest identifies the image; a tag in that field breaks the registry push of the attestation.

Permissions are the usual failure. id-token: write mints the OIDC token, attestations: write persists the attestation, and packages: write covers the GHCR push. Adding artifact-metadata: write also creates a storage record on your organization's linked artifacts page (actions/attest). One plan constraint applies: artifact attestations work in public repositories on all current GitHub plans, private and internal repositories need GitHub Enterprise Cloud, and GitHub Enterprise Server does not support them.

actions/attest-build-provenance@v3 takes the same inputs if you already use it. From version 4 it is a thin wrapper around actions/attest, and new workflows should call actions/attest directly.

Detail

What a verifier can check afterwards

The consumer side is one command:

gh attestation verify oci://ghcr.io/OWNER/IMAGE:TAG -R OWNER/REPO

Verification succeeds only when a signed attestation for that digest exists, the Sigstore certificate chains back to a trusted root, and the certificate's identity matches what you asked for. The flags decide how strict "what you asked for" is (gh attestation verify manual, checked on 2026-08-13).

CheckFlagQuestion it settles
Predicate type--predicate-type, defaults to SLSA provenance v1is this build provenance or an SBOM statement
Signing workflow--signer-workflow OWNER/REPO/.github/workflows/release.ymlwhich workflow file signed the artifact
Certificate identity--cert-identity, --cert-identity-regexexact match on the certificate SubjectAlternativeName
Source commit--source-digestwhich commit the artifact was built from
Runner environment--deny-self-hosted-runnerswhether the job ran on a GitHub-hosted runner

Inside the predicate, three facts do most of the work. The source commit arrives as a resolved dependency in SPDX download-location form, git+https://github.com/OWNER/REPO@refs/heads/main with the commit id. The build parameters arrive as external parameters naming the workflow repository, ref, and path, plus internal parameters carrying numeric ids for the actor, repository, and repository owner so renames cannot launder an identity. The builder identity arrives as builder.id (SLSA GitHub Actions workflow build type) and, in the signing certificate, as the Build Signer URI extension 1.3.6.1.4.1.57264.1.9 next to the Runner Environment extension 1.3.6.1.4.1.57264.1.11 (Fulcio OID reference).

For the BuildKit half, read the attestation off the registry with docker buildx imagetools inspect IMAGE --format "{{ json .Provenance }}".

How provenance behaves when the build runs off the runner

A remote Docker builder moves the build to a dedicated builder virtual machine that keeps its layer cache on local disk, while the runner keeps the workflow, the checkout, and the push credentials (Docker builders documentation). That split lands cleanly on both attestations.

BuildKit provenance is produced where the build ran. The builder writes the attestation for the layers it actually assembled and pushes it with the image, so the record stays accurate even though the runner never saw a RUN step (Docker builders documentation). The GitHub attestation is produced on the runner from steps.push.outputs.digest, so it binds the same digest and the same workflow identity no matter which machine assembled the layers (actions/attest). Neither producer needs to know about the other.

One consumer-side detail is worth planning for. WarpBuild registers as a self-hosted runner in the Default runner group of your GitHub organization (public repositories documentation), so the OIDC token that signs the attestation carries runner_environment: self-hosted, the same value ARC pods and your own registered machines produce. A verifier running --deny-self-hosted-runners will reject those attestations. Pin --signer-workflow or --cert-identity instead: naming the workflow file that signed is a tighter check than naming the machine class it ran on. If the build step runs on a runner that is not a WarpBuild runner, pass api-key: ${{ secrets.WARPBUILD_API_KEY }} to the build action so it can reach the builder profile.

The job producing the attestation can sit anywhere in the catalog. Each runner runs in its own virtual machine that is created on demand and destroyed after the build, with an encrypted storage volume per runner (security documentation).

Provenance adds a signing step measured in seconds and no separate line item. You pay runner minutes and builder session minutes: warp-ubuntu-latest-x64-4x is $0.008 per minute, and the 16 vCPU, 32 GB, 100GB disk builder profile is $0.06 per minute, billed per session across the jobs sharing that profile (Docker builders documentation, checked on 2026-08-13). A repository pushing 200 attested images a month, 4 minutes of builder session and 5 minutes of runner time each, pays 800 builder minutes at $0.06 for $48.00 plus 1,000 runner minutes at $0.008 for $8.00, so $56.00 in total.

Do I need both the BuildKit attestation and the GitHub attestation?

They answer different questions. The BuildKit attestation describes how the image was built, down to the build steps at mode=max. The GitHub attestation binds the pushed digest to a workflow identity signed by Sigstore, which is what gh attestation verify reads. Teams publishing images to external consumers usually ship both. The provenance attestation glossary entry covers the format and the terms.

How do I add an SBOM alongside provenance?

Set sbom: true on the build and push step to attach a BuildKit SBOM attestation to the image, or pass sbom-path to actions/attest to sign an SPDX or CycloneDX file you generated yourself. Verifying it takes the --predicate-type flag, since SBOM statements use a different predicate from provenance. Details are in generating an SBOM during a GitHub Actions build.

Does a remote builder change any of this?

No. The builder writes BuildKit provenance for the layers it built and pushes it with the image, and the attest step on the runner signs the returned digest. Builder profiles, sizes, and session billing are in the remote Docker builder catalog, and the workflow shape around them is in the Docker builds on GitHub Actions guide.

Wire provenance into an existing image pipeline with the Docker builds on GitHub Actions guide, price the builder profile against your own build minutes on the pricing page, and check the Docker builders documentation for the action inputs.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.