From ae38b93c0c5c1ff3fe646a38a8d5b09dd674c014 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:21:55 +0100 Subject: [PATCH] fix(release): stop public writer chain on workflow cancellation Preserve successful qualification and optional skipped ancestors while rejecting workflow cancellation independently of completed needs. Cover tag, Docker, Helm, readiness, convergence dispatch and activation; retain evidence and cleanup joins. Change-source: pulse-maintainer --- .github/workflows/create-release.yml | 15 ++++++++------- .../subsystems/deployment-installability.md | 16 ++++++++++++++++ .../release_promotion_policy_test.py | 15 ++++++++++++++- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index bbc826eb1e..7bb147e550 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1417,8 +1417,9 @@ jobs: - prepare - candidate_qualification # Beta qualification deliberately permits skipped integration ancestors. - # Override implicit success(), but never bypass either direct prerequisite. - if: ${{ always() && needs.prepare.result == 'success' && needs.candidate_qualification.result == 'success' }} + # Override implicit success(), but reject workflow cancellation even after + # both direct prerequisites have succeeded. + if: ${{ !cancelled() && needs.prepare.result == 'success' && needs.candidate_qualification.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -1457,7 +1458,7 @@ jobs: - prepare - build_release_candidate - create_release - if: ${{ always() && needs.candidate_qualification.result == 'success' && needs.publish_release_tag.result == 'success' && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} + if: ${{ !cancelled() && needs.candidate_qualification.result == 'success' && needs.publish_release_tag.result == 'success' && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} permissions: contents: read packages: write @@ -1540,7 +1541,7 @@ jobs: - publish_release_tag - prepare - validate_release_assets - if: ${{ always() && needs.candidate_qualification.result == 'success' && needs.publish_release_tag.result == 'success' && 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: ${{ !cancelled() && needs.candidate_qualification.result == 'success' && needs.publish_release_tag.result == 'success' && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} permissions: contents: write packages: write @@ -1560,7 +1561,7 @@ jobs: - publish_release_tag - publish_docker - publish_helm_chart - if: ${{ always() && needs.candidate_qualification.result == 'success' && needs.publish_release_tag.result == 'success' && needs.publish_docker.result == 'success' && needs.publish_helm_chart.result == 'success' }} + if: ${{ !cancelled() && needs.candidate_qualification.result == 'success' && needs.publish_release_tag.result == 'success' && needs.publish_docker.result == 'success' && needs.publish_helm_chart.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 5 steps: @@ -1707,7 +1708,7 @@ jobs: - prepare - create_release - stage_private_pro_runtime - 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.') || needs.stage_private_pro_runtime.result == 'success' ) }} + if: ${{ !cancelled() && 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.') || needs.stage_private_pro_runtime.result == 'success' ) }} runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -1781,7 +1782,7 @@ jobs: - release_readiness - dispatch_release_convergence - stage_private_pro_runtime - if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.release_readiness.result == 'success' && needs.dispatch_release_convergence.result == 'success' }} + if: ${{ !cancelled() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.release_readiness.result == 'success' && needs.dispatch_release_convergence.result == 'success' }} continue-on-error: true runs-on: ubuntu-24.04 timeout-minutes: 15 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index fcb4c09008..c4b1ef4130 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -5479,3 +5479,19 @@ in `scripts/release_control/release_promotion_policy_test.py` models the skipped ancestor and all adverse direct-prerequisite outcomes. Existing writer tests retain the candidate-failure and draft barriers. Source validation is not hosted recovery or evidence that a previously frozen workflow has changed. + +### Workflow cancellation at the public writer boundary + +Explicit status guards for tag, Docker and Helm publication, readiness, convergence +dispatch and activation use `!cancelled()`, not `always()`. A cancelled workflow +must not start or retain these jobs merely because its prerequisites already +succeeded. Successful beta qualification still admits intentionally skipped +ancestors; every existing prerequisite and immutable-identity check is retained. +Cancellation cannot undo an external write already completed; exact-output +reconciliation remains required before recovery. + +`CandidatePublicationBoundaryTest.test_workflow_cancellation_blocks_completed_prerequisite_writers` +models workflow cancellation independently of successful needs, covers all six +boundaries and retains the final `always()` evidence/verdict join. Evidence +uploads and cleanup remain unchanged. This is source-policy regression proof, +not hosted cancellation acceptance or authorization to retry a frozen workflow. diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 1f7f5ecbcf..ffe55f628a 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -2895,7 +2895,7 @@ class CandidatePublicationBoundaryTest(unittest.TestCase): def setUp(self) -> None: self.jobs = yaml.load(read(".github/workflows/create-release.yml"), Loader=UniqueKeyLoader)["jobs"] - def condition(self, job: str, results: dict[str, str], *, draft: bool = False) -> bool: + def condition(self, job: str, results: dict[str, str], *, draft: bool = False, cancelled: bool = False) -> bool: # Execute the workflow's Boolean condition with explicit job outcomes. # This intentionally fails if the workflow adds an unsupported expression. expression = self.jobs[job]["if"].removeprefix("${{").removesuffix("}}").strip() @@ -2903,6 +2903,7 @@ class CandidatePublicationBoundaryTest(unittest.TestCase): expression = expression.replace("needs.prepare.outputs.version", repr("6.4.4-beta.9")) expression = expression.replace("needs.prepare.outputs.historical_asset_backfill_only", repr("false")) expression = expression.replace("github.event.inputs.draft_only", repr(str(draft).lower())) + expression = expression.replace("cancelled()", repr(cancelled)) expression = expression.replace("always()", "True").replace("&&", " and ").replace("||", " or ") expression = re.sub(r"!(?!=)", "not ", expression) return bool(eval(expression, {"__builtins__": {}, "startsWith": lambda value, prefix: value.startswith(prefix)})) @@ -2922,6 +2923,18 @@ class CandidatePublicationBoundaryTest(unittest.TestCase): with self.subTest(dependency=dependency, state=state): self.assertFalse(self.condition("publish_release_tag", outcomes | {dependency: state})) + def test_workflow_cancellation_blocks_completed_prerequisite_writers(self) -> None: + good = dict.fromkeys(self.jobs, "success") + for writer in ("publish_release_tag", "publish_docker", "publish_helm_chart", + "release_readiness", "dispatch_release_convergence", "activate_release"): + with self.subTest(writer=writer): + self.assertTrue(self.condition(writer, good)) + # Cancellation is workflow state, not a changed needs.result: + # all prerequisites may already have completed successfully. + self.assertFalse(self.condition(writer, good, cancelled=True)) + # Keep the final evidence/verdict join available after cancellation. + self.assertTrue(self.condition("release_commit_verdict", good, cancelled=True)) + def test_failed_candidate_cannot_reach_any_public_version_writer(self) -> None: required = { "prepare", "publication_trust_preflight", "build_release_candidate",