from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def run(command: list[str]) -> None:
print(f"+ {' '.join(command)}", flush=True)
subprocess.run(command, cwd=ROOT, check=True)
def capture(command: list[str]) -> str:
return subprocess.check_output(command, cwd=ROOT, text=True).strip()
def workspace_version() -> str:
cargo_toml = ROOT / "Cargo.toml"
for line in cargo_toml.read_text(encoding="utf-8").splitlines():
if line.startswith("version = "):
return line.split('"', 2)[1]
raise RuntimeError("could not determine workspace version")
def require_clean_release_tree() -> None:
status = capture(["git", "status", "--porcelain"])
if status:
print("Refusing to finalize from a dirty tracked worktree:", file=sys.stderr)
print(status, file=sys.stderr)
sys.exit(1)
def require_no_tag(tag: str) -> None:
result = subprocess.run(
["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"],
cwd=ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
if result.returncode == 0:
print(f"tag already exists locally: {tag}", file=sys.stderr)
sys.exit(1)
def main() -> int:
parser = argparse.ArgumentParser(
description="Run release gate, create the release tag, and optionally push."
)
parser.add_argument(
"--version",
default=workspace_version(),
help="Release version without leading v. Defaults to workspace version.",
)
parser.add_argument(
"--push-main",
action="store_true",
help="Push main before pushing the release tag.",
)
parser.add_argument(
"--push-tag",
action="store_true",
help="Push the release tag after it is created.",
)
parser.add_argument(
"--yes",
action="store_true",
help="Skip the release version confirmation prompt.",
)
args = parser.parse_args()
tag = f"v{args.version}"
version_parts = args.version.split(".")
if len(version_parts) != 3:
print("version must use X.Y.Z form", file=sys.stderr)
return 1
gate = f"scripts/release_{version_parts[0]}_{version_parts[1]}_gate.sh"
scratch = ROOT / "PENTEST.md"
report = ROOT / "security" / "pentest" / f"{tag}.md"
if scratch.exists():
print(
"root PENTEST.md must be digested and removed before finalizing",
file=sys.stderr,
)
return 1
if not report.is_file():
print(f"missing pentest report: {report.relative_to(ROOT)}", file=sys.stderr)
return 1
require_clean_release_tree()
require_no_tag(tag)
if not args.yes:
answer = input(f"Type {args.version} to finalize {tag}: ").strip()
if answer != args.version:
print("version confirmation did not match; aborting", file=sys.stderr)
return 1
run([gate])
run(["git", "tag", "-a", tag, "-m", f"BCX {args.version}"])
if args.push_main:
run(["git", "push", "origin", "main"])
if args.push_tag:
run(["git", "push", "origin", tag])
print(f"{tag} finalized.")
return 0
if __name__ == "__main__":
raise SystemExit(main())