Generating provenance for a tagged release Jump to heading

Provenance is produced by the build or not at all โ€” it is a statement about a relationship that only exists during the build, between the source checked out and the artefact emitted. Adding it afterwards means asserting something you did not observe. This recipe wires it into a release pipeline, including the constraint that most setups omit: making sure only a signed tag on a protected branch can produce a release build in the first place. Part of build provenance and attestations.

When to use this approach Jump to heading

  • Releases produce artefacts that are deployed or distributed.
  • You need to answer which commit produced a given artefact.
  • A verification gate exists or is planned at deploy time.
  • Compliance or a customer requires build provenance.
  • If nothing verifies the attestation, generating it is bookkeeping โ€” set up the gate first or alongside.

Step 1 โ€” Constrain what may produce a release build Jump to heading

An attestation is truthful about whatever the build consumed, so the constraint belongs before the build.

# .github/workflows/release.yml
name: release
on:
  push:
    tags: ['v[0-9]+.[0-9]+.[0-9]+']      # only version tags, never branches
jobs:
  guard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - name: The tag must be signed and on the default branch
        run: |
          git verify-tag "${GITHUB_REF_NAME}"
          git merge-base --is-ancestor "${GITHUB_REF_NAME}^{commit}" origin/main
# Verification: a tag on a side branch fails the guard
git tag -s v9.9.9 -m probe side-branch && git push origin v9.9.9
gh run list --workflow release.yml --limit 1 --json conclusion
git push origin --delete v9.9.9 && git tag -d v9.9.9
What may produce an attested releaseA signed tag whose commit is on the default branch is the only input that should reach a release build. A tag on a side branch, or an unsigned tag, produces an artefact whose provenance would be technically valid and semantically worthless.What triggered this release build?signed tag on mainBuild and attestthe intended pathtag on a side branchRefuseprovenance would misleada branch pushRefusenot a releasethe attestation records the truth either way โ€” the guard decides whether the truth is useful

Step 2 โ€” Capture the digest the build produced Jump to heading

The attestationโ€™s subject must be the exact artefact, by digest rather than by tag.

  build:
    needs: guard
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write            # keyless signing
      attestations: write
    outputs:
      digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
      - id: push
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/acme/app:${{ github.ref_name }}
# Verification: the digest is a content hash, not a tag
echo "$DIGEST" | grep -E '^sha256:[0-9a-f]{64}$' && echo "digest captured"

Step 3 โ€” Attest, keylessly Jump to heading

      - uses: actions/attest-build-provenance@v1
        with:
          subject-name: ghcr.io/acme/app
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true
# Or with cosign, for registries or builders without native support
cosign attest --yes \
  --predicate /tmp/provenance.json --type slsaprovenance \
  "ghcr.io/acme/app@${DIGEST}"
# Verification: the attestation is retrievable and names the right commit
gh attestation verify "oci://ghcr.io/acme/app@${DIGEST}" --owner acme --format json \
  | jq -r '.[0].verificationResult.statement.predicate.buildDefinition
           .resolvedDependencies[0].digest.gitCommit'
git rev-parse "${GITHUB_REF_NAME}^{commit}"

Those two values must match. If they differ, the build checked out something other than the tag โ€” usually because the workflow ran on a merge commit created by the forge rather than on the tag itself.

Keyless signing, step by stepThe workflow requests a short-lived identity token from the forge, exchanges it for an ephemeral signing certificate, signs the attestation, and records the signature in a transparency log. No long-lived key exists at any point.workflowidentity providersigning serviceregistryrequest an identity tokentoken naming this workflowexchange for a certificateephemeral cert, minutespush signed attestationwhat is protected is the workflow's identity, not a key in a vault

Step 4 โ€” Push the attestation where verification will look Jump to heading

An attestation stored somewhere the deployment cannot read is not a gate.

# Stored alongside the image in the registry โ€” the usual choice
cosign download attestation "ghcr.io/acme/app@${DIGEST}" | jq -r '.payload' | base64 -d | jq '.predicateType'
# Or in the forge's attestation store, queryable by digest
gh attestation verify "oci://ghcr.io/acme/app@${DIGEST}" --owner acme
# Verification: a deployment-time lookup succeeds using only the digest
gh attestation verify "oci://ghcr.io/acme/app@${DIGEST}" --owner acme --repo acme/app >/dev/null \
  && echo "retrievable by digest alone"

SAFETY WARNING โ€” permissions: id-token: write grants the job the ability to obtain an identity token that proves it is your workflow. A job holding that permission and also checking out untrusted code can be induced to sign an attestation for an artefact an attacker influenced. Grant it only on the job that builds and signs, keep untrusted code out of that job entirely, and never combine it with a trigger that runs on unreviewed contributions.

Step 5 โ€” Record the release facts together Jump to heading

# One place that ties tag, commit, digest and run together
cat >> releases.tsv <<EOF
$(date -u +%FT%TZ)	${GITHUB_REF_NAME}	$(git rev-parse HEAD)	${DIGEST}	${GITHUB_RUN_ID}
EOF
# And as an annotated tag message, which travels with the repository
git tag -s -f "${GITHUB_REF_NAME}" -m "Release ${GITHUB_REF_NAME}

image:  ghcr.io/acme/app@${DIGEST}
commit: $(git rev-parse HEAD)
run:    ${GITHUB_RUN_ID}"
# Verification: the tag message names the digest that was attested
git tag -l "${GITHUB_REF_NAME}" --format='%(contents)' | grep '^image:'
The release pipeline, with provenance in itA guard refuses anything but a signed tag on the default branch. The build pushes an artefact and captures its digest. The attestation is signed with a workflow identity and pushed to the registry. The tag message records the digest so the link survives outside any one system.Signed tagon the default branchGuardverify-tagancestor checkBuild and pushdigest capturedAttestkeylesspushed to registryRecordtag message, ledgerthe last box is what makes the link legible without any tooling at all

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

What if our registry does not support attestations? Jump to heading

Store them in the forgeโ€™s attestation store, which is queryable by digest and independent of the registry. The verification command changes and the gate works identically โ€” what matters is that the deployment can look up an attestation given only the digest it is about to run.

Should every build be attested, or only releases? Jump to heading

Attest anything that can be deployed. Restricting it to tagged releases is common and leaves a gap wherever a branch build can reach an environment โ€” and those paths exist in most organisations, usually for staging. Attesting everything costs nothing extra per build.

Does this work for language packages rather than images? Jump to heading

Yes: the subject is a digest, and packages have digests. The same attestation types and verification commands apply, and several package registries now accept and serve attestations directly. The awkward part is usually the deployment gate rather than the generation.