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:
courtmanr@gmail.com 2026-10-01 21:47:58 +01:00
parent 6448ea3816
commit 6ce41ae9d4
8 changed files with 346 additions and 131 deletions

View file

@ -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]+$ ]] || \

View file

@ -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:

View file

@ -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:

View file

@ -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")' \

View file

@ -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

View file

@ -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

View file

@ -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{

View file

@ -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: