from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError: 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-wire",
"bcx-crypto",
"bcx-model",
)
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 packages_at_version(version: str) -> tuple[str, ...]:
versions = workspace_package_versions()
return tuple(package for package in ALL_PACKAGES if versions[package] == version)
def verify_release_has_packages(version: str) -> None:
packages = packages_at_version(version)
if not packages:
raise RuntimeError(f"no workspace packages are version {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 require_no_scratch_pentest() -> None:
path = ROOT / "PENTEST.md"
if path.exists():
raise RuntimeError("PENTEST.md is scratch input and must not be committed")
def check_pentest_report(version: str) -> None:
tag = f"v{version}"
path = ROOT / "security" / "pentest" / f"{tag}.md"
if not path.is_file() or path.stat().st_size == 0:
raise RuntimeError(f"missing pentest report: {path.relative_to(ROOT)}")
text = path.read_text(encoding="utf-8")
required_patterns = (
rf"^Tag: {tag}$",
r"^Commit: [0-9a-fA-F]{40}$",
r"^Status: PASS$",
r"^Tester: .+",
r"^Scope: .+",
r"^Date: [0-9]{4}-[0-9]{2}-[0-9]{2}$",
)
for pattern in required_patterns:
if re.search(pattern, text, flags=re.MULTILINE) is None:
raise RuntimeError(
f"pentest report {path.relative_to(ROOT)} missing {pattern}"
)
head_commit = capture(["git", "rev-parse", "HEAD"])
parent_commit = ""
if subprocess.run(
["git", "rev-parse", "-q", "--verify", "HEAD^"],
cwd=ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0:
parent_commit = capture(["git", "rev-parse", "HEAD^"])
if f"Commit: {head_commit}" not in text.splitlines() and (
not parent_commit or f"Commit: {parent_commit}" not in text.splitlines()
):
raise RuntimeError(
f"pentest report {path.relative_to(ROOT)} must target HEAD or first parent"
)
if re.search(r"TODO|TBD|Status: FAIL|Status: BLOCKED", text) is not None:
raise RuntimeError(f"pentest report {path.relative_to(ROOT)} is unresolved")
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 check_publish_readiness(version: str, *, require_tag: bool) -> None:
if not require_tag:
print("Skipping publish-readiness checks until --require-tag is used.")
return
require_no_scratch_pentest()
check_pentest_report(version)
print(f"Publish readiness passed for BCX {version}.")
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)
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) -> None:
verify_package_set()
run(["scripts/validate-crate-version-matrix.py"], dry_run=False)
if version is not None:
verify_release_has_packages(version)
check_release_notes(version)
print(f"release script check passed for BCX {version}")
else:
print("release script check passed for BCX workspace")
def main() -> int:
parser = argparse.ArgumentParser(
description="Publish BCX workspace crates in crates.io dependency order."
)
parser.add_argument(
"--version",
help=(
"Repository release version. Only workspace packages at this "
"package version are published. 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
if args.version is None:
args.version = workspace_version()
require_clean_tree(allow_dirty=args.allow_dirty or args.dry_run)
verify_package_set()
verify_release_has_packages(args.version)
check_release_notes(args.version)
check_release_tag(args.version, require_tag=args.require_tag)
check_publish_readiness(args.version, require_tag=args.require_tag)
publish_packages = set(packages_at_version(args.version))
steps = tuple(package for package in selected_steps(args.start_at) if package in publish_packages)
if not steps:
raise RuntimeError(
f"no packages at version {args.version} remain at or after {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 packages_at_version(args.version):
print(f" cargo info {package}@{args.version}")
return 0
if __name__ == "__main__":
raise SystemExit(main())