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.

Anatomy of a Provenance Attestation A document split into three parts. The subject holds the artifact name and its SHA-256 digest. The predicate holds the source repository, the workflow path, the commit SHA and the runner environment. The signature, produced by a workload identity, covers both parts, and an arrow shows the deploy job checking all three before publishing. attestation subject dist.tar.gz · sha256:4f9c…2ab7 predicate repo acme/shop · commit 8d31f0a workflow .github/workflows/deploy.yml runner ubuntu-latest · run 1742099 signature — covers both blocks what the deploy job checks 1 · digest of the file I hold == subject digest 2 · signature valid for this repository's identity 3 · predicate names the expected workflow file Check 3 is what stops an attacker attesting the same bytes from a workflow they control.

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 --delete

Step-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.

Sources of Archive Nondeterminism and Their Fixes Four rows pair a source of digest drift with its remedy: modification times fixed by an explicit mtime, directory ordering fixed by sorting entries by name, owner and group metadata removed by numeric zero owner flags, and the gzip header timestamp removed by the -n flag. CHANGES BETWEEN RUNS FLAG THAT PINS IT file modification times (every checkout is "now") --mtime='UTC 2020-01-01' directory read order (filesystem dependent) --sort=name owner and group names (runner image dependent) --owner=0 --group=0 --numeric-owner gzip header timestamp (written on every compress) gzip -n

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=1

A pipeline where that command exits 0 has a verification step that is not doing anything.

Which Substitution Attempts the Gate Catches Three attack rows. Modified artifact bytes are rejected by the digest comparison. An artifact attested by a different workflow in the same repository is rejected by the signer-workflow pin. An artifact with no attestation at all is rejected because verification finds no matching statement. ATTEMPT CAUGHT BY bytes modified in the artifact store digest comparison — recomputed hash differs from the subject attested from a workflow added by a PR --signer-workflow pin — signer URI does not match artifact uploaded with no attestation verification finds no statement for the digest All three fail closed. None of them are caught if the deploy job simply trusts the download.

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.

← Back to Pipeline Security & Supply Chain Hardening