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.
This commit is contained in:
rcourtman
2026-09-11 21:04:46 +01:00
parent 828c4adc1a
commit 9e5e18f008
7 changed files with 248 additions and 121 deletions
+83 -61
View File
@@ -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;
+3 -4
View File
@@ -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:
+2 -2
View File
@@ -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
@@ -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.
@@ -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,
@@ -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)
@@ -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()