Prepare v6.0.1 patch release

- bump release, Docker, and Helm metadata to 6.0.1

- add committed v6.0.1 patch release notes and changelog

- allow stable patch hotfix releases without a fabricated same-version prerelease tag
This commit is contained in:
rcourtman 2026-07-04 19:32:01 +01:00
parent cefc032a37
commit 84ad973540
17 changed files with 334 additions and 82 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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