diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 5373052731..7bb147e550 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1416,7 +1416,10 @@ jobs: needs: - prepare - candidate_qualification - if: ${{ needs.candidate_qualification.result == 'success' }} + # Beta qualification deliberately permits skipped integration ancestors. + # 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: @@ -1455,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 @@ -1538,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 @@ -1558,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: @@ -1705,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: @@ -1779,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 54f50653c0..c4b1ef4130 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -5464,3 +5464,34 @@ draft shell with recording fake APIs. It must reject changed/unknown/branch historical targets before any API mutation, admit same-identity recovery and private replacement, and preserve activated/published refusals. This is local workflow proof, not publication or installed acceptance. + +### Qualified publication after optional skipped checks + +The public tag writer must use an explicit status function and require both +preparation and candidate qualification to succeed. Beta policy can intentionally +skip integration tests upstream of qualification; GitHub's implicit `success()` +must not suppress tag publication after the qualification join accepts that +skip. Failure, cancellation or skipping of either direct prerequisite must still +block publication. This changes no qualification requirement or immutable identity. + +Regression: `CandidatePublicationBoundaryTest.test_qualified_beta_tag_survives_intentionally_skipped_ancestor` +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 418e9db2f9..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,10 +2903,38 @@ 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)})) + def test_qualified_beta_tag_survives_intentionally_skipped_ancestor(self) -> None: + outcomes = dict.fromkeys(self.jobs, "success") + outcomes["integration_tests"] = "skipped" + self.assertTrue(self.condition("candidate_qualification", outcomes)) + # Actions applies implicit success() when no status function is present. + # A skipped ancestor therefore prevents the writer despite the explicit + # qualification result. Model that status gate as well as its expression. + writer = self.jobs["publish_release_tag"] + has_status = bool(re.search(r"\b(always|success|failure|cancelled)\(", writer["if"])) + self.assertTrue(has_status and self.condition("publish_release_tag", outcomes)) + for dependency in writer["needs"]: + for state in ("failure", "cancelled", "skipped"): + 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",