bcx 0.1.0

Bifrost Casual Exchange protocol primitives for verifiable causal network accountability.
Documentation
#!/usr/bin/env python3
"""Publish the BCX workspace crates in dependency order.

This script intentionally pauses after publishing dependency crates so
crates.io has time to index them before publishing dependents.

Publish order:
1. bcx-core
2. bcx-policy
3. bcx-crypto
4. bcx-model
5. bcx-wire
6. bcx
"""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
from pathlib import Path

try:
    import tomllib
except ModuleNotFoundError:  # pragma: no cover - release host guard.
    print("Python 3.11+ is required because this script uses tomllib.", file=sys.stderr)
    raise


ROOT = Path(__file__).resolve().parents[1]

DEPENDENCY_STEPS = (
    "bcx-core",
    "bcx-policy",
    "bcx-crypto",
    "bcx-model",
    "bcx-wire",
)

FINAL_STEPS = ("bcx",)

ALL_PACKAGES = DEPENDENCY_STEPS + FINAL_STEPS


def run(command: list[str], *, dry_run: bool) -> None:
    print(f"+ {' '.join(command)}", flush=True)
    if dry_run:
        return
    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:
    with (ROOT / "Cargo.toml").open("rb") as handle:
        manifest = tomllib.load(handle)
    return manifest["workspace"]["package"]["version"]


def cargo_metadata() -> dict:
    metadata = capture(["cargo", "metadata", "--no-deps", "--format-version", "1"])
    return json.loads(metadata)


def workspace_package_versions() -> dict[str, str]:
    metadata = cargo_metadata()
    root = str(ROOT)
    versions = {}
    for entry in metadata["packages"]:
        if entry["manifest_path"].startswith(root):
            versions[entry["name"]] = entry["version"]
    return versions


def require_clean_tree(*, allow_dirty: bool) -> None:
    if allow_dirty:
        return

    status = capture(["git", "status", "--porcelain"])
    if status:
        print("Refusing to publish from a dirty worktree:", file=sys.stderr)
        print(status, file=sys.stderr)
        print("Commit or stash changes, or pass --allow-dirty.", file=sys.stderr)
        sys.exit(1)


def verify_package_set() -> None:
    actual = set(workspace_package_versions())
    expected = set(ALL_PACKAGES)
    if actual != expected:
        missing = sorted(expected - actual)
        extra = sorted(actual - expected)
        raise RuntimeError(
            "workspace package set does not match release script; "
            f"missing={missing}, extra={extra}"
        )


def verify_versions(expected_version: str) -> None:
    for package, actual in sorted(workspace_package_versions().items()):
        if actual != expected_version:
            raise RuntimeError(
                f"{package} is version {actual}, expected {expected_version}"
            )


def check_release_notes(version: str) -> None:
    path = ROOT / "release-notes" / f"RELEASE_NOTES_{version}.md"
    if not path.is_file() or path.stat().st_size == 0:
        raise RuntimeError(f"missing release notes: {path.relative_to(ROOT)}")


def check_release_tag(version: str, *, require_tag: bool) -> None:
    tag = f"v{version}"
    tag_ref = f"refs/tags/{tag}"

    if subprocess.run(
        ["git", "rev-parse", "-q", "--verify", tag_ref],
        cwd=ROOT,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=False,
    ).returncode != 0:
        message = f"Release tag {tag!r} was not found."
        if require_tag:
            print(f"Refusing to publish: {message}", file=sys.stderr)
            sys.exit(1)
        print(f"Warning: {message}", file=sys.stderr)
        return

    head = capture(["git", "rev-parse", "HEAD"])
    tagged_commit = capture(["git", "rev-list", "-n", "1", tag_ref])

    if head != tagged_commit:
        message = f"HEAD is not tagged as {tag} (HEAD {head}, {tag} {tagged_commit})."
        if require_tag:
            print(f"Refusing to publish: {message}", file=sys.stderr)
            sys.exit(1)
        print(f"Warning: {message}", file=sys.stderr)
        return

    print(f"Release tag {tag} points at HEAD.")


