From 0dc2c8c16d9612f981a29055cc703f6100cdf8cb Mon Sep 17 00:00:00 2001 From: Pulse Test Date: Fri, 28 Aug 2026 20:39:20 +0100 Subject: [PATCH] Require prerelease observation windows --- .github/workflows/create-release.yml | 5 ++ .../v6/internal/RELEASE_PROMOTION_POLICY.md | 12 ++- .../v6/internal/SOURCE_OF_TRUTH.md | 6 ++ docs/release-control/v6/internal/status.json | 12 +++ .../subsystems/deployment-installability.md | 8 ++ .../release_promotion_policy_test.py | 14 +++ .../resolve_release_promotion.py | 90 +++++++++++++++++++ .../resolve_release_promotion_test.py | 81 +++++++++++++++++ scripts/trigger-release.sh | 1 + 9 files changed, 228 insertions(+), 1 deletion(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 48e4db785..de11a22b9 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -217,6 +217,8 @@ jobs: HOTFIX_REASON_INPUT: ${{ github.event.inputs.hotfix_reason }} UNSIGNED_WINDOWS_EXCEPTION_INPUT: ${{ github.event.inputs.unsigned_windows_exception }} UNSIGNED_WINDOWS_REASON_INPUT: ${{ github.event.inputs.unsigned_windows_reason }} + DRAFT_ONLY_INPUT: ${{ github.event.inputs.draft_only }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -249,6 +251,9 @@ jobs: elif [ -n "${UNSIGNED_WINDOWS_REASON_INPUT:-}" ]; then HELPER_ARGS+=(--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT}") fi + if [ "${DRAFT_ONLY_INPUT:-false}" != "true" ]; then + HELPER_ARGS+=(--enforce-prerelease-observation-window) + fi python3 scripts/release_control/resolve_release_promotion.py "${HELPER_ARGS[@]}" > "$RUNNER_TEMP/promotion-metadata.out" rm -f "$NOTES_FILE" diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index c88fbea23..d1583104d 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -204,7 +204,17 @@ TLS-unverified receipts leave the claim at `implemented` or only structural workflow validation. - A governed prerelease publication record; an accidental git tag by itself does not count as a shipped prerelease. -3. Failed prereleases are fixed forward and replaced with a new prerelease. They are never +3. A published release candidate is a cohort checkpoint, not a delivery vehicle + for each individual fix. After the first RC on a version line, at least 24 + hours of public observation must elapse before another RC on that line may be + published. Accumulate compatible fixes and release-note outcomes during the + window. Candidate preparation, draft creation, and Release Dry Run remain + available throughout it because they do not replace the public cohort. +4. Use an immutable issue-and-commit reporter test image for narrow confirmation + that cannot justify a new public cohort. Broad release qualification remains + on the RC path, but successful narrow validation does not bypass the 24-hour + publication boundary. +5. Failed prereleases are fixed forward and replaced with a new prerelease. They are never promoted as-is to `stable`. ## Paid Pro Artifact Lineage diff --git a/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md b/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md index 75143f156..2081049a7 100644 --- a/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md +++ b/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md @@ -671,6 +671,12 @@ Assertion design rules: lineage validation remains on the governed RC path. Successful reporter confirmation informs severity-based release scheduling but does not force an immediate patch release. +22. A published release candidate is a cohort checkpoint, not a per-fix delivery + mechanism. After the first RC on a version line, at least 24 hours of public + observation must elapse before another RC on that line is published. + Compatible fixes, release packets, drafts, and dry runs may continue during + the window. Narrow reporter confirmation uses the issue-scoped test-image + path and cannot force or bypass another public RC. ## TrueNAS Support Floor diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 6b65c27e5..88388ab1a 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -10745,6 +10745,18 @@ "lane_ids": [ "L1" ] + }, + { + "id": "prerelease-observation-window", + "summary": "After the first release candidate on a version line, each later public RC requires at least 24 hours of observation since the previous published RC. Compatible fixes and release evidence accumulate during the window, while draft creation, dry runs, and issue-scoped reporter test images remain available without replacing the public cohort.", + "kind": "release-policy", + "decided_at": "2026-08-28", + "subsystem_ids": [ + "deployment-installability" + ], + "lane_ids": [ + "L1" + ] } ] } diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 1edebb9e2..1085b5e9a 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -29,6 +29,14 @@ The next-candidate release notes and changelog must also describe newly stable integration fields when external receivers need them to consume the release; packet proof keeps the customer-facing summary and detailed changelog aligned. +A published release candidate is a cohort checkpoint rather than a delivery +vehicle for each individual fix. After the first RC on a version line, public +RC publications on that line are separated by at least 24 hours of observation. +Compatible fixes and packet evidence may accumulate during that window, while +draft creation and Release Dry Run remain available. Narrow reporter validation +uses immutable issue-and-commit test images and does not force or bypass a new +public RC. + Release-note comparison ranges are channel-specific. Each RC compares against the immediately preceding RC on the same version line, with RC1 comparing against the previous stable release. A stable GA release compares against the diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 2ee2e5b4a..c1f09ad65 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -1361,6 +1361,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): source_of_truth = read("docs/release-control/v6/internal/SOURCE_OF_TRUTH.md") runbook = read("docs/releases/V6_PRERELEASE_RUNBOOK.md") resolver = read("scripts/release_control/resolve_release_promotion.py") + dry_run_workflow = read(".github/workflows/release-dry-run.yml") + dry_run_helper = read("scripts/trigger-release-dry-run.sh") contract = read("docs/release-control/v6/internal/subsystems/deployment-installability.md") self.assertIn("It does not automatically check out or build `pulse-enterprise`.", runbook) self.assertIn("public `pulse-v...` release archives are OSS runtime artifacts", runbook) @@ -1397,6 +1399,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('git fetch --prune origin main "${REQUIRED_BRANCH}" --tags', content) self.assertIn('REQUIRED_BRANCH: ${{ steps.branch_policy.outputs.required_branch }}', content) self.assertIn("resolve_release_promotion.py", content) + self.assertIn("--enforce-prerelease-observation-window", content) + self.assertIn("DRAFT_ONLY_INPUT", content) + self.assertIn("--enforce-prerelease-observation-window", helper) + self.assertNotIn("--enforce-prerelease-observation-window", dry_run_workflow) + self.assertNotIn("--enforce-prerelease-observation-window", dry_run_helper) self.assertIn("render_release_body.py", content) self.assertIn("build_rollback_section", renderer) self.assertIn("uses: ./.github/workflows/publish-docker.yml", content) @@ -1417,6 +1424,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("recorded rollback target plus exact", source_of_truth) self.assertIn("hours of prerelease soak", resolver) self.assertIn("minimum is 72 hours unless hotfix_exception is true", resolver) + self.assertIn("MIN_PRERELEASE_OBSERVATION_HOURS = 24", resolver) + self.assertIn("cohort checkpoint, not a delivery vehicle", policy) + self.assertIn("at least 24 hours of public observation", normalize_ws(source_of_truth)) + self.assertIn( + "public RC publications on that line are separated by at least 24 hours", + normalize_ws(contract), + ) self.assertIn("build_rollback_section", renderer) self.assertIn("promotion metadata out of customer notes", renderer) self.assertIn("historical_asset_backfill_only:", content) diff --git a/scripts/release_control/resolve_release_promotion.py b/scripts/release_control/resolve_release_promotion.py index 9729c1c13..75ca35db9 100644 --- a/scripts/release_control/resolve_release_promotion.py +++ b/scripts/release_control/resolve_release_promotion.py @@ -4,7 +4,9 @@ from __future__ import annotations import argparse +from datetime import datetime, timezone import fnmatch +import json import re import subprocess import time @@ -17,6 +19,8 @@ from repo_file_io import REPO_ROOT, git_env SEMVER_STABLE_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") SEMVER_STABLE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") SEMVER_PRERELEASE_RE = re.compile(r"-(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)(?:\+[0-9A-Za-z.-]+)?$") +SEMVER_RC_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$") +MIN_PRERELEASE_OBSERVATION_HOURS = 24 WINDOWS_AUTHENTICODE_AVAILABLE = False WINDOWS_AUTHENTICODE_STANDING_UNSIGNED_MIN_VERSION = (6, 3, 2) WINDOWS_AUTHENTICODE_UNAVAILABLE_REASON = ( @@ -176,6 +180,56 @@ def list_same_version_rc_tags(version: str) -> list[str]: return [tag for tag in result.stdout.splitlines() if tag.strip()] +def list_published_prereleases() -> list[tuple[str, int]]: + result = subprocess.run( + [ + "gh", + "release", + "list", + "--limit", + "100", + "--json", + "tagName,isDraft,isPrerelease,publishedAt", + ], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + releases = json.loads(result.stdout) + published: list[tuple[str, int]] = [] + for release in releases: + if release.get("isDraft") or not release.get("isPrerelease"): + continue + published_at = (release.get("publishedAt") or "").strip() + if not published_at: + continue + timestamp = int(datetime.fromisoformat(published_at.replace("Z", "+00:00")).timestamp()) + published.append((normalize_tag(release.get("tagName", "")), timestamp)) + return published + + +def latest_same_version_rc_publication( + version: str, + candidate_tag: str, + published_prereleases: list[tuple[str, int]], +) -> tuple[str, int] | None: + candidate_match = SEMVER_RC_RE.match(version) + if not candidate_match: + return None + version_base = candidate_match.groups()[:3] + matches: list[tuple[str, int]] = [] + for release_tag, published_unix in published_prereleases: + release_match = re.match(r"^v(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$", release_tag) + if ( + release_match + and release_match.groups()[:3] == version_base + and release_tag != candidate_tag + ): + matches.append((release_tag, published_unix)) + return max(matches, key=lambda release: release[1]) if matches else None + + def changed_paths_between(base_tag: str) -> list[str]: result = subprocess.run( ["git", "diff", "--name-only", f"{base_tag}..HEAD"], @@ -232,8 +286,10 @@ def resolve_metadata( unsigned_windows_reason_input: str = "", windows_authenticode_available: bool = WINDOWS_AUTHENTICODE_AVAILABLE, derive_rollback_when_missing: bool = False, + enforce_prerelease_observation_window: bool = False, list_stable_tags_fn: Callable[[], list[str]] = list_stable_tags, 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, tag_exists_fn: Callable[[str], bool] = tag_exists, tag_commit_fn: Callable[[str], str] = tag_commit, @@ -308,9 +364,35 @@ def resolve_metadata( promoted_from_tag = "" soak_hours = "" + previous_prerelease_tag = "" + prerelease_observation_hours = "" if is_prerelease: if hotfix_exception: raise ValueError("hotfix_exception applies only to stable promotions.") + if enforce_prerelease_observation_window: + previous_publication = latest_same_version_rc_publication( + version, + tag, + list_published_prereleases_fn(), + ) + if previous_publication: + previous_prerelease_tag, previous_published_unix = previous_publication + now_unix = now_unix_fn() + observation_seconds = now_unix - previous_published_unix + observation_hours = int(observation_seconds / 3600) + prerelease_observation_hours = str(observation_hours) + if observation_seconds < MIN_PRERELEASE_OBSERVATION_HOURS * 3600: + next_publish_at = datetime.fromtimestamp( + previous_published_unix + MIN_PRERELEASE_OBSERVATION_HOURS * 3600, + tz=timezone.utc, + ).isoformat().replace("+00:00", "Z") + raise ValueError( + f"Prerelease {tag} would replace {previous_prerelease_tag} after only " + f"{observation_hours} hours of public observation. Same-version release " + f"candidates require {MIN_PRERELEASE_OBSERVATION_HOURS} hours between " + f"publications. Accumulate fixes or use an issue-scoped reporter test image " + f"until {next_publish_at}." + ) else: promoted_from_tag = normalize_tag(promoted_from_tag_input) if not promoted_from_tag: @@ -429,6 +511,8 @@ def resolve_metadata( "unsigned_windows_reason": unsigned_windows_reason, "require_windows_signing": "true" if require_windows_signing else "false", "soak_hours": soak_hours, + "previous_prerelease_tag": previous_prerelease_tag, + "prerelease_observation_hours": prerelease_observation_hours, } @@ -452,6 +536,11 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--unsigned-windows-exception", action="store_true") parser.add_argument("--unsigned-windows-reason", default="") parser.add_argument("--release-notes-file", default="") + parser.add_argument( + "--enforce-prerelease-observation-window", + action="store_true", + help="Reject public same-version RC publication less than 24 hours after the prior RC.", + ) return parser.parse_args() @@ -473,6 +562,7 @@ def main() -> int: release_notes_input=release_notes, unsigned_windows_exception=args.unsigned_windows_exception, unsigned_windows_reason_input=args.unsigned_windows_reason, + enforce_prerelease_observation_window=args.enforce_prerelease_observation_window, ) for key, value in metadata.items(): diff --git a/scripts/release_control/resolve_release_promotion_test.py b/scripts/release_control/resolve_release_promotion_test.py index cb4f789ec..cadfd5822 100644 --- a/scripts/release_control/resolve_release_promotion_test.py +++ b/scripts/release_control/resolve_release_promotion_test.py @@ -45,6 +45,87 @@ class ResolveReleasePromotionTest(unittest.TestCase): self.assertEqual(metadata["promoted_from_tag"], "") self.assertEqual(metadata["soak_hours"], "") + def test_first_rc_has_no_observation_window_to_wait_for(self) -> None: + metadata = resolver.resolve_metadata( + version="6.4.0-rc.1", + promoted_from_tag_input="", + rollback_version_input="6.3.2", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + enforce_prerelease_observation_window=True, + list_published_prereleases_fn=lambda: [ + ("v6.3.0-rc.8", 100), + ("v6.5.0-rc.1", 200), + ], + tag_exists_fn=lambda tag: tag == "v6.3.2", + now_unix_fn=lambda: 10_000, + ) + self.assertEqual(metadata["previous_prerelease_tag"], "") + self.assertEqual(metadata["prerelease_observation_hours"], "") + + def test_same_version_rc_is_rejected_inside_observation_window(self) -> None: + with self.assertRaisesRegex( + ValueError, + "after only 23 hours of public observation.*require 24 hours", + ): + resolver.resolve_metadata( + version="6.4.0-rc.13", + promoted_from_tag_input="", + rollback_version_input="6.3.2", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + enforce_prerelease_observation_window=True, + list_published_prereleases_fn=lambda: [ + ("v6.4.0-rc.11", 100), + ("v6.4.0-rc.12", 200), + ], + tag_exists_fn=lambda tag: tag == "v6.3.2", + now_unix_fn=lambda: 200 + (23 * 3600) + 3599, + ) + + def test_same_version_rc_is_allowed_after_observation_window(self) -> None: + metadata = resolver.resolve_metadata( + version="6.4.0-rc.13", + promoted_from_tag_input="", + rollback_version_input="6.3.2", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + enforce_prerelease_observation_window=True, + list_published_prereleases_fn=lambda: [ + ("v6.3.0-rc.8", 300), + ("v6.4.0-rc.12", 200), + ], + tag_exists_fn=lambda tag: tag == "v6.3.2", + now_unix_fn=lambda: 200 + (24 * 3600), + ) + self.assertEqual(metadata["previous_prerelease_tag"], "v6.4.0-rc.12") + self.assertEqual(metadata["prerelease_observation_hours"], "24") + + def test_rehearsal_does_not_query_or_enforce_publication_window(self) -> None: + metadata = resolver.resolve_metadata( + version="6.4.0-rc.13", + promoted_from_tag_input="", + rollback_version_input="6.3.2", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + enforce_prerelease_observation_window=False, + list_published_prereleases_fn=lambda: self.fail("publication lookup should not run"), + tag_exists_fn=lambda tag: tag == "v6.3.2", + ) + self.assertEqual(metadata["previous_prerelease_tag"], "") + def test_missing_rollback_is_rejected_without_derivation(self) -> None: with self.assertRaisesRegex( ValueError, diff --git a/scripts/trigger-release.sh b/scripts/trigger-release.sh index 351dbcd74..c9bf90229 100755 --- a/scripts/trigger-release.sh +++ b/scripts/trigger-release.sh @@ -317,6 +317,7 @@ RESOLVER_ARGS=( --rollback-version "$ROLLBACK_VERSION" --hotfix-reason "$HOTFIX_REASON" --release-notes-file "$NOTES_FILE" + --enforce-prerelease-observation-window ) if [ -n "$PROMOTED_FROM_TAG" ]; then RESOLVER_ARGS+=(--promoted-from-tag "$PROMOTED_FROM_TAG")