From fa45c8edcac0ca92742a4ed1ae0c026cb4ede2a3 Mon Sep 17 00:00:00 2001 From: lgoyal6 Date: Sat, 5 Sep 2026 17:33:14 -0700 Subject: [PATCH 1/3] Attest the web build so the shipped bytes can be verified The repository had no workflows at all, so nothing built the site and nothing said where a given build came from. Anyone handed a copy of web/.next had no way to tell it apart from one assembled anywhere else. This adds a push-triggered job that installs from the lockfile, builds, packs .next into one tar so it has a digest at all, and signs a SLSA provenance statement and an SPDX SBOM over that tar. Both are bound to its sha256 digest and to an OIDC identity only this repository can mint, so an attestation cannot be produced out of band. gh attestation verify now passes for the exact bytes the job built and fails for any other bytes, including a single flipped bit. Actions are pinned by commit SHA rather than tag: a tag is a mutable pointer its owner can repoint at different code at any time, so a tag pin does not fix what runs. Co-Authored-By: Claude Opus 5 --- .github/workflows/attest.yml | 105 +++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 .github/workflows/attest.yml diff --git a/.github/workflows/attest.yml b/.github/workflows/attest.yml new file mode 100644 index 0000000..1a867a6 --- /dev/null +++ b/.github/workflows/attest.yml @@ -0,0 +1,105 @@ +name: attest + +# Build provenance and SBOM attestation for the built site. +# +# Push-triggered on purpose: the attestation is a statement about a build that +# actually happened, signed with an identity only GitHub's OIDC issuer can mint +# for this repository. There is no way to produce one out of band, which is what +# makes `gh attestation verify` mean anything. +# +# The subject is web/.next, the compiled application, because it is the only +# thing in the tree that a build turns into shippable bytes. The Python half is +# scripts and Modal entrypoints with no packaging, so there is nothing there to +# sign. +# +# A directory has no digest, so it is packed into one tar first and the tar is +# what gets signed. +on: + push: + branches: ['**'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + provenance: + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + id-token: write # mint the OIDC token Sigstore binds the signature to + attestations: write # write the signed bundle to this repository's attestation store + defaults: + run: + working-directory: web + steps: + # Every action is pinned to a commit SHA rather than a tag: a tag is a + # mutable pointer its owner can move to different code at any time, so a + # tag pin does not fix what actually runs here. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + + # npm ci, not npm install: it installs exactly what package-lock.json + # pins, so the dependency set the SBOM records is the set that was linked + # into the build rather than whatever resolved that minute. + - name: install + run: npm ci + + # After install, so the scan sees the resolved node_modules tree and not + # just the ranges in package.json. + - name: sbom + uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + with: + path: web + format: spdx-json + output-file: winnow-web.spdx.json + syft-version: v1.51.1 + upload-artifact: false + upload-release-assets: false + dependency-snapshot: false + + - name: build + run: npm run build + + # Sorted names, zeroed timestamps and zeroed ownership: without them the + # tar digest would change on every run for reasons that have nothing to do + # with the code, and a digest that moves on its own cannot be compared. + - name: pack the build output + run: | + tar --sort=name --format=posix \ + --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner \ + -cf winnow-web-build.tar -C .next . + + # The digest the attestation will carry. Printed so the value a verifier + # computes locally can be compared against the run that produced it. + - name: artifact digest + run: sha256sum winnow-web-build.tar | tee artifact-digests.txt + + - name: attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: web/winnow-web-build.tar + + - name: attest sbom + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-path: web/winnow-web-build.tar + sbom-path: web/winnow-web.spdx.json + + # The attested bytes themselves, so verification can be run against the + # exact artifact the attestation names rather than a local rebuild. + - name: upload attested artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: winnow-attested + path: | + web/winnow-web-build.tar + web/winnow-web.spdx.json + web/artifact-digests.txt + if-no-files-found: error From 0ee96e3b4ba4b5ec1a69d4f8ddd74f1dd3ca3730 Mon Sep 17 00:00:00 2001 From: lgoyal6 Date: Sat, 5 Sep 2026 17:37:00 -0700 Subject: [PATCH 2/3] Write the SBOM where the attestation looks for it The scan step wrote its output to the workspace root while the attest step read it from the job's working-directory, so "SBOM file not found" failed the run. An action's paths resolve from the workspace root; defaults.run.working-directory only applies to run steps. Giving output-file the directory prefix puts the file where the attest step already expects it. Co-Authored-By: Claude Opus 5 --- .github/workflows/attest.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/attest.yml b/.github/workflows/attest.yml index 1a867a6..75f0bc5 100644 --- a/.github/workflows/attest.yml +++ b/.github/workflows/attest.yml @@ -52,13 +52,15 @@ jobs: run: npm ci # After install, so the scan sees the resolved node_modules tree and not - # just the ranges in package.json. + # just the ranges in package.json. output-file carries the directory + # prefix because an action's paths resolve from the workspace root, not + # from the job's working-directory. - name: sbom uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 with: path: web format: spdx-json - output-file: winnow-web.spdx.json + output-file: web/winnow-web.spdx.json syft-version: v1.51.1 upload-artifact: false upload-release-assets: false From fd5e9d8d6a359d01226807181a374c580492ed46 Mon Sep 17 00:00:00 2001 From: lgoyal6 Date: Sat, 5 Sep 2026 17:48:07 -0700 Subject: [PATCH 3/3] Leave the build cache out of the attested artifact The tar took all of .next, so it carried .next/cache: the incremental build cache, which is not deployed and which differs between runs that produced identical output. That made the artifact hundreds of megabytes and gave the digest a reason to move that has nothing to do with the code. Co-Authored-By: Claude Opus 5 --- .github/workflows/attest.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/attest.yml b/.github/workflows/attest.yml index 75f0bc5..d3f5b34 100644 --- a/.github/workflows/attest.yml +++ b/.github/workflows/attest.yml @@ -72,10 +72,15 @@ jobs: # Sorted names, zeroed timestamps and zeroed ownership: without them the # tar digest would change on every run for reasons that have nothing to do # with the code, and a digest that moves on its own cannot be compared. + # .next/cache is the incremental build cache, not shipped output. Leaving + # it in would make the attested artifact several hundred megabytes of + # bytes nobody deploys, and would move the digest between runs that + # produced identical output. - name: pack the build output run: | tar --sort=name --format=posix \ --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner \ + --exclude=./cache \ -cf winnow-web-build.tar -C .next . # The digest the attestation will carry. Printed so the value a verifier