From 3f78211785f3fed209d3abf041d7cb70a02abf5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 11:39:32 +0000 Subject: [PATCH] ci(release): dry run the build on pull requests (AUREO-1483) Release runs only on a push to main, so the checksum bug from #952 was first exercised by a real release. The build job moves into a reusable release-build.yml and the artifact check into a composite action; release.yml and a new release-dry-run.yml both call them, so the rehearsal cannot drift from the release. No secrets in the dry run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0142U4PXAPmyuVayPHSAHpqZ --- .github/actions/verify-dist/action.yml | 27 +++++++++ .github/workflows/release-build.yml | 84 ++++++++++++++++++++++++++ .github/workflows/release-dry-run.yml | 53 ++++++++++++++++ .github/workflows/release.yml | 79 ++---------------------- 4 files changed, 170 insertions(+), 73 deletions(-) create mode 100644 .github/actions/verify-dist/action.yml create mode 100644 .github/workflows/release-build.yml create mode 100644 .github/workflows/release-dry-run.yml diff --git a/.github/actions/verify-dist/action.yml b/.github/actions/verify-dist/action.yml new file mode 100644 index 000000000..75163bded --- /dev/null +++ b/.github/actions/verify-dist/action.yml @@ -0,0 +1,27 @@ +name: Verify build output +description: >- + Check that dist/ holds exactly the five files the release build hands + over, each matching its checksum. Shell only, from the same commit as + the workflow that calls it: nothing installed, nothing from scripts/. + +runs: + using: composite + steps: + - name: Verify build output + shell: bash + run: | + set -euo pipefail + expected='catalog-index.json + catalog-index.json.sha256 + catalog-sbom.spdx.json + catalog.sqlite + catalog.sqlite.sha256' + if [ "$(ls -A dist | LC_ALL=C sort)" != "$expected" ]; then + echo "::error::dist/ holds something other than the five build files; refusing to sign it" + ls -la dist + exit 1 + fi + # Checked from the repository root: each .sha256 line names its + # file as `dist/`, the path the build job hashed it under, + # so a `cd dist` first makes sha256sum look for dist/dist/. + sha256sum -c dist/catalog.sqlite.sha256 dist/catalog-index.json.sha256 diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 000000000..7b50fd31a --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,84 @@ +name: Release build + +# The release's `build` job, callable so that release.yml and +# release-dry-run.yml run the same steps. A dry run that copied them +# would drift from the release it is meant to rehearse, which is how +# the checksum check in #952 reached main untested. +on: + workflow_call: + inputs: + assign-ids: + description: >- + Run `pnpm assign-ids` locally before validating. A pull request + gets its ids from assign-ids.yml in parallel, so its tree may + not carry them yet; main always does. + type: boolean + default: false + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + # fetch-depth 1 is enough: the build stamps `updated_at` with the + # HEAD commit's date, which a shallow clone carries. + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "24" + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Assign ids and io keys (local, not committed) + if: inputs.assign-ids + run: pnpm assign-ids + + - name: Validate before release + run: pnpm validate + + - name: Build database + run: pnpm build + + - name: Build web index + run: pnpm build:index + + - name: Generate checksum + run: | + sha256sum dist/catalog.sqlite > dist/catalog.sqlite.sha256 + sha256sum dist/catalog-index.json > dist/catalog-index.json.sha256 + + # Here rather than beside the signature because the SBOM describes + # the dependencies the build ran with, and node_modules exists only + # in this job. + - name: Generate SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 + with: + artifact-name: catalog-sbom.spdx.json + output-file: dist/catalog-sbom.spdx.json + + - name: Upload build output + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: catalog-dist + path: | + dist/catalog.sqlite + dist/catalog.sqlite.sha256 + dist/catalog-index.json + dist/catalog-index.json.sha256 + dist/catalog-sbom.spdx.json + retention-days: 1 + if-no-files-found: error diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml new file mode 100644 index 000000000..d062648aa --- /dev/null +++ b/.github/workflows/release-dry-run.yml @@ -0,0 +1,53 @@ +name: Release dry run + +# Rehearses release.yml up to the signature: the same build job, then the +# same artifact check sign-and-release runs before it touches the key. +# Release only runs on a push to main, so without this a change to the +# release path is first exercised by a real release. No secret is +# referenced anywhere here, so a pull request's code can run it. +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/release.yml" + - ".github/workflows/release-build.yml" + - ".github/workflows/release-dry-run.yml" + - ".github/actions/verify-dist/**" + - "scripts/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + uses: ./.github/workflows/release-build.yml + with: + assign-ids: true + + verify: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Download build output + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: catalog-dist + path: dist + + - name: Verify build output + uses: ./.github/actions/verify-dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 112ef2a93..621757e15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,8 @@ permissions: # that ran install before the split. # build install, validate, build, index, checksums, SBOM. A # read-only token and nothing else; hands dist/ on as -# an artifact. +# an artifact. Lives in release-build.yml, which +# release-dry-run.yml also calls on pull requests. # sign-and-release downloads that artifact, installs nothing, verifies # minisign by digest, signs under umask 077 with the # key in a mktemp file, attests and publishes. Until @@ -68,64 +69,9 @@ jobs: build: needs: version if: needs.version.outputs.has-changesets == 'false' - runs-on: ubuntu-latest permissions: contents: read - - steps: - # fetch-depth 1 is enough: the build stamps `updated_at` with the - # HEAD commit's date, which a shallow clone carries. - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - persist-credentials: false - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: "24" - - - name: Setup pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Validate before release - run: pnpm validate - - - name: Build database - run: pnpm build - - - name: Build web index - run: pnpm build:index - - - name: Generate checksum - run: | - sha256sum dist/catalog.sqlite > dist/catalog.sqlite.sha256 - sha256sum dist/catalog-index.json > dist/catalog-index.json.sha256 - - # Here rather than beside the signature because the SBOM describes - # the dependencies the build ran with, and node_modules exists only - # in this job. - - name: Generate SBOM - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 - with: - artifact-name: catalog-sbom.spdx.json - output-file: dist/catalog-sbom.spdx.json - - - name: Upload build output - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: catalog-dist - path: | - dist/catalog.sqlite - dist/catalog.sqlite.sha256 - dist/catalog-index.json - dist/catalog-index.json.sha256 - dist/catalog-sbom.spdx.json - retention-days: 1 - if-no-files-found: error + uses: ./.github/workflows/release-build.yml sign-and-release: needs: build @@ -155,23 +101,10 @@ jobs: # The artifact was produced by a job that ran code from the tree, so # it is data to check, not a directory to trust: exactly the files # the build is expected to hand over, each matching its checksum. + # A composite action from this commit's .github/, shell only, shared + # with release-dry-run.yml so a pull request exercises this check. - name: Verify build output - run: | - set -euo pipefail - expected='catalog-index.json - catalog-index.json.sha256 - catalog-sbom.spdx.json - catalog.sqlite - catalog.sqlite.sha256' - if [ "$(ls -A dist | LC_ALL=C sort)" != "$expected" ]; then - echo "::error::dist/ holds something other than the five build files; refusing to sign it" - ls -la dist - exit 1 - fi - # Checked from the repository root: each .sha256 line names its - # file as `dist/`, the path the build job hashed it under, - # so a `cd dist` first makes sha256sum look for dist/dist/. - sha256sum -c dist/catalog.sqlite.sha256 dist/catalog-index.json.sha256 + uses: ./.github/actions/verify-dist # Verified by a digest pinned here, in review, in git, before the # signing key exists anywhere in this job. `curl | tar xz` ran an