Generating Build Provenance Attestations for Frontend Artifacts
Your deploy job downloads a tarball from an artifact store and pushes it to a CDN. Nothing in that sequence checks where the tarball came from — so anything that can write to the store can ship to production. Provenance closes that gap: the build signs a statement binding the artifact’s digest to the workflow, repository, and commit that produced it, and the deploy refuses anything that does not match.
When to use this pattern
- Build and deploy are separate jobs, or separate workflows, with an artifact store between them.
- More than one identity can write to that store — a promotion pipeline, a release bot, an operator.
- You need to answer “which commit produced the bundle currently in production?” after the fact.
Prerequisites
What an attestation actually says
An attestation is a small signed document. It names a subject (the artifact, by digest) and a predicate (how it was produced), and it is signed by a workload identity derived from the same OIDC exchange used in authenticating to AWS from GitHub Actions with OIDC. The important consequence is that the signature covers the digest, not the file: change one byte and the statement no longer applies to anything you hold.
Check three is the one most implementations skip, and it matters more than it looks. Without it, any workflow in the repository — including one added by a pull request — can produce a validly signed attestation for arbitrary bytes. Verification would pass, and the property you thought you had would be gone.
Complete working example
# .github/workflows/deploy.yml — build attests, deploy verifies, nothing else can publish.
name: Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # workload identity for signing
attestations: write # write the attestation to the repository's store
outputs:
digest: ${{ steps.pack.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci --ignore-scripts
- run: npm run build
- id: pack
name: Pack reproducibly
run: |
# Deterministic archive: fixed mtime, sorted entries, no owner metadata, no gzip timestamp.
tar --sort=name --owner=0 --group=0 --numeric-owner \
--mtime='UTC 2020-01-01' -cf dist.tar dist/
gzip -n -9 dist.tar # -n omits the timestamp from the gzip header
echo "digest=$(sha256sum dist.tar.gz | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- uses: actions/attest-build-provenance@v2
with:
subject-path: dist.tar.gz # binds the attestation to this digest
- uses: actions/upload-artifact@v4
with: { name: bundle, path: dist.tar.gz }
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
environment: production
steps:
- uses: actions/download-artifact@v4
with: { name: bundle }
- name: Verify provenance — fail closed
env:
GH_TOKEN: ${{ github.token }}
run: |
# Recompute the digest and compare it to what the build job recorded.
got=$(sha256sum dist.tar.gz | cut -d' ' -f1)
[ "$got" = "${{ needs.build.outputs.digest }}" ] || { echo "digest mismatch"; exit 1; }
# Verify the signature AND pin the workflow that was allowed to sign it.
gh attestation verify dist.tar.gz \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/deploy.yml"
- run: tar -xzf dist.tar.gz && aws s3 sync dist s3://shop-assets --deleteStep-by-step walkthrough
Reproducibility comes first. tar records modification times, ownership, and directory order by default, and gzip stamps its own timestamp into the header. Any of those makes the digest change between two runs over identical content, and a digest that changes is a digest nobody can verify. The four flags above — --sort=name, fixed --mtime, numeric owner, and gzip -n — remove every source of nondeterminism in the packaging step.
Each of those flags removes a specific, independent source of drift, and it is worth knowing which is which — when a digest changes unexpectedly, the culprit is almost always one you did not disable.
Attest in the job that built the artifact. The signing identity is derived from the running workflow, so an attestation created anywhere else describes that other place. This is the difference between “this bundle came from main at commit 8d31f0a” and “some job saw these bytes”.
Pass the digest as a job output, not just the file. The artifact store is a mutable, shared surface; the job output is not. Comparing the recomputed digest to the recorded one catches corruption or substitution during the artifact round trip before the signature check even runs, and gives a clearer error when it happens.
--signer-workflow is not optional. It is what pins the attestation to the workflow file you reviewed. Verifying only --repo accepts an attestation produced by any workflow in the repository.
Fail closed. A verification step that logs a warning and continues provides documentation, not enforcement. If the check cannot pass, the deploy must not happen — which also means the step must not be wrapped in continue-on-error.
Verification
Confirm the attestation exists and describes what you expect:
gh attestation verify dist.tar.gz --repo acme/shop --format json \
| jq '{
subject: .[0].verificationResult.statement.subject[0].digest.sha256,
workflow: .[0].verificationResult.signature.certificate.buildSignerURI,
commit: .[0].verificationResult.signature.certificate.sourceRepositoryDigest
}'Expected shape:
{
"subject": "4f9c1b0e…2ab7",
"workflow": "https://github.com/acme/shop/.github/workflows/deploy.yml@refs/heads/main",
"commit": "8d31f0a4c7e2b91d0f6a5c3e8b7d2914ff03ac6b"
}Then prove the gate actually blocks. Tamper with a byte and re-run the verification — it must exit non-zero:
printf '\0' >> dist.tar.gz
gh attestation verify dist.tar.gz --repo acme/shop; echo "exit=$?" # → exit=1A pipeline where that command exits 0 has a verification step that is not doing anything.
Common pitfalls
Re-packing in the deploy job. Extracting and re-archiving produces new bytes and a digest the attestation does not cover. Carry the original archive through unchanged, and extract only after verification has passed.
Attesting many small files. Each subject-path entry costs a round trip to the signing service, so twelve separate bundles cost roughly twelve times one. Pack the deploy set into a single archive and attest that.
Verifying only in production. If staging deploys skip the check, the first time anyone exercises the verification path is during a production release, which is the worst moment to discover a reproducibility bug. Run the same verification in every environment, including preview deploys, so the failure surfaces on a pull request instead of during a release window.
Treating the attestation as a build artifact. It is not stored beside the tarball and does not need uploading; it lives in the repository’s attestation store, keyed by digest. Copying it around, or attempting to pass it between jobs as a file, usually means someone will eventually verify a stale copy against fresh bytes and conclude the whole mechanism is unreliable.
Related
- Pipeline Security & Supply Chain Hardening — the parent guide and the rest of the trust chain.
- Artifact Management Strategies for Frontend Builds — how artifacts move between jobs, which is what provenance protects.
- Authenticating to AWS from GitHub Actions with OIDC — the same workload identity, used for cloud access.
- CI/CD Pipeline Architecture & Fundamentals — the section overview.