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
This commit is contained in:
pulse-triage[bot]
2026-09-13 14:21:55 +01:00
parent f9569426f5
commit ae38b93c0c
3 changed files with 38 additions and 8 deletions
+8 -7
View File
@@ -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
@@ -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.
@@ -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",