From 9e5e18f008b33fe62945ed321e20cad0fa958ba1 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:04:46 +0100 Subject: [PATCH] fix(release): qualify candidates before public version writes A draft GitHub release does not hide its public Git tag or registry images. Publishing those before qualification consumed a beta identity when the candidate later failed. Stage drafts without Git refs, join exact-source checks before public tag, Docker and Helm publication, and preserve exposed tag identities. Keep final digest verification before release activation. --- .github/workflows/create-release.yml | 144 ++++++++++-------- .github/workflows/publish-docker.yml | 7 +- .github/workflows/publish-helm-chart.yml | 4 +- .../v6/internal/RELEASE_PROMOTION_POLICY.md | 26 +++- .../subsystems/deployment-installability.md | 18 ++- .../installtests/build_release_assets_test.go | 66 ++++---- .../release_promotion_policy_test.py | 104 +++++++++++-- 7 files changed, 248 insertions(+), 121 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 3be410c4de..a0e997d9cb 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -861,8 +861,8 @@ jobs: - prepare - build_release_candidate - release_note_visuals - # Draft metadata and immutable assets are inert staging. Qualification is - # joined at release_readiness before any activation boundary can open. + # Only restricted draft metadata/assets are staged here. No public Git tag + # is created until candidate_qualification 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 @@ -877,7 +877,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - persist-credentials: true # required: authenticated git writes + persist-credentials: false fetch-depth: 0 - name: Download immutable release candidate @@ -1002,50 +1002,23 @@ jobs: python3 scripts/write_github_output.py release_published_at "${RELEASE_PUBLISHED_AT}" python3 scripts/write_github_output.py release_activation_committed "${RELEASE_ACTIVATION_COMMITTED}" - - name: Create tag + - name: Check existing public tag without changing it env: - GH_TOKEN: ${{ github.token }} - WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }} - WORKFLOW_OUTPUT_2: ${{ steps.existing_release.outputs.release_id }} - WORKFLOW_OUTPUT_3: ${{ steps.existing_release.outputs.release_is_draft }} - WORKFLOW_OUTPUT_4: ${{ steps.existing_release.outputs.release_published_at }} - WORKFLOW_OUTPUT_5: ${{ steps.existing_release.outputs.release_activation_committed }} + TAG: ${{ needs.prepare.outputs.tag }} run: | - TAG="${WORKFLOW_OUTPUT_1}" + set -euo pipefail HEAD_SHA=$(git rev-parse HEAD) - EXISTING_RELEASE_ID="${WORKFLOW_OUTPUT_2}" - EXISTING_RELEASE_DRAFT="${WORKFLOW_OUTPUT_3}" - EXISTING_RELEASE_PUBLISHED_AT="${WORKFLOW_OUTPUT_4}" - EXISTING_RELEASE_ACTIVATION_COMMITTED="${WORKFLOW_OUTPUT_5}" - REMOTE_TAG_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}" | awk '{print $1}') - if [ -n "$REMOTE_TAG_SHA" ]; then REMOTE_COMMIT_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}^{}" | awk '{print $1}') [ -z "$REMOTE_COMMIT_SHA" ] && REMOTE_COMMIT_SHA="$REMOTE_TAG_SHA" - - if [ "$REMOTE_COMMIT_SHA" = "$HEAD_SHA" ]; then - echo "Tag ${TAG} already exists and points to HEAD - continuing" - elif [ -n "$EXISTING_RELEASE_ID" ] && [ "$EXISTING_RELEASE_DRAFT" = "true" ] && [ "$EXISTING_RELEASE_ACTIVATION_COMMITTED" != "true" ]; then - if [ -n "$EXISTING_RELEASE_PUBLISHED_AT" ]; then - echo "Resuming quarantined draft for ${TAG}; GitHub retained historical published_at=${EXISTING_RELEASE_PUBLISHED_AT}." - fi - echo "Retargeting existing draft tag ${TAG} from ${REMOTE_COMMIT_SHA} to ${HEAD_SHA}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -fa "${TAG}" -m "Release ${TAG}" "${HEAD_SHA}" - git push origin "refs/tags/${TAG}" --force - else - echo "::error::Tag ${TAG} already exists but points to ${REMOTE_COMMIT_SHA}, not HEAD (${HEAD_SHA}). Delete the tag first: git push origin --delete ${TAG}" + if [ "$REMOTE_COMMIT_SHA" != "$HEAD_SHA" ]; then + echo "::error::Public tag ${TAG} already identifies ${REMOTE_COMMIT_SHA}; it cannot be reused for ${HEAD_SHA}, even when its release is a draft." exit 1 fi - else - echo "Creating tag ${TAG}..." - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "${TAG}" -m "Release ${TAG}" - git push origin "${TAG}" fi + # A draft binds its intended tag to target_commitish without pushing + # a public Git ref. Failed candidates remain private and replaceable. - name: Create draft release id: create_release @@ -1405,12 +1378,74 @@ jobs: echo "[SUCCESS] Historical release assets repaired" echo "Release: ${WORKFLOW_OUTPUT_1}" + # Restricted candidate checks finish before the first public versioned write. + candidate_qualification: + 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 + 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 + if: ${{ needs.candidate_qualification.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Checkout qualified source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: true # required: authenticated git writes + fetch-depth: 0 + - name: Publish qualified tag without rewriting an existing identity + env: + TAG: ${{ needs.prepare.outputs.tag }} + run: | + set -euo pipefail + HEAD_SHA=$(git rev-parse HEAD) + REMOTE_TAG_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}" | awk '{print $1}') + if [ -n "$REMOTE_TAG_SHA" ]; then + REMOTE_COMMIT_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}^{}" | awk '{print $1}') + [ -z "$REMOTE_COMMIT_SHA" ] && REMOTE_COMMIT_SHA="$REMOTE_TAG_SHA" + if [ "$REMOTE_COMMIT_SHA" != "$HEAD_SHA" ]; then + echo "::error::Public tag ${TAG} already identifies a different source. Preserve that identity." + exit 1 + fi + else + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${TAG}" -m "Release ${TAG}" "${HEAD_SHA}" + git push origin "refs/tags/${TAG}" + fi + publish_docker: needs: + - candidate_qualification + - publish_release_tag - prepare - build_release_candidate - create_release - if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} + if: ${{ always() && needs.candidate_qualification.result == 'success' && needs.publish_release_tag.result == 'success' && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} permissions: contents: read packages: write @@ -1489,9 +1524,11 @@ jobs: # release has not crossed the operator-controlled publication boundary. publish_helm_chart: needs: + - candidate_qualification + - publish_release_tag - prepare - validate_release_assets - if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} + if: ${{ always() && needs.candidate_qualification.result == 'success' && needs.publish_release_tag.result == 'success' && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} permissions: contents: write packages: write @@ -1503,35 +1540,20 @@ jobs: chart_version: ${{ needs.prepare.outputs.version }} app_version: ${{ needs.prepare.outputs.version }} - # One immutable-readiness gate joins every exact-version path before the - # GitHub release crosses its public activation boundary. v6 additionally - # requires the staged Pro image and signed packet; older release lines have - # no private Pro job. Mutable indexes, aliases, brokers, and live environments - # are deliberately excluded from this pre-activation join. + # Candidate qualification owns prepublication checks. This final join adds + # verified public registry digests before GitHub release activation. release_readiness: 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 + - candidate_qualification + - publish_release_tag - publish_docker - - validate_release_assets - - install_sh_smoke - publish_helm_chart - - 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.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.install_sh_smoke.result == 'success' && needs.publish_helm_chart.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' }} + if: ${{ always() && 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 "All exact-version release paths are ready for customer activation." + 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 @@ -2005,7 +2027,7 @@ jobs: exit 1 fi - # Publication is now the only irreversible boundary. GitHub must + # Public version artifacts already exist. GitHub activation must # confirm the repository setting before publication and report the # complete release as immutable afterward. The immediate setting # check prevents a mutable public interval if configuration drifts; diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index d23c07f0bb..cc616f969f 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -1,10 +1,9 @@ name: Publish Docker Images run-name: Publish Docker Images ${{ inputs.tag }} -# Called by create-release.yml as soon as the immutable candidate exists. -# Exact-version images are inert staging surfaces, so assembly and publication -# overlap container qualification and draft creation. The release-readiness -# barrier still joins both paths before any customer-facing alias can move. +# 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. on: workflow_call: inputs: diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index 15113ce111..2cad43274d 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -3,8 +3,8 @@ name: Publish Helm Chart # Triggers: # - release: published — fires when a release is initially created with # draft=false (legacy publish path). -# - workflow_call — called from create-release.yml after validate_release_assets -# succeeds. create-release.yml's publish step creates releases as drafts and +# - workflow_call — called from create-release.yml after candidate qualification +# and qualified public tag creation succeed. create-release.yml's publish step creates releases as drafts and # PATCHes to draft=false later, which does NOT fire the `release: published` # webhook (GitHub-documented quirk). Without this workflow_call trigger, # v6 rc.1 → rc.5 published successfully but never produced a Helm chart on diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index 2e1396ce51..4e9876e7d8 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -613,9 +613,10 @@ without the other lanes changing the candidate underneath it. `create-release.yml`. The workflow builds one exact-SHA candidate with the native signing lanes required by that version's governed policy while frontend, backend, Docker, Helm, and integration checks run in parallel. The - candidate build and required release-note visuals must pass before tag and - draft assembly. The draft remains unpublished while every applicable check - and exact-version staging path converges at the immutable readiness gate. + candidate build and required release-note visuals must pass before restricted + draft assembly. The draft binds its intended version to the exact source SHA + without creating a public Git tag. Candidate checks must finish before any + public versioned artifact is written. 2. The release candidate is uploaded as a one-day Actions artifact with a machine-readable manifest that pins source SHA, version, filename, size, and SHA-256 for every release asset. Publication downloads and verifies that @@ -625,10 +626,21 @@ without the other lanes changing the candidate underneath it. not re-download the multi-gigabyte release packet merely to recompute hashes already proven before upload. Manual and release-edit repair validation may retain the full-download fallback when no same-run candidate manifest exists. -4. Exact-version Docker publication, staged release-asset verification, exact - Helm OCI publication and staged install smoke consume the unpublished draft. - They join the exact-version private Pro build and all applicable checks at - one immutable readiness gate. The pipeline must durably dispatch a viable +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. + 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 + exposure require exact-source recovery or an explicitly explained successor, + not silent replacement. Cross-registry publication is not atomic. + The pipeline must durably dispatch a viable release convergence run before publication. After readiness, `activate_release` stages `release-activation.json` in the draft, verifies its server-side SHA-256 digest, and rechecks repository release immutability. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 1db4acbd2d..1a32201cf9 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1298,11 +1298,19 @@ artifact-selection behaviour. container qualification and draft creation when release-line validation is 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 tags are inert staging surfaces; the - canonical `release_readiness` join must still require candidate container - qualification, draft release validation, exact Docker publication, installer - smoke, and every other immutable gate before activation or floating-alias - promotion. The exact-version server and provider control-plane image builds + 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 + 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 + 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 are independent consumers of the same immutable container payload and must publish and attest in separate matrix jobs. Each matrix leg independently verifies the exact checkout and candidate manifest. After both legs finish, diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 972b5aabab..437b956c8b 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -986,7 +986,7 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { `gh release upload failed after ${max_attempts} attempts`, `release/release-build-provenance.sigstore.json`, `gh api "repos/${{ github.repository }}/releases?per_page=100" --paginate`, - `git push origin "refs/tags/${TAG}" --force`, + `git push origin "refs/tags/${TAG}"`, `--rawfile body "$NOTES_FILE"`, `--input "$RELEASE_PAYLOAD"`, `--expected-body-file "$NOTES_FILE"`, @@ -1059,9 +1059,9 @@ 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") } - readinessJob := workflowJobBlock(t, workflow, "release_readiness") - if !strings.Contains(readinessJob, publishedReleaseGuard) { - t.Fatal("release_readiness must skip historical backfill and draft-only runs") + 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{"promote_floating_tags", "publish_helm_pages", "promote_private_pro_runtime", "update_stable_demo"} { if strings.Contains(workflow, "\n "+job+":\n") { @@ -3290,6 +3290,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") dispatchJob := workflowJobBlock(t, createWorkflow, "dispatch_release_convergence") activationJob := workflowJobBlock(t, createWorkflow, "activate_release") @@ -3442,21 +3443,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"} { + 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) + } + } publishDockerJob := workflowJobBlock(t, createWorkflow, "publish_docker") for _, needle := range []string{ "- build_release_candidate", "- create_release", + "- publish_release_tag", "needs.create_release.result == 'success'", + "needs.publish_release_tag.result == 'success'", `source_sha: ${{ github.sha }}`, } { if !strings.Contains(publishDockerJob, needle) { - t.Fatalf("inert exact-version Docker staging missing recovery-safe dependency contract: %s", needle) + t.Fatalf("qualified Docker publication missing exact-source dependency contract: %s", needle) } } - for _, forbiddenDependency := range []string{"- qualify_release_containers"} { - if strings.Contains(publishDockerJob, forbiddenDependency) { - t.Fatalf("inert exact-version Docker staging must overlap independent qualification: %s", forbiddenDependency) - } + if strings.Contains(createJob, "git push") || strings.Contains(createWorkflow, `git push origin "refs/tags/${TAG}" --force`) { + t.Fatal("restricted draft staging must not publish or rewrite a public tag") } for _, needle := range []string{ @@ -3485,7 +3493,7 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { t.Fatal("integration release gate must not target the quarantined multi-tenant spec") } if strings.Contains(validationJob, "- publish_docker") { - t.Fatal("release asset digest validation must run in parallel with Docker publication") + t.Fatal("release asset digest validation must not depend on public Docker publication") } if !strings.Contains(privateStageJob, "- prepare") || strings.Contains(privateStageJob, "- create_release") || @@ -3502,21 +3510,23 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { } } for _, dependency := range []string{ - "- publication_trust_preflight", - "- create_release", - "- publish_docker", - "- validate_release_assets", - "- install_sh_smoke", - "- publish_helm_chart", - "- stage_private_pro_runtime", + "publication_trust_preflight", "create_release", "validate_release_assets", + "install_sh_smoke", "stage_private_pro_runtime", } { - if !strings.Contains(readinessJob, dependency) { - t.Fatalf("immutable release readiness missing dependency: %s", dependency) + if !strings.Contains(qualificationJob, "- "+dependency) || + !strings.Contains(qualificationJob, "needs."+dependency+".result == 'success'") { + t.Fatalf("candidate qualification must require successful %s", dependency) } } - if !strings.Contains(readinessJob, `needs.publication_trust_preflight.result == 'success'`) { - t.Fatal("immutable release readiness must require successful publication trust preflight") + for _, dependency := range []string{ + "candidate_qualification", "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(commitVerdictJob, "- publication_trust_preflight") || !strings.Contains(commitVerdictJob, `require_result "publication trust preflight"`) { t.Fatal("release commit verdict must surface publication trust preflight failure") @@ -3588,12 +3598,10 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { !strings.Contains(activationJob, `already committed by successful recovery run`) { t.Fatal("release activation reruns must recognize a qualified recovery before considering marker replacement") } - if !strings.Contains(createJob, `Resuming quarantined draft for ${TAG}`) || - !strings.Contains(createJob, `Resuming quarantined draft release for ${TAG}`) { + if !strings.Contains(createJob, `Resuming quarantined draft release for ${TAG}`) { t.Fatal("release creation must explicitly support resuming a quarantined draft") } if !strings.Contains(createJob, `write_github_output.py release_activation_committed "${RELEASE_ACTIVATION_COMMITTED}"`) || - !strings.Contains(createJob, `[ "$EXISTING_RELEASE_ACTIVATION_COMMITTED" != "true" ]`) || !strings.Contains(createJob, `[ "$ACTIVATION_COMMITTED" != "true" ]`) { t.Fatal("release creation must refuse to retarget a draft whose irreversible activation marker exists") } @@ -3782,7 +3790,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") - readinessJob := workflowJobBlock(t, workflow, "release_readiness") + qualificationJob := workflowJobBlock(t, workflow, "candidate_qualification") verdictJob := workflowJobBlock(t, workflow, "release_commit_verdict") for _, needle := range []string{ @@ -3810,8 +3818,8 @@ func TestReleaseCutGatesCriticalFrontendAndWindowsRuntimeProof(t *testing.T) { t.Fatalf("release render smoke missing %s", needle) } } - if !strings.Contains(readinessJob, `needs.windows_install_command_smoke.result == 'success'`) { - t.Fatal("release readiness must fail closed on the Windows install-command smoke") + if !strings.Contains(qualificationJob, `needs.windows_install_command_smoke.result == 'success'`) { + t.Fatal("candidate qualification must fail closed on the Windows install-command smoke") } if strings.Contains(createJob, `needs.windows_install_command_smoke.result`) { t.Fatal("inert draft staging must overlap independent Windows qualification") @@ -3822,8 +3830,8 @@ func TestReleaseCutGatesCriticalFrontendAndWindowsRuntimeProof(t *testing.T) { "needs.backend_tests.result == 'success'", "needs.release_smoke.result == 'success'", } { - if !strings.Contains(readinessJob, result) { - t.Fatalf("release readiness missing deferred qualification join: %s", result) + if !strings.Contains(qualificationJob, result) { + t.Fatalf("candidate qualification missing required proof: %s", result) } if strings.Contains(createJob, result) { t.Fatalf("inert draft staging must not serialize on deferred qualification: %s", result) diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index e2e35011a1..befbb2dc0b 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -459,13 +459,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("- publication_trust_preflight", early_job) for dependency in ( - "publication_trust_preflight", - "create_release", - "publish_docker", - "validate_release_assets", - "install_sh_smoke", - "publish_helm_chart", - "stage_private_pro_runtime", + "candidate_qualification", "publish_release_tag", "publish_docker", "publish_helm_chart", ): self.assertIn(f"- {dependency}", readiness) for mutable_job in ( @@ -483,7 +477,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("- release_readiness", activation) self.assertIn( - "needs.publication_trust_preflight.result == 'success'", readiness + "needs.publication_trust_preflight.result == 'success'", + workflow_job_block(workflow, "candidate_qualification") ) self.assertIn("- publication_trust_preflight", commit_verdict) self.assertIn( @@ -1816,16 +1811,14 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("activate_release:", content) self.assertIn("Publish the fully staged release", content) self.assertIn('gh api "repos/${{ github.repository }}/releases?per_page=100" --paginate', content) - self.assertIn('git push origin "refs/tags/${TAG}" --force', content) - - self.assertIn('Retargeting existing draft tag ${TAG}', content) - self.assertIn('Resuming quarantined draft for ${TAG}', content) + self.assertNotIn('git push origin "refs/tags/${TAG}" --force', content) + self.assertNotIn('Retargeting existing draft tag', content) + self.assertIn('Public tag ${TAG} already identifies', content) self.assertIn('Resuming quarantined draft release for ${TAG}', content) self.assertIn( 'write_github_output.py release_activation_committed "${RELEASE_ACTIVATION_COMMITTED}"', content, ) - self.assertIn('[ "$EXISTING_RELEASE_ACTIVATION_COMMITTED" != "true" ]', content) self.assertIn('[ "$ACTIVATION_COMMITTED" != "true" ]', content) self.assertNotIn('[ -z "$EXISTING_RELEASE_PUBLISHED_AT" ]', content) self.assertNotIn('[ -z "$PUBLISHED_AT" ]', content) @@ -2841,5 +2834,90 @@ The product owner explicitly approved this change after a cohort reached 7.9%. self.assertEqual(failures, [], "\n".join(failures)) +class CandidatePublicationBoundaryTest(unittest.TestCase): + def setUp(self) -> None: + self.jobs = yaml.load(read(".github/workflows/create-release.yml"), Loader=UniqueKeyLoader)["jobs"] + + def condition(self, job: str, results: dict[str, str], *, draft: bool = False) -> bool: + # Execute the workflow's Boolean condition with explicit job outcomes. + # This intentionally fails if the workflow adds an unsupported expression. + expression = self.jobs[job]["if"].removeprefix("${{").removesuffix("}}").strip() + expression = re.sub(r"needs\.([a-z_]+)\.result", lambda m: repr(results[m[1]]), expression) + expression = expression.replace("needs.prepare.outputs.version", repr("6.4.4-beta.9")) + expression = expression.replace("needs.prepare.outputs.historical_asset_backfill_only", repr("false")) + expression = expression.replace("github.event.inputs.draft_only", repr(str(draft).lower())) + expression = expression.replace("always()", "True").replace("&&", " and ").replace("||", " or ") + expression = re.sub(r"!(?!=)", "not ", expression) + return bool(eval(expression, {"__builtins__": {}, "startsWith": lambda value, prefix: value.startswith(prefix)})) + + def test_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) + 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 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"]) + for state in ("failure", "cancelled", "skipped"): + with self.subTest(writer=writer, state=state): + self.assertFalse(self.condition(writer, good | {"candidate_qualification": 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: + self.assertNotIn(name, stack) + deps = self.jobs[name].get("needs", []) + for dependency in ([deps] if isinstance(deps, str) else deps): + visit(dependency, (*stack, name)) + for name in self.jobs: + visit(name) + + def test_draft_never_pushes_a_tag_and_publication_never_retargets(self) -> None: + draft = self.jobs["create_release"] + self.assertFalse(draft["steps"][0]["with"]["persist-credentials"]) + self.assertTrue(self.jobs["publish_release_tag"]["steps"][0]["with"]["persist-credentials"]) + scripts = "\n".join(step.get("run", "") for step in draft["steps"]) + self.assertNotRegex(scripts, r"git (?:push|tag)\b") + self.assertIn('--arg target_commitish "$HEAD_SHA"', scripts) + checks = [ + next(step["run"] for step in draft["steps"] if step["name"] == "Check existing public tag without changing it"), + self.jobs["publish_release_tag"]["steps"][-1]["run"], + ] + for check_index, script in enumerate(checks): + for existing in ("", "a" * 40, "b" * 40): + with self.subTest(step=check_index, existing=existing), tempfile.TemporaryDirectory() as temp: + root = Path(temp) + fake = root / "git" + fake.write_text("#!/usr/bin/env python3\nimport os,sys,json\na=sys.argv[1:]\n" + "with open(os.environ['CALL_LOG'],'a') as f: f.write(json.dumps(a)+'\\n')\n" + "if a[0]=='rev-parse': print('a'*40)\n" + "elif a[0]=='ls-remote' and os.environ['EXISTING']: print(os.environ['EXISTING']+'\\tref')\n") + fake.chmod(0o755) + log = root / "calls" + env = os.environ | {"PATH": str(root) + os.pathsep + os.environ["PATH"], + "TAG": "v6.4.4-beta.9", "EXISTING": existing, "CALL_LOG": str(log)} + result = subprocess.run(["bash", "-c", script], env=env, text=True, capture_output=True) + self.assertEqual(result.returncode == 0, existing != "b" * 40, result.stderr) + calls = [json.loads(line) for line in log.read_text().splitlines()] + writes = [call for call in calls if call[0] in ("tag", "push")] + if check_index == 1 and not existing: + self.assertEqual([call[0] for call in writes], ["tag", "push"]) + self.assertNotIn("--force", str(writes)) + else: + self.assertEqual(writes, []) + + if __name__ == "__main__": unittest.main()