mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user