From 9393b710daf58ee9723d45caf190763d9fbbcb91 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:21:03 +0100 Subject: [PATCH] Preflight immutable release publication --- .github/workflows/create-release.yml | 64 +++++++++++++++++-- docs/CODE_SIGNING_POLICY.md | 5 +- .../installtests/build_release_assets_test.go | 41 +++++++++++- .../release_promotion_policy_test.py | 32 ++++++++++ 4 files changed, 131 insertions(+), 11 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 68a2d6d1c..24bd9900d 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -265,9 +265,45 @@ jobs: echo "[OK] Promotion policy validated for ${TAG}" + # Repository release immutability is configuration outside this commit. Prove + # that prerequisite on a GitHub-hosted runner before starting compilation, + # signing, private staging, or draft assembly. Activation repeats the same + # check immediately before publication so later setting drift still fails + # closed. Inert draft-only and historical-backfill runs do not publish and + # therefore do not require this repository setting. + publication_trust_preflight: + name: Publication Trust Preflight + needs: prepare + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout release trust control + if: ${{ github.event.inputs.draft_only != 'true' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }} + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Require immutable release publication capability + if: ${{ github.event.inputs.draft_only != 'true' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }} + env: + GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::WORKFLOW_PAT with repository Administration (read) is required to prove release immutability." + exit 1 + fi + ./scripts/check-github-release-immutability.sh "${GITHUB_REPOSITORY}" + + - name: Confirm inert release mode + if: ${{ github.event.inputs.draft_only == 'true' || needs.prepare.outputs.historical_asset_backfill_only == 'true' }} + run: echo "Publication trust preflight is not required for an inert draft-only or historical-backfill run." + build_release_candidate: name: Build Immutable Release Candidate - needs: prepare + needs: + - prepare + - publication_trust_preflight if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} permissions: actions: write @@ -297,7 +333,9 @@ jobs: # Build the embed bundle independently so backend and smoke lanes can start # without waiting for the full frontend quality suite. frontend_bundle: - needs: prepare + needs: + - prepare + - publication_trust_preflight if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: ${{ !contains(inputs.version, '-') && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","pulse-pve-build"]') }} timeout-minutes: 10 @@ -330,7 +368,9 @@ jobs: # Frontend checks run independently from the bundle and backend lanes. frontend_checks: - needs: prepare + needs: + - prepare + - publication_trust_preflight if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 20 @@ -367,7 +407,9 @@ jobs: windows_install_command_smoke: name: Windows PowerShell 5.1 Install Command Smoke - needs: prepare + needs: + - prepare + - publication_trust_preflight if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: windows-2025 timeout-minutes: 15 @@ -702,7 +744,9 @@ jobs: retention-days: 14 release_note_visuals: - needs: prepare + needs: + - prepare + - publication_trust_preflight if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 45 @@ -1188,6 +1232,7 @@ jobs: backfill_release_assets: needs: - prepare + - publication_trust_preflight if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 30 @@ -1348,6 +1393,7 @@ jobs: release_readiness: needs: - prepare + - publication_trust_preflight - build_release_candidate - qualify_release_containers - frontend_bundle @@ -1362,7 +1408,7 @@ jobs: - install_sh_smoke - publish_helm_chart - stage_private_pro_runtime - if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.qualify_release_containers.result == 'success' && needs.frontend_bundle.result == 'success' && needs.frontend_checks.result == 'success' && needs.windows_install_command_smoke.result == 'success' && needs.backend_tests.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') && needs.create_release.result == 'success' && needs.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.install_sh_smoke.result == 'success' && needs.publish_helm_chart.result == 'success' && ( !startsWith(needs.prepare.outputs.version, '6.') || needs.stage_private_pro_runtime.result == 'success' ) && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} + if: ${{ always() && needs.prepare.result == 'success' && needs.publication_trust_preflight.result == 'success' && needs.build_release_candidate.result == 'success' && needs.qualify_release_containers.result == 'success' && needs.frontend_bundle.result == 'success' && needs.frontend_checks.result == 'success' && needs.windows_install_command_smoke.result == 'success' && needs.backend_tests.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') && needs.create_release.result == 'success' && needs.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.install_sh_smoke.result == 'success' && needs.publish_helm_chart.result == 'success' && ( !startsWith(needs.prepare.outputs.version, '6.') || needs.stage_private_pro_runtime.result == 'success' ) && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 5 steps: @@ -1376,7 +1422,8 @@ jobs: stage_private_pro_runtime: needs: - prepare - if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }} + - publication_trust_preflight + if: ${{ always() && needs.prepare.result == 'success' && needs.publication_trust_preflight.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: 120 outputs: @@ -1904,6 +1951,7 @@ jobs: name: Release Activation Commit Verdict needs: - prepare + - publication_trust_preflight - release_smoke - windows_install_command_smoke - create_release @@ -1931,6 +1979,7 @@ jobs: TAG: ${{ needs.prepare.outputs.tag }} EXPECTED_COMMIT: ${{ needs.create_release.outputs.target_commitish }} RELEASE_ID: ${{ needs.create_release.outputs.release_id }} + PUBLICATION_TRUST_RESULT: ${{ needs.publication_trust_preflight.result }} CREATE_RESULT: ${{ needs.create_release.result }} SMOKE_RESULT: ${{ needs.release_smoke.result }} WINDOWS_INSTALL_COMMAND_RESULT: ${{ needs.windows_install_command_smoke.result }} @@ -1959,6 +2008,7 @@ jobs: fi } + require_result "publication trust preflight" "$PUBLICATION_TRUST_RESULT" success require_result "release smoke" "$SMOKE_RESULT" success require_result "Windows install command smoke" "$WINDOWS_INSTALL_COMMAND_RESULT" success require_result "release staging" "$CREATE_RESULT" success diff --git a/docs/CODE_SIGNING_POLICY.md b/docs/CODE_SIGNING_POLICY.md index 37af8aeb0..594136ceb 100644 --- a/docs/CODE_SIGNING_POLICY.md +++ b/docs/CODE_SIGNING_POLICY.md @@ -89,7 +89,10 @@ Normal stable publication and stable dry runs select `signpath` directly. - Every new release is assembled and validated as a draft. Its activation marker is uploaded and digest-checked before publication; GitHub must then report the published release as immutable, protecting its tag and complete - asset set from replacement. + asset set from replacement. A GitHub-hosted publication preflight proves the + repository setting before compilation, signing, private staging, or draft + assembly begins, and activation repeats that setting check immediately before + publication to catch later drift. - Customer-facing image aliases, Helm indexes, paid-runtime pointers, and demo environments are not promoted until `gh release verify --repo rcourtman/Pulse` validates GitHub's signed release attestation and `gh release diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 3805ea06f..f47f0619f 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2774,6 +2774,7 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { } createJob := workflowJobBlock(t, createWorkflow, "create_release") prepareJob := workflowJobBlock(t, createWorkflow, "prepare") + publicationPreflightJob := workflowJobBlock(t, createWorkflow, "publication_trust_preflight") frontendBundleJob := workflowJobBlock(t, createWorkflow, "frontend_bundle") backendJob := workflowJobBlock(t, createWorkflow, "backend_tests") integrationJob := workflowJobBlock(t, createWorkflow, "integration_tests") @@ -2782,6 +2783,7 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { readinessJob := workflowJobBlock(t, createWorkflow, "release_readiness") dispatchJob := workflowJobBlock(t, createWorkflow, "dispatch_release_convergence") activationJob := workflowJobBlock(t, createWorkflow, "activate_release") + commitVerdictJob := workflowJobBlock(t, createWorkflow, "release_commit_verdict") leaseJob := workflowJobBlock(t, convergenceWorkflow, "acquire_customer_promotion_lease") privatePromotionJob := workflowJobBlock(t, convergenceWorkflow, "promote_private_pro_runtime") floatingJob := workflowJobBlock(t, convergenceWorkflow, "promote_floating_tags") @@ -2803,13 +2805,37 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { if strings.Contains(prepareJob, "sparse-checkout") { t.Fatal("release preparation must leave a complete worktree for the following compile job") } + for _, needle := range []string{ + `runs-on: ubuntu-24.04`, + `GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}`, + `./scripts/check-github-release-immutability.sh "${GITHUB_REPOSITORY}"`, + `github.event.inputs.draft_only != 'true'`, + `historical_asset_backfill_only != 'true'`, + } { + if !strings.Contains(publicationPreflightJob, needle) { + t.Fatalf("publication trust preflight missing early immutable-setting contract: %s", needle) + } + } + for label, job := range map[string]string{ + "release candidate": workflowJobBlock(t, createWorkflow, "build_release_candidate"), + "frontend bundle": frontendBundleJob, + "frontend checks": workflowJobBlock(t, createWorkflow, "frontend_checks"), + "Windows smoke": workflowJobBlock(t, createWorkflow, "windows_install_command_smoke"), + "release-note visuals": workflowJobBlock(t, createWorkflow, "release_note_visuals"), + "private Pro staging": privateStageJob, + } { + if !strings.Contains(job, "- publication_trust_preflight") { + t.Fatalf("%s must wait for publication trust preflight", label) + } + } for _, needle := range []string{ `fromJSON('["self-hosted","Linux","X64","pulse-pve-compile"]')`, `GITHUB_WORKFLOW_SHA`, `ref: ${{ inputs.source_sha }}`, `PULSE_RELEASE_BUILD_JOBS: "2"`, - `./scripts/build-release-binaries.sh "${{ inputs.version }}"`, + `VERSION: ${{ inputs.version }}`, + `./scripts/build-release-binaries.sh "${VERSION}" "$RUNNER_TEMP/release-compiled"`, `release-compiled-${{ inputs.source_sha }}-${{ inputs.version }}-${{ inputs.request_id }}`, } { if !strings.Contains(compileJob, needle) { @@ -2886,8 +2912,9 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { } for _, needle := range []string{ - `./scripts/build-release.sh "${{ inputs.version }}"`, - `scripts/validate-release.sh "${{ inputs.version }}" --skip-docker`, + `VERSION: ${{ inputs.version }}`, + `./scripts/build-release.sh "${VERSION}"`, + `scripts/validate-release.sh "${VERSION}" --skip-docker`, `scripts/release_candidate_manifest.py create`, `compression-level: 0`, `retention-days: 1`, @@ -2956,6 +2983,7 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { } } for _, dependency := range []string{ + "- publication_trust_preflight", "- create_release", "- publish_docker", "- validate_release_assets", @@ -2967,6 +2995,13 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { t.Fatalf("immutable release readiness missing dependency: %s", dependency) } } + if !strings.Contains(readinessJob, `needs.publication_trust_preflight.result == 'success'`) { + t.Fatal("immutable release readiness must require successful publication trust preflight") + } + if !strings.Contains(commitVerdictJob, "- publication_trust_preflight") || + !strings.Contains(commitVerdictJob, `require_result "publication trust preflight"`) { + t.Fatal("release commit verdict must surface publication trust preflight failure") + } for _, forbiddenDependency := range []string{ "- publish_helm_pages", "- promote_floating_tags", diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index e3b50a1b1..4537029d8 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -347,6 +347,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): def test_release_commit_dispatches_durable_convergence_before_activation(self) -> None: workflow = read(".github/workflows/create-release.yml") convergence = read(".github/workflows/release-convergence.yml") + publication_preflight = workflow_job_block( + workflow, "publication_trust_preflight" + ) readiness = workflow_job_block(workflow, "release_readiness") dispatch = workflow_job_block(workflow, "dispatch_release_convergence") activation = workflow_job_block(workflow, "activate_release") @@ -354,7 +357,29 @@ class ReleasePromotionPolicyTest(unittest.TestCase): recovery = read(".github/workflows/recover-release-activation.yml") recovery_activation = workflow_job_block(recovery, "recover_activation") + self.assertIn("runs-on: ubuntu-24.04", publication_preflight) + self.assertIn("secrets.WORKFLOW_PAT", publication_preflight) + self.assertIn( + "check-github-release-immutability.sh", publication_preflight + ) + self.assertIn("github.event.inputs.draft_only != 'true'", publication_preflight) + self.assertIn( + "historical_asset_backfill_only != 'true'", publication_preflight + ) + for early_job_name in ( + "build_release_candidate", + "frontend_bundle", + "frontend_checks", + "windows_install_command_smoke", + "release_note_visuals", + "stage_private_pro_runtime", + ): + with self.subTest(early_job_name=early_job_name): + early_job = workflow_job_block(workflow, early_job_name) + self.assertIn("- publication_trust_preflight", early_job) + for dependency in ( + "publication_trust_preflight", "create_release", "publish_docker", "validate_release_assets", @@ -377,6 +402,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("needs: acquire_customer_promotion_lease", mutable) self.assertIn("- release_readiness", activation) + self.assertIn( + "needs.publication_trust_preflight.result == 'success'", readiness + ) + self.assertIn("- publication_trust_preflight", commit_verdict) + self.assertIn( + 'require_result "publication trust preflight"', commit_verdict + ) self.assertIn("- dispatch_release_convergence", activation) self.assertNotIn("- release_readiness", dispatch) self.assertIn("- create_release", dispatch)