mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-11-07 18:46:02 +00:00
576f54b5b1
The 48-hour rule governs a new rc.1 cut, not promotion of its already-cut unchanged source. Preserve the current-stable and source fences at both activation boundaries without inventing an additional promotion hold. Change-source: pulse-maintainer
281 lines
14 KiB
Python
281 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Reconstruct a mechanical stable patch. No network, provider or publication operations.
|
|
|
|
The train supplies GitHub's closing-PR selection. Admission reconstructs that
|
|
selection again and admits only release metadata on top, never other main work.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from datetime import datetime, timezone
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import sys
|
|
import time
|
|
|
|
sys.dont_write_bytecode = True
|
|
|
|
SHA = re.compile(r"[0-9a-f]{40}")
|
|
VERSION = re.compile(r"(\d+)\.(\d+)\.([1-9]\d*)")
|
|
CONTROLS = (".github/workflows", "scripts/release_control")
|
|
SCHEMA = 1
|
|
# Founder direction, 14/26 October: weekly minor, 24-hour candidate soak.
|
|
# This publication fence has no caller-selected cadence or clock override.
|
|
MINOR_CYCLE_SECONDS = 7 * 86400
|
|
MINOR_SOAK_SECONDS = 24 * 3600
|
|
PATCH_MARGIN_SECONDS = 48 * 3600
|
|
REPOSITORY = "rcourtman/Pulse"
|
|
|
|
|
|
def git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
|
|
return subprocess.run(["git", "-c", "user.name=Pulse release",
|
|
"-c", "user.email=release@invalid", "-C", str(root), *args],
|
|
text=True, capture_output=True, check=check)
|
|
|
|
|
|
def value(root: Path, *args: str) -> str:
|
|
return git(root, *args).stdout.strip()
|
|
|
|
|
|
def manifest_path(version: str) -> str:
|
|
if not VERSION.fullmatch(version):
|
|
raise ValueError("a stable patch needs X.Y.Z with a positive patch number")
|
|
return f"docs/releases/PATCH_v{version}.json"
|
|
|
|
|
|
def validate_selection(root: Path, selection: dict) -> None:
|
|
if set(selection) != {"schema_version", "version", "stable_tag", "stable_sha", "control_sha", "fixes"}:
|
|
raise ValueError("invalid patch selection fields")
|
|
if selection["schema_version"] != SCHEMA:
|
|
raise ValueError("unsupported patch selection schema")
|
|
manifest_path(selection["version"])
|
|
major, minor, patch = map(int, selection["version"].split("."))
|
|
if selection["stable_tag"] != f"v{major}.{minor}.{patch - 1}":
|
|
raise ValueError("a patch must immediately follow its stable base")
|
|
for key in ("stable_sha", "control_sha"):
|
|
if not isinstance(selection[key], str) or not SHA.fullmatch(selection[key]):
|
|
raise ValueError(f"invalid {key}")
|
|
if value(root, "rev-parse", selection["stable_tag"] + "^{commit}") != selection["stable_sha"]:
|
|
raise ValueError("stable tag does not identify the selected stable source")
|
|
git(root, "merge-base", selection["stable_sha"], selection["control_sha"])
|
|
fixes = selection["fixes"]
|
|
if not isinstance(fixes, list) or not fixes:
|
|
raise ValueError("a patch needs at least one closing pull request")
|
|
# First-parent order is the order merge results reached main, including squashes.
|
|
order = value(root, "rev-list", "--first-parent", "--reverse",
|
|
f"{selection['stable_sha']}..{selection['control_sha']}").splitlines()
|
|
indices = []
|
|
seen = set()
|
|
for fix in fixes:
|
|
if (not isinstance(fix, dict) or set(fix) != {"number", "merge_sha", "issues"}
|
|
or type(fix["number"]) is not int or fix["number"] <= 0
|
|
or fix["number"] in seen or not SHA.fullmatch(fix.get("merge_sha", ""))
|
|
or not isinstance(fix["issues"], list) or not fix["issues"]
|
|
or any(type(n) is not int or n <= 0 for n in fix["issues"])):
|
|
raise ValueError("invalid or duplicate closing-PR identity")
|
|
seen.add(fix["number"])
|
|
if fix["merge_sha"] not in order:
|
|
raise ValueError("fix is not an unshipped first-parent merge result on selected main")
|
|
indices.append(order.index(fix["merge_sha"]))
|
|
if indices != sorted(set(indices)):
|
|
raise ValueError("fixes are not in merge order")
|
|
|
|
|
|
def commit(root: Path, message: str) -> None:
|
|
git(root, "commit", "--quiet", "--allow-empty", "-m", message)
|
|
|
|
|
|
def assemble(root: Path, selection: dict) -> dict:
|
|
"""Apply whole merge results, skip only actual conflicts, then copy exact controls."""
|
|
validate_selection(root, selection)
|
|
if value(root, "status", "--porcelain"):
|
|
raise ValueError("patch assembly requires a clean disposable checkout")
|
|
git(root, "checkout", "--quiet", "--detach", selection["stable_sha"])
|
|
applied, skipped = [], []
|
|
for fix in selection["fixes"]:
|
|
before = value(root, "rev-parse", "HEAD")
|
|
parents = value(root, "rev-list", "--parents", "-n", "1", fix["merge_sha"]).split()[1:]
|
|
args = ["cherry-pick", "--no-commit"]
|
|
if len(parents) > 1:
|
|
args += ["-m", "1"]
|
|
result = git(root, *args, fix["merge_sha"], check=False)
|
|
if result.returncode:
|
|
conflicts = value(root, "diff", "--name-only", "--diff-filter=U").splitlines()
|
|
# No empty/error/authentication failure is reclassified as a conflict.
|
|
if not conflicts:
|
|
raise ValueError(f"merge result #{fix['number']} failed without a content conflict")
|
|
git(root, "reset", "--hard", before)
|
|
git(root, "cherry-pick", "--quit", check=False)
|
|
skipped.append({**fix, "conflicts": conflicts})
|
|
continue
|
|
commit(root, f"Apply the merged fix from #{fix['number']}\n\nMerge-result: {fix['merge_sha']}")
|
|
applied.append(fix)
|
|
git(root, "restore", "--source=" + selection["control_sha"], "--staged", "--worktree", "--", *CONTROLS)
|
|
commit(root, "Use the reviewed release controls at " + selection["control_sha"])
|
|
receipt = {**selection, "applied": applied, "skipped": skipped,
|
|
"assembled_tree": value(root, "rev-parse", "HEAD^{tree}")}
|
|
target = root / manifest_path(selection["version"])
|
|
# Do not write through an archive's symlink or a parent directory symlink.
|
|
if not target.resolve().is_relative_to(root.resolve()) or target.is_symlink():
|
|
raise ValueError("patch manifest path escapes its disposable checkout")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n")
|
|
git(root, "add", "--", manifest_path(selection["version"]))
|
|
commit(root, "Record the mechanical stable patch source selection")
|
|
return receipt
|
|
|
|
|
|
def validate(root: Path, version: str, source_branch: str) -> dict:
|
|
"""Rebuild the source tree and reject every extra byte outside release metadata."""
|
|
core = version.removeprefix("v").removesuffix("-rc.1")
|
|
if version.removeprefix("v") not in (core, core + "-rc.1"):
|
|
raise ValueError("a stable patch uses only rc.1 and its unchanged stable promotion")
|
|
if source_branch != "release/v" + core:
|
|
raise ValueError("stable patch is not on its version-bound release branch")
|
|
receipt = json.loads((root / manifest_path(core)).read_text())
|
|
selection = {key: receipt[key] for key in
|
|
("schema_version", "version", "stable_tag", "stable_sha", "control_sha", "fixes")}
|
|
if selection["version"] != core:
|
|
raise ValueError("patch manifest belongs to another version")
|
|
validate_selection(root, selection)
|
|
# Source/workflow controls must have been protected on main, not a foreign branch.
|
|
git(root, "merge-base", "--is-ancestor", selection["control_sha"], "refs/remotes/origin/main")
|
|
head = value(root, "rev-parse", "HEAD")
|
|
with tempfile.TemporaryDirectory(prefix="pulse-patch-admission-") as raw:
|
|
disposable = Path(raw) / "pulse"
|
|
subprocess.run(["git", "clone", "--quiet", "--shared", "--no-checkout", str(root), str(disposable)],
|
|
check=True, capture_output=True)
|
|
git(disposable, "checkout", "--quiet", "--detach", selection["control_sha"])
|
|
rebuilt = assemble(disposable, selection)
|
|
if rebuilt != receipt:
|
|
raise ValueError("patch source receipt does not match reconstructed merge results")
|
|
rebuilt_head = value(disposable, "rev-parse", "HEAD")
|
|
# Inspect the candidate from the reconstructed checkout without executing its source.
|
|
from resolve_release_promotion import RELEASE_METADATA_PATH_RE, VERSION_PIN_LINE_RES
|
|
paths = value(disposable, "diff", "--name-only", rebuilt_head, head).splitlines()
|
|
for path in paths:
|
|
if RELEASE_METADATA_PATH_RE.fullmatch(path):
|
|
continue
|
|
pin = VERSION_PIN_LINE_RES.get(path)
|
|
if pin:
|
|
old = value(disposable, "show", f"{rebuilt_head}:{path}").splitlines()
|
|
new = value(disposable, "show", f"{head}:{path}").splitlines()
|
|
if (len([x for x in old if pin.fullmatch(x)]) == 1
|
|
and len([x for x in new if pin.fullmatch(x)]) == 1
|
|
and [x for x in old if not pin.fullmatch(x)] == [x for x in new if not pin.fullmatch(x)]):
|
|
continue
|
|
raise ValueError(f"stable patch contains unselected source: {path}")
|
|
return receipt
|
|
|
|
|
|
def api(path: str) -> object:
|
|
"""Read fixed project evidence only; a failed read is never retried or defaulted."""
|
|
result = subprocess.run(["gh", "api", f"repos/{REPOSITORY}/{path}"],
|
|
check=True, text=True, capture_output=True)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def published_at(row: dict, tag: str) -> float:
|
|
if (row.get("tag_name") != tag or row.get("draft") is not False
|
|
or row.get("prerelease") is not False or not row.get("published_at")):
|
|
raise ValueError("patch base/anchor must be a published stable release")
|
|
stamp = datetime.fromisoformat(row["published_at"].replace("Z", "+00:00"))
|
|
if stamp.tzinfo is None:
|
|
raise ValueError("stable publication time has no timezone")
|
|
return stamp.timestamp()
|
|
|
|
|
|
def activation_check(root: Path, version: str, source_sha: str) -> dict:
|
|
"""Re-read current stable and the clock immediately before public activation.
|
|
|
|
Source reconstruction earlier in the pipeline cannot establish that its
|
|
stable base is still current after a long build or activation-only recovery.
|
|
Minor, beta and historical-line publication rules are deliberately unchanged.
|
|
"""
|
|
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)(?:-([a-z]+)\.(\d+))?", version)
|
|
if not match:
|
|
raise ValueError("invalid release version")
|
|
major, minor, patch = map(int, match.groups()[:3])
|
|
if not patch or (major, minor) < (6, 5):
|
|
return {"applicable": False}
|
|
if match.group(4) and match.groups()[3:] != ("rc", "1"):
|
|
raise ValueError("mechanical patches permit only rc.1 and stable")
|
|
if not SHA.fullmatch(source_sha):
|
|
raise ValueError("activation source must be an exact SHA")
|
|
core = f"{major}.{minor}.{patch}"
|
|
receipt = json.loads(value(root, "show", f"{source_sha}:{manifest_path(core)}"))
|
|
if receipt["version"] != core or receipt["stable_tag"] != f"v{major}.{minor}.{patch - 1}":
|
|
raise ValueError("activation manifest does not identify the preceding stable")
|
|
if not SHA.fullmatch(receipt["stable_sha"]):
|
|
raise ValueError("activation manifest has no exact stable source")
|
|
# Pagination is bounded and complete. A short/truncated first page cannot
|
|
# hide a newer stable behind a run of previews or draft releases.
|
|
rows = []
|
|
for page in range(1, 101):
|
|
batch = api(f"releases?per_page=100&page={page}")
|
|
if not isinstance(batch, list):
|
|
raise ValueError("stable release inventory unavailable")
|
|
for row in batch:
|
|
if not isinstance(row, dict) or not isinstance(row.get("tag_name"), str):
|
|
raise ValueError("malformed release inventory")
|
|
if re.fullmatch(r"v\d+\.\d+\.\d+", row["tag_name"]):
|
|
if type(row.get("draft")) is not bool or type(row.get("prerelease")) is not bool:
|
|
raise ValueError("stable release inventory has unknown publication state")
|
|
rows.extend(batch)
|
|
if len(batch) < 100:
|
|
break
|
|
else:
|
|
raise ValueError("stable release inventory exceeds bounded pagination")
|
|
stable = [row for row in rows if row.get("draft") is False
|
|
and row.get("prerelease") is False
|
|
and re.fullmatch(r"v\d+\.\d+\.\d+", row.get("tag_name", ""))]
|
|
if not stable:
|
|
raise ValueError("no current published stable")
|
|
current = max(stable, key=lambda row: tuple(map(int, row["tag_name"][1:].split("."))))
|
|
if current["tag_name"] != receipt["stable_tag"]:
|
|
raise ValueError("a newer stable superseded the patch base before activation")
|
|
published_at(current, receipt["stable_tag"])
|
|
commit = api(f"commits/{receipt['stable_tag']}")
|
|
if not isinstance(commit, dict) or commit.get("sha") != receipt["stable_sha"]:
|
|
raise ValueError("published patch base source changed before activation")
|
|
if match.group(4) is None:
|
|
# The 48-hour rule forbids a NEW patch cut. It must not become an
|
|
# additional hold on promotion of its already-cut, soaked rc.1.
|
|
return {"applicable": True, "source_sha": source_sha, "stable_tag": current["tag_name"],
|
|
"stable_sha": receipt["stable_sha"], "observed_at": time.time(), "new_cut": False}
|
|
anchor_tag = f"v{major}.{minor}.0"
|
|
anchor = current if current["tag_name"] == anchor_tag else api(f"releases/tags/{anchor_tag}")
|
|
due = published_at(anchor, anchor_tag) + MINOR_CYCLE_SECONDS - MINOR_SOAK_SECONDS
|
|
# Read the time after provider evidence, not before potentially slow reads.
|
|
observed = time.time()
|
|
if observed >= due - PATCH_MARGIN_SECONDS:
|
|
raise ValueError("patch activation is within 48 hours of minor candidate eligibility (or overdue)")
|
|
return {"applicable": True, "new_cut": True, "source_sha": source_sha, "stable_tag": current["tag_name"],
|
|
"stable_sha": receipt["stable_sha"], "observed_at": observed,
|
|
"not_after": datetime.fromtimestamp(due - PATCH_MARGIN_SECONDS, timezone.utc).isoformat()}
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("operation", choices=("assemble", "validate", "activation-check"))
|
|
parser.add_argument("--selection", type=Path)
|
|
parser.add_argument("--version")
|
|
parser.add_argument("--source-branch")
|
|
parser.add_argument("--source-sha")
|
|
args = parser.parse_args()
|
|
root = Path.cwd().resolve()
|
|
if args.operation == "assemble":
|
|
result = assemble(root, json.loads(args.selection.read_text()))
|
|
elif args.operation == "validate":
|
|
result = validate(root, args.version, args.source_branch)
|
|
else:
|
|
result = activation_check(root, args.version, args.source_sha)
|
|
print(json.dumps(result, sort_keys=True))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|