def run_release_readiness(version: str, *, dry_run: bool, require_tag: bool) -> None:
    if not require_tag:
        print("Skipping release-readiness script until --require-tag is used.")
        return
    run(["scripts/validate-release-readiness.sh", f"v{version}"], dry_run=dry_run)


def wait_for_index(package: str, version: str, *, dry_run: bool) -> None:
    url = f"https://crates.io/crates/{package}/{version}"
    print()
    print(f"Published {package} {version}.")
    print(f"Wait until crates.io shows it here: {url}")
    print("Then press Enter to continue with dependent crates.")
    if dry_run:
        print("[dry-run] skipping wait")
        return
    input()


def publish(package: str, args: argparse.Namespace) -> None:
    command = ["cargo", "publish", "-p", package]
    if args.allow_dirty:
        command.append("--allow-dirty")
    if args.no_verify:
        command.append("--no-verify")
    run(command, dry_run=args.dry_run)


def run_preflight(args: argparse.Namespace) -> None:
    if args.skip_checks:
        print("Skipping preflight checks by request.")
        return

    run(["scripts/checks.sh"], dry_run=args.dry_run)
    run(["scripts/validate-release-readiness.sh", f"v{args.version}"], dry_run=args.dry_run)


def selected_steps(start_at: str) -> tuple[str, ...]:
    try:
        index = ALL_PACKAGES.index(start_at)
    except ValueError as exc:
        raise RuntimeError(f"unknown package for --start-at: {start_at}") from exc
    return ALL_PACKAGES[index:]


def check_only(version: str) -> None:
    verify_package_set()
    verify_versions(version)
    check_release_notes(version)
    print(f"release script check passed for BCX {version}")


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Publish BCX workspace crates in crates.io dependency order."
    )
    parser.add_argument(
        "--version",
        default=workspace_version(),
        help="Expected workspace/package version. Defaults to workspace version.",
    )
    parser.add_argument(
        "--start-at",
        default=ALL_PACKAGES[0],
        choices=ALL_PACKAGES,
        help="Resume publishing at a package if an earlier step already succeeded.",
    )
    parser.add_argument(
        "--check",
        action="store_true",
        help="Verify release-script metadata without publishing.",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Print release commands without running them or waiting.",
    )
    parser.add_argument(
        "--allow-dirty",
        action="store_true",
        help="Allow publishing from a dirty worktree and pass --allow-dirty to cargo.",
    )
    parser.add_argument(
        "--skip-checks",
        action="store_true",
        help="Skip local checks before publishing.",
    )
    parser.add_argument(
        "--no-verify",
        action="store_true",
        help="Pass --no-verify to cargo publish. Use only if you understand why.",
    )
    parser.add_argument(
        "--require-tag",
        action="store_true",
        help="Refuse to publish unless HEAD matches the v<version> release tag.",
    )
    parser.add_argument(
        "--yes",
        action="store_true",
        help="Do not ask for the initial confirmation.",
    )
    args = parser.parse_args()

    if args.check:
        check_only(args.version)
        return 0

    require_clean_tree(allow_dirty=args.allow_dirty or args.dry_run)
    verify_package_set()
    verify_versions(args.version)
    check_release_notes(args.version)
    check_release_tag(args.version, require_tag=args.require_tag)
    run_release_readiness(args.version, dry_run=args.dry_run, require_tag=args.require_tag)

    steps = selected_steps(args.start_at)

    print(f"Workspace root: {ROOT}")
    print(f"Release version: {args.version}")
    print("Publish sequence:")
    for package in steps:
        print(f"  - {package}")
    print()

    if not args.yes:
        answer = input("Type the release version to start publishing: ").strip()
        if answer != args.version:
            print("Version confirmation did not match; aborting.", file=sys.stderr)
            return 1

    run_preflight(args)

    for package in steps:
        publish(package, args)
        if package in DEPENDENCY_STEPS:
            wait_for_index(package, args.version, dry_run=args.dry_run)

    print()
    print("BCX release publish sequence completed.")
    print("Recommended follow-up:")
    for package in ALL_PACKAGES:
        print(f"  cargo info {package}@{args.version}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())