Merge pull request #2074 from rcourtman/maintainer/20260913T142652Z

Keep qualified releases safe through skipped checks and cancellation
This commit is contained in:
pulse-triage[bot]
2026-09-13 15:06:07 +00:00
committed by GitHub
3 changed files with 69 additions and 7 deletions
+9 -6
View File
@@ -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
@@ -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.
@@ -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",