mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 12:47:49 +00:00
Fold the echo-only release joins into their public writers
candidate_qualification and release_readiness only restated other jobs' results. publish_release_tag, publish_docker, publish_helm_chart and activate_release now each carry the exact candidate predicate in their own needs and if, guarded by !cancelled() with the same allowed integration skip, and activation joins the tag and both registry publications directly. The commit verdict restates every candidate result in place of the readiness join. recover-release-activation.yml accepts both shapes. A source run that has release_readiness still has to prove only that join, as before. A run without it has to prove publish_release_tag and at least one, and only successful, jobs under the publish_docker and publish_helm_chart caller IDs. Failure diagnostics and the inspection-only Helm chart artifact, which nothing downloads, are kept for three days instead of 14 and 90.
This commit is contained in:
parent
6448ea3816
commit
6ce41ae9d4
8 changed files with 346 additions and 131 deletions
136
.github/workflows/create-release.yml
vendored
136
.github/workflows/create-release.yml
vendored
|
|
@ -724,7 +724,7 @@ jobs:
|
|||
name: release-integration-playwright-report
|
||||
path: tests/integration/playwright-report/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
retention-days: 3
|
||||
|
||||
- name: Upload integration failures
|
||||
if: failure()
|
||||
|
|
@ -735,7 +735,7 @@ jobs:
|
|||
tests/integration/test-results/
|
||||
tests/integration/release-integration-diagnostics/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
retention-days: 3
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
|
|
@ -846,7 +846,7 @@ jobs:
|
|||
tests/integration/test-results/
|
||||
tests/integration/playwright-report/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
retention-days: 3
|
||||
|
||||
release_note_visuals:
|
||||
needs:
|
||||
|
|
@ -899,7 +899,8 @@ jobs:
|
|||
- build_release_candidate
|
||||
- release_note_visuals
|
||||
# Only restricted draft metadata/assets are staged here. No public Git tag
|
||||
# is created until candidate_qualification passes every prepublication check.
|
||||
# is created until publish_release_tag's candidate predicate passes every
|
||||
# prepublication check.
|
||||
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.release_note_visuals.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
|
|
@ -1427,7 +1428,13 @@ jobs:
|
|||
echo "Release: ${WORKFLOW_OUTPUT_1}"
|
||||
|
||||
# Restricted candidate checks finish before the first public versioned write.
|
||||
candidate_qualification:
|
||||
# Every public writer below repeats this exact candidate predicate instead
|
||||
# of trusting an echo-only join, so no writer can start unless every
|
||||
# exact-source candidate check succeeded. Beta qualification deliberately
|
||||
# permits skipped integration ancestors, so the predicate overrides the
|
||||
# implicit success() with !cancelled(): skips are judged explicitly, and
|
||||
# workflow cancellation is rejected even after every prerequisite succeeded.
|
||||
publish_release_tag:
|
||||
needs:
|
||||
- prepare
|
||||
- publication_trust_preflight
|
||||
|
|
@ -1443,21 +1450,7 @@ jobs:
|
|||
- validate_release_assets
|
||||
- install_sh_smoke
|
||||
- stage_private_pro_runtime
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.publication_trust_preflight.result == 'success' && needs.build_release_candidate.result == 'success' && needs.qualify_release_containers.result == 'success' && needs.frontend_bundle.result == 'success' && needs.frontend_checks.result == 'success' && needs.windows_install_command_smoke.result == 'success' && needs.backend_tests.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') && needs.create_release.result == 'success' && needs.validate_release_assets.result == 'success' && needs.install_sh_smoke.result == 'success' && ( !startsWith(needs.prepare.outputs.version, '6.') || needs.stage_private_pro_runtime.result == 'success' ) && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Confirm candidate qualification before public exposure
|
||||
run: echo "All exact-source candidate checks passed. Public version publication may begin."
|
||||
|
||||
publish_release_tag:
|
||||
needs:
|
||||
- prepare
|
||||
- candidate_qualification
|
||||
# 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' }}
|
||||
if: ${{ !cancelled() && needs.prepare.result == 'success' && needs.publication_trust_preflight.result == 'success' && needs.build_release_candidate.result == 'success' && needs.qualify_release_containers.result == 'success' && needs.frontend_bundle.result == 'success' && needs.frontend_checks.result == 'success' && needs.windows_install_command_smoke.result == 'success' && needs.backend_tests.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') && needs.create_release.result == 'success' && needs.validate_release_assets.result == 'success' && needs.install_sh_smoke.result == 'success' && ( !startsWith(needs.prepare.outputs.version, '6.') || needs.stage_private_pro_runtime.result == 'success' ) && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
|
|
@ -1491,12 +1484,22 @@ jobs:
|
|||
|
||||
publish_docker:
|
||||
needs:
|
||||
- candidate_qualification
|
||||
- publish_release_tag
|
||||
- prepare
|
||||
- publication_trust_preflight
|
||||
- build_release_candidate
|
||||
- qualify_release_containers
|
||||
- frontend_bundle
|
||||
- frontend_checks
|
||||
- windows_install_command_smoke
|
||||
- backend_tests
|
||||
- integration_tests
|
||||
- release_smoke
|
||||
- create_release
|
||||
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' }}
|
||||
- validate_release_assets
|
||||
- install_sh_smoke
|
||||
- stage_private_pro_runtime
|
||||
- publish_release_tag
|
||||
if: ${{ !cancelled() && needs.prepare.result == 'success' && needs.publication_trust_preflight.result == 'success' && needs.build_release_candidate.result == 'success' && needs.qualify_release_containers.result == 'success' && needs.frontend_bundle.result == 'success' && needs.frontend_checks.result == 'success' && needs.windows_install_command_smoke.result == 'success' && needs.backend_tests.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') && needs.create_release.result == 'success' && needs.validate_release_assets.result == 'success' && needs.install_sh_smoke.result == 'success' && ( !startsWith(needs.prepare.outputs.version, '6.') || needs.stage_private_pro_runtime.result == 'success' ) && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && needs.publish_release_tag.result == 'success' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
|
@ -1575,11 +1578,22 @@ jobs:
|
|||
# release has not crossed the operator-controlled publication boundary.
|
||||
publish_helm_chart:
|
||||
needs:
|
||||
- candidate_qualification
|
||||
- publish_release_tag
|
||||
- prepare
|
||||
- publication_trust_preflight
|
||||
- build_release_candidate
|
||||
- qualify_release_containers
|
||||
- frontend_bundle
|
||||
- frontend_checks
|
||||
- windows_install_command_smoke
|
||||
- backend_tests
|
||||
- integration_tests
|
||||
- release_smoke
|
||||
- create_release
|
||||
- validate_release_assets
|
||||
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' }}
|
||||
- install_sh_smoke
|
||||
- stage_private_pro_runtime
|
||||
- publish_release_tag
|
||||
if: ${{ !cancelled() && needs.prepare.result == 'success' && needs.publication_trust_preflight.result == 'success' && needs.build_release_candidate.result == 'success' && needs.qualify_release_containers.result == 'success' && needs.frontend_bundle.result == 'success' && needs.frontend_checks.result == 'success' && needs.windows_install_command_smoke.result == 'success' && needs.backend_tests.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') && needs.create_release.result == 'success' && needs.validate_release_assets.result == 'success' && needs.install_sh_smoke.result == 'success' && ( !startsWith(needs.prepare.outputs.version, '6.') || needs.stage_private_pro_runtime.result == 'success' ) && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && needs.publish_release_tag.result == 'success' }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
|
@ -1591,21 +1605,6 @@ jobs:
|
|||
chart_version: ${{ needs.prepare.outputs.version }}
|
||||
app_version: ${{ needs.prepare.outputs.version }}
|
||||
|
||||
# Candidate qualification owns prepublication checks. This final join adds
|
||||
# verified public registry digests before GitHub release activation.
|
||||
release_readiness:
|
||||
needs:
|
||||
- candidate_qualification
|
||||
- publish_release_tag
|
||||
- publish_docker
|
||||
- publish_helm_chart
|
||||
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:
|
||||
- name: Confirm immutable release readiness
|
||||
run: echo "Qualified source and verified public artifacts are ready for release activation."
|
||||
|
||||
# Stage the exact private Pro image and signed R2 packet from the anticipated
|
||||
# tag and immutable public SHA as soon as preparation succeeds. These assets
|
||||
# remain inert until public readiness and activation allow the separate
|
||||
|
|
@ -1811,16 +1810,31 @@ jobs:
|
|||
# draft. Publishing that complete packet is the irreversible commit: GitHub
|
||||
# must lock its tag/assets and issue a verifiable release attestation before
|
||||
# customer convergence may use the marker.
|
||||
#
|
||||
# Activation joins the candidate predicate and verified public registry
|
||||
# digests directly: the tag, both Docker images and the Helm chart must all
|
||||
# have published before the GitHub release may be activated.
|
||||
activate_release:
|
||||
needs:
|
||||
- prepare
|
||||
- publication_trust_preflight
|
||||
- build_release_candidate
|
||||
- qualify_release_containers
|
||||
- frontend_bundle
|
||||
- frontend_checks
|
||||
- windows_install_command_smoke
|
||||
- backend_tests
|
||||
- integration_tests
|
||||
- release_smoke
|
||||
- create_release
|
||||
- validate_release_assets
|
||||
- install_sh_smoke
|
||||
- stage_private_pro_runtime
|
||||
- publish_release_tag
|
||||
- publish_docker
|
||||
- publish_helm_chart
|
||||
- release_readiness
|
||||
- dispatch_release_convergence
|
||||
- stage_private_pro_runtime
|
||||
if: ${{ !cancelled() && 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.publication_trust_preflight.result == 'success' && needs.build_release_candidate.result == 'success' && needs.qualify_release_containers.result == 'success' && needs.frontend_bundle.result == 'success' && needs.frontend_checks.result == 'success' && needs.windows_install_command_smoke.result == 'success' && needs.backend_tests.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') && needs.create_release.result == 'success' && needs.validate_release_assets.result == 'success' && needs.install_sh_smoke.result == 'success' && ( !startsWith(needs.prepare.outputs.version, '6.') || needs.stage_private_pro_runtime.result == 'success' ) && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && needs.publish_release_tag.result == 'success' && needs.publish_docker.result == 'success' && needs.publish_helm_chart.result == 'success' && needs.dispatch_release_convergence.result == 'success' }}
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
|
|
@ -2186,15 +2200,21 @@ jobs:
|
|||
needs:
|
||||
- prepare
|
||||
- publication_trust_preflight
|
||||
- release_smoke
|
||||
- build_release_candidate
|
||||
- qualify_release_containers
|
||||
- frontend_bundle
|
||||
- frontend_checks
|
||||
- windows_install_command_smoke
|
||||
- backend_tests
|
||||
- integration_tests
|
||||
- release_smoke
|
||||
- create_release
|
||||
- publish_docker
|
||||
- validate_release_assets
|
||||
- install_sh_smoke
|
||||
- publish_helm_chart
|
||||
- release_readiness
|
||||
- stage_private_pro_runtime
|
||||
- publish_release_tag
|
||||
- publish_docker
|
||||
- publish_helm_chart
|
||||
- dispatch_release_convergence
|
||||
- activate_release
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
||||
|
|
@ -2222,7 +2242,13 @@ jobs:
|
|||
VALIDATE_RESULT: ${{ needs.validate_release_assets.result }}
|
||||
INSTALL_RESULT: ${{ needs.install_sh_smoke.result }}
|
||||
HELM_RESULT: ${{ needs.publish_helm_chart.result }}
|
||||
READINESS_RESULT: ${{ needs.release_readiness.result }}
|
||||
BUILD_CANDIDATE_RESULT: ${{ needs.build_release_candidate.result }}
|
||||
CONTAINER_QUALIFICATION_RESULT: ${{ needs.qualify_release_containers.result }}
|
||||
FRONTEND_BUNDLE_RESULT: ${{ needs.frontend_bundle.result }}
|
||||
FRONTEND_CHECKS_RESULT: ${{ needs.frontend_checks.result }}
|
||||
BACKEND_RESULT: ${{ needs.backend_tests.result }}
|
||||
INTEGRATION_RESULT: ${{ needs.integration_tests.result }}
|
||||
TAG_RESULT: ${{ needs.publish_release_tag.result }}
|
||||
PRIVATE_PRO_STAGE_RESULT: ${{ needs.stage_private_pro_runtime.result }}
|
||||
CONVERGENCE_DISPATCH_RESULT: ${{ needs.dispatch_release_convergence.result }}
|
||||
CONVERGENCE_RUN_ID: ${{ needs.dispatch_release_convergence.outputs.run_id }}
|
||||
|
|
@ -2255,7 +2281,17 @@ jobs:
|
|||
require_result "exact-version Docker staging" "$DOCKER_RESULT" success
|
||||
require_result "staged install.sh smoke" "$INSTALL_RESULT" success
|
||||
require_result "Helm staging" "$HELM_RESULT" success
|
||||
require_result "immutable release readiness" "$READINESS_RESULT" success
|
||||
# The candidate predicate the public writers carry, restated as
|
||||
# an explicit verdict now that no echo-only join summarizes it.
|
||||
require_result "release candidate build" "$BUILD_CANDIDATE_RESULT" success
|
||||
require_result "exact-candidate container qualification" "$CONTAINER_QUALIFICATION_RESULT" success
|
||||
require_result "frontend bundle" "$FRONTEND_BUNDLE_RESULT" success
|
||||
require_result "frontend checks" "$FRONTEND_CHECKS_RESULT" success
|
||||
require_result "backend tests" "$BACKEND_RESULT" success
|
||||
if [ "$INTEGRATION_RESULT" != "skipped" ]; then
|
||||
require_result "integration tests" "$INTEGRATION_RESULT" success
|
||||
fi
|
||||
require_result "qualified tag publication" "$TAG_RESULT" success
|
||||
require_result "durable customer convergence dispatch" "$CONVERGENCE_DISPATCH_RESULT" success
|
||||
if [[ "$VERSION" =~ -rc\.[1-9][0-9]*$ ]]; then
|
||||
if [[ ! "$SECURE_RUNTIME_QUALIFICATION_RUN_ID" =~ ^[0-9]+$ ]] || \
|
||||
|
|
|
|||
2
.github/workflows/publish-docker.yml
vendored
2
.github/workflows/publish-docker.yml
vendored
|
|
@ -3,7 +3,7 @@ run-name: Publish Docker Images ${{ inputs.tag }}
|
|||
|
||||
# Called only after exact candidate qualification and public tag creation.
|
||||
# Versioned registry images are public release surfaces, not private staging.
|
||||
# The release-readiness join verifies published digests before activation.
|
||||
# activate_release joins the published digests before activation.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
|
|
|
|||
3
.github/workflows/publish-helm-chart.yml
vendored
3
.github/workflows/publish-helm-chart.yml
vendored
|
|
@ -163,6 +163,9 @@ jobs:
|
|||
with:
|
||||
name: pulse-chart-${{ steps.versions.outputs.chart_version }}
|
||||
path: dist/pulse-${{ steps.versions.outputs.chart_version }}.tgz
|
||||
# Inspection copy only. The chart is published to GHCR and Pages, and
|
||||
# no job or workflow downloads this artifact.
|
||||
retention-days: 3
|
||||
|
||||
- name: Authenticate with GHCR
|
||||
env:
|
||||
|
|
|
|||
28
.github/workflows/recover-release-activation.yml
vendored
28
.github/workflows/recover-release-activation.yml
vendored
|
|
@ -78,14 +78,32 @@ jobs:
|
|||
"repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RELEASE_RUN_ID}/jobs?per_page=100" \
|
||||
| jq '[.[].jobs[]]' > "${source_jobs}"
|
||||
# release_readiness is the canonical DAG join for every immutable
|
||||
# gate. Do not duplicate reusable-workflow display names here: those
|
||||
# names are presentation details and can change while the join stays
|
||||
# authoritative. The surrounding all-jobs verdict below still
|
||||
# rejects every failure outside the activation boundary.
|
||||
# gate in runs made before the join was folded into its writers.
|
||||
# Such a run must still prove that join and nothing else, so it
|
||||
# recovers exactly as before. A run without that job carries the
|
||||
# candidate predicate on publish_release_tag itself, and must prove
|
||||
# the tag, every publish_docker reusable job and the Helm chart
|
||||
# publication that the join used to require. Reusable jobs are
|
||||
# matched by caller ID prefix, and at least one must exist. The
|
||||
# surrounding all-jobs verdict below still rejects every failure
|
||||
# outside the activation boundary.
|
||||
immutable_join=release_readiness
|
||||
if ! jq -e 'any(.[]; .name == "release_readiness")' "${source_jobs}" >/dev/null; then
|
||||
immutable_join=publish_release_tag
|
||||
for reusable_job in publish_docker publish_helm_chart; do
|
||||
jq -e --arg name "${reusable_job}" '
|
||||
[.[] | select(.name == $name or (.name | startswith($name + " / ")))] |
|
||||
length > 0 and all(.[]; .status == "completed" and .conclusion == "success")' \
|
||||
"${source_jobs}" >/dev/null || {
|
||||
echo "::error::Source run lacks successful immutable gate: ${reusable_job}."
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
fi
|
||||
for required_job in \
|
||||
prepare \
|
||||
create_release \
|
||||
release_readiness \
|
||||
"${immutable_join}" \
|
||||
dispatch_release_convergence; do
|
||||
jq -e --arg name "${required_job}" \
|
||||
'any(.[]; .name == $name and .status == "completed" and .conclusion == "success")' \
|
||||
|
|
|
|||
|
|
@ -641,14 +641,18 @@ without the other lanes changing the candidate underneath it.
|
|||
already proven before upload. Manual and release-edit repair validation may
|
||||
retain the full-download fallback when no same-run candidate manifest exists.
|
||||
4. Restricted draft asset verification and staged install smoke join container,
|
||||
frontend, backend, integration and private Pro qualification at
|
||||
`candidate_qualification`. A failed, cancelled, or missing required check
|
||||
prevents public Git tags, versioned Docker images and Helm charts from being
|
||||
created. A draft-only run never crosses this boundary. Public versioned
|
||||
artifacts are publication, even before the GitHub release is announced.
|
||||
After candidate qualification, publish the exact Git tag without rewriting
|
||||
any existing identity, then publish and verify Docker and Helm artifacts.
|
||||
`release_readiness` joins those verified public digests before activation.
|
||||
frontend, backend, integration and private Pro qualification in one
|
||||
candidate predicate that every public writer (`publish_release_tag`,
|
||||
`publish_docker`, `publish_helm_chart` and `activate_release`) carries in
|
||||
its own `needs` and `if`. There is no echo-only join job. A failed,
|
||||
cancelled, or missing required check prevents public Git tags, versioned
|
||||
Docker images and Helm charts from being created. A draft-only run never
|
||||
crosses this boundary. Public versioned artifacts are publication, even
|
||||
before the GitHub release is announced. After candidate qualification,
|
||||
publish the exact Git tag without rewriting any existing identity, then
|
||||
publish and verify Docker and Helm artifacts. `activate_release` joins the
|
||||
tag and those verified public digests directly before activation, and the
|
||||
commit verdict restates every candidate result.
|
||||
A draft with no exposed tag or versioned artifacts may be repaired under its
|
||||
intended version. An already exposed version must retain its source identity,
|
||||
even if its GitHub release remains a draft. Publication failures after first
|
||||
|
|
|
|||
|
|
@ -1461,15 +1461,21 @@ artifact-selection behaviour.
|
|||
bound to the anticipated exact 40-character source SHA, verifies that SHA is
|
||||
reachable from the governed release branch, and rejects an existing tag at
|
||||
any other commit. Exact-version registry tags are public publication surfaces.
|
||||
The `candidate_qualification` join must require all exact-source candidate
|
||||
checks, including container qualification, draft validation, installer smoke
|
||||
and private Pro qualification, before the first public Git tag, Docker tag or
|
||||
Helm chart write. Restricted drafts bind `target_commitish` without pushing
|
||||
The candidate predicate must require all exact-source candidate checks,
|
||||
including container qualification, draft validation, installer smoke and
|
||||
private Pro qualification, before the first public Git tag, Docker tag or
|
||||
Helm chart write. `publish_release_tag`, `publish_docker`,
|
||||
`publish_helm_chart` and `activate_release` each carry that exact predicate,
|
||||
guarded by `!cancelled()` so integration skips are judged explicitly and
|
||||
workflow cancellation still blocks every writer. No echo-only join job may
|
||||
stand in for it. Restricted drafts bind `target_commitish` without pushing
|
||||
a Git ref and do not retain checkout credentials. Only the qualified Git-tag
|
||||
publication job retains credentials for its authenticated ref write. Draft
|
||||
state never authorizes rewriting an existing public tag.
|
||||
The `release_readiness` join then requires verified Docker and Helm digests
|
||||
before activation or floating-alias promotion. A failure before qualification
|
||||
`activate_release` then requires the published tag and verified Docker and
|
||||
Helm digests directly before activation or floating-alias promotion, and
|
||||
`release_commit_verdict` restates every candidate result in place of the
|
||||
former readiness join. A failure before qualification
|
||||
leaves the unexposed candidate repairable under its intended version. A
|
||||
failure during public distribution retains the exposed source identity for
|
||||
recovery or a clearly explained successor, since registries are not atomic. The exact-version server and provider control-plane image builds
|
||||
|
|
@ -2183,9 +2189,14 @@ artifact-selection behaviour.
|
|||
activation-only recovery workflow. Recovery must accept only a completed
|
||||
failed `create-release.yml` run whose failures are confined to activation,
|
||||
require the successful `release_readiness` DAG join as the canonical proof
|
||||
that every immutable gate succeeded, and reject every failure outside the
|
||||
activation boundary. Recovery must not duplicate reusable-workflow display
|
||||
names as a parallel gate catalog. It must revalidate GitHub's stored
|
||||
that every immutable gate succeeded when the source run has that job, and
|
||||
reject every failure outside the activation boundary. Runs made after the
|
||||
join was folded into its writers have no `release_readiness` job; for them
|
||||
recovery requires a successful `publish_release_tag`, which carries the
|
||||
candidate predicate itself, and at least one job and only successful jobs
|
||||
under each of the `publish_docker` and `publish_helm_chart` caller IDs.
|
||||
Old-shape runs must keep recovering exactly as before, and recovery must not
|
||||
grow a per-display-name gate catalog beyond those caller-ID prefixes. It must revalidate GitHub's stored
|
||||
asset digests against that source run's unexpired candidate manifest, and
|
||||
require the same draft release ID, tag, target commit, and absent activation
|
||||
marker. It then dispatches a fresh durable convergence owner and repeats the
|
||||
|
|
@ -2311,6 +2322,9 @@ artifact-selection behaviour.
|
|||
containing Playwright `test-results/` plus
|
||||
`release-integration-diagnostics/docker.log`; that Docker log must capture
|
||||
container state and the Pulse test server plus mock GitHub server logs.
|
||||
Failure diagnostics and the inspection-only packaged Helm chart artifact are
|
||||
kept for three days, since no job or workflow downloads them. Artifacts that
|
||||
later jobs, recovery or other workflows consume keep their own retention.
|
||||
The release integration job must also name at least one current,
|
||||
non-quarantined browser spec. For the v6.1.0 release line that proof is
|
||||
`tests/66-organization-sharing-approval-ui.spec.ts`; the job must not point
|
||||
|
|
|
|||
|
|
@ -1077,9 +1077,10 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) {
|
|||
if !strings.Contains(installSmokeJob, "contents: write") {
|
||||
t.Fatal("create-release.yml install_sh_smoke must grant contents: write so the called workflow can read unpublished draft assets")
|
||||
}
|
||||
qualificationJob := workflowJobBlock(t, workflow, "candidate_qualification")
|
||||
if !strings.Contains(qualificationJob, publishedReleaseGuard) {
|
||||
t.Fatal("candidate qualification must skip historical backfill and draft-only runs")
|
||||
for _, job := range []string{"publish_release_tag", "publish_docker", "activate_release"} {
|
||||
if !strings.Contains(workflowJobBlock(t, workflow, job), publishedReleaseGuard) {
|
||||
t.Fatalf("public writer %s must carry the candidate predicate that skips historical backfill and draft-only runs", job)
|
||||
}
|
||||
}
|
||||
for _, job := range []string{"promote_floating_tags", "publish_helm_pages", "promote_private_pro_runtime", "update_stable_demo"} {
|
||||
if strings.Contains(workflow, "\n "+job+":\n") {
|
||||
|
|
@ -3433,8 +3434,7 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
|
|||
integrationJob := workflowJobBlock(t, createWorkflow, "integration_tests")
|
||||
validationJob := workflowJobBlock(t, createWorkflow, "validate_release_assets")
|
||||
privateStageJob := workflowJobBlock(t, createWorkflow, "stage_private_pro_runtime")
|
||||
qualificationJob := workflowJobBlock(t, createWorkflow, "candidate_qualification")
|
||||
readinessJob := workflowJobBlock(t, createWorkflow, "release_readiness")
|
||||
qualificationJob := workflowJobBlock(t, createWorkflow, "publish_release_tag")
|
||||
dispatchJob := workflowJobBlock(t, createWorkflow, "dispatch_release_convergence")
|
||||
activationJob := workflowJobBlock(t, createWorkflow, "activate_release")
|
||||
commitVerdictJob := workflowJobBlock(t, createWorkflow, "release_commit_verdict")
|
||||
|
|
@ -3580,11 +3580,28 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
|
|||
t.Fatalf("build-release-candidate.yml missing single-build contract: %s", needle)
|
||||
}
|
||||
}
|
||||
for _, jobName := range []string{"publish_release_tag", "publish_docker", "publish_helm_chart"} {
|
||||
for _, removedJoin := range []string{"candidate_qualification", "release_readiness"} {
|
||||
if strings.Contains(createWorkflow, "\n "+removedJoin+":\n") ||
|
||||
strings.Contains(createWorkflow, "needs."+removedJoin+".") {
|
||||
t.Fatalf("create-release.yml must not reintroduce the echo-only %s join", removedJoin)
|
||||
}
|
||||
}
|
||||
for _, jobName := range []string{"publish_release_tag", "publish_docker", "publish_helm_chart", "activate_release"} {
|
||||
job := workflowJobBlock(t, createWorkflow, jobName)
|
||||
if !strings.Contains(job, "- candidate_qualification") ||
|
||||
!strings.Contains(job, "needs.candidate_qualification.result == 'success'") {
|
||||
t.Fatalf("public writer %s must require successful candidate qualification", jobName)
|
||||
for _, dependency := range []string{
|
||||
"publication_trust_preflight", "build_release_candidate", "qualify_release_containers",
|
||||
"frontend_bundle", "frontend_checks", "windows_install_command_smoke", "backend_tests",
|
||||
"release_smoke", "create_release", "validate_release_assets", "install_sh_smoke",
|
||||
} {
|
||||
if !strings.Contains(job, "- "+dependency) ||
|
||||
!strings.Contains(job, "needs."+dependency+".result == 'success'") {
|
||||
t.Fatalf("public writer %s must require successful candidate check %s itself", jobName, dependency)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(job, "!cancelled() && needs.prepare.result == 'success'") ||
|
||||
!strings.Contains(job, "(needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped')") ||
|
||||
!strings.Contains(job, "needs.stage_private_pro_runtime.result == 'success'") {
|
||||
t.Fatalf("public writer %s must carry the complete cancellation-safe candidate predicate", jobName)
|
||||
}
|
||||
}
|
||||
publishDockerJob := workflowJobBlock(t, createWorkflow, "publish_docker")
|
||||
|
|
@ -3656,11 +3673,11 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
|
|||
}
|
||||
}
|
||||
for _, dependency := range []string{
|
||||
"candidate_qualification", "publish_release_tag", "publish_docker", "publish_helm_chart",
|
||||
"publish_release_tag", "publish_docker", "publish_helm_chart",
|
||||
} {
|
||||
if !strings.Contains(readinessJob, "- "+dependency) ||
|
||||
!strings.Contains(readinessJob, "needs."+dependency+".result == 'success'") {
|
||||
t.Fatalf("release readiness must require successful %s", dependency)
|
||||
if !strings.Contains(activationJob, "- "+dependency) ||
|
||||
!strings.Contains(activationJob, "needs."+dependency+".result == 'success'") {
|
||||
t.Fatalf("release activation must require successful %s", dependency)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3674,8 +3691,8 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
|
|||
"- promote_private_pro_runtime",
|
||||
"- update_stable_demo",
|
||||
} {
|
||||
if strings.Contains(readinessJob, forbiddenDependency) {
|
||||
t.Fatalf("immutable readiness must exclude mutable customer state: %s", forbiddenDependency)
|
||||
if strings.Contains(activationJob, forbiddenDependency) {
|
||||
t.Fatalf("immutable activation must exclude mutable customer state: %s", forbiddenDependency)
|
||||
}
|
||||
}
|
||||
for _, dependency := range []string{"- create_release", "- stage_private_pro_runtime"} {
|
||||
|
|
@ -3683,8 +3700,8 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
|
|||
t.Fatalf("durable convergence dispatch missing staged dependency: %s", dependency)
|
||||
}
|
||||
}
|
||||
if strings.Contains(dispatchJob, "- release_readiness") {
|
||||
t.Fatal("durable convergence dispatch must prewarm before the readiness join")
|
||||
if strings.Contains(dispatchJob, "- publish_release_tag") {
|
||||
t.Fatal("durable convergence dispatch must prewarm before public publication")
|
||||
}
|
||||
if !strings.Contains(dispatchJob, "github.event.inputs.draft_only != 'true'") ||
|
||||
!strings.Contains(dispatchJob, "historical_asset_backfill_only != 'true'") {
|
||||
|
|
@ -4250,7 +4267,7 @@ func TestReleaseCutGatesCriticalFrontendAndWindowsRuntimeProof(t *testing.T) {
|
|||
windowsJob := workflowJobBlock(t, workflow, "windows_install_command_smoke")
|
||||
smokeJob := workflowJobBlock(t, workflow, "release_smoke")
|
||||
createJob := workflowJobBlock(t, workflow, "create_release")
|
||||
qualificationJob := workflowJobBlock(t, workflow, "candidate_qualification")
|
||||
qualificationJob := workflowJobBlock(t, workflow, "publish_release_tag")
|
||||
verdictJob := workflowJobBlock(t, workflow, "release_commit_verdict")
|
||||
|
||||
for _, needle := range []string{
|
||||
|
|
|
|||
|
|
@ -495,7 +495,6 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
|||
publication_preflight = workflow_job_block(
|
||||
workflow, "publication_trust_preflight"
|
||||
)
|
||||
readiness = workflow_job_block(workflow, "release_readiness")
|
||||
dispatch = workflow_job_block(workflow, "dispatch_release_convergence")
|
||||
activation = workflow_job_block(workflow, "activate_release")
|
||||
commit_verdict = workflow_job_block(workflow, "release_commit_verdict")
|
||||
|
|
@ -523,10 +522,12 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
|||
early_job = workflow_job_block(workflow, early_job_name)
|
||||
self.assertIn("- publication_trust_preflight", early_job)
|
||||
|
||||
for dependency in (
|
||||
"candidate_qualification", "publish_release_tag", "publish_docker", "publish_helm_chart",
|
||||
):
|
||||
self.assertIn(f"- {dependency}", readiness)
|
||||
# The echo-only joins are gone: activation joins the tag and registry
|
||||
# publications directly.
|
||||
self.assertNotRegex(workflow, r"(?m)^ (candidate_qualification|release_readiness):$")
|
||||
for dependency in ("publish_release_tag", "publish_docker", "publish_helm_chart"):
|
||||
self.assertIn(f"- {dependency}", activation)
|
||||
self.assertIn(f"needs.{dependency}.result == 'success'", activation)
|
||||
for mutable_job in (
|
||||
"publish_helm_pages",
|
||||
"promote_floating_tags",
|
||||
|
|
@ -534,23 +535,21 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
|||
"update_stable_demo",
|
||||
):
|
||||
with self.subTest(mutable_job=mutable_job):
|
||||
self.assertNotIn(f"- {mutable_job}", readiness)
|
||||
self.assertNotIn(f"- {mutable_job}", activation)
|
||||
self.assertNotRegex(workflow, rf"(?m)^ {mutable_job}:$")
|
||||
mutable = workflow_job_block(convergence, mutable_job)
|
||||
self.assertIn("needs: acquire_customer_promotion_lease", mutable)
|
||||
|
||||
self.assertIn("- release_readiness", activation)
|
||||
self.assertIn(
|
||||
"needs.publication_trust_preflight.result == 'success'",
|
||||
workflow_job_block(workflow, "candidate_qualification")
|
||||
workflow_job_block(workflow, "publish_release_tag")
|
||||
)
|
||||
self.assertIn("- publication_trust_preflight", commit_verdict)
|
||||
self.assertIn(
|
||||
'require_result "publication trust preflight"', commit_verdict
|
||||
)
|
||||
self.assertIn("- dispatch_release_convergence", activation)
|
||||
self.assertNotIn("- release_readiness", dispatch)
|
||||
self.assertNotIn("- publish_release_tag", dispatch)
|
||||
self.assertIn("- create_release", dispatch)
|
||||
self.assertIn("- stage_private_pro_runtime", dispatch)
|
||||
self.assertIn("github.event.inputs.draft_only != 'true'", dispatch)
|
||||
|
|
@ -650,6 +649,105 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
|||
self.assertNotIn("release_id:", demo_workflow)
|
||||
self.assertNotIn("unpublished draft", demo_workflow)
|
||||
|
||||
def test_activation_recovery_accepts_runs_with_and_without_readiness_join(self) -> None:
|
||||
# Execute the recovery's source-job qualification against job listings
|
||||
# in both create-release shapes. Old-shape runs must recover exactly as
|
||||
# before; joinless runs must prove the publication jobs the join used
|
||||
# to require. Job names mirror a real v6.4 release run.
|
||||
job = workflow_job_block(read(".github/workflows/recover-release-activation.yml"), "recover_activation")
|
||||
start = job.index("immutable_join=release_readiness")
|
||||
end = job.index('gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100"')
|
||||
script = "set -euo pipefail\n" + job[start:end] + "echo QUALIFIED\n"
|
||||
common = [
|
||||
("prepare", "success"), ("create_release", "success"),
|
||||
("dispatch_release_convergence", "success"), ("backend_tests", "success"),
|
||||
("integration_tests", "skipped"),
|
||||
("publish_release_tag", "success"),
|
||||
("publish_docker / Publish server image", "success"),
|
||||
("publish_docker / Publish control-plane image", "success"),
|
||||
("publish_docker / Verify exact image identities and provenance", "success"),
|
||||
("publish_helm_chart / Package and Push Helm Chart", "success"),
|
||||
("activate_release", "failure"),
|
||||
("Release Activation Commit Verdict", "failure"),
|
||||
]
|
||||
|
||||
def qualifies(jobs: list[tuple[str, str]]) -> bool:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
listing = Path(temp) / "jobs.json"
|
||||
listing.write_text(json.dumps([
|
||||
{"name": name, "status": "completed", "conclusion": conclusion}
|
||||
for name, conclusion in jobs
|
||||
]))
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], env=os.environ | {"source_jobs": str(listing)},
|
||||
text=True, capture_output=True,
|
||||
)
|
||||
self.assertEqual(result.returncode == 0, "QUALIFIED" in result.stdout, result.stderr)
|
||||
return result.returncode == 0
|
||||
|
||||
def without(jobs: list[tuple[str, str]], prefix: str) -> list[tuple[str, str]]:
|
||||
return [item for item in jobs if not item[0].startswith(prefix)]
|
||||
|
||||
def replaced(jobs: list[tuple[str, str]], name: str, conclusion: str) -> list[tuple[str, str]]:
|
||||
return [(item[0], conclusion if item[0] == name else item[1]) for item in jobs]
|
||||
|
||||
old_shape = [("candidate_qualification", "success"), ("release_readiness", "success"), *common]
|
||||
self.assertTrue(qualifies(old_shape))
|
||||
# The old shape trusts only its join, as before, so reusable display
|
||||
# names are never consulted for it.
|
||||
self.assertTrue(qualifies(without(without(old_shape, "publish_docker"), "publish_helm_chart")))
|
||||
self.assertFalse(qualifies(replaced(old_shape, "release_readiness", "skipped")))
|
||||
self.assertFalse(qualifies(replaced(old_shape, "dispatch_release_convergence", "skipped")))
|
||||
|
||||
new_shape = common
|
||||
self.assertTrue(qualifies(new_shape))
|
||||
for name in ("prepare", "create_release", "dispatch_release_convergence", "publish_release_tag",
|
||||
"publish_docker / Publish server image",
|
||||
"publish_helm_chart / Package and Push Helm Chart"):
|
||||
with self.subTest(skipped=name):
|
||||
self.assertFalse(qualifies(replaced(new_shape, name, "skipped")))
|
||||
for prefix in ("publish_release_tag", "publish_docker", "publish_helm_chart"):
|
||||
with self.subTest(missing=prefix):
|
||||
self.assertFalse(qualifies(without(new_shape, prefix)))
|
||||
# A skipped reusable caller is listed under its bare job ID.
|
||||
for name in ("publish_docker", "publish_helm_chart"):
|
||||
with self.subTest(skipped_caller=name):
|
||||
self.assertFalse(qualifies([*without(new_shape, name), (name, "skipped")]))
|
||||
# Failures outside the activation boundary still reject either shape.
|
||||
self.assertFalse(qualifies(replaced(new_shape, "backend_tests", "failure")))
|
||||
self.assertFalse(qualifies(replaced(old_shape, "backend_tests", "failure")))
|
||||
|
||||
def test_commit_verdict_restates_the_candidate_predicate(self) -> None:
|
||||
# The verdict no longer reads a readiness join, so it must reject every
|
||||
# candidate failure itself. Run its result checks with each outcome.
|
||||
verdict = workflow_job_block(read(".github/workflows/create-release.yml"), "release_commit_verdict")
|
||||
step = yaml.safe_load("jobs:\n" + verdict)["jobs"]["release_commit_verdict"]["steps"][-1]
|
||||
script = step["run"]
|
||||
script = script[:script.index("./scripts/verify-github-release-integrity.sh")] + "exit 0\nfi\n"
|
||||
results = {
|
||||
"PUBLICATION_TRUST_RESULT", "SMOKE_RESULT", "WINDOWS_INSTALL_COMMAND_RESULT",
|
||||
"CREATE_RESULT", "VALIDATE_RESULT", "DOCKER_RESULT", "INSTALL_RESULT", "HELM_RESULT",
|
||||
"BUILD_CANDIDATE_RESULT", "CONTAINER_QUALIFICATION_RESULT", "FRONTEND_BUNDLE_RESULT",
|
||||
"FRONTEND_CHECKS_RESULT", "BACKEND_RESULT", "INTEGRATION_RESULT", "TAG_RESULT",
|
||||
"CONVERGENCE_DISPATCH_RESULT", "PRIVATE_PRO_STAGE_RESULT",
|
||||
}
|
||||
self.assertTrue(results <= set(step["env"]))
|
||||
self.assertNotIn("READINESS_RESULT", step["env"])
|
||||
good = dict.fromkeys(results, "success") | {"DRAFT_ONLY": "false", "VERSION": "6.6.0"}
|
||||
|
||||
def passes(env: dict[str, str]) -> bool:
|
||||
result = subprocess.run(["bash", "-c", script], env=os.environ | env, text=True, capture_output=True)
|
||||
return result.returncode == 0
|
||||
|
||||
self.assertTrue(passes(good))
|
||||
self.assertTrue(passes(good | {"INTEGRATION_RESULT": "skipped"}))
|
||||
for name in sorted(results):
|
||||
for state in ("failure", "cancelled", "skipped"):
|
||||
if name == "INTEGRATION_RESULT" and state == "skipped":
|
||||
continue
|
||||
with self.subTest(result=name, state=state):
|
||||
self.assertFalse(passes(good | {name: state}))
|
||||
|
||||
def test_activation_recovery_reuses_the_qualified_candidate_without_rebuilding(self) -> None:
|
||||
release = read(".github/workflows/create-release.yml")
|
||||
recovery = read(".github/workflows/recover-release-activation.yml")
|
||||
|
|
@ -661,6 +759,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
|||
self.assertIn("release_readiness", job)
|
||||
self.assertIn("dispatch_release_convergence", job)
|
||||
self.assertIn("release_readiness is the canonical DAG join", job)
|
||||
# Runs made without the echo-only join prove its publication jobs.
|
||||
self.assertIn("immutable_join=publish_release_tag", job)
|
||||
self.assertIn("for reusable_job in publish_docker publish_helm_chart", job)
|
||||
self.assertNotIn("docker_build", job)
|
||||
self.assertNotIn("helm_smoke", job)
|
||||
self.assertIn("failure outside the recoverable activation boundary", job)
|
||||
|
|
@ -2960,25 +3061,37 @@ class CandidatePublicationBoundaryTest(unittest.TestCase):
|
|||
expression = re.sub(r"!(?!=)", "not ", expression)
|
||||
return bool(eval(expression, {"__builtins__": {}, "startsWith": lambda value, prefix: value.startswith(prefix)}))
|
||||
|
||||
CANDIDATE_CHECKS = frozenset({
|
||||
"prepare", "publication_trust_preflight", "build_release_candidate",
|
||||
"qualify_release_containers", "frontend_bundle", "frontend_checks",
|
||||
"windows_install_command_smoke", "backend_tests", "integration_tests",
|
||||
"release_smoke", "create_release", "validate_release_assets",
|
||||
"install_sh_smoke", "stage_private_pro_runtime",
|
||||
})
|
||||
PUBLIC_WRITERS = ("publish_release_tag", "publish_docker", "publish_helm_chart", "activate_release")
|
||||
|
||||
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}))
|
||||
# A skipped ancestor would then prevent every writer despite the
|
||||
# explicit candidate predicate. Model that status gate as well.
|
||||
for name in self.PUBLIC_WRITERS:
|
||||
writer = self.jobs[name]
|
||||
has_status = bool(re.search(r"\b(always|success|failure|cancelled)\(", writer["if"]))
|
||||
with self.subTest(writer=name):
|
||||
self.assertTrue(has_status and self.condition(name, outcomes))
|
||||
for dependency in writer["needs"]:
|
||||
for state in ("failure", "cancelled", "skipped"):
|
||||
if dependency == "integration_tests" and state == "skipped":
|
||||
continue # Already skipped above, by policy.
|
||||
with self.subTest(writer=name, dependency=dependency, state=state):
|
||||
self.assertFalse(self.condition(name, 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"):
|
||||
"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:
|
||||
|
|
@ -2988,29 +3101,39 @@ class CandidatePublicationBoundaryTest(unittest.TestCase):
|
|||
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",
|
||||
"qualify_release_containers", "frontend_bundle", "frontend_checks",
|
||||
"windows_install_command_smoke", "backend_tests", "integration_tests",
|
||||
"release_smoke", "create_release", "validate_release_assets",
|
||||
"install_sh_smoke", "stage_private_pro_runtime",
|
||||
}
|
||||
self.assertEqual(set(self.jobs["candidate_qualification"]["needs"]), required)
|
||||
required = set(self.CANDIDATE_CHECKS)
|
||||
# The echo-only candidate and readiness joins were folded into the
|
||||
# writers. Each writer depends on, and judges, every candidate check
|
||||
# itself instead of trusting a join job's result.
|
||||
self.assertNotIn("candidate_qualification", self.jobs)
|
||||
self.assertNotIn("release_readiness", self.jobs)
|
||||
tag_predicate = self.jobs["publish_release_tag"]["if"].removesuffix("}}").strip()
|
||||
self.assertEqual(set(self.jobs["publish_release_tag"]["needs"]), required)
|
||||
good = dict.fromkeys(self.jobs, "success")
|
||||
self.assertTrue(self.condition("candidate_qualification", good))
|
||||
self.assertFalse(self.condition("candidate_qualification", good, draft=True))
|
||||
for failed in required:
|
||||
for writer in self.PUBLIC_WRITERS:
|
||||
with self.subTest(writer=writer):
|
||||
self.assertTrue(required <= set(self.jobs[writer]["needs"]))
|
||||
# Every writer repeats the tag's exact candidate predicate.
|
||||
self.assertTrue(self.jobs[writer]["if"].startswith(tag_predicate))
|
||||
self.assertTrue(self.condition(writer, good))
|
||||
self.assertFalse(self.condition(writer, good, draft=True))
|
||||
for failed in required:
|
||||
for state in ("failure", "cancelled", "skipped"):
|
||||
if failed == "integration_tests" and state == "skipped":
|
||||
continue # Existing alpha/beta policy omits integration tests.
|
||||
with self.subTest(writer=writer, failed=failed, state=state):
|
||||
self.assertFalse(self.condition(writer, good | {failed: state}))
|
||||
# Old shape: release_readiness also required the tag and both registry
|
||||
# publications before activation. The new activation predicate must
|
||||
# match the old readiness-gated one for every single-job outcome.
|
||||
for writer in ("publish_docker", "publish_helm_chart", "activate_release"):
|
||||
for state in ("failure", "cancelled", "skipped"):
|
||||
if failed == "integration_tests" and state == "skipped":
|
||||
continue # Existing alpha/beta policy omits integration tests.
|
||||
with self.subTest(failed=failed, state=state):
|
||||
outcomes = good | {failed: state}
|
||||
self.assertFalse(self.condition("candidate_qualification", outcomes))
|
||||
for writer in ("publish_release_tag", "publish_docker", "publish_helm_chart"):
|
||||
self.assertIn("candidate_qualification", self.jobs[writer]["needs"])
|
||||
with self.subTest(writer=writer, tag=state):
|
||||
self.assertFalse(self.condition(writer, good | {"publish_release_tag": state}))
|
||||
for dependency in ("publish_docker", "publish_helm_chart", "dispatch_release_convergence"):
|
||||
for state in ("failure", "cancelled", "skipped"):
|
||||
with self.subTest(writer=writer, state=state):
|
||||
self.assertFalse(self.condition(writer, good | {"candidate_qualification": state}))
|
||||
with self.subTest(activation_dependency=dependency, state=state):
|
||||
self.assertFalse(self.condition("activate_release", good | {dependency: state}))
|
||||
# Detect accidental dependency cycles, including moving publication into
|
||||
# the candidate join that publication itself must wait for.
|
||||
def visit(name: str, stack: tuple[str, ...] = ()) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue