diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 119f4f41cf..fd15e183d1 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -10401,7 +10401,21 @@ ] } ], - "work_claims": [], + "work_claims": [ + { + "id": "pulse-maintainer-lane-l1", + "agent_id": "pulse-maintainer", + "summary": "Bind future maintenance stable promotion to its soaked candidate, preserving explicit hotfix reason and soak checks", + "target_id": "v6-release-reliability", + "claimed_at": "2026-09-14T23:35:36Z", + "heartbeat_at": "2026-09-14T23:35:36Z", + "expires_at": "2026-09-15T01:35:36Z", + "work_item": { + "kind": "lane", + "id": "L1" + } + } + ], "open_decisions": [], "source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md", "resolved_decisions": [ diff --git a/scripts/release_control/resolve_release_promotion.py b/scripts/release_control/resolve_release_promotion.py index d1034bea9b..572864b117 100644 --- a/scripts/release_control/resolve_release_promotion.py +++ b/scripts/release_control/resolve_release_promotion.py @@ -521,16 +521,16 @@ def resolve_metadata( promoted_tag_ts = release_published_unix_fn(promoted_from_tag) soak_hours_value = int((now_unix_fn() - promoted_tag_ts) / 3600) soak_hours = str(soak_hours_value) - # The release train governs v6.5.0 and later. Earlier lines shipped - # under the previous regime and their recorded exceptions stand. + # The seven-day minor-release cadence starts at v6.5.0. + # Source binding below applies independently to every future stable. train_governed = bool(stable_version and stable_version >= RELEASE_TRAIN_MIN_VERSION) - candidate_content_drift: list[str] = [] - if train_governed: - candidate_content_drift = [ - path - for path in changed_paths_fn(promoted_from_tag) - if not RELEASE_METADATA_PATH_RE.match(path) - ] + # Every new stable promotion must ship its observed source, including + # maintenance lines predating the minor-release train cadence. + candidate_content_drift = [ + path + for path in changed_paths_fn(promoted_from_tag) + if not RELEASE_METADATA_PATH_RE.match(path) + ] if hotfix_exception: if not hotfix_reason: diff --git a/scripts/release_control/resolve_release_promotion_test.py b/scripts/release_control/resolve_release_promotion_test.py index b059735559..fd39623829 100644 --- a/scripts/release_control/resolve_release_promotion_test.py +++ b/scripts/release_control/resolve_release_promotion_test.py @@ -268,6 +268,7 @@ class ResolveReleasePromotionTest(unittest.TestCase): def test_stable_requires_matching_promoted_rc_and_soak(self) -> None: metadata = resolver.resolve_metadata( + changed_paths_fn=lambda tag: ["VERSION"], version="6.0.0", promoted_from_tag_input="6.0.0-rc.2", rollback_version_input="5.1.14", @@ -289,6 +290,7 @@ class ResolveReleasePromotionTest(unittest.TestCase): def test_v610_owner_exception_allows_disclosed_unsigned_windows_candidate(self) -> None: metadata = resolver.resolve_metadata( + changed_paths_fn=lambda tag: ["VERSION"], version="6.1.0", promoted_from_tag_input="v6.1.0-rc.4", rollback_version_input="v6.0.5", @@ -378,6 +380,7 @@ class ResolveReleasePromotionTest(unittest.TestCase): def test_v620_owner_exception_allows_disclosed_stable_promotion(self) -> None: metadata = resolver.resolve_metadata( + changed_paths_fn=lambda tag: ["VERSION"], version="6.2.0", promoted_from_tag_input="v6.2.0-rc.11", rollback_version_input="v6.1.2", @@ -437,6 +440,7 @@ class ResolveReleasePromotionTest(unittest.TestCase): def test_v630_owner_exception_allows_disclosed_stable_promotion(self) -> None: metadata = resolver.resolve_metadata( + changed_paths_fn=lambda tag: ["VERSION"], version="6.3.0", promoted_from_tag_input="v6.3.0-rc.6", rollback_version_input="v6.2.1", @@ -526,6 +530,7 @@ class ResolveReleasePromotionTest(unittest.TestCase): def test_future_stable_release_stays_unsigned_until_authenticode_is_restored(self) -> None: metadata = resolver.resolve_metadata( + changed_paths_fn=lambda tag: ["VERSION"], version="6.4.0", promoted_from_tag_input="v6.4.0-rc.1", rollback_version_input="v6.3.2", @@ -553,6 +558,7 @@ class ResolveReleasePromotionTest(unittest.TestCase): "approved only for stable v6.1.0, v6.1.1, v6.1.2, v6.2.0, v6.2.1, v6.3.0, v6.3.1, or v6.3.2", ): resolver.resolve_metadata( + changed_paths_fn=lambda tag: ["VERSION"], version="6.4.0", promoted_from_tag_input="", rollback_version_input="v6.3.2", @@ -602,6 +608,7 @@ class ResolveReleasePromotionTest(unittest.TestCase): "release_notes must include the Pulse v5 maintenance-only support notice", ): resolver.resolve_metadata( + changed_paths_fn=lambda tag: ["VERSION"], version="6.0.0", promoted_from_tag_input="6.0.0-rc.2", rollback_version_input="5.1.14", @@ -620,6 +627,7 @@ class ResolveReleasePromotionTest(unittest.TestCase): def test_current_stable_v6_packet_resolves_with_publish_dates(self) -> None: release_notes = (REPO_ROOT / "docs/releases/RELEASE_NOTES_v6.md").read_text(encoding="utf-8") metadata = resolver.resolve_metadata( + changed_paths_fn=lambda tag: ["VERSION"], version="6.0.0", promoted_from_tag_input="v6.0.0-rc.7", rollback_version_input="v5.1.35", @@ -784,6 +792,7 @@ class ResolveReleasePromotionTest(unittest.TestCase): def test_stable_hotfix_requires_reason(self) -> None: with self.assertRaisesRegex(ValueError, "hotfix_reason is required"): resolver.resolve_metadata( + changed_paths_fn=lambda tag: ["VERSION"], version="6.0.2", promoted_from_tag_input="6.0.2-rc.1", rollback_version_input="6.0.1", @@ -802,6 +811,7 @@ class ResolveReleasePromotionTest(unittest.TestCase): def test_stable_rejects_short_soak_without_hotfix(self) -> None: with self.assertRaisesRegex(ValueError, "hours of prerelease soak"): resolver.resolve_metadata( + changed_paths_fn=lambda tag: ["VERSION"], version="6.0.2", promoted_from_tag_input="6.0.2-rc.1", rollback_version_input="6.0.1", @@ -888,8 +898,10 @@ class ReleaseTrainPromotionTest(unittest.TestCase): ] with self.assertRaisesRegex(ValueError, "never soaked.*2 paths beyond release metadata"): self.promote("6.5.0", changed_paths_fn=lambda base_tag: drift) - with self.assertRaisesRegex(ValueError, "never soaked"): - self.promote("6.5.1", changed_paths_fn=lambda base_tag: drift) + for version in ("6.5.1", "6.4.5", "6.3.2"): + with self.subTest(version=version): + with self.assertRaisesRegex(ValueError, "never soaked"): + self.promote(version, changed_paths_fn=lambda base_tag: drift) def test_hotfix_exception_still_requires_a_reason_for_drift(self) -> None: drift = ["VERSION", "internal/api/router.go"] @@ -905,7 +917,7 @@ class ReleaseTrainPromotionTest(unittest.TestCase): self.assertEqual(metadata["hotfix_exception"], "true") def test_repaired_candidate_restarts_full_publication_soak(self) -> None: - for version, hours in (("6.5.0", 168), ("6.5.1", 72)): + for version, hours in (("6.5.0", 168), ("6.5.1", 72), ("6.4.5", 72)): tag = f"v{version}-rc.2" observed = [] def publication(candidate):