Require prerelease observation windows
Some checks are pending
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Canonical Governance / governance (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Public docs / check (push) Waiting to run
Core E2E Tests / Validate E2E tier selection (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Blocked by required conditions
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run

This commit is contained in:
Pulse Test 2026-08-28 20:39:20 +01:00
parent 7ebda0e87a
commit 0dc2c8c16d
9 changed files with 228 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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