mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
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
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:
parent
7ebda0e87a
commit
0dc2c8c16d
9 changed files with 228 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue