From 4fcb90673b18e318a0187a38e0d685fa080f6726 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 4 Jul 2026 20:49:47 +0100 Subject: [PATCH] Allow stable patch artifact publishing Reuse shared release-line validation for Docker, floating-tag, and Helm artifact workflows so stable patch tags can publish from the previous stable tag without a fabricated same-version RC. --- .github/workflows/helm-pages.yml | 27 +-- .github/workflows/promote-floating-tags.yml | 51 +---- .github/workflows/publish-docker.yml | 57 +----- .github/workflows/publish-helm-chart.yml | 26 +-- .../subsystems/deployment-installability.md | 31 +-- .../v6/internal/subsystems/registry.json | 5 +- .../release_promotion_policy_test.py | 20 +- .../release_control/subsystem_lookup_test.py | 6 + .../validate_artifact_release_line.py | 176 ++++++++++++++++++ .../validate_artifact_release_line_test.py | 91 +++++++++ 10 files changed, 329 insertions(+), 161 deletions(-) create mode 100644 scripts/release_control/validate_artifact_release_line.py create mode 100644 scripts/release_control/validate_artifact_release_line_test.py diff --git a/.github/workflows/helm-pages.yml b/.github/workflows/helm-pages.yml index 115151ca0..e5fecb784 100644 --- a/.github/workflows/helm-pages.yml +++ b/.github/workflows/helm-pages.yml @@ -57,35 +57,18 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 + fetch-tags: true - name: Validate release line policy id: release_line env: - VERSION: ${{ steps.version.outputs.version }} RELEASE_TAG: ${{ steps.version.outputs.release_tag }} run: | set -euo pipefail - - REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${VERSION}")" - - if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then - git fetch --prune --unshallow origin - fi - git fetch --prune origin "${REQUIRED_BRANCH}" --tags - - if ! git rev-parse -q --verify "refs/tags/${RELEASE_TAG}" >/dev/null; then - echo "::error::Tag ${RELEASE_TAG} does not exist. Helm pages release must map to a real Git tag." - exit 1 - fi - - TAG_COMMIT="$(git rev-list -n1 "refs/tags/${RELEASE_TAG}")" - if ! git merge-base --is-ancestor "$TAG_COMMIT" "origin/${REQUIRED_BRANCH}"; then - echo "::error::Tag ${RELEASE_TAG} is not reachable from origin/${REQUIRED_BRANCH}. Refusing cross-line Helm pages release." - exit 1 - fi - - echo "required_branch=${REQUIRED_BRANCH}" >> "$GITHUB_OUTPUT" - echo "[OK] ${RELEASE_TAG} validated against release line ${REQUIRED_BRANCH}" + python3 scripts/release_control/validate_artifact_release_line.py \ + --tag "${RELEASE_TAG}" \ + --purpose "Helm pages release" \ + --github-output "$GITHUB_OUTPUT" - name: Check out governed release branch env: diff --git a/.github/workflows/promote-floating-tags.yml b/.github/workflows/promote-floating-tags.yml index 7d6c48e3e..b9e02c437 100644 --- a/.github/workflows/promote-floating-tags.yml +++ b/.github/workflows/promote-floating-tags.yml @@ -111,56 +111,11 @@ jobs: - name: Validate release line policy env: TAG: ${{ steps.extract.outputs.tag }} - PRERELEASE: ${{ steps.extract.outputs.prerelease }} run: | set -euo pipefail - - VERSION="${TAG#v}" - REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${VERSION}")" - - if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then - git fetch --prune --unshallow origin - fi - git fetch --prune origin "${REQUIRED_BRANCH}" --tags - - if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then - echo "::error::Tag ${TAG} does not exist in repository tags." - exit 1 - fi - - TAG_COMMIT="$(git rev-list -n1 "refs/tags/${TAG}")" - if ! git merge-base --is-ancestor "$TAG_COMMIT" "origin/${REQUIRED_BRANCH}"; then - echo "::error::Tag ${TAG} is not reachable from origin/${REQUIRED_BRANCH}. Refusing floating-tag promotion." - exit 1 - fi - - if [ "$PRERELEASE" != "true" ]; then - BASE_VERSION="${TAG#v}" - BASE_VERSION="${BASE_VERSION%%-*}" - mapfile -t RC_TAGS < <(git tag -l "v${BASE_VERSION}-rc.*" --sort=-version:refname) - if [ "${#RC_TAGS[@]}" -eq 0 ]; then - echo "::error::Stable tag ${TAG} has no matching prerelease tags for base version ${BASE_VERSION}." - exit 1 - fi - - MATCHED_RC="" - for rc_tag in "${RC_TAGS[@]}"; do - rc_commit="$(git rev-list -n1 "refs/tags/${rc_tag}")" - if git merge-base --is-ancestor "$rc_commit" "$TAG_COMMIT"; then - MATCHED_RC="$rc_tag" - break - fi - done - - if [ -z "$MATCHED_RC" ]; then - echo "::error::Stable tag ${TAG} does not descend from any matching prerelease tag for base version ${BASE_VERSION}." - exit 1 - fi - - echo "[OK] ${TAG} descends from prerelease ${MATCHED_RC}" - fi - - echo "[OK] ${TAG} validated against release line ${REQUIRED_BRANCH}" + python3 scripts/release_control/validate_artifact_release_line.py \ + --tag "${TAG}" \ + --purpose "floating-tag promotion" - name: Log in to Docker Hub uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index d5ce40e6d..16341579f 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -32,7 +32,6 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ inputs.tag }} fetch-depth: 0 fetch-tags: true @@ -57,56 +56,18 @@ jobs: - name: Validate release line policy env: TAG: ${{ steps.version.outputs.tag }} - IS_PRERELEASE: ${{ steps.version.outputs.is_prerelease }} run: | set -euo pipefail + python3 scripts/release_control/validate_artifact_release_line.py \ + --tag "${TAG}" \ + --purpose "Docker publish" - VERSION="${TAG#v}" - REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${VERSION}")" - - if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then - git fetch --prune --unshallow origin - fi - git fetch --prune origin "${REQUIRED_BRANCH}" --tags - - if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then - echo "::error::Tag ${TAG} does not exist in repository tags." - exit 1 - fi - - TAG_COMMIT="$(git rev-list -n1 "refs/tags/${TAG}")" - if ! git merge-base --is-ancestor "$TAG_COMMIT" "origin/${REQUIRED_BRANCH}"; then - echo "::error::Tag ${TAG} is not reachable from origin/${REQUIRED_BRANCH}. Refusing cross-line Docker publish." - exit 1 - fi - - if [ "$IS_PRERELEASE" != "true" ]; then - BASE_VERSION="${TAG#v}" - BASE_VERSION="${BASE_VERSION%%-*}" - mapfile -t RC_TAGS < <(git tag -l "v${BASE_VERSION}-rc.*" --sort=-version:refname) - if [ "${#RC_TAGS[@]}" -eq 0 ]; then - echo "::error::Stable tag ${TAG} has no matching prerelease tags for base version ${BASE_VERSION}." - exit 1 - fi - - MATCHED_RC="" - for rc_tag in "${RC_TAGS[@]}"; do - rc_commit="$(git rev-list -n1 "refs/tags/${rc_tag}")" - if git merge-base --is-ancestor "$rc_commit" "$TAG_COMMIT"; then - MATCHED_RC="$rc_tag" - break - fi - done - - if [ -z "$MATCHED_RC" ]; then - echo "::error::Stable tag ${TAG} does not descend from any matching prerelease tag for base version ${BASE_VERSION}." - exit 1 - fi - - echo "[OK] ${TAG} descends from prerelease ${MATCHED_RC}" - fi - - echo "[OK] ${TAG} validated against release line ${REQUIRED_BRANCH}" + - name: Check out validated release tag + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + set -euo pipefail + git checkout --detach "refs/tags/${TAG}" - name: Set up QEMU uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index 4bbfaa9d2..4c745741d 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -99,30 +99,12 @@ jobs: id: release_line env: RELEASE_TAG: ${{ steps.versions.outputs.release_tag }} - APP_VERSION: ${{ steps.versions.outputs.app_version }} run: | set -euo pipefail - - REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${APP_VERSION}")" - - if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then - git fetch --prune --unshallow origin - fi - git fetch --prune origin "${REQUIRED_BRANCH}" --tags - - if ! git rev-parse -q --verify "refs/tags/${RELEASE_TAG}" >/dev/null; then - echo "::error::Tag ${RELEASE_TAG} does not exist. Helm publish must map to a real Git tag." - exit 1 - fi - - TAG_COMMIT="$(git rev-list -n1 "refs/tags/${RELEASE_TAG}")" - if ! git merge-base --is-ancestor "$TAG_COMMIT" "origin/${REQUIRED_BRANCH}"; then - echo "::error::Tag ${RELEASE_TAG} is not reachable from origin/${REQUIRED_BRANCH}. Refusing cross-line Helm publish." - exit 1 - fi - - echo "required_branch=${REQUIRED_BRANCH}" >> "$GITHUB_OUTPUT" - echo "[OK] ${RELEASE_TAG} validated against release line ${REQUIRED_BRANCH}" + python3 scripts/release_control/validate_artifact_release_line.py \ + --tag "${RELEASE_TAG}" \ + --purpose "Helm publish" \ + --github-output "$GITHUB_OUTPUT" - name: Check out validated release tag env: diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 953640bda..7dd8b3d71 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -93,16 +93,17 @@ TLS floor in the dynamic config. 57. `scripts/release_control/record_rc_to_ga_rehearsal.py` 58. `scripts/release_control/release_promotion_policy_support.py` 59. `scripts/release_control/resolve_release_promotion.py` -60. `scripts/release_ldflags.sh` -61. `scripts/run_cloud_public_signup_smoke.sh` -62. `scripts/run_demo_public_browser_smoke.sh` -63. `scripts/demo_public_browser_smoke.cjs` -64. `scripts/run_hosted_staging_smoke.sh` -65. `scripts/trigger-release-dry-run.sh` -66. `scripts/trigger-release.sh` -67. `scripts/toggle-mock.sh` -68. `deploy/provider-msp/` -69. `deploy/helm/pulse/` +60. `scripts/release_control/validate_artifact_release_line.py` +61. `scripts/release_ldflags.sh` +62. `scripts/run_cloud_public_signup_smoke.sh` +63. `scripts/run_demo_public_browser_smoke.sh` +64. `scripts/demo_public_browser_smoke.cjs` +65. `scripts/run_hosted_staging_smoke.sh` +66. `scripts/trigger-release-dry-run.sh` +67. `scripts/trigger-release.sh` +68. `scripts/toggle-mock.sh` +69. `deploy/provider-msp/` +70. `deploy/helm/pulse/` 70. `tests/integration/playwright.config.ts` 71. `tests/integration/QUICK_START.md` 72. `tests/integration/README.md` @@ -292,7 +293,7 @@ TLS floor in the dynamic config. ## Extension Points 1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/` -2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml` +2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml` Release-facing agent-paradigm blurbs under `docs/releases/` must describe `pulse-mcp` as a generic MCP adapter for MCP-speaking clients, not a client-specific release artifact, and full-surface token guidance must come @@ -451,7 +452,7 @@ TLS floor in the dynamic config. `PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS`) so live dev verification of host-side LXC Docker inventory does not silently restart into default-off monitoring. -6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` +6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` That release-promotion boundary also owns prerelease note packet lineage: shipped RC notes must remain historically accurate, the top-level `docs/RELEASE_NOTES.md` index must continue to point at the current shipped @@ -971,6 +972,12 @@ for stable-versus-prerelease metadata validation shared by `.github/workflows/re and `.github/workflows/create-release.yml`. Promotion rollback targets, promoted prerelease lineage, soak checks, and GA/v5 notice metadata may not drift between those two workflows through duplicated inline shell validation. +`scripts/release_control/validate_artifact_release_line.py` is the canonical +owner for follow-on artifact workflow release-line validation shared by Docker +publication, floating-tag promotion, Helm chart publication, and Helm Pages +publication. It must keep first stable/minor promotions tied to matching +prerelease lineage while allowing stable patch tags to follow the previous +stable tag without fabricating same-version RC tags. That same promotion-governance boundary also owns the release-dispatch helpers and artifact follow-on workflows that consume those same decisions. Demo deployment, Docker publication, Helm chart publication, Helm Pages release, and diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 49941f7e2..4c2512b20 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -3367,6 +3367,7 @@ "scripts/release_control/release_promotion_policy_support.py", "scripts/release_control/render_release_body.py", "scripts/release_control/resolve_release_promotion.py", + "scripts/release_control/validate_artifact_release_line.py", "scripts/release_ldflags.sh", "scripts/run_cloud_public_signup_smoke.sh", "scripts/run_demo_public_browser_smoke.sh", @@ -3473,6 +3474,7 @@ "scripts/release_control/release_promotion_policy_support.py", "scripts/release_control/render_release_body.py", "scripts/release_control/resolve_release_promotion.py", + "scripts/release_control/validate_artifact_release_line.py", "scripts/trigger-release-dry-run.sh", "scripts/trigger-release.sh", "VERSION" @@ -3485,7 +3487,8 @@ "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", - "scripts/release_control/resolve_release_promotion_test.py" + "scripts/release_control/resolve_release_promotion_test.py", + "scripts/release_control/validate_artifact_release_line_test.py" ] }, { diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index c29a27990..e78b125aa 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -697,11 +697,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): release_workflow = read(".github/workflows/create-release.yml") helm = read(".github/workflows/publish-helm-chart.yml") helm_pages = read(".github/workflows/helm-pages.yml") + artifact_validator = read("scripts/release_control/validate_artifact_release_line.py") chart = read("deploy/helm/pulse/Chart.yaml") chart_sync = read("scripts/sync_chart_release_metadata.py") demo_smoke = read("scripts/demo_public_browser_smoke.cjs") runbook = read("docs/releases/V6_PRERELEASE_RUNBOOK.md") - self.assertIn("control_plane.py --branch-for-version", publish) + self.assertIn("validate_artifact_release_line.py", publish) + self.assertIn('git checkout --detach "refs/tags/${TAG}"', publish) self.assertIn("provenance: mode=max", publish) self.assertIn("sbom: true", publish) self.assertIn("uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4", publish) @@ -718,7 +720,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}", publish) self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", publish) self.assertNotIn("provenance: false", publish) - self.assertIn("control_plane.py --branch-for-version", promote) + self.assertIn("validate_artifact_release_line.py", promote) self.assertIn("control_plane.py --branch-for-version", demo) self.assertIn("demo-stable", demo) self.assertIn("Refusing prerelease tag", demo) @@ -756,17 +758,19 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("extract_entry_asset()", preview_deploy) self.assertIn(r']*\bsrc=\"(/assets/index-[^\"]*\.js)\"', preview_deploy) self.assertIn("Public demo is serving $PUBLIC_ASSET but the build expected $EXPECTED_ASSET.", preview_deploy) - self.assertIn("control_plane.py --branch-for-version", helm) - self.assertIn("control_plane.py --branch-for-version", helm_pages) - self.assertIn("does not descend from any matching prerelease tag", publish) - self.assertIn("does not descend from any matching prerelease tag", promote) - self.assertIn("Refusing cross-line Helm pages release", helm_pages) + self.assertIn("validate_artifact_release_line.py", helm) + self.assertIn("validate_artifact_release_line.py", helm_pages) + self.assertIn("release_branch_for_version", artifact_validator) + self.assertIn("matching prerelease tag", artifact_validator) + self.assertIn("previous stable tag", artifact_validator) + self.assertIn("stable_patch", artifact_validator) + self.assertIn("Refusing {purpose}", artifact_validator) self.assertIn("Build local Pulse runtime image for Helm smoke", release_workflow) self.assertIn('kind load docker-image "${SMOKE_IMAGE_REPOSITORY}:${SMOKE_IMAGE_TAG}" --name pulse-test', release_workflow) self.assertIn('--set image.repository="${SMOKE_IMAGE_REPOSITORY}"', release_workflow) self.assertIn('--set image.pullPolicy=Never', release_workflow) self.assertIn("needs.helm_smoke.result == 'success'", release_workflow) - self.assertIn('echo "required_branch=${REQUIRED_BRANCH}" >> "$GITHUB_OUTPUT"', helm_pages) + self.assertIn('--github-output "$GITHUB_OUTPUT"', helm_pages) self.assertIn('git checkout -B "$REQUIRED_BRANCH" "origin/$REQUIRED_BRANCH"', helm_pages) self.assertIn('git pull --rebase origin "$REQUIRED_BRANCH"', helm_pages) self.assertIn('git push origin HEAD:"$REQUIRED_BRANCH"', helm_pages) diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 6f6868fa4..bf3521048 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3566,6 +3566,7 @@ class SubsystemLookupTest(unittest.TestCase): "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", "scripts/release_control/resolve_release_promotion_test.py", + "scripts/release_control/validate_artifact_release_line_test.py", ], ) @@ -3601,6 +3602,7 @@ class SubsystemLookupTest(unittest.TestCase): "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", "scripts/release_control/resolve_release_promotion_test.py", + "scripts/release_control/validate_artifact_release_line_test.py", ], ) @@ -3643,6 +3645,7 @@ class SubsystemLookupTest(unittest.TestCase): "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", "scripts/release_control/resolve_release_promotion_test.py", + "scripts/release_control/validate_artifact_release_line_test.py", ], ) @@ -3678,6 +3681,7 @@ class SubsystemLookupTest(unittest.TestCase): "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", "scripts/release_control/resolve_release_promotion_test.py", + "scripts/release_control/validate_artifact_release_line_test.py", ], ) @@ -3713,6 +3717,7 @@ class SubsystemLookupTest(unittest.TestCase): "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", "scripts/release_control/resolve_release_promotion_test.py", + "scripts/release_control/validate_artifact_release_line_test.py", ], ) @@ -3748,6 +3753,7 @@ class SubsystemLookupTest(unittest.TestCase): "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", "scripts/release_control/resolve_release_promotion_test.py", + "scripts/release_control/validate_artifact_release_line_test.py", ], ) diff --git a/scripts/release_control/validate_artifact_release_line.py b/scripts/release_control/validate_artifact_release_line.py new file mode 100644 index 000000000..326970594 --- /dev/null +++ b/scripts/release_control/validate_artifact_release_line.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Validate public artifact publication against the governed release line.""" + +from __future__ import annotations + +import argparse +import subprocess +from pathlib import Path +from typing import Callable + +from control_plane import release_branch_for_version +from repo_file_io import REPO_ROOT, git_env +from resolve_release_promotion import SEMVER_STABLE_RE, is_prerelease_version, normalize_tag + + +def run_git(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=REPO_ROOT, + env=git_env(), + check=check, + capture_output=True, + text=True, + ) + + +def ensure_release_refs(required_branch: str) -> None: + shallow = run_git(["rev-parse", "--is-shallow-repository"]).stdout.strip() + if shallow == "true": + run_git(["fetch", "--prune", "--unshallow", "origin"]) + run_git(["fetch", "--prune", "origin", required_branch]) + + +def tag_exists(tag: str) -> bool: + result = run_git(["rev-parse", "-q", "--verify", f"refs/tags/{tag}"], check=False) + return result.returncode == 0 + + +def tag_commit(tag: str) -> str: + return run_git(["rev-list", "-n1", f"refs/tags/{tag}"]).stdout.strip() + + +def ref_commit(ref: str) -> str: + return run_git(["rev-list", "-n1", ref]).stdout.strip() + + +def ref_is_ancestor(ancestor: str, descendant: str) -> bool: + result = run_git(["merge-base", "--is-ancestor", ancestor, descendant], check=False) + return result.returncode == 0 + + +def matching_prerelease_tags(version: str) -> tuple[str, ...]: + output = run_git(["tag", "-l", f"v{version}-rc.*", "--sort=-version:refname"]).stdout + return tuple(line.strip() for line in output.splitlines() if line.strip()) + + +def previous_stable_tag(version: str) -> str: + match = SEMVER_STABLE_RE.match(version) + if not match: + return "" + major, minor, patch = (int(part) for part in match.groups()) + if patch <= 0: + return "" + return f"v{major}.{minor}.{patch - 1}" + + +def validate_artifact_release_line( + *, + tag: str, + purpose: str, + branch_for_version_fn: Callable[[str], str] = release_branch_for_version, + fetch_refs_fn: Callable[[str], None] = ensure_release_refs, + tag_exists_fn: Callable[[str], bool] = tag_exists, + tag_commit_fn: Callable[[str], str] = tag_commit, + ref_commit_fn: Callable[[str], str] = ref_commit, + ref_is_ancestor_fn: Callable[[str, str], bool] = ref_is_ancestor, + prerelease_tags_fn: Callable[[str], tuple[str, ...]] = matching_prerelease_tags, +) -> dict[str, str]: + normalized_tag = normalize_tag(tag) + if not normalized_tag: + raise ValueError("release tag is required") + + version = normalized_tag.removeprefix("v") + required_branch = branch_for_version_fn(version) + fetch_refs_fn(required_branch) + + if not tag_exists_fn(normalized_tag): + raise ValueError(f"Tag {normalized_tag} does not exist in repository tags.") + + tag_sha = tag_commit_fn(normalized_tag) + branch_ref = f"origin/{required_branch}" + branch_sha = ref_commit_fn(branch_ref) + if not ref_is_ancestor_fn(tag_sha, branch_sha): + raise ValueError( + f"Tag {normalized_tag} is not reachable from {branch_ref}. Refusing {purpose}." + ) + + lineage = "prerelease" + lineage_tag = "" + if not is_prerelease_version(version): + lineage = "" + for rc_tag in prerelease_tags_fn(version): + rc_sha = tag_commit_fn(rc_tag) + if ref_is_ancestor_fn(rc_sha, tag_sha): + lineage = "promoted_prerelease" + lineage_tag = rc_tag + break + + if not lineage: + previous_tag = previous_stable_tag(version) + if previous_tag and tag_exists_fn(previous_tag): + previous_sha = tag_commit_fn(previous_tag) + if ref_is_ancestor_fn(previous_sha, tag_sha): + lineage = "stable_patch" + lineage_tag = previous_tag + + if not lineage: + previous_tag = previous_stable_tag(version) + if previous_tag: + raise ValueError( + f"Stable patch tag {normalized_tag} must descend from a matching prerelease tag " + f"or previous stable tag {previous_tag}." + ) + raise ValueError( + f"Stable tag {normalized_tag} does not descend from any matching prerelease tag " + f"for base version {version}." + ) + + return { + "tag": normalized_tag, + "version": version, + "required_branch": required_branch, + "lineage": lineage, + "lineage_tag": lineage_tag, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--tag", required=True) + parser.add_argument("--purpose", default="artifact publication") + parser.add_argument("--github-output", default="") + return parser.parse_args() + + +def write_github_output(path: str, values: dict[str, str]) -> None: + if not path: + return + with Path(path).open("a", encoding="utf-8") as output: + for key, value in values.items(): + output.write(f"{key}={value}\n") + + +def main() -> int: + args = parse_args() + result = validate_artifact_release_line(tag=args.tag, purpose=args.purpose) + + if result["lineage"] == "promoted_prerelease": + print(f"[OK] {result['tag']} descends from prerelease {result['lineage_tag']}") + elif result["lineage"] == "stable_patch": + print(f"[OK] {result['tag']} descends from previous stable {result['lineage_tag']}") + print(f"[OK] {result['tag']} validated against release line {result['required_branch']}") + + write_github_output( + args.github_output, + { + "required_branch": result["required_branch"], + "lineage": result["lineage"], + "lineage_tag": result["lineage_tag"], + }, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/validate_artifact_release_line_test.py b/scripts/release_control/validate_artifact_release_line_test.py new file mode 100644 index 000000000..c37e5f46d --- /dev/null +++ b/scripts/release_control/validate_artifact_release_line_test.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Unit tests for artifact release-line validation.""" + +from __future__ import annotations + +import unittest + +import validate_artifact_release_line as validator + + +class ValidateArtifactReleaseLineTest(unittest.TestCase): + def validate( + self, + *, + tag: str, + existing_tags: set[str], + commits: dict[str, str], + ancestors: set[tuple[str, str]], + prerelease_tags: tuple[str, ...] = (), + ) -> dict[str, str]: + return validator.validate_artifact_release_line( + tag=tag, + purpose="test publish", + branch_for_version_fn=lambda version: "pulse/v6-release", + fetch_refs_fn=lambda required_branch: None, + tag_exists_fn=lambda candidate: candidate in existing_tags, + tag_commit_fn=lambda candidate: commits[candidate], + ref_commit_fn=lambda ref: commits[ref], + ref_is_ancestor_fn=lambda ancestor, descendant: (ancestor, descendant) in ancestors, + prerelease_tags_fn=lambda version: prerelease_tags, + ) + + def test_stable_ga_requires_matching_prerelease_lineage(self) -> None: + result = self.validate( + tag="v6.0.0", + existing_tags={"v6.0.0", "v6.0.0-rc.7"}, + commits={ + "v6.0.0": "ga", + "v6.0.0-rc.7": "rc7", + "origin/pulse/v6-release": "branch", + }, + ancestors={("ga", "branch"), ("rc7", "ga")}, + prerelease_tags=("v6.0.0-rc.7",), + ) + + self.assertEqual(result["lineage"], "promoted_prerelease") + self.assertEqual(result["lineage_tag"], "v6.0.0-rc.7") + + def test_stable_patch_can_follow_previous_stable_without_fabricated_rc(self) -> None: + result = self.validate( + tag="v6.0.1", + existing_tags={"v6.0.0", "v6.0.1"}, + commits={ + "v6.0.0": "ga", + "v6.0.1": "patch", + "origin/pulse/v6-release": "branch", + }, + ancestors={("patch", "branch"), ("ga", "patch")}, + ) + + self.assertEqual(result["lineage"], "stable_patch") + self.assertEqual(result["lineage_tag"], "v6.0.0") + + def test_stable_patch_rejects_without_previous_stable_or_rc(self) -> None: + with self.assertRaisesRegex(ValueError, "previous stable tag v6.0.0"): + self.validate( + tag="v6.0.1", + existing_tags={"v6.0.1"}, + commits={ + "v6.0.1": "patch", + "origin/pulse/v6-release": "branch", + }, + ancestors={("patch", "branch")}, + ) + + def test_cross_line_tag_is_rejected_before_lineage(self) -> None: + with self.assertRaisesRegex(ValueError, "Refusing test publish"): + self.validate( + tag="v6.0.1", + existing_tags={"v6.0.0", "v6.0.1"}, + commits={ + "v6.0.0": "ga", + "v6.0.1": "patch", + "origin/pulse/v6-release": "branch", + }, + ancestors={("ga", "patch")}, + ) + + +if __name__ == "__main__": + unittest.main()