mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Summary - add a manual desktop release preparer that regenerates one version-only candidate from current `origin/main` - validate deterministic complete changelog accounting, candidate authorship, allowed files, exact-head approval, required checks, and two-parent merge topology before tagging the reviewed candidate - move desktop tags/releases from `v*` to `desktop-v*` while preserving relay, chart, push-chart, and mobile behavior - stage all four platform outputs in Actions artifacts and grant GitHub release write access only to one final all-platform-gated publisher - publish the versioned release only after complete artifact assembly; update stable `latest.json` last; never promote prereleases or published rebuild outputs ## Safety properties - desktop tags point to the reviewed candidate SHA, not the merge commit - release builds remain tag-bound and reverify tag == checked-out HEAD - one final writer fails closed on artifact basename collisions - per-tag concurrency serializes publication without cancellation - published reruns do not replace immutable versioned assets or promote signatures from a rebuild - candidate branches use an explicit remote OID lease when regenerated ## Validation - `scripts/test-desktop-release-candidate.sh` - `scripts/test-release-ref-contract.sh` - `scripts/test-mobile-release-contract.sh` - changed workflow YAML parsing (Ruby Psych) - changed shell syntax (`bash -n`) - `git diff --check` - push hooks: branch-skew, Rust workspace tests (1,853 passed), desktop Tauri tests (3 passed) ## Coordinated companion - squareup/buzz-releases#79 updates the manually entered desktop source-tag contract to stable-only `desktop-v*` - merge the private contract companion before the first namespaced desktop release ## Rollout blockers (no settings changed here) Before the first candidate/release: 1. enable merge commits in repository settings 2. allow `merge` in ruleset `13596885` 3. require approval after the last push in ruleset `13596885` 4. include `refs/tags/desktop-v*` explicitly in release ruleset `14378754` 5. prove the non-publishing candidate/merge/tag/artifact validation path before any production release Do not test the old workflow with a prerelease: it can still mutate the production rolling updater release. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
215 lines
9.3 KiB
Python
Executable File
215 lines
9.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Generate and validate immutable desktop release candidates."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
CHANGELOG = ROOT / "CHANGELOG.md"
|
|
METADATA = ROOT / ".release" / "desktop-candidate.json"
|
|
SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")
|
|
DESKTOP_PATHS = (
|
|
"desktop/",
|
|
"crates/buzz-core/",
|
|
"crates/buzz-persona/",
|
|
"crates/buzz-sdk/",
|
|
"crates/buzz-agent/",
|
|
"crates/buzz-media/",
|
|
)
|
|
CANDIDATE_FILES = {
|
|
".release/desktop-candidate.json",
|
|
"CHANGELOG.md",
|
|
"desktop/package.json",
|
|
"desktop/src-tauri/tauri.conf.json",
|
|
"desktop/src-tauri/Cargo.toml",
|
|
"desktop/src-tauri/Cargo.lock",
|
|
"pnpm-lock.yaml",
|
|
}
|
|
REQUIRED_CANDIDATE_FILES = {
|
|
".release/desktop-candidate.json",
|
|
"CHANGELOG.md",
|
|
"desktop/package.json",
|
|
"desktop/src-tauri/tauri.conf.json",
|
|
"desktop/src-tauri/Cargo.toml",
|
|
}
|
|
|
|
|
|
def git(*args: str) -> str:
|
|
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()
|
|
|
|
|
|
def commit_list(range_spec: str, paths: tuple[str, ...] | None = None) -> list[dict[str, str]]:
|
|
args = ["log", range_spec, "--no-merges", "--format=%H%x00%s"]
|
|
if paths:
|
|
args += ["--", *paths]
|
|
out = git(*args)
|
|
if not out:
|
|
return []
|
|
return [dict(zip(("sha", "subject"), line.split("\0", 1))) for line in out.splitlines()]
|
|
|
|
|
|
def stable_tags(base_sha: str) -> list[tuple[int, str, str]]:
|
|
tags: list[tuple[int, str, str]] = []
|
|
for tag in git("tag", "--merged", base_sha, "--list").splitlines():
|
|
if not re.fullmatch(r"(?:desktop-)?v[0-9]+\.[0-9]+\.[0-9]+", tag):
|
|
continue
|
|
sha = git("rev-list", "-n", "1", tag)
|
|
distance = int(git("rev-list", "--count", f"{sha}..{base_sha}"))
|
|
tags.append((distance, tag, sha))
|
|
return tags
|
|
|
|
|
|
def previous_tag(base_sha: str) -> str:
|
|
tags = stable_tags(base_sha)
|
|
if not tags:
|
|
return ""
|
|
min_distance = min(item[0] for item in tags)
|
|
nearest = [item for item in tags if item[0] == min_distance]
|
|
commits = {item[2] for item in nearest}
|
|
if len(commits) != 1:
|
|
detail = ", ".join(f"{tag}@{sha}" for _, tag, sha in nearest)
|
|
raise SystemExit(f"ambiguous previous desktop release tags: {detail}")
|
|
# During migration, prefer the namespaced tag when aliases share a commit.
|
|
nearest.sort(key=lambda item: (not item[1].startswith("desktop-v"), item[1]))
|
|
return nearest[0][1]
|
|
|
|
|
|
def bullet(commit: dict[str, str], repo: str) -> str:
|
|
sha, subject = commit["sha"], commit["subject"]
|
|
short = sha[:12]
|
|
pr_match = re.search(r" \(#([0-9]+)\)$", subject)
|
|
if pr_match:
|
|
pr = pr_match.group(1)
|
|
subject = subject[: pr_match.start()]
|
|
return f"- {subject} ([#{pr}](https://github.com/{repo}/pull/{pr})) ([`{sha}`](https://github.com/{repo}/commit/{sha}))"
|
|
return f"- {subject} ([`{sha}`](https://github.com/{repo}/commit/{sha}))"
|
|
|
|
|
|
def expected(base_sha: str, previous: str) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
|
# With no prior desktop tag, account for the repository's root commit too.
|
|
# A ``root..base`` range silently drops that first commit.
|
|
range_spec = f"{previous}..{base_sha}" if previous else base_sha
|
|
all_commits = commit_list(range_spec)
|
|
relevant_shas = {c["sha"] for c in commit_list(range_spec, DESKTOP_PATHS)}
|
|
relevant = [c for c in all_commits if c["sha"] in relevant_shas]
|
|
other = [c for c in all_commits if c["sha"] not in relevant_shas]
|
|
return relevant, other
|
|
|
|
|
|
def render(version: str, base_sha: str, previous: str, repo: str) -> tuple[str, list[str]]:
|
|
relevant, other = expected(base_sha, previous)
|
|
lines = [f"## v{version}", "", "### Desktop and shared changes", ""]
|
|
lines += [bullet(c, repo) for c in relevant] or ["- None"]
|
|
lines += ["", "### Other repository changes", ""]
|
|
lines += [bullet(c, repo) for c in other] or ["- None"]
|
|
compare_start = previous or git("rev-list", "--max-parents=0", base_sha).splitlines()[0]
|
|
lines += ["", f"[Compare {compare_start}...desktop-v{version}](https://github.com/{repo}/compare/{compare_start}...desktop-v{version})"]
|
|
return "\n".join(lines) + "\n", [c["sha"] for c in relevant + other]
|
|
|
|
|
|
def generate(args: argparse.Namespace) -> None:
|
|
if not SEMVER.fullmatch(args.version):
|
|
raise SystemExit(f"invalid semver: {args.version}")
|
|
base_sha = git("rev-parse", args.base)
|
|
previous = previous_tag(base_sha)
|
|
repo = args.repo or re.sub(r".*github\.com[:/]", "", git("remote", "get-url", "origin")).removesuffix(".git")
|
|
block, commits = render(args.version, base_sha, previous, repo)
|
|
old = CHANGELOG.read_text() if CHANGELOG.exists() else "# Changelog\n"
|
|
if not old.startswith("# Changelog"):
|
|
raise SystemExit("CHANGELOG.md must begin with '# Changelog'")
|
|
remainder = old.split("\n", 1)[1].lstrip("\n") if "\n" in old else ""
|
|
CHANGELOG.write_text(f"# Changelog\n\n{block}\n{remainder}")
|
|
METADATA.parent.mkdir(parents=True, exist_ok=True)
|
|
METADATA.write_text(json.dumps({
|
|
"schema": 1,
|
|
"version": args.version,
|
|
"base_sha": base_sha,
|
|
"previous_tag": previous or None,
|
|
"tag": f"desktop-v{args.version}",
|
|
"commit_count": len(commits),
|
|
}, indent=2) + "\n")
|
|
|
|
|
|
def validate(args: argparse.Namespace) -> None:
|
|
data = json.loads(METADATA.read_text())
|
|
version = args.version or data["version"]
|
|
if data != {**data, "version": version}:
|
|
raise SystemExit("candidate version does not match metadata")
|
|
if data["tag"] != f"desktop-v{version}":
|
|
raise SystemExit("candidate tag does not match version")
|
|
candidate = git("rev-parse", args.candidate)
|
|
parents = git("show", "-s", "--format=%P", candidate).split()
|
|
if len(parents) != 1 or parents[0] != data["base_sha"]:
|
|
raise SystemExit("candidate must be one commit directly above recorded base_sha")
|
|
changed = set(git("diff-tree", "--no-commit-id", "--name-only", "-r", candidate).splitlines())
|
|
unexpected = changed - CANDIDATE_FILES
|
|
missing = REQUIRED_CANDIDATE_FILES - changed
|
|
if unexpected or missing:
|
|
detail = []
|
|
if unexpected:
|
|
detail.append(f"unexpected files: {', '.join(sorted(unexpected))}")
|
|
if missing:
|
|
detail.append(f"missing required files: {', '.join(sorted(missing))}")
|
|
raise SystemExit("candidate is not version-only (" + "; ".join(detail) + ")")
|
|
previous = data["previous_tag"] or ""
|
|
actual_previous = previous_tag(data["base_sha"])
|
|
if previous != actual_previous:
|
|
raise SystemExit(
|
|
f"recorded previous tag {previous or '<none>'} does not match "
|
|
f"nearest release tag {actual_previous or '<none>'}"
|
|
)
|
|
repo = args.repo or "block/buzz"
|
|
expected_block, shas = render(version, data["base_sha"], previous, repo)
|
|
text = CHANGELOG.read_text()
|
|
blocks = re.findall(rf"(?ms)^## v{re.escape(version)}\n.*?(?=^## v|\Z)", text)
|
|
if len(blocks) != 1:
|
|
raise SystemExit(f"expected exactly one changelog block for v{version}")
|
|
if blocks[0].rstrip() != expected_block.rstrip():
|
|
raise SystemExit("changelog block is not deterministic for recorded candidate base")
|
|
found = re.findall(r"\[`([0-9a-f]{40})`\]", blocks[0])
|
|
if len(found) != len(set(found)) or set(found) != set(shas) or len(found) != data["commit_count"]:
|
|
raise SystemExit("changelog does not account for every expected non-merge commit exactly once")
|
|
manifests = {
|
|
ROOT / "desktop/package.json": json.loads((ROOT / "desktop/package.json").read_text())["version"],
|
|
ROOT / "desktop/src-tauri/tauri.conf.json": json.loads((ROOT / "desktop/src-tauri/tauri.conf.json").read_text())["version"],
|
|
}
|
|
cargo = re.search(r'(?m)^version = "([^"]+)"', (ROOT / "desktop/src-tauri/Cargo.toml").read_text())
|
|
manifests[ROOT / "desktop/src-tauri/Cargo.toml"] = cargo.group(1) if cargo else ""
|
|
bad = [str(path.relative_to(ROOT)) for path, value in manifests.items() if value != version]
|
|
if bad:
|
|
raise SystemExit(f"version mismatch in: {', '.join(bad)}")
|
|
author = git("show", "-s", "--format=%an <%ae>", candidate)
|
|
body = git("show", "-s", "--format=%B", candidate)
|
|
if author != "Wes <wesbillman@users.noreply.github.com>":
|
|
raise SystemExit(f"unexpected candidate author: {author}")
|
|
if "Signed-off-by: Wes <wesbillman@users.noreply.github.com>" not in body:
|
|
raise SystemExit("candidate is missing Wes Signed-off-by trailer")
|
|
if not re.search(r"(?m)^Co-authored-by: .+ <.+>$", body):
|
|
raise SystemExit("candidate is missing automation Co-authored-by trailer")
|
|
print(f"validated immutable desktop candidate {candidate} for desktop-v{version}")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
gen = sub.add_parser("generate")
|
|
gen.add_argument("version")
|
|
gen.add_argument("--base", required=True)
|
|
gen.add_argument("--repo")
|
|
val = sub.add_parser("validate")
|
|
val.add_argument("--candidate", default="HEAD")
|
|
val.add_argument("--version")
|
|
val.add_argument("--repo")
|
|
args = parser.parse_args()
|
|
generate(args) if args.command == "generate" else validate(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|