Preflight immutable release publication

This commit is contained in:
pulse-triage[bot]
2026-08-30 10:21:03 +01:00
parent 68887f7114
commit 9393b710da
4 changed files with 131 additions and 11 deletions
+57 -7
View File
@@ -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
+4 -1
View File
@@ -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 <tag> --repo
rcourtman/Pulse` validates GitHub's signed release attestation and `gh release
@@ -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",
@@ -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)