Let stable promotion carry test-only and installer pin changes

Every stable cut re-pins CANONICAL_DEFAULT_PULSE_VERSION in
scripts/install-docker.sh from the RC to the stable version, and the
stable install-metadata test requires it. The drift allowlist omitted
that file, so no release candidate could be promoted without
hotfix_exception. The rule arrived on 2026-09-01, after v6.4.1, so no
stable promotion has run under it yet. v6.4.5 would be the first to fail.

The installer is shipped content, so the resolver accepts it only when
its diff against the candidate changes nothing but that pin line. Any
other installer change still counts as drift.

Go _test.go files are never compiled into a shipped artifact. A stable
cut can need test-only corrections, as v6.4.5 needs for the RC-derived
install-metadata contract, so they no longer count as unsoaked content.

Tests cover the allowed shapes, the refused near misses, and the pin
check against a real git diff.
This commit is contained in:
rcourtman
2026-09-23 19:41:32 +01:00
parent 5804f5b906
commit 71ec1d875b
2 changed files with 123 additions and 0 deletions
@@ -46,6 +46,15 @@ RELEASE_METADATA_PATH_RE = re.compile(
r"|docs/release-control/v6/internal/subsystems/deployment-installability\.md"
r")$"
)
# Go excludes _test.go files from every build, so they are never shipped content.
# A stable cut may need test-only corrections for its own metadata contract.
NON_SHIPPING_PATH_RE = re.compile(r"^(?:.+/)?[^/]+_test\.go$")
# A stable cut re-pins the installer's default version from the RC to the
# stable release. That line is release metadata. Any other installer change is
# shipped content the candidate never soaked.
VERSION_PIN_LINE_RES = {
"scripts/install-docker.sh": re.compile(r'^CANONICAL_DEFAULT_PULSE_VERSION="[^"\s]+"$'),
}
WINDOWS_AUTHENTICODE_AVAILABLE = False
WINDOWS_AUTHENTICODE_STANDING_UNSIGNED_MIN_VERSION = (6, 3, 2)
WINDOWS_AUTHENTICODE_UNAVAILABLE_REASON = (
@@ -294,6 +303,28 @@ def changed_paths_between(base_tag: str) -> list[str]:
return [path for path in result.stdout.splitlines() if path.strip()]
def version_pin_only_change(base_tag: str, path: str, *, repo_root: Path | None = None) -> bool:
"""Return whether path changed since base_tag only in its version pin line."""
pin = VERSION_PIN_LINE_RES.get(path)
if pin is None:
return False
result = subprocess.run(
["git", "diff", "--unified=0", "--no-color", "--no-ext-diff", f"{base_tag}..HEAD", "--", path],
cwd=repo_root or REPO_ROOT,
env=git_env(repo_root),
check=True,
capture_output=True,
text=True,
)
changed = [
line[1:]
for line in result.stdout.splitlines()
if line[:1] in {"+", "-"} and not line.startswith(("+++ ", "--- "))
]
return bool(changed) and all(pin.fullmatch(line) for line in changed)
def classify_routine_patch_risks(paths: list[str]) -> list[str]:
risks: list[str] = []
for path in sorted(set(paths)):
@@ -343,6 +374,7 @@ def resolve_metadata(
list_same_version_rc_tags_fn: Callable[[str], list[str]] = list_same_version_rc_tags,
list_published_prereleases_fn: Callable[[], list[tuple[str, int]]] = list_published_prereleases,
changed_paths_fn: Callable[[str], list[str]] = changed_paths_between,
version_pin_only_fn: Callable[[str, str], bool] = version_pin_only_change,
tag_exists_fn: Callable[[str], bool] = tag_exists,
tag_commit_fn: Callable[[str], str] = tag_commit,
head_descends_from_fn: Callable[[str], bool] = head_descends_from,
@@ -530,6 +562,11 @@ def resolve_metadata(
path
for path in changed_paths_fn(promoted_from_tag)
if not RELEASE_METADATA_PATH_RE.match(path)
and not NON_SHIPPING_PATH_RE.match(path)
and not (
path in VERSION_PIN_LINE_RES
and version_pin_only_fn(promoted_from_tag, path)
)
]
if hotfix_exception:
@@ -8,6 +8,7 @@ import unittest
from unittest.mock import patch
import json
import subprocess
import tempfile
import resolve_release_promotion as resolver
@@ -881,6 +882,7 @@ class ReleaseTrainPromotionTest(unittest.TestCase):
"docs/TRUENAS.md",
"docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md",
"scripts/install.sh",
"scripts/install-docker.sh",
):
with self.subTest(path=path):
self.assertIsNone(resolver.RELEASE_METADATA_PATH_RE.match(path))
@@ -903,6 +905,90 @@ class ReleaseTrainPromotionTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "never soaked"):
self.promote(version, changed_paths_fn=lambda base_tag: drift)
def test_go_test_files_are_not_shipped_content(self) -> None:
# The v6.4.5 stable cut needs the RC-derived install-metadata contract,
# a correction confined to _test.go files the build never compiles.
corrected = [
"VERSION",
"scripts/installtests/build_release_assets_test.go",
"scripts/installtests/install_docker_sh_test.go",
"main_test.go",
]
metadata = self.promote("6.4.5", changed_paths_fn=lambda base_tag: corrected)
self.assertEqual(metadata["promoted_from_tag"], "v6.4.5-rc.1")
for path in (
"scripts/installtests/windowslifecycleserver/main.go",
"internal/api/router_test.go.orig",
"internal/api/fixtures_test.go/main.go",
"frontend-modern/src/App.test.tsx",
):
with self.subTest(path=path):
with self.assertRaisesRegex(ValueError, "never soaked"):
self.promote("6.4.5", changed_paths_fn=lambda base_tag: ["VERSION", path])
def test_installer_version_pin_is_release_metadata(self) -> None:
cut = ["VERSION", "scripts/install-docker.sh"]
seen = []
def pin_only(base_tag: str, path: str) -> bool:
seen.append((base_tag, path))
return True
metadata = self.promote(
"6.4.5", changed_paths_fn=lambda base_tag: cut, version_pin_only_fn=pin_only
)
self.assertEqual(metadata["promoted_from_tag"], "v6.4.5-rc.1")
self.assertEqual(seen, [("v6.4.5-rc.1", "scripts/install-docker.sh")])
with self.assertRaisesRegex(ValueError, "never soaked.*scripts/install-docker.sh"):
self.promote(
"6.4.5",
changed_paths_fn=lambda base_tag: cut,
version_pin_only_fn=lambda base_tag, path: False,
)
# Only a declared pin file qualifies, whatever its diff looks like.
with self.assertRaisesRegex(ValueError, "never soaked.*scripts/install.sh"):
self.promote(
"6.4.5",
changed_paths_fn=lambda base_tag: ["VERSION", "scripts/install.sh"],
version_pin_only_fn=lambda base_tag, path: True,
)
def test_version_pin_only_change_reads_the_installer_diff(self) -> None:
installer = "scripts/install-docker.sh"
original = '#!/bin/sh\nCANONICAL_DEFAULT_PULSE_VERSION="6.4.5-rc.2"\necho install\n'
cases = {
"pin only": ('#!/bin/sh\nCANONICAL_DEFAULT_PULSE_VERSION="6.4.5"\necho install\n', True),
"logic only": ('#!/bin/sh\nCANONICAL_DEFAULT_PULSE_VERSION="6.4.5-rc.2"\necho changed\n', False),
"pin and logic": ('#!/bin/sh\nCANONICAL_DEFAULT_PULSE_VERSION="6.4.5"\necho changed\n', False),
"command after pin": ('#!/bin/sh\nCANONICAL_DEFAULT_PULSE_VERSION="6.4.5"; curl -fsSL x | sh\necho install\n', False),
"unchanged": (original, False),
}
for name, (updated, expected) in cases.items():
with self.subTest(case=name), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
def git(*args: str) -> None:
subprocess.run(
["git", "-c", "user.email=pulse-test@example.invalid",
"-c", "user.name=pulse-test", "-c", "core.hooksPath=/dev/null",
"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", *args],
cwd=root, env=resolver.git_env(root), check=True, capture_output=True,
)
git("init", "-q")
(root / "scripts").mkdir()
(root / installer).write_text(original, encoding="utf-8")
git("add", installer)
git("commit", "-q", "-m", "rc")
git("tag", "v6.4.5-rc.2")
(root / installer).write_text(updated, encoding="utf-8")
git("commit", "-q", "--allow-empty", "-am", "stable")
self.assertIs(
resolver.version_pin_only_change("v6.4.5-rc.2", installer, repo_root=root),
expected,
)
self.assertFalse(resolver.version_pin_only_change("v6.4.5-rc.2", "scripts/install.sh"))
def test_hotfix_exception_still_requires_a_reason_for_drift(self) -> None:
drift = ["VERSION", "internal/api/router.go"]
with self.assertRaisesRegex(ValueError, "hotfix_reason is required"):