diff --git a/scripts/release_control/resolve_release_promotion.py b/scripts/release_control/resolve_release_promotion.py index 572864b117..b19da00995 100644 --- a/scripts/release_control/resolve_release_promotion.py +++ b/scripts/release_control/resolve_release_promotion.py @@ -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: diff --git a/scripts/release_control/resolve_release_promotion_test.py b/scripts/release_control/resolve_release_promotion_test.py index fd39623829..460543a159 100644 --- a/scripts/release_control/resolve_release_promotion_test.py +++ b/scripts/release_control/resolve_release_promotion_test.py @@ -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"):