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.
This commit is contained in:
rcourtman 2026-07-04 20:49:47 +01:00
parent 84ad973540
commit 4fcb90673b
10 changed files with 329 additions and 161 deletions

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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"
]
},
{

View file

@ -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'<script\b[^>]*\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)

View file

@ -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",
],
)

View file

@ -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())

View file

@ -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()