bcx 0.2.0

Bifrost Causal Exchange protocol primitives for signed causal meaning and proof composition.
Documentation
#!/usr/bin/env python3
"""Finalize a BCX release after pentest evidence and GitHub are green."""

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