diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index e3e227c4c0..c332b02334 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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]+$ ]] || \ diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index cc616f969f..4a851fb20c 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -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: diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index 2cad43274d..632e780619 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -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: diff --git a/.github/workflows/recover-release-activation.yml b/.github/workflows/recover-release-activation.yml index 8ee25fe501..5a4274f3dd 100644 --- a/.github/workflows/recover-release-activation.yml +++ b/.github/workflows/recover-release-activation.yml @@ -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")' \ diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index a26453a16f..096e3066f6 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index cf42b95974..ed9052473e 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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 diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index d5f0966dd2..94688451c0 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -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{ diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 764f2ae41c..5f4278134e 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -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: