From bdb213b70bdcc7fe396247bbfd468e5571587154 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 12 Aug 2026 20:16:06 +0700 Subject: [PATCH 1/3] fix(release): rehearse production paths before release Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 1 + .github/workflows/ci.yml | 12 +- .github/workflows/docs-pages.yml | 7 +- .github/workflows/evidence-dev.yml | 10 +- .github/workflows/nightly-rust-coverage.yml | 2 +- .github/workflows/nightly-security.yml | 4 +- .github/workflows/release-canary.yml | 4 +- .../workflows/release-candidate-cleanup.yml | 2 +- .github/workflows/release-candidate.yml | 18 +- .github/workflows/release-rehearsal.yml | 65 +++ .github/workflows/release-repeatability.yml | 2 +- .github/workflows/release.yml | 20 +- docs/site/astro.config.mjs | 4 +- docs/site/package.json | 7 +- .../scripts/check-evidence-links.test.mjs | 2 +- .../scripts/generate-cli-reference.test.mjs | 10 +- docs/site/scripts/page-markdown.test.mjs | 16 +- docs/site/src/lib/page-markdown.ts | 17 +- docs/site/src/pages/[...slug].md.ts | 7 +- release/OPERATIONS.md | 39 +- release/scripts/check-gates-inventory.py | 30 +- release/scripts/registry-release | 479 +++++++++++++++++- release/scripts/rehearse-release | 107 ++++ release/scripts/test_check_gates_inventory.py | 21 +- release/scripts/test_registry_release.py | 82 +++ .../scripts/test_registry_release_plans.py | 156 +++++- release/scripts/test_release_rehearsal.py | 86 ++++ .../test_release_workflow_structure.py | 2 + release/scripts/test_verify_public_release.py | 262 ++++++++++ release/scripts/verify_public_release.py | 457 +++++++++++++++++ 30 files changed, 1876 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/release-rehearsal.yml create mode 100755 release/scripts/rehearse-release create mode 100644 release/scripts/test_release_rehearsal.py create mode 100644 release/scripts/test_verify_public_release.py create mode 100644 release/scripts/verify_public_release.py diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 0cbbff287..69b1e2023 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -186,6 +186,7 @@ ".github/workflows/release-candidate.yml", ".github/workflows/release-repeatability.yml", ".github/workflows/release-candidate-cleanup.yml", + ".github/workflows/release-rehearsal.yml", } ) REPO_ROOT = Path(__file__).resolve().parents[2] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cc13aa1b..5935d0862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -305,7 +305,7 @@ jobs: - name: Upload fuzz failures if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: platform-fuzz-${{ matrix.target }} path: products/platform/fuzz/artifacts/${{ matrix.target }}/ @@ -598,6 +598,9 @@ jobs: - name: Test release candidate manifest and promotion verifier run: python3 -m unittest release/scripts/test_release_candidate.py + - name: Test public release verifier + run: python3 -m unittest release/scripts/test_verify_public_release.py + - name: Test release storage preflight run: python3 -m unittest release/scripts/test_check_release_storage.py @@ -607,6 +610,9 @@ jobs: - name: Test release repeatability workflow run: python3 -m unittest release/scripts/test_release_repeatability_workflow.py + - name: Test release rehearsal workflow + run: python3 -m unittest release/scripts/test_release_rehearsal.py + - name: Test release workflow structure run: python3 -m unittest release/scripts/test_release_workflow_structure.py @@ -855,9 +861,9 @@ jobs: working-directory: docs/site run: npm test - - name: Check docs build + - name: Check production-shaped docs build working-directory: docs/site - run: npm run check + run: npm run check:production docs-required: # Branch protection requires this exact context. Keep it successful for diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index d4dc466b6..f30db493a 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -168,12 +168,7 @@ jobs: - name: Build protected main at /dev/ working-directory: docs/site - run: | - set -euo pipefail - npm run generate - DOCS_DOCSET=latest DOCS_BASE=/dev/ npx astro check - DOCS_DOCSET=latest DOCS_BASE=/dev/ npx astro build --outDir dist/dev - node scripts/apply-archive-seo.mjs dist/dev + run: npm run build:dev env: DOCS_RELEASED_TAG: ${{ steps.release.outputs.released_tag }} PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.PUBLIC_UMAMI_WEBSITE_ID }} diff --git a/.github/workflows/evidence-dev.yml b/.github/workflows/evidence-dev.yml index 046ce33bc..5883c1e23 100644 --- a/.github/workflows/evidence-dev.yml +++ b/.github/workflows/evidence-dev.yml @@ -188,7 +188,7 @@ jobs: done - name: Upload native Evidence development binaries - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: evidence-dev-${{ matrix.asset }}-${{ github.run_id }}-${{ github.run_attempt }} path: development-platform @@ -482,7 +482,7 @@ jobs: node smoke.js - name: Upload Evidence development client packages - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: evidence-dev-clients-${{ matrix.asset }}-${{ github.run_id }}-${{ github.run_attempt }} path: development-clients @@ -510,7 +510,7 @@ jobs: submodules: false - name: Download exact native binaries and client packages - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: evidence-dev-*-${{ github.run_id }}-${{ github.run_attempt }} path: development-inputs @@ -577,7 +577,7 @@ jobs: done - name: Upload exact Evidence development assets - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: evidence-dev-assets-${{ github.run_id }}-${{ github.run_attempt }} path: development-assets @@ -596,7 +596,7 @@ jobs: contents: write steps: - name: Download exact assembled development assets - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: evidence-dev-assets-${{ github.run_id }}-${{ github.run_attempt }} path: development-assets diff --git a/.github/workflows/nightly-rust-coverage.yml b/.github/workflows/nightly-rust-coverage.yml index 19556857e..80308ab3e 100644 --- a/.github/workflows/nightly-rust-coverage.yml +++ b/.github/workflows/nightly-rust-coverage.yml @@ -154,7 +154,7 @@ jobs: --output-path "${coverage_dir}/${{ matrix.name }}.lcov" - name: Upload shard coverage artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: nightly-rust-${{ matrix.name }}-coverage path: target/nightly-rust-coverage/coverage/${{ matrix.name }}.lcov diff --git a/.github/workflows/nightly-security.yml b/.github/workflows/nightly-security.yml index 88091e4f7..fffcac052 100644 --- a/.github/workflows/nightly-security.yml +++ b/.github/workflows/nightly-security.yml @@ -188,7 +188,7 @@ jobs: - name: Upload platform fuzz artifacts if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: nightly-platform-fuzz-artifacts path: products/platform/fuzz/artifacts @@ -238,7 +238,7 @@ jobs: - name: Upload manifest fuzz artifacts if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: nightly-manifest-fuzz-artifacts path: products/manifest/fuzz/artifacts diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index e8a27fb6f..cc0a440d3 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -294,7 +294,7 @@ jobs: test -s canary/provenance-subjects.sha256 - name: Upload canary candidate - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-canary-${{ github.run_id }}-${{ github.run_attempt }} path: | @@ -311,7 +311,7 @@ jobs: canary/registry-stack-v1.2.3-candidate.tar.gz - name: Download and authenticate canary candidate - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-canary-${{ github.run_id }}-${{ github.run_attempt }} path: canary/downloaded diff --git a/.github/workflows/release-candidate-cleanup.yml b/.github/workflows/release-candidate-cleanup.yml index 5e6c1539a..a4a9151b4 100644 --- a/.github/workflows/release-candidate-cleanup.yml +++ b/.github/workflows/release-candidate-cleanup.yml @@ -71,7 +71,7 @@ jobs: - name: Upload cleanup receipt if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-candidate-cleanup-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/release-candidate-cleanup.json diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 55acec7da..05baa8a4c 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -1,4 +1,5 @@ name: RegistryStack Release Candidate +run-name: Release candidate ${{ github.event.client_payload.release_id }} v${{ github.event.client_payload.version }} (${{ github.event.client_payload.request_id }}) on: repository_dispatch: @@ -60,6 +61,7 @@ jobs: REQUEST_VERSION: ${{ github.event.client_payload.version }} REQUEST_RELEASE_ID: ${{ github.event.client_payload.release_id }} REQUEST_SOURCE_SHA: ${{ github.event.client_payload.source_sha }} + REQUEST_ID: ${{ github.event.client_payload.request_id }} shell: bash run: | set -euo pipefail @@ -81,6 +83,10 @@ jobs: echo "release_id is invalid" >&2 exit 1 fi + if [[ ! "${REQUEST_ID}" =~ ^[0-9a-f]{32}$ ]]; then + echo "request_id is invalid" >&2 + exit 1 + fi if [[ ! "${REQUEST_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then echo "source_sha must be one exact commit" >&2 exit 1 @@ -331,7 +337,7 @@ jobs: candidate-canonical/ - name: Upload canonical build products - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: candidate-canonical-${{ github.run_id }}-${{ github.run_attempt }} path: candidate-canonical @@ -392,7 +398,7 @@ jobs: mv platform candidate-platform/platform - name: Upload native platform payload - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: candidate-${{ matrix.asset }}-${{ github.run_id }}-${{ github.run_attempt }} path: candidate-platform @@ -558,7 +564,7 @@ jobs: done - name: Upload native client packages - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: candidate-clients-${{ matrix.asset }}-${{ github.run_id }}-${{ github.run_attempt }} path: candidate-client-package @@ -588,7 +594,7 @@ jobs: submodules: false - name: Download exact build products - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: candidate-*-${{ github.run_id }}-${{ github.run_attempt }} path: inputs @@ -1073,7 +1079,7 @@ jobs: --output candidate/release-candidate-manifest.json - name: Upload one candidate manifest and bundle - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: registry-stack-release-candidate-${{ github.run_id }}-${{ github.run_attempt }} path: | @@ -1104,7 +1110,7 @@ jobs: submodules: false - name: Download compact candidate - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: registry-stack-release-candidate-${{ github.run_id }}-${{ github.run_attempt }} path: candidate diff --git a/.github/workflows/release-rehearsal.yml b/.github/workflows/release-rehearsal.yml new file mode 100644 index 000000000..b97d2c19e --- /dev/null +++ b/.github/workflows/release-rehearsal.yml @@ -0,0 +1,65 @@ +name: Rehearse RegistryStack Release +run-name: Rehearse ${{ inputs.release_id }} v${{ inputs.version }} + +on: + workflow_dispatch: + inputs: + version: + description: Prepared canonical semantic version without the v prefix + required: true + type: string + release_id: + description: Prepared release train identity + required: true + type: string + +permissions: + contents: read + +jobs: + rehearse: + name: Exercise future-tag release paths on Ubuntu + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Require a branch rehearsal + shell: bash + run: | + set -euo pipefail + if [[ "${GITHUB_REF}" != refs/heads/* ]]; then + echo "release rehearsal must run from a prepared branch" >&2 + exit 1 + fi + + - name: Checkout prepared branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + submodules: false + + - name: Refresh protected-main comparison ref + run: >- + git fetch --no-tags --force origin + refs/heads/main:refs/remotes/origin/main + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4.4.0 + with: + node-version: 22.12.0 + cache: npm + cache-dependency-path: docs/site/package-lock.json + + - name: Install exact docs dependencies + working-directory: docs/site + run: npm ci + + - name: Rehearse prepared release without publishing + env: + REHEARSAL_VERSION: ${{ inputs.version }} + REHEARSAL_RELEASE_ID: ${{ inputs.release_id }} + run: >- + release/scripts/rehearse-release + --version "${REHEARSAL_VERSION}" + --release-id "${REHEARSAL_RELEASE_ID}" + --base-ref origin/main diff --git a/.github/workflows/release-repeatability.yml b/.github/workflows/release-repeatability.yml index 7557bfde4..1f3da935d 100644 --- a/.github/workflows/release-repeatability.yml +++ b/.github/workflows/release-repeatability.yml @@ -297,7 +297,7 @@ jobs: }' > proof/release-repeatability-result.json - name: Upload 30-day repeatability evidence - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-repeatability-${{ steps.release.outputs.tag }}-${{ github.run_id }}-${{ github.run_attempt }} path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4d3d423f..332a0915b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -266,7 +266,7 @@ jobs: fi - name: Upload verified promotion input - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-promotion-input-${{ github.run_id }} path: promotion/candidate @@ -301,7 +301,7 @@ jobs: submodules: false - name: Download verified promotion input - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-promotion-input-${{ github.run_id }} path: promotion @@ -483,7 +483,7 @@ jobs: cp "${RUNNER_TEMP}/staged-draft.json" contract/draft-release.json - name: Upload draft reconciliation contract - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-draft-contract-${{ github.run_id }} path: contract @@ -512,13 +512,13 @@ jobs: submodules: false - name: Download verified candidate and draft contract - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: release-*-input-${{ github.run_id }} path: inputs - name: Download exact draft contract - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-draft-contract-${{ github.run_id }} path: contract @@ -675,13 +675,13 @@ jobs: submodules: false - name: Download exact candidate and staged draft contract - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: release-*-input-${{ github.run_id }} path: inputs - name: Download exact staged draft contract - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-draft-contract-${{ github.run_id }} path: contract @@ -857,7 +857,7 @@ jobs: | jq -s 'sort_by(.name)' > contract/final-assets.json - name: Upload final reconciliation contract - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-final-contract-${{ github.run_id }} path: contract @@ -879,13 +879,13 @@ jobs: contents: write steps: - name: Download exact candidate manifest - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-promotion-input-${{ github.run_id }} path: promotion - name: Download exact final contract - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-final-contract-${{ github.run_id }} path: contract diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index d4c00c514..d5e01c840 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -10,7 +10,7 @@ import remarkGfm from 'remark-gfm'; // Single source of truth for the machine-discovery pointer. Reused as the // llms.txt `details` block so it can never drift from the header the per-page // .md endpoint prepends (src/pages/[...slug].md.ts). -import { DISCOVERY_HEADER } from './src/lib/page-markdown.ts'; +import { discoveryHeaderForBase } from './src/lib/page-markdown.ts'; import { cliReferenceSidebar } from './src/lib/cli-reference-sidebar.mjs'; import { buildNotaryRetirementRedirects } from './src/lib/notary-retirement-redirects.mjs'; import { buildRelayV2RetirementRedirects } from './src/lib/relay-v2-retirement-redirects.mjs'; @@ -224,7 +224,7 @@ export default defineConfig({ // canonical root. Historical archives retain their sealed output. ...(isHistoricalArchiveBuild ? [] : [starlightLlmsTxt({ description: 'Documentation for Registry Stack: tutorials, product docs, explanation, and API reference for Registry Relay and Evidence Gateway.', - details: DISCOVERY_HEADER, + details: discoveryHeaderForBase(base), exclude: ['reference/apis/**'], promote: ['index*', 'explanation/**'], demote: ['reference/**', 'decisions/**'], diff --git a/docs/site/package.json b/docs/site/package.json index db403ed69..948d00a28 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -9,6 +9,7 @@ "scripts": { "dev": "npm run generate && astro dev", "build": "npm run generate && astro check && astro build && node scripts/apply-archive-seo.mjs dist", + "build:dev": "npm run generate && DOCS_DOCSET=latest DOCS_BASE=/dev/ astro check && DOCS_DOCSET=latest DOCS_BASE=/dev/ astro build --outDir dist/dev && node scripts/apply-archive-seo.mjs dist/dev", "build:archive": "node scripts/build-archive.mjs", "build:archives": "node scripts/build-archives.mjs", "build:production-search": "node scripts/build-production-search.mjs", @@ -43,7 +44,11 @@ "check:tutorial:evidence": "bash scripts/check-evidence-tutorials.sh", "check:tutorial:evidence:dry-run": "bash scripts/check-evidence-tutorials.sh --dry-run", "check:links": "npm run build && npm run check:links:built", - "check": "npm run check:cli-reference && npm run generate && npm run check:evidence-links && npm run check:research-banners && npm run check:docset && npm run check:release-manifests && npm run check:archive-lock && npm run check:content && npm run check:cutover && npm run check:notary-surface && npm run check:markdown && npm run check:style && npm run check:style:fixtures && npm run check:openapi && npm run check:config-vocabulary && npm run check:tutorial:dry-run && npm run check:tutorial:evidence:dry-run && npm run check:svg && npm run build && npm run check:accessibility:built && npm run check:llms:built && npm run check:seo:current && npm run check:links:current", + "check": "npm run check:source && npm run build && npm run check:built:current", + "check:source": "npm run check:cli-reference && npm run generate && npm run check:evidence-links && npm run check:research-banners && npm run check:docset && npm run check:release-manifests && npm run check:archive-lock && npm run check:content && npm run check:cutover && npm run check:notary-surface && npm run check:markdown && npm run check:style && npm run check:style:fixtures && npm run check:openapi && npm run check:config-vocabulary && npm run check:tutorial:dry-run && npm run check:tutorial:evidence:dry-run && npm run check:svg", + "check:built:current": "npm run check:accessibility:built && npm run check:llms:built && npm run check:seo:current && npm run check:links:current", + "check:production": "npm run check:source && npm run build:dev && npm run check:production:built", + "check:production:built": "DOCS_DIST_DIR=$PWD/dist/dev DOCS_PUBLIC_BASE=/dev/ npm run check:accessibility:built && DOCS_DIST_DIR=$PWD/dist/dev DOCS_PUBLIC_BASE=/dev/ npm run check:llms:built && DOCS_DIST_DIR=$PWD/dist/dev DOCS_PUBLIC_BASE=/dev/ npm run check:seo:current && DOCS_DIST_DIR=$PWD/dist/dev DOCS_PUBLIC_BASE=/dev/ npm run check:links:current", "check:archives": "npm run build && npm run check:llms:built && npm run assemble:archives -- --bootstrap && npm run check:seo:built && npm run check:links:built", "check:archive-lock": "node scripts/archive-lock.mjs check", "check:seo:current": "node scripts/check-seo.mjs --scope current", diff --git a/docs/site/scripts/check-evidence-links.test.mjs b/docs/site/scripts/check-evidence-links.test.mjs index c47de7866..a91731a1e 100644 --- a/docs/site/scripts/check-evidence-links.test.mjs +++ b/docs/site/scripts/check-evidence-links.test.mjs @@ -334,7 +334,7 @@ test('Pages authenticates an exact release before build and smokes one assembled assert.match(workflow, /push:\n\s+branches:\n\s+- main/); assert.match(workflow, /Resolve latest published docs release/); assert.match(workflow, /registry-stack-\$\{RELEASED_TAG\}-SHA256SUMS\.sigstore\.json/); - assert.match(workflow, /DOCS_BASE=\/dev\//); + assert.match(workflow, /run: npm run build:dev/); assert.match(workflow, /--exclude-docset "\$\{RELEASED_TAG\}"/); assert.doesNotMatch(workflow, /--bootstrap|registry-docset-redirect/); }); diff --git a/docs/site/scripts/generate-cli-reference.test.mjs b/docs/site/scripts/generate-cli-reference.test.mjs index d0f2c9cba..ed6042410 100644 --- a/docs/site/scripts/generate-cli-reference.test.mjs +++ b/docs/site/scripts/generate-cli-reference.test.mjs @@ -136,8 +136,12 @@ test('the docs check detects CLI drift before generation', async () => { const packageJson = JSON.parse( await readFile(new URL('../package.json', import.meta.url), 'utf8'), ); - const steps = packageJson.scripts.check.split(' && '); + const checkSteps = packageJson.scripts.check.split(' && '); + const sourceSteps = packageJson.scripts['check:source'].split(' && '); - assert.equal(steps[0], 'npm run check:cli-reference'); - assert.ok(steps.indexOf('npm run check:cli-reference') < steps.indexOf('npm run generate')); + assert.equal(checkSteps[0], 'npm run check:source'); + assert.equal(sourceSteps[0], 'npm run check:cli-reference'); + assert.ok( + sourceSteps.indexOf('npm run check:cli-reference') < sourceSteps.indexOf('npm run generate'), + ); }); diff --git a/docs/site/scripts/page-markdown.test.mjs b/docs/site/scripts/page-markdown.test.mjs index 6bc339c2e..bb60948f1 100644 --- a/docs/site/scripts/page-markdown.test.mjs +++ b/docs/site/scripts/page-markdown.test.mjs @@ -24,7 +24,12 @@ const jsSource = readFileSync(srcPath, 'utf8') .replace(/:\s*string/g, ''); // ": string" annotations const dataUrl = 'data:text/javascript,' + encodeURIComponent(jsSource); -const { DISCOVERY_HEADER, entrySlugToOutputPath, buildPageMarkdown } = await import(dataUrl); +const { + DISCOVERY_HEADER, + discoveryHeaderForBase, + entrySlugToOutputPath, + buildPageMarkdown, +} = await import(dataUrl); test('per-page Markdown route excludes Starlight draft entries', () => { const routeSource = readFileSync(routePath, 'utf8'); @@ -51,6 +56,15 @@ test('discovery header contains llms.txt and llms-full.txt URLs', () => { assert.ok(out.includes('https://docs.registrystack.org/llms-full.txt'), 'missing llms-full.txt URL'); }); +test('discovery header follows the production public base', () => { + const expected = discoveryHeaderForBase('/dev/'); + const out = buildPageMarkdown('Title', undefined, 'Body.', '/dev/'); + assert.ok(out.startsWith(expected)); + assert.ok(out.includes('https://docs.registrystack.org/dev/llms.txt')); + assert.ok(out.includes('https://docs.registrystack.org/dev/llms-full.txt')); + assert.ok(!out.includes('https://docs.registrystack.org/llms.txt')); +}); + test('title is rendered as an H1 heading', () => { const out = buildPageMarkdown('Architecture overview', undefined, 'Body.'); assert.ok(out.includes('\n# Architecture overview\n'), 'title heading not found'); diff --git a/docs/site/src/lib/page-markdown.ts b/docs/site/src/lib/page-markdown.ts index 773d3e603..009ce52b8 100644 --- a/docs/site/src/lib/page-markdown.ts +++ b/docs/site/src/lib/page-markdown.ts @@ -7,6 +7,20 @@ export const DISCOVERY_HEADER = `Registry stack documentation: machine-readable Index of all pages: https://docs.registrystack.org/llms.txt Full corpus: https://docs.registrystack.org/llms-full.txt`; +/** + * Bind discovery URLs to the public base used by this build. + * + * @param publicBase - Astro base path, such as "/" or "/dev/". + */ +export function discoveryHeaderForBase(publicBase: string | undefined = '/'): string { + const normalizedBase = `/${String(publicBase) + .replace(/^\/+|\/+$/g, '')}/`.replace(/^\/\/$/, '/'); + return DISCOVERY_HEADER.replaceAll( + 'https://docs.registrystack.org/', + `https://docs.registrystack.org${normalizedBase}`, + ); +} + /** * Map a docs collection entry slug to the output path param used in * src/pages/[...slug].md.ts so the generated file URL matches the page URL. @@ -43,8 +57,9 @@ export function buildPageMarkdown( title: string, description: string | undefined, body: string, + publicBase: string | undefined = '/', ): string { - const parts: string[] = [DISCOVERY_HEADER, '', `# ${title}`]; + const parts: string[] = [discoveryHeaderForBase(publicBase), '', `# ${title}`]; if (description) { parts.push('', `> ${description}`); } diff --git a/docs/site/src/pages/[...slug].md.ts b/docs/site/src/pages/[...slug].md.ts index 9c6545f09..bf010ed0b 100644 --- a/docs/site/src/pages/[...slug].md.ts +++ b/docs/site/src/pages/[...slug].md.ts @@ -24,7 +24,12 @@ export async function getStaticPaths(): Promise { export async function GET({ props }: { props: { entry: Awaited>>[number] } }) { const { entry } = props; - const body = buildPageMarkdown(entry.data.title, entry.data.description, entry.body ?? ''); + const body = buildPageMarkdown( + entry.data.title, + entry.data.description, + entry.body ?? '', + import.meta.env.BASE_URL, + ); return new Response(body, { headers: { 'content-type': 'text/markdown; charset=utf-8' }, }); diff --git a/release/OPERATIONS.md b/release/OPERATIONS.md index 6f9daaded..ce324690d 100644 --- a/release/OPERATIONS.md +++ b/release/OPERATIONS.md @@ -79,6 +79,23 @@ or release-tool implementation changes into this PR. Merge after the protected checks pass. The merge commit is both the candidate source and future tag target. There is no finalization or closeout PR. +Before opening the release PR, push the prepared branch and run the read-only +Ubuntu rehearsal from that branch: + +```sh +gh workflow run release-rehearsal.yml \ + --repo registrystack/registry-stack \ + --ref "$(git branch --show-current)" \ + -f version= \ + -f release_id= +``` + +The rehearsal requires the future tag to remain absent. It validates the +prepared plan, current manifest and source model, reproduces the exact archive +lock on Ubuntu, exercises unpublished-tag archive bootstrap, and checks the +production-shaped `/dev/` documentation links. It publishes nothing and stays +outside the release clock. + Starting with version `0.19.1`, the release manifest records the committed identifier catalog path, SHA-256 digest, and active entry count. The planner checks that binding against the source tree. Live resolver availability remains @@ -98,9 +115,16 @@ source_sha="$(git rev-parse origin/main)" release/scripts/registry-release request-candidate \ --version \ --release-id \ - --source-sha "${source_sha}" + --source-sha "${source_sha}" \ + --wait-for-ci \ + --wait ``` +The command prints the exact candidate run ID and URL immediately after the +dispatch is correlated. `--wait-for-ci` waits only for protected-main `ci.yml` +at the exact source SHA. `--wait` then follows only that candidate run. Omit +either flag when another operator or monitor owns the corresponding wait. + The request is accepted only when `source_sha` is the exact protected-main workflow revision and that revision has successful protected-main CI. The candidate workflow then: @@ -175,6 +199,19 @@ model. Ordinary Beta publication does not generate a second generic SLSA provenance asset. Pre-v0.19 release finalizers remain only in their immutable historical release tags. +After publication, run the minimum public verifier from a checkout whose +`origin` is the Registry Stack repository: + +```sh +release/scripts/registry-release verify-public --tag v +``` + +It verifies the annotated tag target, latest published non-prerelease state, +every downloadable asset against GitHub's digest metadata, the exact +`SHA256SUMS` closure and its protected-main Sigstore identity, the release-body +manifest binding, every final OCI digest, and one maintained binary version +smoke. It is read-only and can be rerun independently. + ## Failure handling | Failure state | Response | diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 629aa0d69..51d31ce04 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -54,6 +54,10 @@ "Release candidate cleanup workflow change classification", '".github/workflows/release-candidate-cleanup.yml",', ), + ( + "Release rehearsal workflow change classification", + '".github/workflows/release-rehearsal.yml",', + ), ("actionlint version pin", 'ACTIONLINT_VERSION: "1.7.7"'), ( "actionlint archive checksum", @@ -166,6 +170,10 @@ "Release candidate manifest and promotion verifier tests", "run: python3 -m unittest release/scripts/test_release_candidate.py", ), + ( + "Public release verifier tests", + "run: python3 -m unittest release/scripts/test_verify_public_release.py", + ), ( "Release storage preflight tests", "run: python3 -m unittest release/scripts/test_check_release_storage.py", @@ -178,6 +186,10 @@ "Release repeatability workflow tests", "run: python3 -m unittest release/scripts/test_release_repeatability_workflow.py", ), + ( + "Release rehearsal workflow tests", + "run: python3 -m unittest release/scripts/test_release_rehearsal.py", + ), ( "Release workflow structure tests", "run: python3 -m unittest release/scripts/test_release_workflow_structure.py", @@ -226,7 +238,7 @@ ), ("Docs dependency install", "run: npm ci"), ("Docs tests", "run: npm test"), - ("Docs build check", "run: npm run check"), + ("Production-shaped docs build check", "run: npm run check:production"), ) RELEASE_SECURITY_POLICY_PATHS = ( @@ -237,9 +249,11 @@ ".github/workflows/release-canary.yml", ".github/workflows/release-repeatability.yml", ".github/workflows/release-candidate-cleanup.yml", + ".github/workflows/release-rehearsal.yml", "release/scripts/release_candidate.py", "release/scripts/cleanup-release-candidates.py", "release/scripts/verify_latest_published_release.py", + "release/scripts/verify_public_release.py", ) # The compact v2 release contract is the active release inventory. @@ -361,6 +375,18 @@ "name: Deploy to GitHub Pages", ), ), + ( + "Nonpublishing future-tag release rehearsal", + ".github/workflows/release-rehearsal.yml", + ( + "workflow_dispatch:", + "name: Exercise future-tag release paths on Ubuntu", + "runs-on: ubuntu-24.04", + "name: Rehearse prepared release without publishing", + "release/scripts/rehearse-release", + "--base-ref origin/main", + ), + ), ( "Latest docs release metadata fails closed", "release/scripts/verify_latest_published_release.py", @@ -376,6 +402,8 @@ ".github/workflows/release-candidate.yml", ( "repository_dispatch:\n types: [release_candidate]", + "run-name: Release candidate ${{ github.event.client_payload.release_id }}", + "REQUEST_ID: ${{ github.event.client_payload.request_id }}", "name: Validate request, source, CI, and destinations", "git merge-base --is-ancestor", "tag_lookup_status=$?", diff --git a/release/scripts/registry-release b/release/scripts/registry-release index daca61d60..9af8141aa 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -7,16 +7,19 @@ import argparse import hashlib import json import re +import secrets import shlex import subprocess import sys import tempfile +import time import tomllib from datetime import date, datetime from pathlib import Path from typing import Any import release_candidate +import verify_public_release ROOT = Path(__file__).resolve().parents[2] @@ -71,6 +74,37 @@ DOCSET_STATUSES_BY_MANIFEST_STATUS = { RELAY_V2_OPENAPI_RELEASE_SURFACE_PATHS = ( "products/evidence/generated/registry-evidence.openapi.json", ) +NODE_CLIENT_RELEASE_SURFACES = ( + ( + "crates/registry-evidence-client-node", + "@registrystack/evidence-client", + ), + ( + "crates/registry-relay-client-node", + "@registrystack/relay-client", + ), +) +PYTHON_CLIENT_RELEASE_SURFACES = ( + ( + "crates/registry-evidence-client-py", + "registry-evidence-client", + "evidence-client-sdk", + ), + ( + "crates/registry-relay-client-py", + "registry-relay-client", + "relay-client-sdk", + ), +) +FUZZ_LOCK_RELEASE_SURFACE_PATHS = ( + "products/manifest/fuzz/Cargo.lock", + "products/platform/fuzz/Cargo.lock", +) +DOCS_RELEASE_IDENTITY_ROOTS = ( + "docs/site/scripts", + "docs/site/src/content/docs", + "docs/site/src/data", +) RELEASE_CANDIDATE_WORKFLOW = ".github/workflows/release-candidate.yml" RELEASE_BUILDER_IMAGE = ( "rust:1.95-trixie@sha256:" @@ -1206,6 +1240,173 @@ def validate_workspace_versions(repo: Path, version: str) -> dict[str, Any]: } +def validate_client_package_versions(repo: Path, version: str) -> list[Path]: + surfaces: list[Path] = [] + for relative_root, expected_name in NODE_CLIENT_RELEASE_SURFACES: + root = repo / relative_root + package_path = root / "package.json" + lock_path = root / "package-lock.json" + loader_path = root / "index.js" + package = required_json(package_path) + lock = required_json(lock_path) + if ( + not isinstance(package, dict) + or package.get("name") != expected_name + or package.get("version") != version + ): + raise ReleasePlanError( + f"{relative_path(repo, package_path)} must identify " + f"{expected_name} at version {version}" + ) + lock_root = lock.get("packages") if isinstance(lock, dict) else None + lock_package = lock_root.get("") if isinstance(lock_root, dict) else None + if ( + not isinstance(lock, dict) + or lock.get("name") != expected_name + or lock.get("version") != version + or not isinstance(lock_package, dict) + or lock_package.get("name") != expected_name + or lock_package.get("version") != version + ): + raise ReleasePlanError( + f"{relative_path(repo, lock_path)} must bind {expected_name} " + f"at version {version} in both root identities" + ) + try: + loader = loader_path.read_text(encoding="utf-8") + except OSError as exc: + raise ReleasePlanError( + f"required release surface is missing: {loader_path}" + ) from exc + loader_versions = set( + re.findall(r"bindingPackageVersion !== '([^']+)'", loader) + ) + if loader_versions != {version}: + raise ReleasePlanError( + f"{relative_path(repo, loader_path)} generated binding loader " + f"must use only version {version}, found {sorted(loader_versions)!r}" + ) + surfaces.extend((package_path, lock_path, loader_path)) + + for relative_root, expected_name, dependency_name in PYTHON_CLIENT_RELEASE_SURFACES: + root = repo / relative_root + project_path = root / "pyproject.toml" + cargo_path = root / "Cargo.toml" + project = required_toml(project_path) + cargo = required_toml(cargo_path) + project_identity = project.get("project") + dependency = cargo.get("dependencies", {}).get(dependency_name) + if ( + not isinstance(project_identity, dict) + or project_identity.get("name") != expected_name + or project_identity.get("version") != version + ): + raise ReleasePlanError( + f"{relative_path(repo, project_path)} must identify " + f"{expected_name} at version {version}" + ) + if not isinstance(dependency, dict) or dependency.get("version") != version: + raise ReleasePlanError( + f"{relative_path(repo, cargo_path)} dependency {dependency_name} " + f"must use version {version}" + ) + surfaces.extend((project_path, cargo_path)) + return surfaces + + +def validate_fuzz_lock_versions( + repo: Path, + version: str, + workspace_lock: dict[str, Any], +) -> list[Path]: + workspace_packages = workspace_lock.get("package") + if not isinstance(workspace_packages, list): + raise ReleasePlanError("Cargo.lock must contain a package list") + workspace_names = { + package.get("name") + for package in workspace_packages + if isinstance(package, dict) + and isinstance(package.get("name"), str) + and package["name"].startswith("registry-") + and "source" not in package + } + surfaces = [] + for relative in FUZZ_LOCK_RELEASE_SURFACE_PATHS: + path = repo / relative + lock = required_toml(path) + packages = lock.get("package") + if not isinstance(packages, list): + raise ReleasePlanError(f"{relative} must contain a package list") + selected = [ + package + for package in packages + if isinstance(package, dict) + and package.get("name") in workspace_names + and "source" not in package + ] + if not selected: + raise ReleasePlanError(f"{relative} has no Registry Stack path packages") + stale = sorted( + str(package.get("name")) + for package in selected + if package.get("version") != version + ) + if stale: + raise ReleasePlanError( + f"{relative} path packages must use version {version}: " + + ", ".join(stale) + ) + surfaces.append(path) + return surfaces + + +def validate_release_archive_lock(repo: Path, version: str) -> Path: + path = repo / "docs/site/src/data/archive-lock.yaml" + document = required_yaml(path) + archives = document.get("archives") if isinstance(document, dict) else None + entry = archives.get(f"v{version}") if isinstance(archives, dict) else None + required_fields = { + "bundle_sha256", + "root_tree_sha256", + "version_tree_sha256", + } + if not isinstance(entry, dict) or set(entry) != required_fields: + raise ReleasePlanError( + f"archive-lock.yaml v{version} must contain exactly " + + ", ".join(sorted(required_fields)) + ) + for field in sorted(required_fields): + value = entry.get(field) + if not isinstance(value, str) or SHA256_HEX.fullmatch(value) is None: + raise ReleasePlanError( + f"archive-lock.yaml v{version}.{field} must be a lowercase SHA-256 digest" + ) + return path + + +def release_identity_surfaces( + repo: Path, + version: str, + release_id: str, +) -> list[Path]: + tokens = (version, f"v{version}", release_id) + selected: set[Path] = set() + for relative_root in DOCS_RELEASE_IDENTITY_ROOTS: + root = repo / relative_root + if not root.is_dir(): + raise ReleasePlanError(f"required release surface is missing: {root}") + for path in root.rglob("*"): + if path.is_symlink() or not path.is_file(): + continue + try: + body = path.read_text(encoding="utf-8") + except UnicodeError: + continue + if any(token in body for token in tokens): + selected.add(path) + return sorted(selected, key=lambda path: relative_path(repo, path)) + + def selected_docsets( repo: Path, record: dict[str, Any], @@ -1433,11 +1634,33 @@ def prepare_release_context(repo: Path, version: str, release_id: str) -> dict[s f"{workspace['workspace_package_count']} lock packages use {version}", ) ) + client_surfaces = validate_client_package_versions(repo, version) + checks.append( + passed_check( + "client-package-versions", + f"{len(client_surfaces)} native client package and generated loader " + f"surfaces use {version}", + ) + ) + fuzz_lock_surfaces = validate_fuzz_lock_versions( + repo, + version, + workspace["cargo_lock"], + ) + checks.append( + passed_check( + "excluded-fuzz-locks", + f"{len(fuzz_lock_surfaces)} excluded fuzz lockfiles use the " + f"{version} path-package graph", + ) + ) parsed_version = release_version_tuple(version) uses_release_docs = parsed_version >= DOCS_RELEASE_RESUMPTION_VERSION docsets_data = None selected_docset = None selected_docset_index = None + archive_lock_path = None + identity_surfaces: list[Path] = [] if uses_release_docs: docsets_data, selected_docset, selected_docset_index = selected_docsets( repo, selected @@ -1448,6 +1671,21 @@ def prepare_release_context(repo: Path, version: str, release_id: str) -> dict[s f"current unreleased docs and versioned v{version} agree with their sources", ) ) + archive_lock_path = validate_release_archive_lock(repo, version) + checks.append( + passed_check( + "docs-archive-lock", + f"v{version} has exact bundle, canonical-root, and version-tree locks", + ) + ) + identity_surfaces = release_identity_surfaces(repo, version, release_id) + checks.append( + passed_check( + "active-release-identity-surfaces", + f"{len(identity_surfaces)} active docs, generated overlay, content " + "digest, and inventory surfaces name the release identity", + ) + ) repo_docs_count = validate_repo_docs(repo) checks.append( passed_check( @@ -1484,9 +1722,13 @@ def prepare_release_context(repo: Path, version: str, release_id: str) -> dict[s "previous": previous, "advisory": advisory, "workspace": workspace, + "client_surfaces": client_surfaces, + "fuzz_lock_surfaces": fuzz_lock_surfaces, "docsets_data": docsets_data, "selected_docset": selected_docset, "selected_docset_index": selected_docset_index, + "archive_lock_path": archive_lock_path, + "identity_surfaces": identity_surfaces, "documents": documents, "openapi_paths": openapi_paths, "uses_release_docs": uses_release_docs, @@ -1513,6 +1755,16 @@ def prepare_changes(context: dict[str, Any]) -> list[dict[str, Any]]: (repo / "Cargo.lock", "workspace-lock"), (context["documents"]["release_note"], "release-note"), ] + for path in context["client_surfaces"]: + kind = ( + "generated-binding-loader" + if path.name == "index.js" + else "client-package-version" + ) + surfaces.append((path, kind)) + surfaces.extend( + (path, "excluded-fuzz-lock") for path in context["fuzz_lock_surfaces"] + ) identifier_catalog = context["selected"]["data"].get("identifier_catalog") if isinstance(identifier_catalog, dict) and isinstance( identifier_catalog.get("path"), str @@ -1528,13 +1780,23 @@ def prepare_changes(context: dict[str, Any]) -> list[dict[str, Any]]: repo / "docs/site/src/data/generated/docsets.json", "generated-mirror", ), + (repo / "docs/site/src/data/repo-docs.yaml", "active-docs-mirror"), ) ) + surfaces.append((context["archive_lock_path"], "docs-archive-lock")) + surfaces.extend( + (path, "active-release-identity") + for path in context["identity_surfaces"] + ) surfaces.extend((path, "changelog") for path in context["documents"]["changelogs"]) surfaces.extend( (path, "product-release-note") for path in context["documents"]["product_notes"] ) surfaces.extend((path, "openapi-version") for path in context["openapi_paths"]) + unique_surfaces: dict[str, tuple[Path, str]] = {} + for path, kind in surfaces: + key = relative_path(repo, path) + unique_surfaces.setdefault(key, (path, kind)) return [ { "path": relative_path(repo, path), @@ -1542,7 +1804,7 @@ def prepare_changes(context: dict[str, Any]) -> list[dict[str, Any]]: "from": previous_version, "to": version, } - for path, kind in surfaces + for path, kind in unique_surfaces.values() ] @@ -1655,6 +1917,150 @@ def refresh_protected_main(repo: Path, repository: str) -> str: return resolve_commit(repo, "refs/remotes/origin/main", "protected main") +def workflow_runs( + repository: str, + workflow: str, + *, + event: str, + head_sha: str, +) -> list[dict[str, Any]]: + document = json.loads( + run_checked( + [ + "gh", + "api", + f"repos/{repository}/actions/workflows/{workflow}/runs" + f"?event={event}&head_sha={head_sha}&per_page=100", + ] + ) + ) + runs = document.get("workflow_runs") if isinstance(document, dict) else None + if not isinstance(runs, list) or any(not isinstance(run, dict) for run in runs): + raise ReleasePlanError(f"GitHub returned malformed runs for {workflow}") + return [ + run + for run in runs + if run.get("event") == event and run.get("head_sha") == head_sha + ] + + +def workflow_run_identity(run: dict[str, Any], label: str) -> tuple[int, str]: + run_id = run.get("id") + url = run.get("html_url") + if ( + not isinstance(run_id, int) + or isinstance(run_id, bool) + or run_id < 1 + or not isinstance(url, str) + or not url.startswith("https://github.com/") + ): + raise ReleasePlanError(f"{label} has no valid run ID and URL") + return run_id, url + + +def wait_for_exact_protected_ci(repository: str, source_sha: str) -> dict[str, Any]: + deadline = time.monotonic() + 120 + while True: + runs = workflow_runs( + repository, + "ci.yml", + event="push", + head_sha=source_sha, + ) + successful = [run for run in runs if run.get("conclusion") == "success"] + if successful: + return max(successful, key=lambda run: int(run.get("id", 0))) + active = [ + run + for run in runs + if run.get("status") in {"queued", "in_progress", "pending", "waiting"} + ] + if active: + run = max(active, key=lambda item: int(item.get("id", 0))) + run_id, _ = workflow_run_identity(run, "protected-main CI") + watch_workflow_run(repository, run_id, "protected-main CI") + repeated = workflow_runs( + repository, + "ci.yml", + event="push", + head_sha=source_sha, + ) + successful = [ + item for item in repeated if item.get("conclusion") == "success" + ] + if successful: + return max(successful, key=lambda item: int(item.get("id", 0))) + raise ReleasePlanError( + f"protected-main CI did not succeed for exact source {source_sha}" + ) + concluded = [run for run in runs if run.get("status") == "completed"] + if concluded: + latest = max(concluded, key=lambda run: int(run.get("id", 0))) + conclusion = latest.get("conclusion") + raise ReleasePlanError( + f"protected-main CI concluded {conclusion!r} for exact source {source_sha}" + ) + if time.monotonic() >= deadline: + raise ReleasePlanError( + f"no protected-main CI run appeared for exact source {source_sha}" + ) + time.sleep(2) + + +def wait_for_dispatched_run( + repository: str, + *, + source_sha: str, + display_title: str, + request_id: str, +) -> dict[str, Any]: + deadline = time.monotonic() + 60 + while True: + matches = [ + run + for run in workflow_runs( + repository, + "release-candidate.yml", + event="repository_dispatch", + head_sha=source_sha, + ) + if run.get("display_title") == display_title + ] + if len(matches) == 1: + workflow_run_identity(matches[0], "release candidate") + return matches[0] + if len(matches) > 1: + raise ReleasePlanError( + f"candidate request {request_id} resolved to multiple workflow runs" + ) + if time.monotonic() >= deadline: + raise ReleasePlanError( + "candidate dispatch may have been accepted, but its exact run could not " + f"be resolved for request {request_id}; do not dispatch it again" + ) + time.sleep(1) + + +def watch_workflow_run(repository: str, run_id: int, label: str) -> None: + try: + result = subprocess.run( + [ + "gh", + "run", + "watch", + str(run_id), + "--repo", + repository, + "--exit-status", + ], + check=False, + ) + except OSError as exc: + raise ReleasePlanError(f"cannot watch {label} run {run_id}: {exc}") from exc + if result.returncode != 0: + raise ReleasePlanError(f"{label} run {run_id} did not succeed") + + def download_gh_api_bytes( endpoint: str, destination: Path, @@ -1688,6 +2094,8 @@ def request_release_candidate( repository: str, *, print_request: bool, + wait_for_ci: bool = False, + wait_for_candidate: bool = False, ) -> int: try: context = prepare_release_context(repo, version, release_id) @@ -1715,6 +2123,17 @@ def request_release_candidate( "active candidate preparation must omit legacy stack.source_ref " "and stack.status" ) + if wait_for_ci: + ci_run = wait_for_exact_protected_ci(repository, resolved_source) + ci_run_id, ci_url = workflow_run_identity(ci_run, "protected-main CI") + print( + f"protected-main CI passed for {resolved_source}: " + f"run {ci_run_id} {ci_url}" + ) + request_id = secrets.token_hex(16) + display_title = ( + f"Release candidate {release_id} v{version} ({request_id})" + ) command = [ "gh", "api", @@ -1729,15 +2148,34 @@ def request_release_candidate( f"client_payload[release_id]={release_id}", "-F", f"client_payload[source_sha]={resolved_source}", + "-F", + f"client_payload[request_id]={request_id}", ] if print_request: print(" ".join(shlex.quote(part) for part in command)) else: run_checked(command, cwd=context["repo"]) + candidate_run = wait_for_dispatched_run( + repository, + source_sha=resolved_source, + display_title=display_title, + request_id=request_id, + ) + candidate_run_id, candidate_url = workflow_run_identity( + candidate_run, "release candidate" + ) print( f"requested release candidate {release_id} " - f"v{version} at {resolved_source}" + f"v{version} at {resolved_source}: run {candidate_run_id} " + f"{candidate_url}" ) + if wait_for_candidate: + watch_workflow_run( + repository, + candidate_run_id, + "release candidate", + ) + print(f"release candidate run {candidate_run_id} passed") except (OSError, UnicodeError, ReleasePlanError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 @@ -2115,6 +2553,16 @@ def main() -> int: ) candidate_parser.add_argument("--repo", type=Path, default=ROOT) candidate_parser.add_argument("--print-request", action="store_true") + candidate_parser.add_argument( + "--wait-for-ci", + action="store_true", + help="wait for successful protected-main CI at the exact source before dispatch", + ) + candidate_parser.add_argument( + "--wait", + action="store_true", + help="wait for the exact dispatched candidate run to complete", + ) verify_candidate_parser = subparsers.add_parser("verify-candidate") verify_candidate_parser.add_argument("--version", required=True) verify_candidate_parser.add_argument("--release-id", required=True) @@ -2125,6 +2573,12 @@ def main() -> int: verify_candidate_parser.add_argument( "--repository", default="registrystack/registry-stack" ) + verify_public_parser = subparsers.add_parser("verify-public") + verify_public_parser.add_argument("--tag", required=True) + verify_public_parser.add_argument("--repo", type=Path, default=ROOT) + verify_public_parser.add_argument( + "--repository", default="registrystack/registry-stack" + ) args = parser.parse_args() if args.command == "validate": @@ -2157,6 +2611,8 @@ def main() -> int: args.default_branch, args.repository, print_request=args.print_request, + wait_for_ci=args.wait_for_ci, + wait_for_candidate=args.wait, ) if args.command == "verify-candidate": return verify_candidate_for_tag( @@ -2168,6 +2624,25 @@ def main() -> int: repository=args.repository, plan_output=args.plan_output, ) + if args.command == "verify-public": + try: + verify_origin_repository(args.repo.resolve(), args.repository) + result = verify_public_release.verify( + repo=args.repo.resolve(), + repository=args.repository, + tag=args.tag, + ) + print(json.dumps(result, indent=2, sort_keys=True)) + except ( + OSError, + UnicodeError, + json.JSONDecodeError, + ReleasePlanError, + verify_public_release.PublicReleaseError, + ) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 raise AssertionError(args.command) diff --git a/release/scripts/rehearse-release b/release/scripts/rehearse-release new file mode 100755 index 000000000..eed4cfb58 --- /dev/null +++ b/release/scripts/rehearse-release @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +VERSION="" +RELEASE_ID="" +BASE_REF="origin/main" + +usage() { + cat <<'EOF' +usage: rehearse-release --version --release-id [--base-ref ] + +Run the nonpublishing Ubuntu release rehearsal after preparing and committing a +release branch, but before opening its pull request. +EOF +} + +while (($#)); do + case "$1" in + --version) + VERSION="${2:-}" + shift 2 + ;; + --release-id) + RELEASE_ID="${2:-}" + shift 2 + ;; + --base-ref) + BASE_REF="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ ! "${VERSION}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "--version must be canonical semantic version text" >&2 + exit 2 +fi +if [[ ! "${RELEASE_ID}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]]; then + echo "--release-id is invalid" >&2 + exit 2 +fi +if [[ -z "${BASE_REF}" ]]; then + echo "--base-ref must not be empty" >&2 + exit 2 +fi + +cd "${ROOT}" +if [[ -n "$(git status --short)" ]]; then + echo "release rehearsal requires a committed, clean checkout" >&2 + exit 1 +fi + +tag="v${VERSION}" +if git show-ref --verify --quiet "refs/tags/${tag}"; then + echo "release rehearsal requires the future tag ${tag} to be absent" >&2 + exit 1 +fi +if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + echo "release rehearsal requires the future tag ${tag} to be absent on origin" >&2 + exit 1 +else + tag_lookup_status=$? + if [[ "${tag_lookup_status}" -ne 2 ]]; then + echo "cannot prove future tag ${tag} is absent on origin" >&2 + exit 1 + fi +fi + +release/scripts/registry-release prepare \ + --version "${VERSION}" \ + --release-id "${RELEASE_ID}" +release/scripts/registry-release validate-current +release/scripts/registry-release validate-docsets +REGISTRY_RELEASE_SOURCE_MODE=monorepo \ + release/scripts/check-release-source-model.sh + +if [[ -n "${RUNNER_TEMP:-}" ]]; then + rehearsal_dir="$(mktemp -d "${RUNNER_TEMP%/}/registry-release-rehearsal.XXXXXX")" +else + rehearsal_dir="$(mktemp -d)" +fi +archive="${rehearsal_dir}/registry-docs-${tag}.tar.gz" + +cd docs/site +npm run check:archive-lock -- --base-ref "${BASE_REF}" +DOCS_ARCHIVE_OUTPUT="${archive}" DOCS_DOCSET="${tag}" npm run build:archive +npm run archive:snapshot -- \ + "${tag}" \ + --output "${archive}" \ + --verify-lock +npm run build:dev +npm run check:production:built +npm run check:archives + +cd "${ROOT}" +git diff --exit-code +echo "Release rehearsal passed for ${RELEASE_ID} ${tag}." diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index 208628637..1d948408f 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -598,6 +598,14 @@ def test_missing_new_release_security_tests_are_reported(self) -> None: "release/scripts/test_release_repeatability_workflow.py", "Release repeatability workflow tests", ), + ( + "release/scripts/test_release_rehearsal.py", + "Release rehearsal workflow tests", + ), + ( + "release/scripts/test_verify_public_release.py", + "Public release verifier tests", + ), ) for path, gate in tests: with self.subTest(path=path): @@ -628,6 +636,10 @@ def test_missing_release_workflow_classification_is_reported(self) -> None: ".github/workflows/release-candidate-cleanup.yml", "Release candidate cleanup workflow change classification", ), + ( + ".github/workflows/release-rehearsal.yml", + "Release rehearsal workflow change classification", + ), ) for path, gate in workflows: with self.subTest(path=path): @@ -720,12 +732,15 @@ def test_missing_platform_fuzz_bound_is_reported(self) -> None: text = self.workflow.replace("-max_total_time=60", "-runs=0") self.assertIn("Platform fuzz bounded runtime", self.module.missing_gates(text)) - def test_missing_docs_build_check_is_reported(self) -> None: + def test_missing_production_shaped_docs_build_check_is_reported(self) -> None: text = self.workflow.replace( - "run: npm run check", + "run: npm run check:production", "run: npm run verify", ) - self.assertIn("Docs build check", self.module.missing_gates(text)) + self.assertIn( + "Production-shaped docs build check", + self.module.missing_gates(text), + ) def test_missing_manifest_profile_validation_is_reported(self) -> None: text = self.workflow.replace( diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 070306162..dd5e2a272 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -77,6 +77,15 @@ def test_candidate_request_requires_current_source_workflow_revision( return_value=source, ), mock.patch.object(registry_release, "run_checked") as dispatch, + mock.patch.object( + registry_release, + "wait_for_dispatched_run", + return_value={ + "id": 42, + "html_url": "https://github.com/registrystack/registry-stack/actions/runs/42", + }, + ), + redirect_stdout(io.StringIO()) as output, ): accepted = registry_release.request_release_candidate( ROOT, @@ -90,6 +99,11 @@ def test_candidate_request_requires_current_source_workflow_revision( self.assertEqual(0, accepted) dispatch.assert_called_once() + self.assertIn("run 42", output.getvalue()) + request = dispatch.call_args.args[0] + self.assertTrue( + any(part.startswith("client_payload[request_id]=") for part in request) + ) stale_source = "b" * 40 @@ -113,6 +127,10 @@ def resolve(_repo: Path, revision: str, _description: str) -> str: side_effect=resolve, ), mock.patch.object(registry_release, "run_checked") as no_dispatch, + mock.patch.object( + registry_release, + "wait_for_dispatched_run", + ) as no_run_lookup, ): rejected = registry_release.request_release_candidate( ROOT, @@ -126,6 +144,70 @@ def resolve(_repo: Path, revision: str, _description: str) -> str: self.assertEqual(1, rejected) no_dispatch.assert_not_called() + no_run_lookup.assert_not_called() + + def test_candidate_run_lookup_uses_unique_display_title(self) -> None: + registry_release = load_registry_release() + source = "a" * 40 + expected = { + "id": 123, + "html_url": "https://github.com/registrystack/registry-stack/actions/runs/123", + "event": "repository_dispatch", + "head_sha": source, + "display_title": "Release candidate beta-20 v1.2.3 (request)", + } + with mock.patch.object( + registry_release, + "workflow_runs", + return_value=[ + { + **expected, + "id": 122, + "display_title": "Release candidate beta-19 v1.2.2 (other)", + }, + expected, + ], + ): + observed = registry_release.wait_for_dispatched_run( + "registrystack/registry-stack", + source_sha=source, + display_title=expected["display_title"], + request_id="request", + ) + + self.assertEqual(expected, observed) + + def test_wait_for_ci_watches_only_the_exact_source_run(self) -> None: + registry_release = load_registry_release() + source = "a" * 40 + active = { + "id": 77, + "html_url": "https://github.com/registrystack/registry-stack/actions/runs/77", + "event": "push", + "head_sha": source, + "status": "in_progress", + "conclusion": None, + } + passed = {**active, "status": "completed", "conclusion": "success"} + with ( + mock.patch.object( + registry_release, + "workflow_runs", + side_effect=[[active], [passed]], + ), + mock.patch.object(registry_release, "watch_workflow_run") as watch, + ): + observed = registry_release.wait_for_exact_protected_ci( + "registrystack/registry-stack", + source, + ) + + self.assertEqual(passed, observed) + watch.assert_called_once_with( + "registrystack/registry-stack", + 77, + "protected-main CI", + ) def test_candidate_ancestry_accepts_main_advancement_and_rejects_unreachable_source( self, diff --git a/release/scripts/test_registry_release_plans.py b/release/scripts/test_registry_release_plans.py index 0ccb6ff2c..bde85cbb6 100644 --- a/release/scripts/test_registry_release_plans.py +++ b/release/scripts/test_registry_release_plans.py @@ -249,6 +249,19 @@ def _write_surfaces(self) -> None: data = root / "docs/site/src/data" write_yaml(data / "docsets.yaml", docsets) write_json(data / "generated/docsets.json", docsets) + write_yaml( + data / "archive-lock.yaml", + { + "schema_version": "registry-docs.archive-lock.v1", + "archives": { + "v1.1.0": { + "bundle_sha256": "a" * 64, + "root_tree_sha256": "b" * 64, + "version_tree_sha256": "c" * 64, + } + }, + }, + ) write_yaml( data / "repo-docs.yaml", { @@ -302,6 +315,85 @@ def _write_surfaces(self) -> None: write_json(data / "generated/contracts.json", contracts) write_yaml(data / "standards.yaml", standards) write_json(data / "generated/standards.json", standards) + write( + root / "docs/site/scripts/release-identity.test.mjs", + "const version = 'v1.1.0'; const train = 'beta-9';\n", + ) + write( + root / "docs/site/src/content/docs/changelog.mdx", + "---\ntitle: Changelog\n---\n\n## v1.1.0 beta-9\n", + ) + for relative_root, name in ( + ( + "crates/registry-evidence-client-node", + "@registrystack/evidence-client", + ), + ( + "crates/registry-relay-client-node", + "@registrystack/relay-client", + ), + ): + client_root = self.root / relative_root + write_json( + client_root / "package.json", + {"name": name, "version": "1.1.0"}, + ) + write_json( + client_root / "package-lock.json", + { + "name": name, + "version": "1.1.0", + "lockfileVersion": 3, + "packages": {"": {"name": name, "version": "1.1.0"}}, + }, + ) + write( + client_root / "index.js", + "if (bindingPackageVersion !== '1.1.0') throw new Error();\n", + ) + for relative_root, name, dependency in ( + ( + "crates/registry-evidence-client-py", + "registry-evidence-client", + "evidence-client-sdk", + ), + ( + "crates/registry-relay-client-py", + "registry-relay-client", + "relay-client-sdk", + ), + ): + client_root = self.root / relative_root + write( + client_root / "pyproject.toml", + f'''[project] +name = "{name}" +version = "1.1.0" +''', + ) + write( + client_root / "Cargo.toml", + f'''[package] +name = "{name}-py" +version.workspace = true + +[dependencies] +{dependency} = {{ package = "{name}", path = "../{name}", version = "1.1.0" }} +''', + ) + for relative in ( + "products/manifest/fuzz/Cargo.lock", + "products/platform/fuzz/Cargo.lock", + ): + write( + self.root / relative, + '''version = 4 + +[[package]] +name = "registry-core" +version = "1.1.0" +''', + ) write( root / "products/manifest/CHANGELOG.md", "# Changelog\n\n## [1.1.0]\n\n- Ready.\n", @@ -438,7 +530,11 @@ def test_prepare_emits_stable_json_plan_without_mutating_repo(self) -> None: "release-identity", "immutable-release-tag", "workspace-versions", + "client-package-versions", + "excluded-fuzz-locks", "docsets", + "docs-archive-lock", + "active-release-identity-surfaces", "repo-docs", "release-documents", "openapi-versions", @@ -453,7 +549,7 @@ def test_prepare_emits_stable_json_plan_without_mutating_repo(self) -> None: self.assertTrue( all(set(change) == {"path", "kind", "from", "to"} for change in plan["changes"]) ) - self.assertNotIn( + self.assertIn( "docs/site/src/data/repo-docs.yaml", {change["path"] for change in plan["changes"]}, ) @@ -465,6 +561,19 @@ def test_prepare_emits_stable_json_plan_without_mutating_repo(self) -> None: "docs/site/src/data/generated/docsets.json", {change["path"] for change in plan["changes"]}, ) + for required_surface in ( + "crates/registry-evidence-client-node/package.json", + "crates/registry-evidence-client-node/index.js", + "crates/registry-relay-client-py/pyproject.toml", + "products/manifest/fuzz/Cargo.lock", + "products/platform/fuzz/Cargo.lock", + "docs/site/src/data/archive-lock.yaml", + "docs/site/src/data/repo-docs.yaml", + ): + self.assertIn( + required_surface, + {change["path"] for change in plan["changes"]}, + ) keys = [(change["path"], change.get("pointer")) for change in plan["changes"]] self.assertEqual(len(keys), len(set(keys))) self.assertEqual( @@ -487,6 +596,45 @@ def test_prepare_requires_release_docs_metadata(self) -> None: self.assertIn("required release surface is missing", result.stderr) self.assertIn("docs/site/src/data/docsets.yaml", result.stderr) + def test_prepare_rejects_stale_client_loader_and_excluded_fuzz_lock(self) -> None: + loader = self.repo.root / "crates/registry-evidence-client-node/index.js" + write(loader, "if (bindingPackageVersion !== '1.0.0') throw new Error();\n") + stale_loader = self.prepare() + self.assertEqual(1, stale_loader.returncode) + self.assertIn("generated binding loader must use only version 1.1.0", stale_loader.stderr) + + write(loader, "if (bindingPackageVersion !== '1.1.0') throw new Error();\n") + fuzz_lock = self.repo.root / "products/platform/fuzz/Cargo.lock" + write( + fuzz_lock, + '''version = 4 + +[[package]] +name = "registry-core" +version = "1.0.0" +''', + ) + stale_lock = self.prepare() + self.assertEqual(1, stale_lock.returncode) + self.assertIn( + "products/platform/fuzz/Cargo.lock path packages must use version 1.1.0", + stale_lock.stderr, + ) + + def test_prepare_requires_exact_release_archive_lock(self) -> None: + archive_lock = self.repo.root / "docs/site/src/data/archive-lock.yaml" + document = yaml.safe_load(archive_lock.read_text(encoding="utf-8")) + document["archives"]["v1.1.0"].pop("root_tree_sha256") + write_yaml(archive_lock, document) + + result = self.prepare() + + self.assertEqual(1, result.returncode) + self.assertIn( + "archive-lock.yaml v1.1.0 must contain exactly", + result.stderr, + ) + def test_prepare_writes_an_identical_optional_plan_output(self) -> None: output = Path(self.temporary.name) / "release-plan.json" result = self.prepare("--plan-output", str(output)) @@ -703,11 +851,17 @@ def test_finalize_and_capsule_commands_are_not_active(self) -> None: self.assertNotIn("closeout", help_result.stdout) self.assertNotIn("source-ref", help_result.stdout) self.assertIn("verify-candidate", help_result.stdout) + self.assertIn("verify-public", help_result.stdout) request_help = run("request-candidate", "--help") self.assertEqual(0, request_help.returncode, request_help.stderr) self.assertNotIn("--proof-level", request_help.stdout) self.assertNotIn("--milestone", request_help.stdout) self.assertNotIn("--measurement-bootstrap", request_help.stdout) + self.assertIn("--wait-for-ci", request_help.stdout) + self.assertIn("--wait", request_help.stdout) + public_help = run("verify-public", "--help") + self.assertEqual(0, public_help.returncode, public_help.stderr) + self.assertIn("--tag", public_help.stdout) def test_prepare_rejects_stale_local_tag_when_origin_has_target(self) -> None: git(self.repo.root, "tag", "--annotate", "v1.1.0", "--message", "release") diff --git a/release/scripts/test_release_rehearsal.py b/release/scripts/test_release_rehearsal.py new file mode 100644 index 000000000..5233e84a7 --- /dev/null +++ b/release/scripts/test_release_rehearsal.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/release-rehearsal.yml" +SCRIPT = ROOT / "release/scripts/rehearse-release" + + +class ReleaseRehearsalTest(unittest.TestCase): + def test_workflow_is_manual_read_only_and_ubuntu_bounded(self) -> None: + text = WORKFLOW.read_text(encoding="utf-8") + document = yaml.safe_load(text) + trigger = text.split("permissions:", 1)[0] + self.assertIn("workflow_dispatch:", trigger) + self.assertNotIn("push:", trigger) + self.assertNotIn("pull_request:", trigger) + self.assertNotIn("schedule:", trigger) + self.assertEqual({"contents": "read"}, document["permissions"]) + self.assertEqual(["rehearse"], list(document["jobs"])) + job = document["jobs"]["rehearse"] + self.assertEqual("ubuntu-24.04", job["runs-on"]) + self.assertLessEqual(job["timeout-minutes"], 15) + self.assertFalse(any("upload-artifact@" in str(step) for step in job["steps"])) + rehearsal = job["steps"][-1] + self.assertEqual("${{ inputs.version }}", rehearsal["env"]["REHEARSAL_VERSION"]) + self.assertEqual( + "${{ inputs.release_id }}", + rehearsal["env"]["REHEARSAL_RELEASE_ID"], + ) + self.assertNotIn("${{ inputs.", rehearsal["run"]) + + def test_script_exercises_future_tag_source_archive_and_dev_base_in_order(self) -> None: + text = SCRIPT.read_text(encoding="utf-8") + ordered = ( + "git ls-remote --exit-code --tags origin", + "registry-release prepare", + "registry-release validate-current", + "registry-release validate-docsets", + "check-release-source-model.sh", + "npm run check:archive-lock", + "npm run build:archive", + "npm run archive:snapshot", + "npm run build:dev", + "npm run check:production:built", + "npm run check:archives", + "git diff --exit-code", + ) + positions = [text.index(marker) for marker in ordered] + self.assertEqual(sorted(positions), positions) + for forbidden in ( + "git tag -a", + "git push", + "gh release", + "release.yml", + "crane copy", + ): + self.assertNotIn(forbidden, text) + + def test_docs_ci_replaces_root_build_with_dev_base_build(self) -> None: + package = json.loads( + (ROOT / "docs/site/package.json").read_text(encoding="utf-8") + ) + scripts = package["scripts"] + self.assertIn("DOCS_BASE=/dev/", scripts["build:dev"]) + self.assertIn("--outDir dist/dev", scripts["build:dev"]) + self.assertIn("DOCS_PUBLIC_BASE=/dev/", scripts["check:production:built"]) + self.assertIn("check:links:current", scripts["check:production:built"]) + self.assertEqual( + "npm run check:source && npm run build:dev && npm run check:production:built", + scripts["check:production"], + ) + ci = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + docs_job = ci.split("\n docs:\n", 1)[1].split("\n docs-required:\n", 1)[0] + self.assertIn("run: npm run check:production", docs_job) + self.assertNotIn("run: npm run check\n", docs_job) + + +if __name__ == "__main__": + unittest.main() diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index fba23d475..bd282a378 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -391,6 +391,8 @@ def test_validates_exact_main_source_ci_and_unused_destinations(self) -> None: "Validate request, source, CI, and destinations", ) self.assertIn('[[ "${REQUEST_SOURCE_SHA}" != "${workflow_revision}" ]]', validation) + self.assertIn("github.event.client_payload.request_id", text) + self.assertIn('[[ ! "${REQUEST_ID}" =~ ^[0-9a-f]{32}$ ]]', validation) self.assertIn("refs/remotes/origin/main", validation) self.assertIn("actions/workflows/ci.yml/runs", validation) self.assertIn("git ls-remote --exit-code --tags", validation) diff --git a/release/scripts/test_verify_public_release.py b/release/scripts/test_verify_public_release.py new file mode 100644 index 000000000..69a055c00 --- /dev/null +++ b/release/scripts/test_verify_public_release.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT = Path(__file__).with_name("verify_public_release.py") + + +def load_module(): + spec = importlib.util.spec_from_file_location("verify_public_release", SCRIPT) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {SCRIPT}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def digest(body: bytes) -> str: + return hashlib.sha256(body).hexdigest() + + +class PublicReleaseVerifierTest(unittest.TestCase): + def setUp(self) -> None: + self.module = load_module() + + def test_checksum_parser_requires_one_local_unique_asset_per_line(self) -> None: + parsed = self.module.parse_sha256sums( + f"{'a' * 64} payload.tar.gz\n{'b' * 64} relay-v1.2.3-linux-amd64\n" + ) + self.assertEqual( + {"payload.tar.gz": "a" * 64, "relay-v1.2.3-linux-amd64": "b" * 64}, + parsed, + ) + for body, expected in ( + (f"{'a' * 64} ../payload\n", "nonlocal asset"), + ( + f"{'a' * 64} payload\n{'b' * 64} payload\n", + "repeats asset payload", + ), + ("", "SHA256SUMS is empty"), + ): + with self.subTest(expected=expected), self.assertRaisesRegex( + self.module.PublicReleaseError, + expected, + ): + self.module.parse_sha256sums(body) + + def test_release_metadata_requires_latest_published_exact_assets(self) -> None: + release = { + "tag_name": "v1.2.3", + "draft": False, + "prerelease": False, + "published_at": "2026-08-12T00:00:00Z", + "assets": [ + { + "name": "payload", + "digest": f"sha256:{'a' * 64}", + "size": 1, + } + ], + } + assets = self.module.validate_release_metadata( + release, + {"tag_name": "v1.2.3"}, + tag="v1.2.3", + ) + self.assertEqual({"payload"}, set(assets)) + + with self.assertRaisesRegex( + self.module.PublicReleaseError, + "latest release", + ): + self.module.validate_release_metadata( + release, + {"tag_name": "v1.2.2"}, + tag="v1.2.3", + ) + + def test_downloaded_asset_verification_closes_checksums_and_api_digests( + self, + ) -> None: + tag = "v1.2.3" + payloads = { + "payload.bin": b"payload\n", + f"registry-stack-{tag}-release-manifest.json": b"{}\n", + } + sums = "".join( + f"{digest(body)} {name}\n" for name, body in sorted(payloads.items()) + ).encode() + files = { + **payloads, + "SHA256SUMS": sums, + f"registry-stack-{tag}-SHA256SUMS.sigstore.json": b"{\"bundle\":true}\n", + } + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for name, body in files.items(): + (root / name).write_bytes(body) + assets = { + name: { + "name": name, + "digest": f"sha256:{digest(body)}", + "size": len(body), + } + for name, body in files.items() + } + checksums, bundle = self.module.verify_downloaded_assets( + root, + assets, + tag=tag, + ) + self.assertEqual(set(payloads), set(checksums)) + self.assertEqual( + f"registry-stack-{tag}-SHA256SUMS.sigstore.json", + bundle, + ) + + (root / "payload.bin").write_bytes(b"tampered\n") + with self.assertRaisesRegex( + self.module.PublicReleaseError, + "wrong downloaded size|digest does not match", + ): + self.module.verify_downloaded_assets(root, assets, tag=tag) + + def test_manifest_binds_tag_source_workflow_and_final_images(self) -> None: + source = "a" * 40 + payload_sha256 = "d" * 64 + document = { + "schema_version": "registry-stack.release-candidate.v2", + "repository": "registrystack/registry-stack", + "release": { + "tag": "v1.2.3", + "version": "1.2.3", + "release_id": "beta-1", + "source_sha": source, + }, + "workflow": { + "path": ".github/workflows/release-candidate.yml", + "revision": source, + "run_id": 42, + "run_attempt": 1, + }, + "validity": { + "created_at": "2026-08-12T00:00:00Z", + "expires_at": "2026-08-19T00:00:00Z", + }, + "payloads": [ + { + "name": "payload.bin", + "kind": "binary", + "sha256": payload_sha256, + "size": 7, + } + ], + "images": [ + { + "digest": f"sha256:{'c' * 64}", + "final_ref": "ghcr.io/registrystack/relay:v1.2.3", + } + ], + } + checksums = { + "payload.bin": payload_sha256, + "registry-stack-v1.2.3-release-manifest.json": "e" * 64, + } + assets = {"payload.bin": {"size": 7}} + with mock.patch.object( + self.module.release_candidate, + "validate_candidate_manifest", + return_value=document, + ) as validate: + images = self.module.validate_release_manifest( + document, + repository="registrystack/registry-stack", + tag="v1.2.3", + source_sha=source, + checksums=checksums, + assets=assets, + ) + self.assertEqual("ghcr.io/registrystack/relay:v1.2.3", images[0]["final_ref"]) + self.assertEqual(source, validate.call_args.kwargs["expected_source_sha"]) + + with ( + mock.patch.object( + self.module.release_candidate, + "validate_candidate_manifest", + return_value=document, + ), + self.assertRaisesRegex( + self.module.PublicReleaseError, + "payload closure differs", + ), + ): + self.module.validate_release_manifest( + document, + repository="registrystack/registry-stack", + tag="v1.2.3", + source_sha=source, + checksums={**checksums, "unlisted.bin": "f" * 64}, + assets={**assets, "unlisted.bin": {"size": 1}}, + ) + + document["images"][0]["final_ref"] = "ghcr.io/registrystack/relay:latest" + with ( + mock.patch.object( + self.module.release_candidate, + "validate_candidate_manifest", + return_value=document, + ), + self.assertRaisesRegex( + self.module.PublicReleaseError, + "image identity is invalid", + ), + ): + self.module.validate_release_manifest( + document, + repository="registrystack/registry-stack", + tag="v1.2.3", + source_sha=source, + checksums=checksums, + assets=assets, + ) + + def test_binary_smoke_does_not_inherit_credentials(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + script = Path(temporary) / "evidence" + script.write_text( + "#!/bin/sh\n" + "test -z \"${GH_TOKEN:-}\"\n" + "test -z \"${AWS_SECRET_ACCESS_KEY:-}\"\n" + "printf 'evidence 1.2.3\\n'\n", + encoding="utf-8", + ) + script.chmod(0o700) + original_gh = os.environ.get("GH_TOKEN") + original_aws = os.environ.get("AWS_SECRET_ACCESS_KEY") + os.environ["GH_TOKEN"] = "must-not-leak" + os.environ["AWS_SECRET_ACCESS_KEY"] = "must-not-leak" + try: + observed = self.module.run_binary_smoke(script) + finally: + if original_gh is None: + os.environ.pop("GH_TOKEN", None) + else: + os.environ["GH_TOKEN"] = original_gh + if original_aws is None: + os.environ.pop("AWS_SECRET_ACCESS_KEY", None) + else: + os.environ["AWS_SECRET_ACCESS_KEY"] = original_aws + self.assertEqual("evidence 1.2.3", observed) + + +if __name__ == "__main__": + unittest.main() diff --git a/release/scripts/verify_public_release.py b/release/scripts/verify_public_release.py new file mode 100644 index 000000000..efed43acf --- /dev/null +++ b/release/scripts/verify_public_release.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""Verify the minimum public Registry Stack Beta release contract.""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import re +import shutil +import stat +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +import release_candidate + + +TAG_PATTERN = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +HEX40 = re.compile(r"^[0-9a-f]{40}$") +DIGEST = re.compile(r"^sha256:([0-9a-f]{64})$") +CHECKSUM_LINE = re.compile(r"^([0-9a-f]{64}) ([^\r\n]+)$") +SIGNER_ISSUER = "https://token.actions.githubusercontent.com" + + +class PublicReleaseError(RuntimeError): + """The public release does not satisfy its minimum contract.""" + + +def run_text(command: list[str], *, cwd: Path | None = None) -> str: + try: + result = subprocess.run( + command, + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + except OSError as exc: + raise PublicReleaseError(f"cannot run {command[0]}: {exc}") from exc + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise PublicReleaseError( + f"{' '.join(command)} failed: {detail}" + ) + return result.stdout + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def run_binary_smoke(path: Path) -> str: + environment = { + "LANG": "C", + "LC_ALL": "C", + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + } + try: + result = subprocess.run( + [str(path), "--version"], + env=environment, + text=True, + capture_output=True, + check=False, + ) + except OSError as exc: + raise PublicReleaseError(f"cannot smoke {path.name}: {exc}") from exc + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise PublicReleaseError(f"{path.name} --version failed: {detail}") + return result.stdout.strip() + + +def parse_sha256sums(body: str) -> dict[str, str]: + checksums: dict[str, str] = {} + lines = body.splitlines() + if not lines: + raise PublicReleaseError("SHA256SUMS is empty") + for number, line in enumerate(lines, start=1): + match = CHECKSUM_LINE.fullmatch(line) + if match is None: + raise PublicReleaseError(f"SHA256SUMS line {number} is not canonical") + digest, name = match.groups() + if name in {".", ".."} or Path(name).name != name: + raise PublicReleaseError( + f"SHA256SUMS line {number} names a nonlocal asset" + ) + if name in checksums: + raise PublicReleaseError(f"SHA256SUMS repeats asset {name}") + checksums[name] = digest + return checksums + + +def validate_release_metadata( + release: Any, + latest: Any, + *, + tag: str, +) -> dict[str, dict[str, Any]]: + if not isinstance(release, dict) or not isinstance(latest, dict): + raise PublicReleaseError("GitHub release metadata is malformed") + if ( + release.get("tag_name") != tag + or release.get("draft") is not False + or release.get("prerelease") is not False + or not isinstance(release.get("published_at"), str) + or not release["published_at"] + ): + raise PublicReleaseError(f"GitHub Release {tag} is not a published Beta") + if latest.get("tag_name") != tag: + raise PublicReleaseError( + f"GitHub latest release is {latest.get('tag_name')!r}, not {tag}" + ) + assets = release.get("assets") + if not isinstance(assets, list) or not assets: + raise PublicReleaseError(f"GitHub Release {tag} has no assets") + by_name: dict[str, dict[str, Any]] = {} + for asset in assets: + if not isinstance(asset, dict): + raise PublicReleaseError("GitHub release asset metadata is malformed") + name = asset.get("name") + if not isinstance(name, str) or not name or Path(name).name != name: + raise PublicReleaseError("GitHub release asset has an invalid name") + if name in by_name: + raise PublicReleaseError(f"GitHub Release repeats asset {name}") + digest = asset.get("digest") + size = asset.get("size") + if ( + not isinstance(digest, str) + or DIGEST.fullmatch(digest) is None + or not isinstance(size, int) + or isinstance(size, bool) + or size < 1 + ): + raise PublicReleaseError(f"GitHub asset {name} has no exact digest and size") + by_name[name] = asset + return by_name + + +def resolve_annotated_tag(repo: Path, tag: str) -> str: + output = run_text( + [ + "git", + "ls-remote", + "--tags", + "origin", + f"refs/tags/{tag}", + f"refs/tags/{tag}^{{}}", + ], + cwd=repo, + ) + refs: dict[str, str] = {} + for line in output.splitlines(): + fields = line.split("\t") + if len(fields) == 2 and HEX40.fullmatch(fields[0]): + refs[fields[1]] = fields[0] + tag_object = refs.get(f"refs/tags/{tag}") + source = refs.get(f"refs/tags/{tag}^{{}}") + if tag_object is None or source is None or tag_object == source: + raise PublicReleaseError(f"{tag} is not one immutable annotated source tag") + return source + + +def verify_downloaded_assets( + directory: Path, + assets: dict[str, dict[str, Any]], + *, + tag: str, +) -> tuple[dict[str, str], str]: + downloaded = { + path.name: path + for path in directory.iterdir() + if path.is_file() and not path.is_symlink() + } + if set(downloaded) != set(assets): + raise PublicReleaseError( + "downloaded release asset inventory differs from GitHub metadata: " + f"missing={sorted(set(assets) - set(downloaded))!r} " + f"unexpected={sorted(set(downloaded) - set(assets))!r}" + ) + actual_digests: dict[str, str] = {} + for name, path in sorted(downloaded.items()): + expected = assets[name] + if path.stat().st_size != expected["size"]: + raise PublicReleaseError(f"GitHub asset {name} has the wrong downloaded size") + actual = sha256_file(path) + if expected["digest"] != f"sha256:{actual}": + raise PublicReleaseError(f"GitHub asset {name} digest does not match its bytes") + actual_digests[name] = actual + + checksum_name = "SHA256SUMS" + bundle_name = f"registry-stack-{tag}-SHA256SUMS.sigstore.json" + for required in (checksum_name, bundle_name): + if required not in downloaded: + raise PublicReleaseError(f"GitHub Release is missing {required}") + checksums = parse_sha256sums( + downloaded[checksum_name].read_text(encoding="utf-8") + ) + expected_closure = set(downloaded) - {checksum_name, bundle_name} + if set(checksums) != expected_closure: + raise PublicReleaseError( + "SHA256SUMS closure differs from downloadable payloads: " + f"missing={sorted(expected_closure - set(checksums))!r} " + f"unexpected={sorted(set(checksums) - expected_closure)!r}" + ) + for name, expected in checksums.items(): + if actual_digests[name] != expected: + raise PublicReleaseError(f"SHA256SUMS rejects asset {name}") + return checksums, bundle_name + + +def validate_release_manifest( + document: Any, + *, + repository: str, + tag: str, + source_sha: str, + checksums: dict[str, str], + assets: dict[str, dict[str, Any]], +) -> list[dict[str, str]]: + if not isinstance(document, dict): + raise PublicReleaseError("public candidate manifest must be an object") + validity = document.get("validity") + created_at = validity.get("created_at") if isinstance(validity, dict) else None + if not isinstance(created_at, str): + raise PublicReleaseError("public candidate manifest has no creation timestamp") + try: + validation_time = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + release_candidate.validate_candidate_manifest( + document, + expected_source_sha=source_sha, + expected_version=tag.removeprefix("v"), + now=validation_time, + ) + except (ValueError, release_candidate.CandidateError) as exc: + raise PublicReleaseError(f"public candidate manifest is invalid: {exc}") from exc + + release = document.get("release") + workflow = document.get("workflow") + images = document.get("images") + if not isinstance(release, dict) or not isinstance(workflow, dict): + raise PublicReleaseError("public candidate manifest has no release workflow binding") + if ( + release.get("tag") != tag + or release.get("version") != tag.removeprefix("v") + or release.get("source_sha") != source_sha + or document.get("repository") != repository + ): + raise PublicReleaseError("public candidate manifest does not match tag identity") + if ( + workflow.get("path") != ".github/workflows/release-candidate.yml" + or not isinstance(workflow.get("revision"), str) + or HEX40.fullmatch(workflow["revision"]) is None + or workflow["revision"] != source_sha + or not isinstance(workflow.get("run_id"), int) + or isinstance(workflow.get("run_id"), bool) + or workflow["run_id"] < 1 + ): + raise PublicReleaseError("public candidate manifest has an invalid workflow identity") + + manifest_name = f"registry-stack-{tag}-release-manifest.json" + payloads = document.get("payloads") + payload_records = { + payload.get("name"): payload + for payload in payloads + if isinstance(payloads, list) + and isinstance(payload, dict) + and isinstance(payload.get("name"), str) + } if isinstance(payloads, list) else {} + expected_payloads = set(checksums) - {manifest_name} + if set(payload_records) != expected_payloads: + raise PublicReleaseError( + "public candidate manifest payload closure differs from release assets: " + f"missing={sorted(expected_payloads - set(payload_records))!r} " + f"unexpected={sorted(set(payload_records) - expected_payloads)!r}" + ) + for name, payload in payload_records.items(): + if ( + name not in assets + or payload.get("sha256") != checksums[name] + or payload.get("size") != assets[name]["size"] + ): + raise PublicReleaseError( + f"public candidate manifest payload {name} differs from release bytes" + ) + + if not isinstance(images, list) or not images: + raise PublicReleaseError("public candidate manifest has no final image inventory") + verified: list[dict[str, str]] = [] + final_refs: set[str] = set() + for image in images: + if not isinstance(image, dict): + raise PublicReleaseError("public candidate image entry is malformed") + digest = image.get("digest") + final_ref = image.get("final_ref") + if ( + not isinstance(digest, str) + or DIGEST.fullmatch(digest) is None + or not isinstance(final_ref, str) + or not final_ref.endswith(f":{tag}") + or final_ref in final_refs + ): + raise PublicReleaseError("public candidate image identity is invalid") + final_refs.add(final_ref) + verified.append({"digest": digest, "final_ref": final_ref}) + return verified + + +def smoke_asset_name(tag: str) -> str: + machine = platform.machine().lower() + if os.sys.platform == "darwin" and machine in {"arm64", "aarch64"}: + platform_name = "macos-arm64" + elif os.sys.platform.startswith("linux") and machine in {"x86_64", "amd64"}: + platform_name = "linux-amd64" + elif os.sys.platform.startswith("linux") and machine in {"arm64", "aarch64"}: + platform_name = "linux-arm64" + else: + raise PublicReleaseError( + f"no maintained public binary smoke for {os.sys.platform}/{machine}" + ) + return f"evidence-{tag}-{platform_name}" + + +def verify( + *, + repo: Path, + repository: str, + tag: str, +) -> dict[str, Any]: + if TAG_PATTERN.fullmatch(tag) is None: + raise PublicReleaseError("tag must be canonical v.. text") + for tool in ("gh", "git", "cosign", "docker"): + if shutil.which(tool) is None: + raise PublicReleaseError(f"public verification requires {tool} on PATH") + + source_sha = resolve_annotated_tag(repo, tag) + release = json.loads( + run_text(["gh", "api", f"repos/{repository}/releases/tags/{tag}"]) + ) + latest = json.loads( + run_text(["gh", "api", f"repos/{repository}/releases/latest"]) + ) + assets = validate_release_metadata(release, latest, tag=tag) + manifest_name = f"registry-stack-{tag}-release-manifest.json" + if manifest_name not in assets: + raise PublicReleaseError(f"GitHub Release is missing {manifest_name}") + + with tempfile.TemporaryDirectory(prefix="registry-public-release-") as temporary: + directory = Path(temporary) + run_text( + [ + "gh", + "release", + "download", + tag, + "--repo", + repository, + "--dir", + str(directory), + ] + ) + checksums, bundle_name = verify_downloaded_assets( + directory, + assets, + tag=tag, + ) + identity = ( + f"https://github.com/{repository}/.github/workflows/" + "release.yml@refs/heads/main" + ) + run_text( + [ + "cosign", + "verify-blob", + str(directory / "SHA256SUMS"), + "--bundle", + str(directory / bundle_name), + "--certificate-identity", + identity, + "--certificate-oidc-issuer", + SIGNER_ISSUER, + ] + ) + manifest_path = directory / manifest_name + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise PublicReleaseError(f"cannot read {manifest_name}: {exc}") from exc + images = validate_release_manifest( + manifest, + repository=repository, + tag=tag, + source_sha=source_sha, + checksums=checksums, + assets=assets, + ) + manifest_sha256 = sha256_file(manifest_path) + marker = f"registry-stack-release-candidate-v2 manifest_sha256:{manifest_sha256}" + if marker not in str(release.get("body", "")): + raise PublicReleaseError("GitHub Release body lacks the exact manifest binding") + for image in images: + manifest = json.loads( + run_text( + [ + "docker", + "buildx", + "imagetools", + "inspect", + image["final_ref"], + "--format", + "{{json .Manifest}}", + ] + ) + ) + observed = manifest.get("digest") if isinstance(manifest, dict) else None + if observed != image["digest"]: + raise PublicReleaseError( + f"public image {image['final_ref']} resolves to {observed}, " + f"not {image['digest']}" + ) + + smoke_name = smoke_asset_name(tag) + smoke_path = directory / smoke_name + if smoke_name not in checksums or not smoke_path.is_file(): + raise PublicReleaseError(f"GitHub Release is missing smoke asset {smoke_name}") + smoke_path.chmod(smoke_path.stat().st_mode | stat.S_IXUSR) + observed_version = run_binary_smoke(smoke_path) + expected_version = f"evidence {tag.removeprefix('v')}" + if observed_version != expected_version: + raise PublicReleaseError( + f"{smoke_name} reports {observed_version!r}, not {expected_version!r}" + ) + + return { + "tag": tag, + "source_sha": source_sha, + "release_url": release.get("html_url"), + "asset_count": len(assets), + "checksum_payload_count": len(checksums), + "image_count": len(images), + "smoke_asset": smoke_name, + "status": "verified", + } From dfbcf8fa921c2e12c33dbe8deb5f020efed24484 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 12 Aug 2026 20:32:28 +0700 Subject: [PATCH 2/3] fix(release): close rehearsal cleanliness gaps Signed-off-by: Jeremi Joslin --- release/scripts/rehearse-release | 7 ++++++- release/scripts/test_release_rehearsal.py | 2 +- release/scripts/test_verify_public_release.py | 8 +++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/release/scripts/rehearse-release b/release/scripts/rehearse-release index eed4cfb58..507859f0f 100755 --- a/release/scripts/rehearse-release +++ b/release/scripts/rehearse-release @@ -103,5 +103,10 @@ npm run check:production:built npm run check:archives cd "${ROOT}" -git diff --exit-code +final_status="$(git status --short)" +if [[ -n "${final_status}" ]]; then + echo "release rehearsal produced uncommitted or untracked files:" >&2 + printf '%s\n' "${final_status}" >&2 + exit 1 +fi echo "Release rehearsal passed for ${RELEASE_ID} ${tag}." diff --git a/release/scripts/test_release_rehearsal.py b/release/scripts/test_release_rehearsal.py index 5233e84a7..bcab43ee3 100644 --- a/release/scripts/test_release_rehearsal.py +++ b/release/scripts/test_release_rehearsal.py @@ -50,7 +50,7 @@ def test_script_exercises_future_tag_source_archive_and_dev_base_in_order(self) "npm run build:dev", "npm run check:production:built", "npm run check:archives", - "git diff --exit-code", + 'final_status="$(git status --short)"', ) positions = [text.index(marker) for marker in ordered] self.assertEqual(sorted(positions), positions) diff --git a/release/scripts/test_verify_public_release.py b/release/scripts/test_verify_public_release.py index 69a055c00..c098ac430 100644 --- a/release/scripts/test_verify_public_release.py +++ b/release/scripts/test_verify_public_release.py @@ -3,12 +3,10 @@ import hashlib import importlib.util -import json import os import tempfile -import unittest from pathlib import Path -from unittest import mock +from unittest import TestCase, main, mock SCRIPT = Path(__file__).with_name("verify_public_release.py") @@ -27,7 +25,7 @@ def digest(body: bytes) -> str: return hashlib.sha256(body).hexdigest() -class PublicReleaseVerifierTest(unittest.TestCase): +class PublicReleaseVerifierTest(TestCase): def setUp(self) -> None: self.module = load_module() @@ -259,4 +257,4 @@ def test_binary_smoke_does_not_inherit_credentials(self) -> None: if __name__ == "__main__": - unittest.main() + main() From d894ae7a3aa5a43c012a4b238d3799866bd0c53a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 12 Aug 2026 20:42:45 +0700 Subject: [PATCH 3/3] fix(release): recheck main after CI wait Signed-off-by: Jeremi Joslin --- release/scripts/registry-release | 15 ++++++ release/scripts/test_registry_release.py | 64 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/release/scripts/registry-release b/release/scripts/registry-release index 9af8141aa..4ed1b5294 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -2130,6 +2130,21 @@ def request_release_candidate( f"protected-main CI passed for {resolved_source}: " f"run {ci_run_id} {ci_url}" ) + remote_default = refresh_protected_main(context["repo"], repository) + resolved_default = resolve_commit( + context["repo"], default_branch, "protected default branch" + ) + if resolved_default != remote_default: + raise ReleasePlanError( + f"trusted {default_branch} does not match protected default " + f"branch {remote_default} after waiting for CI" + ) + if resolved_source != remote_default: + raise ReleasePlanError( + f"protected default branch advanced to {remote_default} while " + f"waiting for candidate source {resolved_source} CI; request a " + "candidate from the new protected-main revision" + ) request_id = secrets.token_hex(16) display_title = ( f"Release candidate {release_id} v{version} ({request_id})" diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index dd5e2a272..3c8943137 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -177,6 +177,70 @@ def test_candidate_run_lookup_uses_unique_display_title(self) -> None: self.assertEqual(expected, observed) + def test_candidate_request_aborts_if_main_advances_while_waiting_for_ci( + self, + ) -> None: + registry_release = load_registry_release() + source = "a" * 40 + advanced = "b" * 40 + context = { + "repo": ROOT, + "selected": {"data": {"stack": {}}}, + } + + def resolve(_repo: Path, revision: str, _description: str) -> str: + if revision == "origin/main" and refresh.call_count > 1: + return advanced + return source + + with ( + mock.patch.object( + registry_release, + "prepare_release_context", + return_value=context, + ), + mock.patch.object( + registry_release, + "refresh_protected_main", + side_effect=[source, advanced], + ) as refresh, + mock.patch.object( + registry_release, + "resolve_commit", + side_effect=resolve, + ), + mock.patch.object( + registry_release, + "wait_for_exact_protected_ci", + return_value={ + "id": 77, + "html_url": "https://github.com/registrystack/registry-stack/actions/runs/77", + }, + ), + mock.patch.object(registry_release, "run_checked") as no_dispatch, + mock.patch.object( + registry_release, + "wait_for_dispatched_run", + ) as no_run_lookup, + redirect_stderr(io.StringIO()) as errors, + ): + result = registry_release.request_release_candidate( + ROOT, + "1.2.3", + "beta-20", + source, + "origin/main", + "registrystack/registry-stack", + print_request=False, + wait_for_ci=True, + ) + + self.assertEqual(1, result) + self.assertEqual(2, refresh.call_count) + no_dispatch.assert_not_called() + no_run_lookup.assert_not_called() + self.assertIn("protected default branch advanced", errors.getvalue()) + def test_wait_for_ci_watches_only_the_exact_source_run(self) -> None: registry_release = load_registry_release() source = "a" * 40