mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Merge pull request #2074 from rcourtman/maintainer/20260913T142652Z
Keep qualified releases safe through skipped checks and cancellation
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user