diff --git a/VERSION b/VERSION index 09b254e90..5fe607230 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.0.0 +6.0.1 diff --git a/deploy/helm/pulse/Chart.yaml b/deploy/helm/pulse/Chart.yaml index f5bb3c089..00bf2bc05 100644 --- a/deploy/helm/pulse/Chart.yaml +++ b/deploy/helm/pulse/Chart.yaml @@ -2,9 +2,9 @@ apiVersion: v2 name: pulse description: Helm chart for deploying the Pulse hub and optional Docker monitoring agent. type: application -version: 6.0.0 -appVersion: "6.0.0" -icon: https://raw.githubusercontent.com/rcourtman/Pulse/v6.0.0/docs/images/pulse-logo.svg +version: 6.0.1 +appVersion: "6.0.1" +icon: https://raw.githubusercontent.com/rcourtman/Pulse/v6.0.1/docs/images/pulse-logo.svg keywords: - monitoring - proxmox @@ -32,7 +32,7 @@ annotations: description: Smoke tests with kind cluster deployment artifacthub.io/links: | - name: Documentation - url: https://github.com/rcourtman/Pulse/blob/v6.0.0/docs/KUBERNETES.md + url: https://github.com/rcourtman/Pulse/blob/v6.0.1/docs/KUBERNETES.md - name: Support url: https://github.com/rcourtman/Pulse/discussions artifacthub.io/maintainers: | diff --git a/deploy/helm/pulse/README.md b/deploy/helm/pulse/README.md index 15b8ab30c..faca22bf7 100644 --- a/deploy/helm/pulse/README.md +++ b/deploy/helm/pulse/README.md @@ -1,6 +1,6 @@ # pulse -![Version: 6.0.0](https://img.shields.io/badge/Version-6.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 6.0.0](https://img.shields.io/badge/AppVersion-6.0.0-informational?style=flat-square) +![Version: 6.0.1](https://img.shields.io/badge/Version-6.0.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 6.0.1](https://img.shields.io/badge/AppVersion-6.0.1-informational?style=flat-square) Helm chart for deploying the Pulse hub and optional Docker monitoring agent. diff --git a/docker-compose.yml b/docker-compose.yml index 217706585..164a4294a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: pulse: - image: ${PULSE_IMAGE:-rcourtman/pulse:6.0.0} + image: ${PULSE_IMAGE:-rcourtman/pulse:6.0.1} container_name: pulse restart: unless-stopped logging: diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 89a026903..8808add12 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -6,7 +6,12 @@ Pulse release notes live on GitHub: For historical v4 notes that previously lived in this repo, see: `docs/releases/RELEASE_NOTES_v4.md` -For the prepared stable v6 release packet, see: +For the current stable v6 patch packet, see: +- `docs/releases/RELEASE_NOTES_v6.0.1.md` +- `docs/releases/V6_CHANGELOG_v6.0.1.md` +- `docs/UPGRADE_v6.md` + +For the first stable v6 release packet, see: - `docs/releases/RELEASE_NOTES_v6.md` - `docs/releases/V6_CHANGELOG.md` - `docs/UPGRADE_v6.md` diff --git a/docs/UPGRADE_v6.md b/docs/UPGRADE_v6.md index 7ae804bce..8def0aae8 100644 --- a/docs/UPGRADE_v6.md +++ b/docs/UPGRADE_v6.md @@ -4,6 +4,8 @@ This guide covers practical upgrade steps for existing Pulse installs moving to For the current stable v6 packet and rollout references, see: +- `docs/releases/RELEASE_NOTES_v6.0.1.md` +- `docs/releases/V6_CHANGELOG_v6.0.1.md` - `docs/releases/RELEASE_NOTES_v6.md` - `docs/releases/V6_CHANGELOG.md` diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index fcd82399f..953640bda 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -608,6 +608,15 @@ TLS floor in the dynamic config. That stable cut must also move the repo-root Docker compose default and `scripts/install-docker.sh` fallback from the final RC image tag to the stable `6.0.0` image tag in the same commit as `VERSION=6.0.0`. + Stable patch releases after `6.0.0` stay on this same governed release + boundary but do not need a fabricated same-version RC tag when the release + owner is intentionally publishing a hotfix patch from the current stable + branch. In that case `resolve_release_promotion.py` may accept an omitted + `promoted_from_tag` only for a stable semver patch version with + `hotfix_exception=true`, a non-empty `hotfix_reason`, and + `rollback_version` set to the previous stable tag. Non-hotfix stable + promotions, and any first-GA or minor-line stable promotion, still require + explicit promoted prerelease lineage and soak proof. 7. Preserve release-matched installer and Helm operator documentation links through `scripts/install.sh`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-helm-chart.yml`, and the chart metadata itself so deployment guidance and packaged chart metadata do not drift back to branch-tip `main` docs when a release line or promoted tag already exists. The same governed Helm boundary also owns `deploy/helm/pulse/` itself: chart metadata, default values, templates, and generated chart docs must @@ -771,11 +780,15 @@ TLS floor in the dynamic config. ## Current State -The active stable `v6.0.0` cut sets the repo-root `VERSION`, repo-root -`docker-compose.yml` image default, and `scripts/install-docker.sh` fallback -to the same `6.0.0` release version. The GA promotion metadata for this cut is +The active stable `v6.0.1` cut sets the repo-root `VERSION`, repo-root +`docker-compose.yml` image default, `scripts/install-docker.sh` fallback, and +Helm chart release metadata to the same `6.0.1` release version. This patch +release uses the stable hotfix path with `rollback_version=v6.0.0`, +`hotfix_exception=true`, a release-owner reason, and no fabricated +same-version RC tag. The initial GA promotion metadata remains `promoted_from_tag=v6.0.0-rc.7`, `rollback_version=v5.1.35`, -`ga_date=2026-07-04`, and `v5_eos_date=2026-10-02`. +`ga_date=2026-07-04`, and `v5_eos_date=2026-10-02` for the first stable +`6.0.0` cut. The shell-installer boundary carries root-agent service hardening for Linux installs. Installer-rendered agent units must keep the health/metrics listener @@ -818,13 +831,14 @@ operators only move to a newer image when they choose a newer explicit tag or override `PULSE_IMAGE`. For every RC or stable release cut, those Docker defaults must move with the same governed `VERSION` change and the installer proof in -`scripts/installtests/install_docker_sh_test.go` must assert both the repo-root -compose image default and the standalone installer fallback constant. A draft -release workflow failure caused by stale Docker image pins is a release-packet -blocker until the defaults, tests, and evidence record are refreshed from the -new branch head. -For the active stable `v6.0.0` cut, the repo-root compose default and -`scripts/install-docker.sh` fallback must both pin `6.0.0`; the stable +`scripts/installtests/install_docker_sh_test.go` and +`scripts/installtests/build_release_assets_test.go` must assert the repo-root +compose image default, standalone installer fallback constant, and packaged +Helm metadata. A draft release workflow failure caused by stale image or chart +pins is a release-packet blocker until the defaults, tests, and evidence +record are refreshed from the new branch head. +For the active stable `v6.0.1` cut, the repo-root compose default and +`scripts/install-docker.sh` fallback must both pin `6.0.1`; the stable promotion guard remains in force by rejecting leftover `-rc.` defaults when the governed `VERSION` is a stable release. The RC7 packet refresh records `fc10de9b5477613316473267b72b05b6b2b7aaff` diff --git a/docs/releases/RELEASE_NOTES_v6.0.1.md b/docs/releases/RELEASE_NOTES_v6.0.1.md new file mode 100644 index 000000000..16e6259d9 --- /dev/null +++ b/docs/releases/RELEASE_NOTES_v6.0.1.md @@ -0,0 +1,25 @@ +# Pulse v6.0.1 Release Notes + +`v6.0.1` is a stable patch release for the Pulse v6 line. It follows the +`v6.0.0` GA release and focuses on upgrade recovery, monitoring correctness, +large backup-set behaviour, and release-day publication cleanup. + +## Fixes + +- Fixed v5.1.x to v6 upgrade recovery when legacy agent update state is missing + or stale. +- Preserved scoped Workloads status filters when returning to platform pages + such as Proxmox Overview. +- Corrected host memory pressure so Linux cache is not counted as unavailable + memory. +- Reapplied router system settings after every monitor reload path, including + mock-setting and auto-registration reloads. +- Bounded PBS backup snapshot polling workers for large backup sets to reduce + release-line memory pressure. +- Retired the separate v6 preview demo target now that v6 is the stable release + line. + +## Upgrade Notes + +Use the normal v6 install or update flow for `v6.0.1`. The rollback target for +this patch release is `v6.0.0`. diff --git a/docs/releases/V6_CHANGELOG_v6.0.1.md b/docs/releases/V6_CHANGELOG_v6.0.1.md new file mode 100644 index 000000000..67e14e424 --- /dev/null +++ b/docs/releases/V6_CHANGELOG_v6.0.1.md @@ -0,0 +1,23 @@ +# Pulse v6.0.1 + +_This changelog describes the stable `v6.0.1` patch release compared with +`v6.0.0`._ + +## Fixed + +- v5.1.x to v6 upgrade recovery now handles missing or stale legacy agent + update state. +- Workloads status filters now survive the return path back to platform pages, + including Proxmox Overview. +- Host memory pressure no longer treats Linux cache as unavailable memory. +- Router system settings are reapplied consistently after monitor reload paths, + including mock-setting and auto-registration reloads. +- PBS backup snapshot polling now uses bounded workers for large backup sets. +- Stable release publication now uses the single post-GA demo target rather + than keeping a separate v6 preview demo path alive. + +## Release Metadata + +- Version: `v6.0.1` +- Rollback target: `v6.0.0` +- Promotion path: stable patch hotfix from `pulse/v6-release` diff --git a/scripts/install-docker.sh b/scripts/install-docker.sh index 10347414c..b3a8db593 100755 --- a/scripts/install-docker.sh +++ b/scripts/install-docker.sh @@ -6,7 +6,7 @@ set -euo pipefail SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" DOCKER_IMAGE_REPO="${DOCKER_IMAGE_REPO:-rcourtman/pulse}" -CANONICAL_DEFAULT_PULSE_VERSION="6.0.0" +CANONICAL_DEFAULT_PULSE_VERSION="6.0.1" resolve_default_pulse_version() { if [ -n "${PULSE_IMAGE_VERSION:-}" ]; then diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 0d4b97cf8..8e896c518 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -657,6 +657,38 @@ func TestDeploymentDefaultsPinVersionedImagesAndHelmDocsChecksum(t *testing.T) { t.Fatalf("install-docker.sh must not default to a floating latest tag:\n%s", installDocker) } + chartBytes, err := os.ReadFile(repoFile("deploy", "helm", "pulse", "Chart.yaml")) + if err != nil { + t.Fatalf("read Helm Chart.yaml: %v", err) + } + chart := string(chartBytes) + chartRequired := []string{ + "version: " + version, + `appVersion: "` + version + `"`, + "https://raw.githubusercontent.com/rcourtman/Pulse/v" + version + "/docs/images/pulse-logo.svg", + "https://github.com/rcourtman/Pulse/blob/v" + version + "/docs/KUBERNETES.md", + } + for _, needle := range chartRequired { + if !strings.Contains(chart, needle) { + t.Fatalf("Helm Chart.yaml must pin the governed release version, missing %s:\n%s", needle, chart) + } + } + + chartReadmeBytes, err := os.ReadFile(repoFile("deploy", "helm", "pulse", "README.md")) + if err != nil { + t.Fatalf("read Helm README.md: %v", err) + } + chartReadme := string(chartReadmeBytes) + chartReadmeRequired := []string{ + "![Version: " + version + "](https://img.shields.io/badge/Version-" + version + "-informational?style=flat-square)", + "![AppVersion: " + version + "](https://img.shields.io/badge/AppVersion-" + version + "-informational?style=flat-square)", + } + for _, needle := range chartReadmeRequired { + if !strings.Contains(chartReadme, needle) { + t.Fatalf("Helm README.md must reflect the governed release version, missing %s:\n%s", needle, chartReadme) + } + } + helmPagesBytes, err := os.ReadFile(repoFile(".github", "workflows", "helm-pages.yml")) if err != nil { t.Fatalf("read helm-pages.yml: %v", err) diff --git a/scripts/installtests/install_docker_sh_test.go b/scripts/installtests/install_docker_sh_test.go index c0af4b30b..ed8838173 100644 --- a/scripts/installtests/install_docker_sh_test.go +++ b/scripts/installtests/install_docker_sh_test.go @@ -102,6 +102,9 @@ func TestRepoDockerComposeDefaultPinsCurrentVersion(t *testing.T) { if !isPrereleaseVersion(version) && version == "6.0.0" && !strings.Contains(text, "rcourtman/pulse:6.0.0") { t.Fatalf("v6 GA repo docker-compose.yml must default to the stable v6 image:\n%s", text) } + if !isPrereleaseVersion(version) && version != "6.0.0" && strings.Contains(text, "rcourtman/pulse:6.0.0") { + t.Fatalf("stable patch repo docker-compose.yml must move off the initial GA image tag:\n%s", text) + } if strings.Contains(text, ":latest") { t.Fatalf("repo docker-compose.yml must not default to a floating latest tag:\n%s", text) } @@ -124,6 +127,9 @@ func TestInstallDockerScriptFallbackPinsCurrentVersion(t *testing.T) { if !isPrereleaseVersion(version) && version == "6.0.0" && !strings.Contains(text, `CANONICAL_DEFAULT_PULSE_VERSION="6.0.0"`) { t.Fatalf("v6 GA install-docker.sh fallback must default to the stable v6 image tag:\n%s", text) } + if !isPrereleaseVersion(version) && version != "6.0.0" && strings.Contains(text, `CANONICAL_DEFAULT_PULSE_VERSION="6.0.0"`) { + t.Fatalf("stable patch install-docker.sh fallback must move off the initial GA image tag:\n%s", text) + } } func runInstallDockerScript(t *testing.T, workDir string, envVars ...string) { diff --git a/scripts/release_control/internal/resolve_release_promotion.py b/scripts/release_control/internal/resolve_release_promotion.py index cf2c683d6..5811bfb41 100644 --- a/scripts/release_control/internal/resolve_release_promotion.py +++ b/scripts/release_control/internal/resolve_release_promotion.py @@ -13,6 +13,7 @@ from typing import Callable from repo_file_io import REPO_ROOT, git_env +SEMVER_STABLE_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") SEMVER_PRERELEASE_RE = re.compile(r"-(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)(?:\+[0-9A-Za-z.-]+)?$") @@ -29,6 +30,11 @@ def is_prerelease_version(version: str) -> bool: return bool(SEMVER_PRERELEASE_RE.search(version)) +def is_stable_patch_version(version: str) -> bool: + match = SEMVER_STABLE_RE.match(version) + return bool(match and int(match.group(3)) > 0) + + def tag_exists(tag: str) -> bool: result = subprocess.run( ["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"], @@ -124,35 +130,41 @@ def resolve_metadata( else: promoted_from_tag = normalize_tag(promoted_from_tag_input) if not promoted_from_tag: - raise ValueError( - "Stable promotion requires promoted_from_tag naming the RC being promoted." - ) - if not re.match(rf"^v{re.escape(version)}-rc\.\d+$", promoted_from_tag): - raise ValueError( - f"promoted_from_tag must reference an RC tag for the same stable version ({version}), got {promoted_from_tag}." - ) - if not tag_exists_fn(promoted_from_tag): - raise ValueError( - f"promoted_from_tag {promoted_from_tag} does not exist as a repository tag." - ) + if is_stable_patch_version(version) and hotfix_exception: + if not hotfix_reason: + raise ValueError("hotfix_reason is required when hotfix_exception is true.") + else: + raise ValueError( + "Stable promotion requires promoted_from_tag naming the prerelease being promoted. " + "Stable patch hotfix releases may omit promoted_from_tag only when hotfix_exception is true and hotfix_reason is set." + ) + else: + if not re.match(rf"^v{re.escape(version)}-rc\.\d+$", promoted_from_tag): + raise ValueError( + f"promoted_from_tag must reference a prerelease tag for the same stable version ({version}), got {promoted_from_tag}." + ) + if not tag_exists_fn(promoted_from_tag): + raise ValueError( + f"promoted_from_tag {promoted_from_tag} does not exist as a repository tag." + ) - promoted_commit = tag_commit_fn(promoted_from_tag) - if not head_descends_from_fn(promoted_commit): - raise ValueError( - f"Stable promotion {tag} must descend from promoted prerelease tag {promoted_from_tag}." - ) + promoted_commit = tag_commit_fn(promoted_from_tag) + if not head_descends_from_fn(promoted_commit): + raise ValueError( + f"Stable promotion {tag} must descend from promoted prerelease tag {promoted_from_tag}." + ) - promoted_tag_ts = tag_created_unix_fn(promoted_from_tag) - soak_hours_value = int((now_unix_fn() - promoted_tag_ts) / 3600) - soak_hours = str(soak_hours_value) + promoted_tag_ts = tag_created_unix_fn(promoted_from_tag) + soak_hours_value = int((now_unix_fn() - promoted_tag_ts) / 3600) + soak_hours = str(soak_hours_value) - if hotfix_exception: - if not hotfix_reason: - raise ValueError("hotfix_reason is required when hotfix_exception is true.") - elif soak_hours_value < 72: - raise ValueError( - f"Stable promotion {tag} has only {soak_hours_value} hours of prerelease soak since {promoted_from_tag}; minimum is 72 hours unless hotfix_exception is true." - ) + if hotfix_exception: + if not hotfix_reason: + raise ValueError("hotfix_reason is required when hotfix_exception is true.") + elif soak_hours_value < 72: + raise ValueError( + f"Stable promotion {tag} has only {soak_hours_value} hours of prerelease soak since {promoted_from_tag}; minimum is 72 hours unless hotfix_exception is true." + ) if version == "6.0.0": if not re.match(r"^\d{4}-\d{2}-\d{2}$", ga_date): diff --git a/scripts/release_control/internal/resolve_release_promotion_test.py b/scripts/release_control/internal/resolve_release_promotion_test.py index 130d6de2a..cabfb9502 100644 --- a/scripts/release_control/internal/resolve_release_promotion_test.py +++ b/scripts/release_control/internal/resolve_release_promotion_test.py @@ -82,6 +82,57 @@ class ResolveReleasePromotionTest(unittest.TestCase): now_unix_fn=lambda: 100 + (73 * 3600), ) + def test_stable_patch_hotfix_can_omit_promoted_prerelease(self) -> None: + metadata = resolver.resolve_metadata( + version="6.0.1", + promoted_from_tag_input="", + rollback_version_input="6.0.0", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=True, + hotfix_reason_input="Patch release for v6.0.0 upgrade and monitoring fixes.", + release_notes_input="", + tag_exists_fn=lambda tag: tag == "v6.0.0", + ) + + self.assertEqual(metadata["promoted_from_tag"], "") + self.assertEqual(metadata["rollback_tag"], "v6.0.0") + self.assertEqual(metadata["rollback_command"], "./scripts/install.sh --version v6.0.0") + self.assertEqual(metadata["hotfix_exception"], "true") + self.assertEqual( + metadata["hotfix_reason"], + "Patch release for v6.0.0 upgrade and monitoring fixes.", + ) + self.assertEqual(metadata["soak_hours"], "") + + def test_stable_patch_without_promoted_tag_requires_hotfix_exception(self) -> None: + with self.assertRaisesRegex(ValueError, "Stable promotion requires promoted_from_tag"): + resolver.resolve_metadata( + version="6.0.1", + promoted_from_tag_input="", + rollback_version_input="6.0.0", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + tag_exists_fn=lambda tag: tag == "v6.0.0", + ) + + def test_stable_patch_hotfix_without_promoted_tag_requires_reason(self) -> None: + with self.assertRaisesRegex(ValueError, "hotfix_reason is required"): + resolver.resolve_metadata( + version="6.0.1", + promoted_from_tag_input="", + rollback_version_input="6.0.0", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=True, + hotfix_reason_input="", + release_notes_input="", + tag_exists_fn=lambda tag: tag == "v6.0.0", + ) + def test_stable_hotfix_requires_reason(self) -> None: with self.assertRaisesRegex(ValueError, "hotfix_reason is required"): resolver.resolve_metadata( diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 7b15211c9..c29a27990 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -78,6 +78,18 @@ def rc_packet_paths_for_version(version: str) -> tuple[str, str, str] | None: ) +def stable_packet_paths_for_version(version: str) -> tuple[str, str] | None: + """Return the stable release-notes and changelog packet paths for a v6 stable VERSION.""" + if not re.match(r"^6\.\d+\.\d+$", version): + return None + if version == "6.0.0": + return ("docs/releases/RELEASE_NOTES_v6.md", "docs/releases/V6_CHANGELOG.md") + return ( + f"docs/releases/RELEASE_NOTES_v{version}.md", + f"docs/releases/V6_CHANGELOG_v{version}.md", + ) + + def staged_files() -> tuple[str, ...]: result = subprocess.run( ["git", "diff", "--cached", "--name-only"], @@ -327,10 +339,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): def test_version_file_matches_current_rc_packet(self) -> None: current_version = read("VERSION").strip() release_index = read("docs/RELEASE_NOTES.md") - if current_version == "6.0.0": - release_notes = read("docs/releases/RELEASE_NOTES_v6.md") - changelog = read("docs/releases/V6_CHANGELOG.md") - self.assertIn("prepared stable v6 release packet", release_index) + stable_packet_paths = stable_packet_paths_for_version(current_version) + if stable_packet_paths is not None: + release_notes_path, changelog_path = stable_packet_paths + release_notes = read(release_notes_path) + changelog = read(changelog_path) + self.assertIn(release_notes_path, release_index) + self.assertIn(changelog_path, release_index) self.assertIn(f"Pulse v{current_version} Release Notes", release_notes) self.assertIn(f"`v{current_version}`", release_notes) self.assertIn(f"Pulse v{current_version}", changelog) @@ -376,9 +391,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertNotIn("`POST /api/license/trial/start`", upgrade_guide) self.assertNotIn("signed activation token to `/auth/trial-activate`", upgrade_guide) self.assertNotIn("25 hosted Patrol", upgrade_guide) - if current_version == "6.0.0": - self.assertIn("docs/releases/RELEASE_NOTES_v6.md", upgrade_guide) - self.assertIn("docs/releases/V6_CHANGELOG.md", upgrade_guide) + stable_packet_paths = stable_packet_paths_for_version(current_version) + if stable_packet_paths is not None: + release_notes_path, changelog_path = stable_packet_paths + self.assertIn(release_notes_path, upgrade_guide) + self.assertIn(changelog_path, upgrade_guide) for _, _, _, support_pack in discover_rc_draft_packets(): self.assertNotIn(support_pack, upgrade_guide) self.assertNotIn("docs/releases/V6_RC_OPERATOR_SUPPORT_PACK.md", upgrade_guide) @@ -453,6 +470,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("default_output_path", recorder) self.assertIn("rollback_version is required for every release rehearsal and promotion", resolver) self.assertIn("Stable promotion requires promoted_from_tag", resolver) + self.assertIn("Stable patch hotfix releases may omit promoted_from_tag", resolver) self.assertIn("Stable v6.0.0 requires ga_date in YYYY-MM-DD form", resolver) self.assertIn("release_notes must include the exact ga_date", resolver) self.assertIn("check-workflow-dispatch-inputs.py", dry_run_trigger) @@ -802,8 +820,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase): blocked = read("docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md") current_version = read("VERSION").strip() active_target_id = read_json("docs/release-control/control_plane.json")["active_target_id"] - if current_version == "6.0.0": - self.assertIn(f"VERSION={current_version}", blocked) + if stable_packet_paths_for_version(current_version) is not None: + self.assertIn("VERSION=6.0.0", blocked) + if current_version != "6.0.0": + self.assertNotIn(f"VERSION={current_version}", blocked) else: self.assertIsNotNone( rc_packet_paths_for_version(current_version), diff --git a/scripts/release_control/resolve_release_promotion.py b/scripts/release_control/resolve_release_promotion.py index 1d4adb576..5811bfb41 100644 --- a/scripts/release_control/resolve_release_promotion.py +++ b/scripts/release_control/resolve_release_promotion.py @@ -13,6 +13,7 @@ from typing import Callable from repo_file_io import REPO_ROOT, git_env +SEMVER_STABLE_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") SEMVER_PRERELEASE_RE = re.compile(r"-(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)(?:\+[0-9A-Za-z.-]+)?$") @@ -29,6 +30,11 @@ def is_prerelease_version(version: str) -> bool: return bool(SEMVER_PRERELEASE_RE.search(version)) +def is_stable_patch_version(version: str) -> bool: + match = SEMVER_STABLE_RE.match(version) + return bool(match and int(match.group(3)) > 0) + + def tag_exists(tag: str) -> bool: result = subprocess.run( ["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"], @@ -124,35 +130,41 @@ def resolve_metadata( else: promoted_from_tag = normalize_tag(promoted_from_tag_input) if not promoted_from_tag: - raise ValueError( - "Stable promotion requires promoted_from_tag naming the prerelease being promoted." - ) - if not re.match(rf"^v{re.escape(version)}-rc\.\d+$", promoted_from_tag): - raise ValueError( - f"promoted_from_tag must reference a prerelease tag for the same stable version ({version}), got {promoted_from_tag}." - ) - if not tag_exists_fn(promoted_from_tag): - raise ValueError( - f"promoted_from_tag {promoted_from_tag} does not exist as a repository tag." - ) + if is_stable_patch_version(version) and hotfix_exception: + if not hotfix_reason: + raise ValueError("hotfix_reason is required when hotfix_exception is true.") + else: + raise ValueError( + "Stable promotion requires promoted_from_tag naming the prerelease being promoted. " + "Stable patch hotfix releases may omit promoted_from_tag only when hotfix_exception is true and hotfix_reason is set." + ) + else: + if not re.match(rf"^v{re.escape(version)}-rc\.\d+$", promoted_from_tag): + raise ValueError( + f"promoted_from_tag must reference a prerelease tag for the same stable version ({version}), got {promoted_from_tag}." + ) + if not tag_exists_fn(promoted_from_tag): + raise ValueError( + f"promoted_from_tag {promoted_from_tag} does not exist as a repository tag." + ) - promoted_commit = tag_commit_fn(promoted_from_tag) - if not head_descends_from_fn(promoted_commit): - raise ValueError( - f"Stable promotion {tag} must descend from promoted prerelease tag {promoted_from_tag}." - ) + promoted_commit = tag_commit_fn(promoted_from_tag) + if not head_descends_from_fn(promoted_commit): + raise ValueError( + f"Stable promotion {tag} must descend from promoted prerelease tag {promoted_from_tag}." + ) - promoted_tag_ts = tag_created_unix_fn(promoted_from_tag) - soak_hours_value = int((now_unix_fn() - promoted_tag_ts) / 3600) - soak_hours = str(soak_hours_value) + promoted_tag_ts = tag_created_unix_fn(promoted_from_tag) + soak_hours_value = int((now_unix_fn() - promoted_tag_ts) / 3600) + soak_hours = str(soak_hours_value) - if hotfix_exception: - if not hotfix_reason: - raise ValueError("hotfix_reason is required when hotfix_exception is true.") - elif soak_hours_value < 72: - raise ValueError( - f"Stable promotion {tag} has only {soak_hours_value} hours of prerelease soak since {promoted_from_tag}; minimum is 72 hours unless hotfix_exception is true." - ) + if hotfix_exception: + if not hotfix_reason: + raise ValueError("hotfix_reason is required when hotfix_exception is true.") + elif soak_hours_value < 72: + raise ValueError( + f"Stable promotion {tag} has only {soak_hours_value} hours of prerelease soak since {promoted_from_tag}; minimum is 72 hours unless hotfix_exception is true." + ) if version == "6.0.0": if not re.match(r"^\d{4}-\d{2}-\d{2}$", ga_date): diff --git a/scripts/release_control/resolve_release_promotion_test.py b/scripts/release_control/resolve_release_promotion_test.py index d41cfee70..4c539c791 100644 --- a/scripts/release_control/resolve_release_promotion_test.py +++ b/scripts/release_control/resolve_release_promotion_test.py @@ -86,7 +86,6 @@ class ResolveReleasePromotionTest(unittest.TestCase): ) def test_current_stable_v6_packet_resolves_with_publish_dates(self) -> None: - self.assertEqual((REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip(), "6.0.0") release_notes = (REPO_ROOT / "docs/releases/RELEASE_NOTES_v6.md").read_text(encoding="utf-8") metadata = resolver.resolve_metadata( version="6.0.0", @@ -110,6 +109,57 @@ class ResolveReleasePromotionTest(unittest.TestCase): self.assertEqual(metadata["ga_date"], "2026-07-04") self.assertEqual(metadata["v5_eos_date"], "2026-10-02") + def test_stable_patch_hotfix_can_omit_promoted_prerelease(self) -> None: + metadata = resolver.resolve_metadata( + version="6.0.1", + promoted_from_tag_input="", + rollback_version_input="6.0.0", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=True, + hotfix_reason_input="Patch release for v6.0.0 upgrade and monitoring fixes.", + release_notes_input="", + tag_exists_fn=lambda tag: tag == "v6.0.0", + ) + + self.assertEqual(metadata["promoted_from_tag"], "") + self.assertEqual(metadata["rollback_tag"], "v6.0.0") + self.assertEqual(metadata["rollback_command"], "./scripts/install.sh --version v6.0.0") + self.assertEqual(metadata["hotfix_exception"], "true") + self.assertEqual( + metadata["hotfix_reason"], + "Patch release for v6.0.0 upgrade and monitoring fixes.", + ) + self.assertEqual(metadata["soak_hours"], "") + + def test_stable_patch_without_promoted_tag_requires_hotfix_exception(self) -> None: + with self.assertRaisesRegex(ValueError, "Stable promotion requires promoted_from_tag"): + resolver.resolve_metadata( + version="6.0.1", + promoted_from_tag_input="", + rollback_version_input="6.0.0", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + tag_exists_fn=lambda tag: tag == "v6.0.0", + ) + + def test_stable_patch_hotfix_without_promoted_tag_requires_reason(self) -> None: + with self.assertRaisesRegex(ValueError, "hotfix_reason is required"): + resolver.resolve_metadata( + version="6.0.1", + promoted_from_tag_input="", + rollback_version_input="6.0.0", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=True, + hotfix_reason_input="", + release_notes_input="", + tag_exists_fn=lambda tag: tag == "v6.0.0", + ) + def test_stable_hotfix_requires_reason(self) -> None: with self.assertRaisesRegex(ValueError, "hotfix_reason is required"): resolver.resolve_metadata(