From 6869612c669ff640f52becfc3f66ce66415195c1 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 23 Aug 2026 15:02:33 +0100 Subject: [PATCH] Isolate PC compilation from SignPath workflow --- .github/workflows/build-release-candidate.yml | 187 ++++++++++-------- .github/workflows/compile-release-payload.yml | 96 +++++++++ .github/workflows/create-release.yml | 2 +- .github/workflows/release-dry-run.yml | 2 +- docs/release-control/v6/internal/status.json | 2 +- .../subsystems/deployment-installability.md | 27 ++- .../installtests/build_release_assets_test.go | 38 +++- .../release_promotion_policy_test.py | 32 +-- 8 files changed, 273 insertions(+), 113 deletions(-) create mode 100644 .github/workflows/compile-release-payload.yml diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 1725ebb70..e31dc7974 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -74,79 +74,103 @@ permissions: contents: read jobs: - compile-release-payload: - name: Compile Exact-SHA Release Payload - # This credential-free worker is the trusted high-capacity compiler for - # every channel. Its output is immutable after upload and is consumed by - # artifact id and digest; hosted jobs verify and package it without a - # second compilation. It is not in the dependency chain leading to the - # separately hosted SignPath request. - runs-on: ${{ fromJSON('["self-hosted","Linux","X64","pulse-pve-compile"]') }} - timeout-minutes: 30 + obtain-release-payload: + name: Obtain Isolated PC Release Payload + # The self-hosted compiler runs in a separate workflow run. This release + # and SignPath workflow therefore contains only GitHub-hosted jobs, while + # the expensive payload still compiles once on the trusted PC. + runs-on: ubuntu-24.04 + timeout-minutes: 40 + permissions: + actions: write + contents: read outputs: - artifact_id: ${{ steps.upload_compiled.outputs.artifact-id }} - artifact_digest: ${{ steps.upload_compiled.outputs.artifact-digest }} - artifact_name: release-compiled-${{ github.sha }}-${{ inputs.version }} + artifact_id: ${{ steps.wait.outputs.artifact_id }} + artifact_digest: ${{ steps.wait.outputs.artifact_digest }} + artifact_name: ${{ steps.wait.outputs.artifact_name }} + compiler_run_id: ${{ steps.dispatch.outputs.compiler_run_id }} steps: - - 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: false - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '20' - - - name: Verify compiler capacity - shell: bash - run: | - set -euo pipefail - available_memory_kib="$(awk '$1 == "MemAvailable:" {print $2}' /proc/meminfo)" - minimum_memory_kib="$((6 * 1024 * 1024))" - available_disk_bytes="$(df --output=avail -B1 "$RUNNER_TEMP" | tail -n 1 | tr -d ' ')" - minimum_disk_bytes="$((10 * 1024 * 1024 * 1024))" - if (( available_memory_kib < minimum_memory_kib )); then - echo "::error::Release compiler has less than 6 GiB available memory." - exit 1 - fi - if (( available_disk_bytes < minimum_disk_bytes )); then - echo "::error::Release compiler has less than 10 GiB free under RUNNER_TEMP." - exit 1 - fi - echo "Release compiler capacity: $((available_memory_kib / 1024)) MiB memory, $((available_disk_bytes / 1024 / 1024 / 1024)) GiB disk." - - - name: Compile credential-free release payload + - name: Dispatch exact-SHA compiler workflow + id: dispatch env: - PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} - # Two bounded workers avoid the compiler-process loss seen when four - # Go links competed with the frontend build on the same VM. - PULSE_RELEASE_BUILD_JOBS: "2" - run: ./scripts/build-release-binaries.sh "${{ inputs.version }}" "$RUNNER_TEMP/release-compiled" + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + SOURCE_SHA: ${{ github.sha }} + SOURCE_REF: ${{ github.ref_name }} + REQUEST_ID: ${{ github.run_id }}-${{ github.run_attempt }} + run: | + set -euo pipefail + [[ "${GITHUB_REF}" == refs/heads/* ]] + dispatch_payload="$(jq -nc \ + --arg ref "${SOURCE_REF}" \ + --arg version "${VERSION}" \ + --arg source_sha "${SOURCE_SHA}" \ + --arg request_id "${REQUEST_ID}" \ + '{ref: $ref, inputs: {version: $version, source_sha: $source_sha, request_id: $request_id}}')" + dispatch_json="$(gh api \ + --method POST \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/compile-release-payload.yml/dispatches" \ + --input - <<<"${dispatch_payload}")" + compiler_run_id="$(jq -er '.workflow_run_id | select(type == "number")' <<<"${dispatch_json}")" + compiler_run_url="$(jq -er '.html_url | select(type == "string" and length > 0)' <<<"${dispatch_json}")" + echo "compiler_run_id=${compiler_run_id}" >> "$GITHUB_OUTPUT" + echo "Compiler workflow: ${compiler_run_url}" - - name: Upload exact-SHA compiled payload - id: upload_compiled - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-compiled-${{ github.sha }}-${{ inputs.version }} - path: ${{ runner.temp }}/release-compiled/ - if-no-files-found: error - retention-days: 1 - compression-level: 0 - overwrite: true + - name: Wait for immutable compiler artifact + id: wait + env: + GH_TOKEN: ${{ github.token }} + COMPILER_RUN_ID: ${{ steps.dispatch.outputs.compiler_run_id }} + VERSION: ${{ inputs.version }} + SOURCE_SHA: ${{ github.sha }} + SOURCE_REF: ${{ github.ref_name }} + REQUEST_ID: ${{ github.run_id }}-${{ github.run_attempt }} + run: | + set -euo pipefail + deadline="$((SECONDS + 35 * 60))" + while true; do + run_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${COMPILER_RUN_ID}")" + status="$(jq -er '.status' <<<"${run_json}")" + if [[ "${status}" == "completed" ]]; then + break + fi + if (( SECONDS >= deadline )); then + echo "::error::Compiler workflow ${COMPILER_RUN_ID} did not complete within 35 minutes." + exit 1 + fi + echo "Compiler workflow ${COMPILER_RUN_ID} is ${status}." + sleep 10 + done + if ! jq -e \ + --argjson run_id "${COMPILER_RUN_ID}" \ + --arg source_ref "${SOURCE_REF}" \ + --arg source_sha "${SOURCE_SHA}" \ + '.id == $run_id and .event == "workflow_dispatch" and .path == ".github/workflows/compile-release-payload.yml" and .head_branch == $source_ref and .head_sha == $source_sha and .conclusion == "success"' \ + <<<"${run_json}" >/dev/null; then + run_summary="$(jq -c '{id, event, path, head_branch, head_sha, status, conclusion, html_url}' <<<"${run_json}")" + echo "::error::Compiler workflow identity or result verification failed: ${run_summary}" + exit 1 + fi + + artifact_name="release-compiled-${SOURCE_SHA}-${VERSION}-${REQUEST_ID}" + artifacts_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${COMPILER_RUN_ID}/artifacts?per_page=100")" + artifact_json="$(jq -ce \ + --arg artifact_name "${artifact_name}" \ + '[.artifacts[] | select(.name == $artifact_name and .expired == false and .size_in_bytes > 0)] | if length == 1 then .[0] else error("expected exactly one compiler artifact") end' \ + <<<"${artifacts_json}")" + artifact_id="$(jq -er '.id | select(type == "number")' <<<"${artifact_json}")" + artifact_digest="$(jq -er '.digest | select(test("^sha256:[0-9a-f]{64}$")) | sub("^sha256:"; "")' <<<"${artifact_json}")" + echo "artifact_id=${artifact_id}" >> "$GITHUB_OUTPUT" + echo "artifact_digest=${artifact_digest}" >> "$GITHUB_OUTPUT" + echo "artifact_name=${artifact_name}" >> "$GITHUB_OUTPUT" signing-configuration: name: Verify Native Signing Configuration @@ -597,8 +621,8 @@ jobs: build: name: Build and Validate Release Candidate - needs: [compile-release-payload, sign-macos-agent, collect-windows-signing] - if: ${{ always() && needs.compile-release-payload.result == 'success' && (!inputs.require_macos_signing || needs.sign-macos-agent.result == 'success') && (!inputs.require_windows_signing || needs.collect-windows-signing.result == 'success') }} + needs: [obtain-release-payload, sign-macos-agent, collect-windows-signing] + if: ${{ always() && needs.obtain-release-payload.result == 'success' && (!inputs.require_macos_signing || needs.sign-macos-agent.result == 'success') && (!inputs.require_windows_signing || needs.collect-windows-signing.result == 'success') }} runs-on: ubuntu-24.04 timeout-minutes: 60 permissions: @@ -668,9 +692,10 @@ jobs: - name: Verify and download exact compiled artifact env: GH_TOKEN: ${{ github.token }} - EXPECTED_ARTIFACT_ID: ${{ needs.compile-release-payload.outputs.artifact_id }} - EXPECTED_ARTIFACT_DIGEST: ${{ needs.compile-release-payload.outputs.artifact_digest }} - EXPECTED_ARTIFACT_NAME: ${{ needs.compile-release-payload.outputs.artifact_name }} + EXPECTED_ARTIFACT_ID: ${{ needs.obtain-release-payload.outputs.artifact_id }} + EXPECTED_ARTIFACT_DIGEST: ${{ needs.obtain-release-payload.outputs.artifact_digest }} + EXPECTED_ARTIFACT_NAME: ${{ needs.obtain-release-payload.outputs.artifact_name }} + EXPECTED_COMPILER_RUN_ID: ${{ needs.obtain-release-payload.outputs.compiler_run_id }} run: | set -euo pipefail [[ "${EXPECTED_ARTIFACT_ID}" =~ ^[0-9]+$ ]] @@ -682,7 +707,7 @@ jobs: --argjson artifact_id "${EXPECTED_ARTIFACT_ID}" \ --arg artifact_name "${EXPECTED_ARTIFACT_NAME}" \ --arg artifact_digest "sha256:${EXPECTED_ARTIFACT_DIGEST}" \ - --argjson run_id "${GITHUB_RUN_ID}" \ + --argjson run_id "${EXPECTED_COMPILER_RUN_ID}" \ --arg source_sha "${GITHUB_SHA}" \ '.id == $artifact_id and .name == $artifact_name and .expired == false and .size_in_bytes > 0 and .digest == $artifact_digest and .workflow_run.id == $run_id and .workflow_run.head_sha == $source_sha' \ "${artifact_json}" >/dev/null @@ -693,9 +718,10 @@ jobs: - name: Verify exact-SHA compiled payload env: - EXPECTED_ARTIFACT_ID: ${{ needs.compile-release-payload.outputs.artifact_id }} - EXPECTED_ARTIFACT_DIGEST: ${{ needs.compile-release-payload.outputs.artifact_digest }} - EXPECTED_ARTIFACT_NAME: ${{ needs.compile-release-payload.outputs.artifact_name }} + EXPECTED_ARTIFACT_ID: ${{ needs.obtain-release-payload.outputs.artifact_id }} + EXPECTED_ARTIFACT_DIGEST: ${{ needs.obtain-release-payload.outputs.artifact_digest }} + EXPECTED_ARTIFACT_NAME: ${{ needs.obtain-release-payload.outputs.artifact_name }} + EXPECTED_COMPILER_RUN_ID: ${{ needs.obtain-release-payload.outputs.compiler_run_id }} run: | set -euo pipefail python3 scripts/release_candidate_manifest.py verify-local \ @@ -710,10 +736,11 @@ jobs: --arg artifact_name "${EXPECTED_ARTIFACT_NAME}" \ --arg artifact_sha256 "${EXPECTED_ARTIFACT_DIGEST}" \ --arg payload_manifest_sha256 "${payload_manifest_sha256}" \ - --argjson workflow_run_id "${GITHUB_RUN_ID}" \ + --argjson compiler_workflow_run_id "${EXPECTED_COMPILER_RUN_ID}" \ + --argjson release_workflow_run_id "${GITHUB_RUN_ID}" \ --arg version "${{ inputs.version }}" \ --arg source_sha "${GITHUB_SHA}" \ - '{schema_version: 1, trust_boundary: "trusted-self-hosted-compiler", verified_on: "github-hosted", artifact_id: $artifact_id, artifact_name: $artifact_name, artifact_sha256: $artifact_sha256, payload_manifest_sha256: $payload_manifest_sha256, workflow_run_id: $workflow_run_id, version: $version, source_sha: $source_sha}' \ + '{schema_version: 2, trust_boundary: "separate-trusted-self-hosted-compiler-workflow", verified_on: "github-hosted", artifact_id: $artifact_id, artifact_name: $artifact_name, artifact_sha256: $artifact_sha256, payload_manifest_sha256: $payload_manifest_sha256, compiler_workflow_run_id: $compiler_workflow_run_id, release_workflow_run_id: $release_workflow_run_id, version: $version, source_sha: $source_sha}' \ > release-candidate-manifest/compiled-payload-verification.json - name: Download signed macOS binaries diff --git a/.github/workflows/compile-release-payload.yml b/.github/workflows/compile-release-payload.yml new file mode 100644 index 000000000..6ece992ca --- /dev/null +++ b/.github/workflows/compile-release-payload.yml @@ -0,0 +1,96 @@ +name: Compile Release Payload on Trusted PC +run-name: Compile v${{ inputs.version }} payload for request ${{ inputs.request_id }} + +on: + workflow_dispatch: + inputs: + version: + description: 'Version number without the leading v' + required: true + type: string + source_sha: + description: 'Exact public Pulse source commit to compile' + required: true + type: string + request_id: + description: 'Parent release workflow run and attempt identifier' + required: true + type: string + +permissions: + contents: read + +jobs: + compile-release-payload: + name: Compile Exact-SHA Release Payload + runs-on: ${{ fromJSON('["self-hosted","Linux","X64","pulse-pve-compile"]') }} + timeout-minutes: 30 + steps: + - name: Validate isolated compiler request + env: + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + REQUEST_ID: ${{ inputs.request_id }} + run: | + set -euo pipefail + [[ "${EXPECTED_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]] + [[ "${REQUEST_ID}" =~ ^[0-9]+-[0-9]+$ ]] + test "${GITHUB_SHA}" = "${EXPECTED_SOURCE_SHA}" + test "${GITHUB_WORKFLOW_SHA}" = "${EXPECTED_SOURCE_SHA}" + + - name: Checkout exact release source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + ref: ${{ inputs.source_sha }} + + - name: Validate candidate identity + run: | + set -euo pipefail + test "$(tr -d '\n' < VERSION)" = "${{ inputs.version }}" + test "$(git rev-parse HEAD)" = "${{ inputs.source_sha }}" + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + + - name: Verify compiler capacity + shell: bash + run: | + set -euo pipefail + available_memory_kib="$(awk '$1 == "MemAvailable:" {print $2}' /proc/meminfo)" + minimum_memory_kib="$((6 * 1024 * 1024))" + available_disk_bytes="$(df --output=avail -B1 "$RUNNER_TEMP" | tail -n 1 | tr -d ' ')" + minimum_disk_bytes="$((10 * 1024 * 1024 * 1024))" + if (( available_memory_kib < minimum_memory_kib )); then + echo "::error::Release compiler has less than 6 GiB available memory." + exit 1 + fi + if (( available_disk_bytes < minimum_disk_bytes )); then + echo "::error::Release compiler has less than 10 GiB free under RUNNER_TEMP." + exit 1 + fi + echo "Release compiler capacity: $((available_memory_kib / 1024)) MiB memory, $((available_disk_bytes / 1024 / 1024 / 1024)) GiB disk." + + - name: Compile credential-free release payload + env: + PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + PULSE_RELEASE_BUILD_JOBS: "2" + run: ./scripts/build-release-binaries.sh "${{ inputs.version }}" "$RUNNER_TEMP/release-compiled" + + - name: Upload exact-SHA compiled payload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-compiled-${{ inputs.source_sha }}-${{ inputs.version }}-${{ inputs.request_id }} + path: ${{ runner.temp }}/release-compiled/ + if-no-files-found: error + retention-days: 1 + compression-level: 0 + overwrite: true diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index b8075a651..bc20caaa0 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -234,7 +234,7 @@ jobs: needs: prepare if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} permissions: - actions: read + actions: write contents: read uses: ./.github/workflows/build-release-candidate.yml secrets: inherit diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 58fb58576..9351747df 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -72,7 +72,7 @@ jobs: name: Build Immutable Release Candidate if: ${{ inputs.version != '' }} permissions: - actions: read + actions: write contents: read uses: ./.github/workflows/build-release-candidate.yml secrets: inherit diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index eff056733..ab41309d4 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -10638,7 +10638,7 @@ }, { "id": "trusted-pc-single-build-artifact-handoff", - "summary": "The project owner designated the dedicated PVE PC runners as trusted, credential-free compilers for public and private release payloads across RC, stable, and patch channels. Each payload is built once, uploaded as an immutable GitHub Actions artifact, and consumed without recompilation only after a GitHub-hosted job verifies the exact artifact id, server-recorded archive SHA-256, workflow run, head SHA, version/source inner manifest, and complete file digests. Signing and publication credentials remain hosted-only, and the SignPath request dependency chain remains entirely GitHub-hosted.", + "summary": "The project owner designated the dedicated PVE PC runners as trusted, credential-free compilers for public and private release payloads across RC, stable, and patch channels. Each payload is built once in a separately dispatched workflow run, uploaded as an immutable GitHub Actions artifact, and consumed without recompilation only after a GitHub-hosted job verifies the exact artifact id, server-recorded archive SHA-256, isolated compiler workflow run, head SHA, version/source inner manifest, and complete file digests. Signing and publication credentials remain hosted-only, and the complete SignPath workflow run contains only GitHub-hosted jobs rather than relying on a sibling-job interpretation of the Foundation rule.", "kind": "release-policy", "decided_at": "2026-08-23", "subsystem_ids": [ diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 6bb87df77..540aa7659 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -539,13 +539,19 @@ upgrade, update, release, or artifact-selection behavior. backend and browser-smoke lanes can start as soon as the bundle is available. Cross-platform compilation for every release channel runs once on the dedicated, credential-free PVE compiler identity using only public embedding - keys. The compiler must produce one exact-version, exact-source-SHA manifest + keys in a separately dispatched GitHub Actions workflow run. The release + candidate and SignPath workflow run must contain only GitHub-hosted jobs; + its hosted handoff job may dispatch and wait for the isolated compiler run, + but the self-hosted job must never appear in the signing run. The compiler + workflow must execute from the same exact workflow SHA as the parent release + and must produce one exact-version, exact-source-SHA manifest covering the complete frontend and binary payload. That manifest may cover canonical relative paths in the payload tree but must reject absolute, traversing, or noncanonical names. The upload step must expose GitHub's immutable artifact id and archive SHA-256 digest. The hosted candidate job must retrieve that exact id, fail closed unless GitHub's artifact API binds - its name, digest, workflow-run id, and head SHA to the current release run, + its name, digest, isolated compiler workflow-run id, and head SHA to the + exact release source, verify the downloaded archive digest itself, and then verify the inner payload manifest before applying required native binaries, packaging, update-signing, SBOM @@ -1640,17 +1646,22 @@ decision is limited to `v6.1.2`, retains the exact-SHA and integrity controls, and requires the public release notes to disclose that Windows binaries are not Authenticode-signed and may show Unknown Publisher. Every caller of the reusable release-candidate builder must delegate -`actions: read` alongside `contents: read`; the Windows signing job reads the -exact uploaded artifact through the GitHub Actions API, and GitHub validates -that nested permission even when a prerelease skips Authenticode signing. +`actions: write` alongside `contents: read`; only the hosted compiler-handoff +job receives that effective permission so it can dispatch the isolated PC +workflow, while the Windows signing and final assembly jobs narrow themselves +back to `actions: read`. GitHub validates that nested permission even when a +prerelease skips Authenticode signing. SignPath Foundation also requires every job leading up to an open-source signing request to execute on GitHub-hosted runners. Stable release preparation, the parallel frontend bundle, backend qualification, signing configuration, and the Windows build/submission dependency chain therefore remain GitHub-hosted regardless of an unsigned-Windows exception. The -credential-free PVE compiler is an independent sibling and never supplies the -artifact submitted to SignPath; only the hosted final assembler joins its -separately verified output after native signing. Rehearsals `32631653966` and +credential-free PVE compiler executes in an entirely separate workflow run and +never supplies the artifact submitted to SignPath. The hosted release workflow +dispatches it by exact source SHA, validates the returned compiler-run identity, +and only the hosted final assembler joins its separately verified output after +native signing. This keeps every job in the SignPath workflow run hosted rather +than relying on a sibling-job interpretation of SignPath's OSS rule. Rehearsals `32631653966` and `32635525554` lost different matrix compiler processes on the PVE runner under four-way compiler and frontend pressure. The single-build compiler now admits only a worker with explicit memory/disk headroom, limits the matrix to two diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 6d765a740..cbdc9d288 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2307,6 +2307,10 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { if err != nil { t.Fatalf("read build-release-candidate.yml: %v", err) } + compilerBytes, err := os.ReadFile(repoFile(".github", "workflows", "compile-release-payload.yml")) + if err != nil { + t.Fatalf("read compile-release-payload.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) @@ -2326,6 +2330,7 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { createWorkflow := string(createBytes) candidateWorkflow := string(candidateBytes) + compilerWorkflow := string(compilerBytes) compileScriptBytes, err := os.ReadFile(repoFile("scripts", "build-release-binaries.sh")) if err != nil { t.Fatalf("read build-release-binaries.sh: %v", err) @@ -2350,7 +2355,8 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { floatingJob := workflowJobBlock(t, convergenceWorkflow, "promote_floating_tags") helmPagesJob := workflowJobBlock(t, convergenceWorkflow, "publish_helm_pages") demoJob := workflowJobBlock(t, convergenceWorkflow, "update_stable_demo") - compileJob := workflowJobBlock(t, candidateWorkflow, "compile-release-payload") + compileJob := workflowJobBlock(t, compilerWorkflow, "compile-release-payload") + obtainPayloadJob := workflowJobBlock(t, candidateWorkflow, "obtain-release-payload") candidateBuildJob := workflowJobBlock(t, candidateWorkflow, "build") for _, needle := range []string{ @@ -2368,11 +2374,11 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { for _, needle := range []string{ `fromJSON('["self-hosted","Linux","X64","pulse-pve-compile"]')`, - `artifact_id: ${{ steps.upload_compiled.outputs.artifact-id }}`, - `artifact_digest: ${{ steps.upload_compiled.outputs.artifact-digest }}`, + `GITHUB_WORKFLOW_SHA`, + `ref: ${{ inputs.source_sha }}`, `PULSE_RELEASE_BUILD_JOBS: "2"`, `./scripts/build-release-binaries.sh "${{ inputs.version }}"`, - `release-compiled-${{ github.sha }}-${{ inputs.version }}`, + `release-compiled-${{ inputs.source_sha }}-${{ inputs.version }}-${{ inputs.request_id }}`, } { if !strings.Contains(compileJob, needle) { t.Fatalf("compiled release payload job missing exact-SHA contract: %s", needle) @@ -2381,6 +2387,21 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { if strings.Contains(compileJob, "PULSE_UPDATE_SIGNING_KEY") { t.Fatal("release compilation job must not receive private update-signing material") } + if strings.Contains(candidateWorkflow, `runs-on: ${{ fromJSON('["self-hosted"`) { + t.Fatal("SignPath release workflow must not contain a self-hosted runner job") + } + for _, needle := range []string{ + `runs-on: ubuntu-24.04`, + `actions: write`, + `actions/workflows/compile-release-payload.yml/dispatches`, + `X-GitHub-Api-Version: 2026-03-10`, + `compiler_run_id: ${{ steps.dispatch.outputs.compiler_run_id }}`, + `.path == ".github/workflows/compile-release-payload.yml"`, + } { + if !strings.Contains(obtainPayloadJob, needle) { + t.Fatalf("hosted compiler handoff job missing isolated-workflow contract: %s", needle) + } + } for label, job := range map[string]string{ "frontend bundle": frontendBundleJob, "backend tests": backendJob, @@ -2414,16 +2435,17 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { } } for _, needle := range []string{ - `needs.compile-release-payload.result == 'success'`, + `needs.obtain-release-payload.result == 'success'`, `actions: read`, - `EXPECTED_ARTIFACT_ID: ${{ needs.compile-release-payload.outputs.artifact_id }}`, - `EXPECTED_ARTIFACT_DIGEST: ${{ needs.compile-release-payload.outputs.artifact_digest }}`, + `EXPECTED_ARTIFACT_ID: ${{ needs.obtain-release-payload.outputs.artifact_id }}`, + `EXPECTED_ARTIFACT_DIGEST: ${{ needs.obtain-release-payload.outputs.artifact_digest }}`, + `EXPECTED_COMPILER_RUN_ID: ${{ needs.obtain-release-payload.outputs.compiler_run_id }}`, `actions/artifacts/${EXPECTED_ARTIFACT_ID}`, `.workflow_run.head_sha == $source_sha`, `sha256sum --check --`, `scripts/release_candidate_manifest.py verify-local`, `compiled-payload-verification.json`, - `trusted-self-hosted-compiler`, + `separate-trusted-self-hosted-compiler-workflow`, `PULSE_RELEASE_COMPILED_PAYLOAD_DIR`, } { if !strings.Contains(candidateBuildJob, needle) { diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 93857f0dc..9afd2a662 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -1270,12 +1270,12 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertRegex( workflow, r"(?ms)^ build_release_candidate:\n.*?^ permissions:\n" - r" actions: read\n contents: read\n uses: \.\/\.github\/workflows\/build-release-candidate\.yml$", + r" actions: write\n contents: read\n uses: \.\/\.github\/workflows\/build-release-candidate\.yml$", ) self.assertRegex( release_workflow, r"(?ms)^ build_release_candidate:\n.*?^ permissions:\n" - r" actions: read\n contents: read\n uses: \.\/\.github\/workflows\/build-release-candidate\.yml$", + r" actions: write\n contents: read\n uses: \.\/\.github\/workflows\/build-release-candidate\.yml$", ) self.assertIn("Definitive Dry-Run Verdict", workflow) self.assertIn('require_result "exact-SHA release candidate" "$CANDIDATE_RESULT" success', workflow) @@ -1327,6 +1327,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): 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") + compiler_workflow = read(".github/workflows/compile-release-payload.yml") qualifier_workflow = read(".github/workflows/qualify-release-containers.yml") docker_build = workflow_job_block(qualifier_workflow, "qualify") release_validator = read("scripts/validate-release.sh") @@ -1456,31 +1457,34 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertNotIn("pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", docker_build) self.assertIn("Validate installer signing key pins", candidate_workflow) self.assertIn("timeout-minutes: 60", candidate_workflow) + self.assertNotRegex(candidate_workflow, r"(?m)^\s+runs-on:.*self-hosted") self.assertIn( 'runs-on: ${{ fromJSON(\'["self-hosted","Linux","X64","pulse-pve-compile"]\') }}', + compiler_workflow, + ) + self.assertIn("GITHUB_WORKFLOW_SHA", compiler_workflow) + self.assertIn("ref: ${{ inputs.source_sha }}", compiler_workflow) + self.assertIn("PULSE_RELEASE_BUILD_JOBS: \"2\"", compiler_workflow) + self.assertIn("actions/workflows/compile-release-payload.yml/dispatches", candidate_workflow) + self.assertIn("X-GitHub-Api-Version: 2026-03-10", candidate_workflow) + self.assertIn("compiler_run_id: ${{ steps.dispatch.outputs.compiler_run_id }}", candidate_workflow) + self.assertIn('.path == ".github/workflows/compile-release-payload.yml"', candidate_workflow) + self.assertIn( + "EXPECTED_ARTIFACT_ID: ${{ needs.obtain-release-payload.outputs.artifact_id }}", candidate_workflow, ) self.assertIn( - "artifact_id: ${{ steps.upload_compiled.outputs.artifact-id }}", + "EXPECTED_ARTIFACT_DIGEST: ${{ needs.obtain-release-payload.outputs.artifact_digest }}", candidate_workflow, ) self.assertIn( - "artifact_digest: ${{ steps.upload_compiled.outputs.artifact-digest }}", - candidate_workflow, - ) - self.assertIn("PULSE_RELEASE_BUILD_JOBS: \"2\"", candidate_workflow) - self.assertIn( - "EXPECTED_ARTIFACT_ID: ${{ needs.compile-release-payload.outputs.artifact_id }}", - candidate_workflow, - ) - self.assertIn( - "EXPECTED_ARTIFACT_DIGEST: ${{ needs.compile-release-payload.outputs.artifact_digest }}", + "EXPECTED_COMPILER_RUN_ID: ${{ needs.obtain-release-payload.outputs.compiler_run_id }}", candidate_workflow, ) self.assertIn(".workflow_run.head_sha == $source_sha", candidate_workflow) self.assertIn("sha256sum --check --", candidate_workflow) self.assertIn("compiled-payload-verification.json", candidate_workflow) - self.assertIn("trusted-self-hosted-compiler", candidate_workflow) + self.assertIn("separate-trusted-self-hosted-compiler-workflow", candidate_workflow) self.assertIn("Verify Native Signing Configuration", candidate_workflow) self.assertEqual(candidate_workflow.count("needs: signing-configuration"), 2) self.assertIn("require_windows_signing: ${{ needs.prepare.outputs.require_windows_signing == 'true' }}", content)