mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
Merge pull request #2056 from rcourtman/fix/release-publication-order
fix(release): qualify candidates before public version writes
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user