From 8dda0b6efaa52a57fbf26642893a09352771de23 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 9 Jul 2026 22:21:34 +0100 Subject: [PATCH] Build releases once and promote verified candidates --- .github/workflows/build-release-candidate.yml | 137 ++++++++++ .github/workflows/create-release.yml | 194 +++++--------- .github/workflows/release-dry-run.yml | 10 + .github/workflows/validate-release-assets.yml | 51 +++- .../HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md | 52 +++- .../v6/internal/RELEASE_PROMOTION_POLICY.md | 38 ++- ...build-release-promotion-path-2026-07-09.md | 78 ++++++ ...atch-unattended-release-path-2026-07-09.md | 15 +- docs/release-control/v6/internal/status.json | 22 +- .../subsystems/deployment-installability.md | 16 +- .../v6/internal/subsystems/registry.json | 17 ++ .../installtests/build_release_assets_test.go | 82 +++++- scripts/release_candidate_manifest.py | 248 ++++++++++++++++++ .../release_candidate_manifest_test.py | 81 ++++++ .../release_promotion_policy_test.py | 27 +- scripts/trigger-stable-patch.sh | 28 +- 16 files changed, 896 insertions(+), 200 deletions(-) create mode 100644 .github/workflows/build-release-candidate.yml create mode 100644 docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md create mode 100644 scripts/release_candidate_manifest.py create mode 100644 scripts/release_control/release_candidate_manifest_test.py diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml new file mode 100644 index 000000000..0d46b12f8 --- /dev/null +++ b/.github/workflows/build-release-candidate.yml @@ -0,0 +1,137 @@ +name: Build Release Candidate + +on: + workflow_call: + inputs: + version: + description: 'Version number without the leading v' + required: true + type: string + outputs: + artifact_name: + description: 'Immutable release candidate artifact name' + value: ${{ jobs.build.outputs.artifact_name }} + manifest_artifact_name: + description: 'Release candidate manifest artifact name' + value: ${{ jobs.build.outputs.manifest_artifact_name }} + +permissions: + contents: read + +jobs: + build: + name: Build and Validate Signed Candidate + runs-on: ubuntu-24.04 + timeout-minutes: 35 + outputs: + artifact_name: ${{ steps.identity.outputs.artifact_name }} + manifest_artifact_name: ${{ steps.identity.outputs.manifest_artifact_name }} + steps: + - name: Resolve candidate identity + id: identity + run: | + set -euo pipefail + echo "artifact_name=release-candidate-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT" + echo "manifest_artifact_name=release-candidate-manifest-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + + - name: Validate candidate identity + run: | + set -euo pipefail + test "$(tr -d '\n' < VERSION)" = "${{ inputs.version }}" + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'frontend-modern/package-lock.json' + + - name: Install release prerequisites + run: | + sudo apt-get update + sudo apt-get install -y zip + + - name: Set up Helm + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 + with: + version: 'v3.15.2' + + - name: Install Syft + run: | + set -euo pipefail + SYFT_VERSION="1.42.4" + SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz" + SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f" + TMP_DIR="$(mktemp -d)" + trap 'rm -rf "$TMP_DIR"' EXIT + curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \ + -o "${TMP_DIR}/${SYFT_ARCHIVE}" + printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check -- + tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft + install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft + + - name: Build signed release candidate + run: ./scripts/build-release.sh "${{ inputs.version }}" + env: + PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} + PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + + - name: Validate installer signing key pins + env: + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + run: | + set -euo pipefail + TRUSTED_SSH_PUBLIC_KEY="$( + go run ./scripts/release_update_key.go public-key-ssh \ + --public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ + --comment pulse-installer + )" + for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do + grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || { + echo "::error::${installer} does not trust the configured release signing key." + exit 1 + } + done + + - name: Validate complete candidate locally + run: ./scripts/validate-release.sh "${{ inputs.version }}" --skip-docker + + - name: Create immutable candidate manifest + run: | + python3 scripts/release_candidate_manifest.py create \ + --release-dir release \ + --version "${{ inputs.version }}" \ + --source-sha "${GITHUB_SHA}" \ + --output release-candidate-manifest/release-candidate.json + + - name: Upload immutable release candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.identity.outputs.artifact_name }} + path: release/ + if-no-files-found: error + retention-days: 1 + compression-level: 0 + overwrite: true + + - name: Upload candidate manifest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.identity.outputs.manifest_artifact_name }} + path: release-candidate-manifest/release-candidate.json + if-no-files-found: error + retention-days: 1 + overwrite: true diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index c7662cf8f..332b5a05f 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -84,7 +84,6 @@ jobs: hotfix_reason: ${{ steps.promotion.outputs.hotfix_reason }} promotion_mode: ${{ steps.promotion.outputs.promotion_mode }} is_stable_patch: ${{ steps.promotion.outputs.is_stable_patch }} - preflight_run_url: ${{ steps.preflight.outputs.run_url }} historical_asset_backfill_only: ${{ steps.extract.outputs.historical_asset_backfill_only }} steps: - name: Extract version @@ -209,35 +208,16 @@ jobs: echo "[OK] Promotion policy validated for ${TAG}" - - name: Require recent exact-SHA stable patch preflight - id: preflight - if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' && steps.promotion.outputs.is_stable_patch == 'true' }} - env: - GH_TOKEN: ${{ github.token }} - VERSION: ${{ steps.extract.outputs.version }} - run: | - set -euo pipefail - CUTOFF="$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%SZ')" - RUN="$(gh api "repos/${{ github.repository }}/actions/workflows/release-dry-run.yml/runs?event=workflow_dispatch&status=success&head_sha=${GITHUB_SHA}&per_page=20" \ - | jq -c \ - --arg sha "$GITHUB_SHA" \ - --arg title "Release Dry Run v${VERSION}" \ - --arg cutoff "$CUTOFF" \ - '[.workflow_runs[] | select( - .conclusion == "success" - and .head_sha == $sha - and .display_title == $title - and .created_at >= $cutoff - )] | sort_by(.created_at) | last // empty')" - - if [ -z "$RUN" ]; then - echo "::error::Stable patch release requires a successful Release Dry Run for v${VERSION} at exact commit ${GITHUB_SHA} within the last 24 hours. Run ./scripts/trigger-stable-patch.sh --dry-run ${VERSION}, wait for success, then dispatch once." - exit 1 - fi - - RUN_URL="$(jq -r '.html_url' <<<"$RUN")" - echo "run_url=${RUN_URL}" >> "$GITHUB_OUTPUT" - echo "[OK] Exact-SHA release preflight passed: ${RUN_URL}" + build_release_candidate: + name: Build Immutable Release Candidate + needs: prepare + if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} + permissions: + contents: read + uses: ./.github/workflows/build-release-candidate.yml + secrets: inherit + with: + version: ${{ needs.prepare.outputs.version }} # Frontend checks run in parallel with backend tests frontend_checks: @@ -268,9 +248,24 @@ jobs: - name: Check frontend copy-paste duplication run: npm --prefix frontend-modern run lint:cpd + - name: Build verified frontend bundle + run: npm --prefix frontend-modern run build + + - name: Upload verified frontend bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-frontend-${{ github.sha }} + path: frontend-modern/dist/ + if-no-files-found: error + retention-days: 1 + compression-level: 0 + overwrite: true + # Backend tests run in parallel with frontend checks backend_tests: - needs: prepare + needs: + - prepare + - frontend_checks if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 30 @@ -280,25 +275,11 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: 'frontend-modern/package-lock.json' - - - name: Restore frontend build cache - id: frontend-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - name: Download verified frontend bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: frontend-modern/dist - key: frontend-build-${{ hashFiles('frontend-modern/package-lock.json', 'frontend-modern/src/**/*', 'frontend-modern/index.html', 'frontend-modern/postcss.config.cjs', 'frontend-modern/tailwind.config.cjs') }} - - - name: Build frontend (if not cached) - if: steps.frontend-cache.outputs.cache-hit != 'true' - run: | - npm --prefix frontend-modern ci - npm --prefix frontend-modern run build + name: release-frontend-${{ github.sha }} - name: Copy frontend to embed location run: | @@ -508,7 +489,7 @@ jobs: integration_tests: needs: - prepare - - backend_tests + - frontend_checks if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && needs.prepare.outputs.is_prerelease != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 45 @@ -525,11 +506,11 @@ jobs: cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' - - name: Restore frontend build cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - name: Download verified frontend bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: frontend-modern/dist - key: frontend-build-${{ hashFiles('frontend-modern/package-lock.json', 'frontend-modern/src/**/*', 'frontend-modern/index.html', 'frontend-modern/postcss.config.cjs', 'frontend-modern/tailwind.config.cjs') }} + name: release-frontend-${{ github.sha }} - name: Copy frontend to embed location run: | @@ -661,13 +642,14 @@ jobs: create_release: needs: - prepare + - build_release_candidate - frontend_checks - backend_tests - docker_build - helm_smoke - integration_tests # Run if integration_tests passed OR was skipped (prereleases) - if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }} + if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }} runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: @@ -685,80 +667,25 @@ jobs: with: fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + - name: Download immutable release candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - go-version-file: go.mod - cache: true + name: ${{ needs.build_release_candidate.outputs.artifact_name }} + path: release - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - name: Download release candidate manifest + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - node-version: '20' - cache: 'npm' - cache-dependency-path: 'frontend-modern/package-lock.json' + name: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }} + path: release-candidate-manifest - - name: Install dependencies + - name: Verify immutable release candidate run: | - sudo apt-get update - sudo apt-get install -y zip - - - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 - with: - version: 'v3.15.2' - - - name: Install Syft - run: | - set -euo pipefail - - SYFT_VERSION="1.42.4" - SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz" - SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f" - TMP_DIR="$(mktemp -d)" - trap 'rm -rf "$TMP_DIR"' EXIT - - curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \ - -o "${TMP_DIR}/${SYFT_ARCHIVE}" - printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check -- - tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft - install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft - syft version - - - name: Build release artifacts - run: | - echo "Building release ${{ needs.prepare.outputs.tag }}..." - ./scripts/build-release.sh ${{ needs.prepare.outputs.version }} - env: - PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} - PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} - - - name: Validate installer signing key pins - env: - PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} - run: | - set -euo pipefail - TRUSTED_SSH_PUBLIC_KEY="$( - go run ./scripts/release_update_key.go public-key-ssh \ - --public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ - --comment pulse-installer - )" - - for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do - grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || { - echo "::error::${installer} does not trust the configured release signing key." - exit 1 - } - done - - - name: Post-build health check - run: | - if [ -x ./pulse ]; then - ./pulse --version - elif [ -x ./cmd/pulse/pulse ]; then - ./cmd/pulse/pulse --version - fi + python3 scripts/release_candidate_manifest.py verify-local \ + --release-dir release \ + --manifest release-candidate-manifest/release-candidate.json \ + --version "${{ needs.prepare.outputs.version }}" \ + --source-sha "${GITHUB_SHA}" - name: Attest release assets uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 @@ -1155,9 +1082,9 @@ jobs: validate_release_assets: needs: - prepare + - build_release_candidate - create_release - - publish_docker - if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && (github.event.inputs.draft_only == 'true' || needs.publish_docker.result == 'success') }} + if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }} permissions: contents: write issues: write @@ -1170,6 +1097,7 @@ jobs: release_id: ${{ needs.create_release.outputs.release_id }} draft: ${{ github.event.inputs.draft_only == 'true' }} target_commitish: ${{ needs.create_release.outputs.target_commitish }} + candidate_manifest_artifact: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }} # End-to-end install.sh smoke against the just-published release. Catches # runtime regressions in the documented Proxmox-LXC / systemd install flow @@ -1243,15 +1171,15 @@ jobs: # when it fails the floating tags don't advance and customers pulling # rcourtman/pulse:latest stay on whatever the previous successful release # tagged. Calling promote-floating-tags as workflow_call after - # validate_release_assets succeeds (which itself waits for the docker - # image to be pullable) guarantees the floating tags advance. Draft-only - # runs must not promote floating tags because the release is still in - # private promotion state. + # validate_release_assets and publish_docker succeed guarantees the floating + # tags advance. Draft-only runs must not promote floating tags because the + # release is still in private promotion state. promote_floating_tags: needs: - prepare + - publish_docker - validate_release_assets - if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} + if: ${{ always() && needs.prepare.result == 'success' && needs.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} permissions: contents: read packages: write @@ -1271,8 +1199,8 @@ jobs: publish_private_pro_runtime: needs: - prepare - - validate_release_assets - if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }} + - create_release + if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }} runs-on: ubuntu-24.04 timeout-minutes: 150 steps: @@ -1398,7 +1326,6 @@ jobs: DRAFT_ONLY: ${{ github.event.inputs.draft_only }} VERSION: ${{ needs.prepare.outputs.version }} IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }} - PREFLIGHT_RUN_URL: ${{ needs.prepare.outputs.preflight_run_url }} CREATE_RESULT: ${{ needs.create_release.result }} DOCKER_RESULT: ${{ needs.publish_docker.result }} VALIDATE_RESULT: ${{ needs.validate_release_assets.result }} @@ -1436,6 +1363,3 @@ jobs: fi echo "Release verdict passed for v${VERSION}." - if [ -n "${PREFLIGHT_RUN_URL:-}" ]; then - echo "Stable patch preflight: ${PREFLIGHT_RUN_URL}" - fi diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 2bc186a2e..ccf4be8eb 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -59,6 +59,16 @@ permissions: contents: read jobs: + build_release_candidate: + name: Build Immutable Release Candidate + if: ${{ github.event_name == 'workflow_dispatch' }} + permissions: + contents: read + uses: ./.github/workflows/build-release-candidate.yml + secrets: inherit + with: + version: ${{ inputs.version }} + dry-run: name: Preflight Release Checks (No Publish) runs-on: ubuntu-24.04 diff --git a/.github/workflows/validate-release-assets.yml b/.github/workflows/validate-release-assets.yml index cdd8a5df1..40e15a4f2 100644 --- a/.github/workflows/validate-release-assets.yml +++ b/.github/workflows/validate-release-assets.yml @@ -23,6 +23,11 @@ on: description: 'Commit SHA associated with the release' required: true type: string + candidate_manifest_artifact: + description: 'Same-run immutable candidate manifest artifact for fast digest verification' + required: false + default: '' + type: string release: types: [edited] workflow_dispatch: @@ -47,6 +52,11 @@ on: description: 'Commit SHA associated with the release' required: true type: string + candidate_manifest_artifact: + description: 'Optional immutable candidate manifest artifact name' + required: false + default: '' + type: string permissions: contents: read @@ -109,8 +119,31 @@ jobs: cat context.env >> "$GITHUB_OUTPUT" cat context.env - - name: Download all release assets + - name: Download immutable candidate manifest + if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.candidate_manifest_artifact }} + path: release-candidate-manifest + + - name: Fetch release asset metadata if: steps.context.outputs.should_run == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh api --paginate \ + "repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}/assets?per_page=100" \ + --slurp > "$RUNNER_TEMP/release-assets.json" + count="$(jq '[.[][]] | length' "$RUNNER_TEMP/release-assets.json")" + if [ "$count" -eq 0 ]; then + echo "::error::No assets found in release" + exit 1 + fi + echo "Found ${count} published release assets." + + - name: Download all release assets for legacy validation + if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == '' id: download env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -156,11 +189,11 @@ jobs: ls -lh - name: Install Docker - if: steps.context.outputs.should_run == 'true' + if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == '' run: docker --version - name: Pull Docker image (with retry logic) - if: steps.context.outputs.should_run == 'true' + if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == '' id: docker continue-on-error: true run: | @@ -204,13 +237,21 @@ jobs: - name: Run validation script if: steps.context.outputs.should_run == 'true' id: validate + env: + CANDIDATE_MANIFEST_ARTIFACT: ${{ inputs.candidate_manifest_artifact }} run: | set +e echo "Running validation script..." - chmod +x scripts/validate-release.sh OUTPUT_FILE=$(mktemp) - if [ "${{ steps.docker.outputs.image_available }}" = "true" ]; then + if [ -n "${CANDIDATE_MANIFEST_ARTIFACT}" ]; then + echo "Validating GitHub's stored SHA-256 digests against the immutable candidate manifest..." + python3 scripts/release_candidate_manifest.py verify-release \ + --manifest release-candidate-manifest/release-candidate.json \ + --assets-json "$RUNNER_TEMP/release-assets.json" \ + --version "${{ steps.context.outputs.version }}" \ + --source-sha "${{ steps.context.outputs.target_commitish }}" 2>&1 | tee "$OUTPUT_FILE" + elif [ "${{ steps.docker.outputs.image_available }}" = "true" ]; then echo "Running full validation (Docker + assets)..." scripts/validate-release.sh \ "${{ steps.context.outputs.version }}" \ diff --git a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md index 2130164a9..cf029d50c 100644 --- a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md +++ b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md @@ -894,22 +894,58 @@ Companion drill: `go test ./scripts/installtests -run 'Test(Demo|DeployDemo|UpdateDemo|Release)' -count=1` - Manual scenario: 1. Push the exact candidate commit to the governed stable branch. - 2. Dispatch `./scripts/trigger-stable-patch.sh --dry-run ` and do not - publish another release. + 2. For a no-public-release rehearsal, dispatch + `./scripts/trigger-stable-patch.sh --dry-run `. 3. Confirm the run passes `Verify Current Stable Demo Path (No Mutation)` on a GitHub-hosted runner, including Tailscale ping, TCP/22, SSH host identity, current stable version, frontend parity, public health, and browser smoke. 4. Confirm the public demo version and health remain unchanged after the run. - Pass when: - The exact-SHA dry run succeeds without host mutation, routine patch metadata - rejects the documented RC-required risk paths, and the publish DAG awaits - Docker, demo, public verification, and the definitive terminal verdict. + The no-public-release rehearsal succeeds without host mutation, routine + patch metadata rejects the documented RC-required risk paths, and the single + publish DAG performs exact-SHA candidate checks before awaiting Docker, demo, + public verification, and the definitive terminal verdict. - Latest exercised record: `docs/release-control/v6/internal/records/stable-patch-unattended-release-path-2026-07-09.md` - Block release if: - The exact-SHA dry run is missing or older than 24 hours, the no-mutation demo - path fails, demo deployment is detached from the release DAG, or routine mode - can bypass a same-version RC or an RC-required runtime change. + The integrated candidate checks can be bypassed, the no-mutation demo path + fails when rehearsed, demo deployment is detached from the release DAG, or + routine mode can bypass a same-version RC or an RC-required runtime change. + +## Gate: `single-build-release-promotion-path` + +- Owner lanes: `L1` +- Risk covered: + A normal release can repeat expensive compilation after successful checks, + serialize integration behind backend tests, download the complete release + packet after upload, or delay independent Docker, asset, and private-runtime + work. The release may be unattended but still consume most of a working day. +- Minimum evidence tier: `real-external-e2e` +- Canonical proof commands: + 1. Push the exact implementation commit to the governed release branch. + 2. Dispatch `Release Dry Run` for the current version and exact SHA. Do not + create or modify a public release. + 3. Confirm `Build Immutable Release Candidate` builds, locally validates, and + uploads both the candidate and manifest artifacts. + 4. Confirm the release checks and candidate build overlap, and confirm the + no-mutation demo path passes on GitHub-hosted infrastructure. + 5. Run the candidate-manifest unit tests, release-promotion policy tests, and + installer/release workflow contract tests. + 6. Confirm `v6.0.5` release timestamps and asset count remain unchanged and + the public demo remains healthy on stable `6.0.5`. +- Pass when: + The external rehearsal proves the canonical candidate builder, local tests + pin candidate-only publication and GitHub digest validation, and the static + release DAG contains no duplicate release build, backend-to-integration + serialization, full-download standard validator, or avoidable post-release + serialization. +- Latest exercised record: + `docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md` +- Block release if: + Publication rebuilds release assets, the candidate manifest does not pin the + exact SHA and complete asset set, standard validation downloads the full + release packet, independent post-release jobs are serialized, or the + definitive verdict can pass without all applicable downstream results. ## Gate Ownership Rule diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index 9a1113400..e54b78dbd 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -163,6 +163,33 @@ Cloud, and self-hosted production users. and retain the prior governed release-pipeline rehearsal evidence as automation lineage rather than claiming the post-RC7 changes were RC-tested. +## Single-Build Release Path + +1. Every normal RC, stable, and patch release is initiated once through + `create-release.yml`. The workflow builds one signed candidate for the exact + pushed SHA while frontend, backend, Docker, Helm, and integration checks run + in parallel. No tag, draft, or public release mutation occurs until those + checks and the candidate build pass. +2. The signed candidate is uploaded as a one-day Actions artifact with a + machine-readable manifest that pins source SHA, version, filename, size, and + SHA-256 for every release asset. Publication downloads and verifies that + exact candidate; it must not rebuild release binaries or installers. +3. Standard post-publication asset verification compares the candidate + manifest with GitHub's server-side release-asset SHA-256 digests. It must + not re-download the multi-gigabyte release packet merely to recompute hashes + already proven before upload. Manual and release-edit repair validation may + retain the full-download fallback when no same-run candidate manifest exists. +4. Docker publication, release-asset verification, and the private Pro build + begin independently as soon as the release exists. Helm, floating tags, + install smoke, stable demo deployment, and private paid-runtime promotion + retain their required dependencies, and `Definitive Release Verdict` still + fails unless every applicable terminal result passes. +5. `Release Dry Run` remains the no-public-release rehearsal surface. It calls + the same candidate builder and no-mutation demo verification, but a separate + dry run is not required before a normal release because the single publish + workflow performs the exact-SHA preflight before crossing the publication + boundary. + ## Routine Stable Patch Path 1. A normal stable patch may omit a same-version RC only when all of these are @@ -176,12 +203,13 @@ Cloud, and self-hosted production users. - the canonical stable release-notes packet exists; - the mobile-impact gate either proves no mobile-facing change or records current candidate evidence; and - - `Release Dry Run` passed for the exact candidate SHA within the previous - 24 hours. That run must include the no-mutation stable-demo path check. + - the integrated exact-SHA candidate build and release checks pass before + the workflow creates or publishes the release. 2. `scripts/trigger-stable-patch.sh` is the standard operator entrypoint. Run - it once with `--dry-run`, monitor asynchronously, then run it once without - `--dry-run`. It derives rollback and release notes, refuses local-only or - dirty state, and supplies the workflow metadata without interactive prompts. + it once without `--dry-run`; it derives rollback and release notes, refuses + local-only or dirty state, and supplies workflow metadata without + interactive prompts. `--dry-run` is optional and exists only for an explicit + no-public-release rehearsal. 3. Creating a same-version RC or touching an RC-required path moves the patch onto the RC promotion path. The resolver enforces that boundary. Do not use the routine helper to relabel a risky patch as routine. diff --git a/docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md b/docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md new file mode 100644 index 000000000..edaf47fbc --- /dev/null +++ b/docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md @@ -0,0 +1,78 @@ +# Single-Build Release Promotion Path - 2026-07-09 + +## Scope + +Make the normal RC, stable, and patch release path one unattended workflow +whose public promotion and definitive verdict complete materially faster than +the v6.0.5 path without weakening release gates. + +## v6.0.5 Timing Baseline + +- The successful public release run `29022145812` started at + `2026-07-09T13:38:55Z` and did not finish its awaited private-runtime work + until `2026-07-09T15:45:54Z`. +- Pre-publication checks consumed approximately 36 minutes because integration + tests waited for the 20-minute backend suite. +- `create_release` then consumed another 21 minutes. Its signed release build + alone took 18 minutes and 44 seconds even though the same SHA had already + completed release checks. +- Post-publication asset validation consumed 22 minutes and 29 seconds because + it downloaded the complete 213-asset, multi-gigabyte release packet. +- Private Pro build and promotion consumed 46 minutes and 35 seconds, but did + not start until release-asset validation finished. +- Stable patch operation also required a separate 37-minute exact-SHA dry run, + making the safe operator path additive rather than a single release run. + +## Canonical Correction + +1. `.github/workflows/build-release-candidate.yml` builds and locally validates + one signed candidate for the exact pushed SHA. It emits a one-day candidate + artifact plus a small manifest containing version, tag, source SHA, + filenames, sizes, and SHA-256 values. +2. Candidate construction runs in parallel with frontend, backend, Docker, + Helm, and integration checks. Frontend lint/build runs once and provides one + verified bundle to backend and integration jobs. Backend and integration no + longer serialize on each other. +3. `create_release` downloads and verifies the immutable candidate instead of + rebuilding release assets. Tag, draft, and publication mutations remain + downstream of every required check. +4. Standard post-upload validation compares the candidate manifest with + GitHub's server-side release-asset SHA-256 digests and sizes. The legacy + full-download validator remains available for manual repair or release-edit + validation when no same-run manifest exists. +5. Docker publication, candidate-backed release-asset validation, and private + Pro publication start independently after release creation. Floating tags + still require both Docker and asset validation; install smoke, Helm, stable + demo, paid-runtime promotion, and the definitive verdict retain their + applicable safety dependencies. +6. `Release Dry Run` calls the same candidate builder for a no-public-release + rehearsal. Normal release publication does not require a separate dry run + because `create-release.yml` contains the exact-SHA candidate and test gates + before publication. + +## Timing Contract + +Using the v6.0.5 timings as the baseline: + +- the public release boundary should normally be reached within 35 minutes of + dispatch rather than after a separate rehearsal plus approximately 58 + minutes of checks and rebuilding; +- the definitive cross-product verdict should normally complete within 80 + minutes, with the private Pro build as the expected critical path rather than + serial release-asset downloads; and +- operator attention remains limited to preparing the release packet and one + dispatch. Runner queueing or external registry degradation may extend wall + time, but no standard job may reintroduce duplicate release builds, complete + packet downloads, or avoidable serial dependencies. + +## Verification + +Pending one pushed `Release Dry Run` for the exact implementation SHA. The run +must build and validate the signed candidate, upload both candidate artifacts, +pass release checks, complete no-mutation demo verification, and leave the +published `v6.0.5` release and demo runtime unchanged. + +## Current Verdict + +Blocked until the pushed no-public-release rehearsal succeeds and its measured +job timings are recorded here. diff --git a/docs/release-control/v6/internal/records/stable-patch-unattended-release-path-2026-07-09.md b/docs/release-control/v6/internal/records/stable-patch-unattended-release-path-2026-07-09.md index 1925f23dd..02f7eac96 100644 --- a/docs/release-control/v6/internal/records/stable-patch-unattended-release-path-2026-07-09.md +++ b/docs/release-control/v6/internal/records/stable-patch-unattended-release-path-2026-07-09.md @@ -79,7 +79,8 @@ deployment, and one definitive release verdict. then emits a terminal `Definitive Release Verdict`. - Routine stable patch release resolution no longer fabricates RC ceremony, but it fails closed for documented high-risk runtime changes, an existing - same-version RC, stale rollback lineage, or a missing exact-SHA dry run. + same-version RC, stale rollback lineage, or failed integrated exact-SHA + candidate checks. - `scripts/trigger-stable-patch.sh` is the noninteractive operator entrypoint. ## End-to-End Rehearsal @@ -111,7 +112,11 @@ change is required for the currently verified `tag:infra` path. ## Current Verdict -Passed. A routine stable patch now requires one recent exact-SHA preflight and -one noninteractive publish dispatch. The publish DAG awaits release promotion, -Docker publication, stable demo deployment, definitive public verification, -and its terminal verdict; manual SSH is not part of the standard path. +Passed. A routine stable patch now uses one noninteractive publish dispatch +whose exact-SHA candidate checks run before publication. `Release Dry Run` +remains available for explicit no-public-release rehearsal. The publish DAG +awaits release promotion, Docker publication, stable demo deployment, +definitive public verification, and its terminal verdict; manual SSH is not +part of the standard path. The release-wide single-build timing contract is +tracked in +`docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md`. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index d9ef4c366..36b1c8fc7 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -6764,9 +6764,29 @@ } ] }, + { + "id": "single-build-release-promotion-path", + "summary": "Confirm normal RC, stable, and patch releases build one exact-SHA signed candidate in parallel with checks, promote that candidate without rebuilding, validate GitHub asset digests without full packet downloads, and run independent post-publication lanes concurrently.", + "owner": "project-owner", + "blocking_level": "release-ready", + "minimum_evidence_tier": "real-external-e2e", + "status": "blocked", + "verification_doc": "docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md", + "lane_ids": [ + "L1" + ], + "evidence": [ + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md", + "kind": "file", + "evidence_tier": "local-rehearsal" + } + ] + }, { "id": "stable-patch-unattended-release-path", - "summary": "Confirm routine stable patches require one recent exact-SHA dry run, one noninteractive publish dispatch, awaited Docker and demo deployment, and a definitive release verdict without manual SSH recovery.", + "summary": "Confirm routine stable patches use one noninteractive publish dispatch with integrated exact-SHA candidate checks, awaited Docker and demo deployment, and a definitive release verdict without manual SSH recovery.", "owner": "project-owner", "blocking_level": "release-ready", "minimum_evidence_tier": "real-external-e2e", diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 6e2e04259..e0f1522e4 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -46,7 +46,8 @@ TLS floor in the dynamic config. 15. `internal/cloudcp/docker/manager.go` 16. `internal/cloudcp/docker/labels.go` 17. `internal/cloudcp/tenant_runtime_rollout.go` -13. `.github/workflows/create-release.yml` +13. `.github/workflows/build-release-candidate.yml` +14. `.github/workflows/create-release.yml` 14. `.github/workflows/deploy-demo-server.yml` 15. `.github/workflows/helm-pages.yml` 16. `.github/workflows/promote-floating-tags.yml` @@ -95,7 +96,8 @@ TLS floor in the dynamic config. 59. `scripts/release_control/resolve_release_promotion.py` 60. `scripts/release_control/mobile_release_gate.py` 61. `scripts/release_control/mobile_release_gate_test.py` -62. `scripts/release_control/validate_artifact_release_line.py` +62. `scripts/release_candidate_manifest.py` +63. `scripts/release_control/validate_artifact_release_line.py` 63. `scripts/release_ldflags.sh` 64. `scripts/run_cloud_public_signup_smoke.sh` 65. `scripts/run_demo_public_browser_smoke.sh` @@ -318,6 +320,16 @@ TLS floor in the dynamic config. 1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/` 2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml` + Normal releases are single-build promotions. The exact pushed SHA must + produce one signed candidate through + `.github/workflows/build-release-candidate.yml` while independent release + checks run in parallel. `create-release.yml` may publish only that candidate + after `scripts/release_candidate_manifest.py` verifies its version, source + SHA, filenames, sizes, and SHA-256 values. Standard post-upload validation + must compare that manifest with GitHub's server-side asset digests instead + of downloading the complete release packet again. Historical repair and + release-edit validation may use the full-download fallback because those + paths do not have a same-run candidate manifest. Release-facing agent-paradigm blurbs under `docs/releases/` must describe `pulse-mcp` as a generic MCP adapter for MCP-speaking clients, not a client-specific release artifact, and full-surface token guidance must come diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 104c87888..c41d12755 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -3302,6 +3302,7 @@ ".github/scripts/check-demo-reachability.sh", ".github/scripts/setup-demo-ssh.sh", ".github/workflows/backfill-release-assets.yml", + ".github/workflows/build-release-candidate.yml", ".github/workflows/create-release.yml", ".github/workflows/deploy-demo-server.yml", ".github/workflows/helm-pages.yml", @@ -3364,6 +3365,7 @@ "scripts/lib/hot-dev-runtime.sh", "scripts/pulse-auto-update.sh", "scripts/release_asset_common.sh", + "scripts/release_candidate_manifest.py", "scripts/release_control/internal/record_rc_to_ga_rehearsal.py", "scripts/release_control/record_rc_to_ga_rehearsal.py", "scripts/release_control/release_promotion_policy_support.py", @@ -3459,6 +3461,7 @@ ".github/scripts/check-demo-reachability.sh", ".github/scripts/setup-demo-ssh.sh", ".github/workflows/backfill-release-assets.yml", + ".github/workflows/build-release-candidate.yml", ".github/workflows/create-release.yml", ".github/workflows/helm-pages.yml", ".github/workflows/install-sh-smoke.yml", @@ -3499,6 +3502,20 @@ "scripts/release_control/validate_artifact_release_line_test.py" ] }, + { + "id": "release-candidate-manifest-runtime", + "label": "immutable release candidate manifest proof", + "match_prefixes": [], + "match_files": [ + "scripts/release_candidate_manifest.py" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "scripts/installtests/build_release_assets_test.go", + "scripts/release_control/release_candidate_manifest_test.py" + ] + }, { "id": "release-build-metadata-runtime", "label": "release build metadata proof", diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 4eb2d1572..e511a40fe 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -155,7 +155,8 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { `git push origin "refs/tags/${TAG}" --force`, `-F target_commitish="${HEAD_SHA}"`, `historical_asset_backfill_only=${HISTORICAL_ASSET_BACKFILL_ONLY}`, - `if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && (github.event.inputs.draft_only == 'true' || needs.publish_docker.result == 'success') }}`, + `if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}`, + `candidate_manifest_artifact: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}`, `if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}`, `permissions:`, `issues: write`, @@ -718,7 +719,11 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { if err != nil { t.Fatalf("read create-release.yml: %v", err) } - createRelease := string(createReleaseBytes) + candidateWorkflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "build-release-candidate.yml")) + if err != nil { + t.Fatalf("read build-release-candidate.yml: %v", err) + } + createRelease := string(createReleaseBytes) + "\n" + string(candidateWorkflowBytes) createReleaseRequired := []string{ `provenance: mode=max`, `sbom: true`, @@ -1494,6 +1499,77 @@ func TestPublishHelmChartReachableViaWorkflowCall(t *testing.T) { } } +func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { + createBytes, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml")) + if err != nil { + t.Fatalf("read create-release.yml: %v", err) + } + candidateBytes, err := os.ReadFile(repoFile(".github", "workflows", "build-release-candidate.yml")) + if err != nil { + t.Fatalf("read build-release-candidate.yml: %v", err) + } + validationBytes, err := os.ReadFile(repoFile(".github", "workflows", "validate-release-assets.yml")) + if err != nil { + t.Fatalf("read validate-release-assets.yml: %v", err) + } + + createWorkflow := string(createBytes) + candidateWorkflow := string(candidateBytes) + validationWorkflow := string(validationBytes) + createJob := workflowJobBlock(t, createWorkflow, "create_release") + backendJob := workflowJobBlock(t, createWorkflow, "backend_tests") + integrationJob := workflowJobBlock(t, createWorkflow, "integration_tests") + validationJob := workflowJobBlock(t, createWorkflow, "validate_release_assets") + privateJob := workflowJobBlock(t, createWorkflow, "publish_private_pro_runtime") + + for _, needle := range []string{ + `./scripts/build-release.sh "${{ inputs.version }}"`, + `scripts/validate-release.sh "${{ inputs.version }}" --skip-docker`, + `scripts/release_candidate_manifest.py create`, + `compression-level: 0`, + `retention-days: 1`, + } { + if !strings.Contains(candidateWorkflow, needle) { + t.Fatalf("build-release-candidate.yml missing single-build contract: %s", needle) + } + } + + for _, needle := range []string{ + `Download immutable release candidate`, + `scripts/release_candidate_manifest.py verify-local`, + `needs.build_release_candidate.outputs.artifact_name`, + } { + if !strings.Contains(createJob, needle) { + t.Fatalf("create_release missing candidate promotion contract: %s", needle) + } + } + if strings.Contains(createJob, "scripts/build-release.sh") { + t.Fatal("create_release must promote the verified candidate instead of rebuilding release assets") + } + + if !strings.Contains(backendJob, "- frontend_checks") || !strings.Contains(integrationJob, "- frontend_checks") { + t.Fatal("backend and integration jobs must consume the shared verified frontend bundle") + } + if strings.Contains(integrationJob, "- backend_tests") { + t.Fatal("integration tests must run in parallel with backend tests") + } + if strings.Contains(validationJob, "- publish_docker") { + t.Fatal("release asset digest validation must run in parallel with Docker publication") + } + if !strings.Contains(privateJob, "- create_release") || strings.Contains(privateJob, "- validate_release_assets") { + t.Fatal("private Pro publication must start after release creation without waiting for asset validation") + } + for _, needle := range []string{ + `inputs.candidate_manifest_artifact != ''`, + `scripts/release_candidate_manifest.py verify-release`, + `inputs.candidate_manifest_artifact == ''`, + } { + if !strings.Contains(validationWorkflow, needle) { + t.Fatalf("validate-release-assets.yml missing fast digest contract: %s", needle) + } + } +} + func TestCreateReleasePublishesPrivateProRuntime(t *testing.T) { content, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml")) if err != nil { @@ -1503,7 +1579,7 @@ func TestCreateReleasePublishesPrivateProRuntime(t *testing.T) { job := workflowJobBlock(t, workflow, "publish_private_pro_runtime") for _, needle := range []string{ - `needs.validate_release_assets.result == 'success'`, + `needs.create_release.result == 'success'`, `github.event.inputs.draft_only != 'true'`, `startsWith(needs.prepare.outputs.version, '6.')`, `GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}`, diff --git a/scripts/release_candidate_manifest.py b/scripts/release_candidate_manifest.py new file mode 100644 index 000000000..47153a0bf --- /dev/null +++ b/scripts/release_candidate_manifest.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Create and verify immutable Pulse release-candidate manifests.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any + + +SCHEMA_VERSION = 1 +VERSION_PATTERN = re.compile( + r"^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|alpha|beta)\.[0-9]+)?$" +) + + +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 collect_assets(release_dir: Path) -> list[dict[str, Any]]: + if not release_dir.is_dir(): + raise ValueError(f"release directory does not exist: {release_dir}") + + assets: list[dict[str, Any]] = [] + for path in sorted(release_dir.rglob("*")): + if path.is_symlink(): + raise ValueError(f"release candidate must not contain symlinks: {path}") + if not path.is_file(): + continue + relative = path.relative_to(release_dir).as_posix() + assets.append( + { + "name": relative, + "size": path.stat().st_size, + "sha256": sha256_file(path), + } + ) + + if not assets: + raise ValueError(f"release candidate is empty: {release_dir}") + return assets + + +def validate_version(version: str) -> None: + if not VERSION_PATTERN.fullmatch(version): + raise ValueError(f"invalid release version: {version!r}") + + +def create_manifest(release_dir: Path, version: str, source_sha: str) -> dict[str, Any]: + validate_version(version) + if not re.fullmatch(r"[0-9a-f]{40}", source_sha): + raise ValueError("source SHA must be a full lowercase Git commit SHA") + return { + "schema_version": SCHEMA_VERSION, + "version": version, + "tag": f"v{version}", + "source_sha": source_sha, + "assets": collect_assets(release_dir), + } + + +def load_manifest(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot read release candidate manifest {path}: {exc}") from exc + if not isinstance(payload, dict): + raise ValueError("release candidate manifest must be a JSON object") + if payload.get("schema_version") != SCHEMA_VERSION: + raise ValueError( + f"unsupported release candidate manifest schema: {payload.get('schema_version')!r}" + ) + if not isinstance(payload.get("assets"), list) or not payload["assets"]: + raise ValueError("release candidate manifest must contain assets") + return payload + + +def manifest_assets_by_name(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for index, asset in enumerate(manifest["assets"]): + if not isinstance(asset, dict): + raise ValueError(f"manifest asset {index} must be an object") + name = asset.get("name") + size = asset.get("size") + digest = asset.get("sha256") + if not isinstance(name, str) or not name or Path(name).name != name: + raise ValueError(f"manifest asset {index} has invalid name: {name!r}") + if not isinstance(size, int) or size < 0: + raise ValueError(f"manifest asset {name!r} has invalid size: {size!r}") + if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise ValueError(f"manifest asset {name!r} has invalid SHA-256 digest") + if name in result: + raise ValueError(f"manifest contains duplicate asset: {name}") + result[name] = asset + return result + + +def verify_manifest_identity( + manifest: dict[str, Any], expected_version: str, expected_source_sha: str +) -> None: + if manifest.get("version") != expected_version: + raise ValueError( + f"candidate version {manifest.get('version')!r} does not match {expected_version!r}" + ) + if manifest.get("tag") != f"v{expected_version}": + raise ValueError(f"candidate tag does not match v{expected_version}") + if manifest.get("source_sha") != expected_source_sha: + raise ValueError( + f"candidate source SHA {manifest.get('source_sha')!r} does not match " + f"{expected_source_sha!r}" + ) + + +def verify_local( + release_dir: Path, + manifest: dict[str, Any], + expected_version: str, + expected_source_sha: str, +) -> None: + verify_manifest_identity(manifest, expected_version, expected_source_sha) + + expected = manifest_assets_by_name(manifest) + actual = {asset["name"]: asset for asset in collect_assets(release_dir)} + if set(actual) != set(expected): + missing = sorted(set(expected) - set(actual)) + extra = sorted(set(actual) - set(expected)) + raise ValueError(f"candidate asset set mismatch: missing={missing}, extra={extra}") + for name, expected_asset in expected.items(): + actual_asset = actual[name] + if actual_asset["size"] != expected_asset["size"]: + raise ValueError(f"candidate asset size mismatch: {name}") + if actual_asset["sha256"] != expected_asset["sha256"]: + raise ValueError(f"candidate asset digest mismatch: {name}") + + +def load_release_assets(path: Path) -> list[dict[str, Any]]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot read release asset metadata {path}: {exc}") from exc + if not isinstance(payload, list): + raise ValueError("release asset metadata must be a JSON array") + if payload and all(isinstance(item, list) for item in payload): + payload = [asset for page in payload for asset in page] + if not all(isinstance(item, dict) for item in payload): + raise ValueError("release asset metadata contains a non-object entry") + return payload + + +def verify_release(manifest: dict[str, Any], release_assets: list[dict[str, Any]]) -> None: + expected = manifest_assets_by_name(manifest) + actual: dict[str, dict[str, Any]] = {} + for index, asset in enumerate(release_assets): + name = asset.get("name") + if not isinstance(name, str) or not name: + raise ValueError(f"release asset {index} has no valid name") + if name in actual: + raise ValueError(f"release contains duplicate asset: {name}") + actual[name] = asset + + if set(actual) != set(expected): + missing = sorted(set(expected) - set(actual)) + extra = sorted(set(actual) - set(expected)) + raise ValueError(f"published asset set mismatch: missing={missing}, extra={extra}") + + for name, expected_asset in expected.items(): + actual_asset = actual[name] + if actual_asset.get("size") != expected_asset["size"]: + raise ValueError(f"published asset size mismatch: {name}") + expected_digest = f"sha256:{expected_asset['sha256']}" + if actual_asset.get("digest") != expected_digest: + raise ValueError( + f"published asset digest mismatch: {name}; " + f"expected {expected_digest}, got {actual_asset.get('digest')!r}" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + create = subparsers.add_parser("create") + create.add_argument("--release-dir", type=Path, required=True) + create.add_argument("--version", required=True) + create.add_argument("--source-sha", required=True) + create.add_argument("--output", type=Path, required=True) + + local = subparsers.add_parser("verify-local") + local.add_argument("--release-dir", type=Path, required=True) + local.add_argument("--manifest", type=Path, required=True) + local.add_argument("--version", required=True) + local.add_argument("--source-sha", required=True) + + release = subparsers.add_parser("verify-release") + release.add_argument("--manifest", type=Path, required=True) + release.add_argument("--assets-json", type=Path, required=True) + release.add_argument("--version", required=True) + release.add_argument("--source-sha", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + if args.command == "create": + manifest = create_manifest(args.release_dir, args.version, args.source_sha) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print( + f"Release candidate manifest created: assets={len(manifest['assets'])} " + f"version={args.version} source_sha={args.source_sha}" + ) + elif args.command == "verify-local": + manifest = load_manifest(args.manifest) + verify_local(args.release_dir, manifest, args.version, args.source_sha) + print( + f"Release candidate verified locally: assets={len(manifest['assets'])} " + f"version={args.version} source_sha={args.source_sha}" + ) + else: + manifest = load_manifest(args.manifest) + verify_manifest_identity(manifest, args.version, args.source_sha) + release_assets = load_release_assets(args.assets_json) + verify_release(manifest, release_assets) + print( + f"Published release matches candidate: assets={len(manifest['assets'])} " + f"version={manifest['version']} source_sha={manifest['source_sha']}" + ) + except (OSError, ValueError) as exc: + print(f"release candidate verification failed: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/release_candidate_manifest_test.py b/scripts/release_control/release_candidate_manifest_test.py new file mode 100644 index 000000000..a8ff0128e --- /dev/null +++ b/scripts/release_control/release_candidate_manifest_test.py @@ -0,0 +1,81 @@ +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from release_candidate_manifest import ( + create_manifest, + load_release_assets, + verify_local, + verify_release, +) + + +SOURCE_SHA = "1" * 40 + + +class ReleaseCandidateManifestTest(unittest.TestCase): + def create_release_dir(self, root: Path) -> Path: + release_dir = root / "release" + release_dir.mkdir() + (release_dir / "checksums.txt").write_text("abc\n", encoding="utf-8") + (release_dir / "pulse-v6.1.0-linux-amd64.tar.gz").write_bytes(b"archive") + return release_dir + + def test_create_and_verify_local_candidate(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + release_dir = self.create_release_dir(Path(temp_dir)) + manifest = create_manifest(release_dir, "6.1.0", SOURCE_SHA) + + self.assertEqual(manifest["tag"], "v6.1.0") + self.assertEqual( + [asset["name"] for asset in manifest["assets"]], + ["checksums.txt", "pulse-v6.1.0-linux-amd64.tar.gz"], + ) + verify_local(release_dir, manifest, "6.1.0", SOURCE_SHA) + + def test_verify_local_rejects_tampered_asset(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + release_dir = self.create_release_dir(Path(temp_dir)) + manifest = create_manifest(release_dir, "6.1.0-rc.1", SOURCE_SHA) + (release_dir / "checksums.txt").write_text("xyz\n", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "digest mismatch"): + verify_local(release_dir, manifest, "6.1.0-rc.1", SOURCE_SHA) + + def test_verify_release_uses_server_side_digests(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + release_dir = self.create_release_dir(Path(temp_dir)) + manifest = create_manifest(release_dir, "6.1.0", SOURCE_SHA) + release_assets = [ + { + "name": asset["name"], + "size": asset["size"], + "digest": f"sha256:{asset['sha256']}", + } + for asset in manifest["assets"] + ] + + verify_release(manifest, release_assets) + release_assets[0]["digest"] = "sha256:" + "0" * 64 + with self.assertRaisesRegex(ValueError, "published asset digest mismatch"): + verify_release(manifest, release_assets) + + def test_release_metadata_loader_flattens_paginated_arrays(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "assets.json" + path.write_text( + json.dumps([[{"name": "a"}], [{"name": "b"}]]), + encoding="utf-8", + ) + self.assertEqual( + [asset["name"] for asset in load_release_assets(path)], + ["a", "b"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index f626a61b8..b465cb2e9 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -529,6 +529,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): demo_ssh_helper = read(".github/scripts/setup-demo-ssh.sh") demo_reachability_helper = read(".github/scripts/check-demo-reachability.sh") validation_workflow = read(".github/workflows/validate-release-assets.yml") + candidate_workflow = read(".github/workflows/build-release-candidate.yml") helper = read("scripts/trigger-release.sh") renderer = read("scripts/release_control/render_release_body.py") policy = read("docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md") @@ -586,9 +587,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('-F target_commitish="${HEAD_SHA}"', content) self.assertIn('historical_asset_backfill_only=${HISTORICAL_ASSET_BACKFILL_ONLY}', content) self.assertIn( - "if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && (github.event.inputs.draft_only == 'true' || needs.publish_docker.result == 'success') }}", + "if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}", content, ) + self.assertIn("candidate_manifest_artifact:", validation_workflow) + self.assertIn("release_candidate_manifest.py verify-release", validation_workflow) self.assertIn("if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}", content) self.assertIn("issues: write", content) self.assertIn("statuses: write", content) @@ -611,9 +614,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", content) self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", content) self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", content) - self.assertIn("Validate installer signing key pins", content) - self.assertIn("go run ./scripts/release_update_key.go public-key-ssh", content) - self.assertIn("does not trust the configured release signing key", content) + self.assertIn("Validate installer signing key pins", candidate_workflow) + self.assertIn("go run ./scripts/release_update_key.go public-key-ssh", candidate_workflow) + self.assertIn("does not trust the configured release signing key", candidate_workflow) self.assertIn("TRUSTED_SSH_PUBLIC_KEY", update_demo_workflow) self.assertIn('sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\\"${TRUSTED_SSH_PUBLIC_KEY}\\"|" /tmp/pulse-install.sh', update_demo_workflow) for demo_workflow in (update_demo_workflow, deploy_demo_workflow): @@ -860,9 +863,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("Public demo is serving $PUBLIC_ASSET but the target service is serving $REMOTE_ASSET.", demo) self.assertIn("uses: ./.github/workflows/publish-docker.yml", release_workflow) self.assertIn("uses: ./.github/workflows/update-demo-server.yml", release_workflow) + self.assertIn("uses: ./.github/workflows/build-release-candidate.yml", release_workflow) + self.assertIn("Build Immutable Release Candidate", release_workflow) self.assertIn("Definitive Release Verdict", release_workflow) - self.assertIn("Require recent exact-SHA stable patch preflight", release_workflow) - self.assertIn("Release Dry Run v${VERSION}", release_workflow) + self.assertNotIn("Require recent exact-SHA stable patch preflight", release_workflow) self.assertNotIn("gh workflow run update-demo-server.yml", release_workflow) self.assertNotIn("gh workflow run publish-docker.yml", release_workflow) self.assertNotIn("preview-v6", preview_deploy) @@ -1012,7 +1016,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): " BLESS_GOVERNANCE_FIXTURES=1 python3 -m unittest release_promotion_policy_test" ) - def test_routine_stable_patch_entrypoint_is_noninteractive_and_preflight_gated(self) -> None: + def test_routine_stable_patch_entrypoint_is_noninteractive_and_integrated(self) -> None: helper = read("scripts/trigger-stable-patch.sh") policy = read("docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md") contract = read("docs/release-control/v6/internal/subsystems/deployment-installability.md") @@ -1022,13 +1026,14 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("--dry-run", helper) self.assertIn("--derive-rollback-latest-stable", helper) self.assertIn("docs/releases/RELEASE_NOTES_v${VERSION}.md", helper) - self.assertIn("Release Dry Run v${VERSION}", helper) - self.assertIn("timedelta(hours=24)", helper) - self.assertIn(".createdAt >= $cutoff", helper) + self.assertIn("Use --dry-run only", helper) + self.assertNotIn("timedelta(hours=24)", helper) + self.assertNotIn(".createdAt >= $cutoff", helper) self.assertIn("gh workflow run create-release.yml", helper) self.assertIn("gh workflow run \"$WORKFLOW\"", helper) + self.assertIn("Single-Build Release Path", policy) self.assertIn("Routine Stable Patch Path", policy) - self.assertIn("exact candidate SHA within the previous 24 hours", normalize_ws(policy)) + self.assertIn("single publish workflow performs the exact-SHA preflight", normalize_ws(policy)) self.assertIn("An asynchronous dispatch or manual SSH deployment is not release completion.", normalize_ws(contract)) diff --git a/scripts/trigger-stable-patch.sh b/scripts/trigger-stable-patch.sh index fd3943a45..e3ef0271a 100755 --- a/scripts/trigger-stable-patch.sh +++ b/scripts/trigger-stable-patch.sh @@ -11,9 +11,9 @@ usage() { cat <<'EOF' Usage: scripts/trigger-stable-patch.sh [--dry-run] [options] [version] -Runs the noninteractive stable patch preflight and dispatches exactly one -governed workflow. Run once with --dry-run, wait for that workflow to pass, -then run again without --dry-run to publish. +Dispatches exactly one governed workflow. The default release workflow builds +and validates an immutable candidate before publication. Use --dry-run only +when a no-public-release rehearsal is required. Options: --dry-run Dispatch Release Dry Run only. @@ -167,27 +167,6 @@ if [ "$MODE" = "dry-run" ]; then -f mobile_release_decision="$MOBILE_RELEASE_DECISION" \ -f mobile_release_evidence="$MOBILE_RELEASE_EVIDENCE" else - PREFLIGHT_CUTOFF="$(python3 - <<'PY' -from datetime import datetime, timedelta, timezone - -cutoff = datetime.now(timezone.utc) - timedelta(hours=24) -print(cutoff.strftime("%Y-%m-%dT%H:%M:%SZ")) -PY -)" - PREFLIGHT_RUN="$(gh run list \ - --workflow=release-dry-run.yml \ - --branch "$CURRENT_BRANCH" \ - --event workflow_dispatch \ - --limit 30 \ - --json displayTitle,headSha,conclusion,url,createdAt \ - | jq -c --arg sha "$LOCAL_SHA" --arg title "Release Dry Run v${VERSION}" --arg cutoff "$PREFLIGHT_CUTOFF" \ - '[.[] | select(.headSha == $sha and .displayTitle == $title and .conclusion == "success" and .createdAt >= $cutoff)] | sort_by(.createdAt) | last // empty')" - if [ -z "$PREFLIGHT_RUN" ]; then - echo "No successful exact-SHA Release Dry Run from the last 24 hours exists for v${VERSION}." >&2 - echo "Run $0 --dry-run ${VERSION}, wait for success, then rerun this command." >&2 - exit 1 - fi - python3 scripts/check-workflow-dispatch-inputs.py \ --workflow-path .github/workflows/create-release.yml \ --branch "$CURRENT_BRANCH" \ @@ -217,7 +196,6 @@ PY -f mobile_release_decision="$MOBILE_RELEASE_DECISION" \ -f mobile_release_evidence="$MOBILE_RELEASE_EVIDENCE" - echo "Accepted preflight: $(jq -r '.url' <<<"$PREFLIGHT_RUN")" fi echo "Dispatched ${WORKFLOW:-create-release.yml} for v${VERSION} at ${LOCAL_SHA}."