from __future__ import annotations
import argparse
import json
import pathlib
import re
import subprocess
import sys
SCRIPTS = pathlib.Path(__file__).resolve().parent
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import sync_version
SEMVER_RE = re.compile(
r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$"
)
def workspace_member_names(root: pathlib.Path) -> set[str]:
result = subprocess.run(
["cargo", "metadata", "--locked", "--no-deps", "--format-version", "1"],
cwd=root,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"cargo metadata failed:\n{result.stderr.strip()}")
metadata = json.loads(result.stdout)
return {pkg["name"] for pkg in metadata["packages"] if pkg["id"] in set(metadata["workspace_members"])}
def workspace_member_paths(root: pathlib.Path) -> dict[str, pathlib.Path]:
result = subprocess.run(
["cargo", "metadata", "--locked", "--no-deps", "--format-version", "1"],
cwd=root,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"cargo metadata failed:\n{result.stderr.strip()}")
metadata = json.loads(result.stdout)
workspace_members = set(metadata["workspace_members"])
paths: dict[str, pathlib.Path] = {}
for package in metadata["packages"]:
if package["id"] not in workspace_members:
continue
manifest_dir = pathlib.Path(package["manifest_path"]).resolve().parent
try:
relative = manifest_dir.relative_to(root.resolve())
except ValueError as error:
raise RuntimeError(
f"workspace crate {package['name']!r} is outside repository root"
) from error
paths[package["name"]] = relative
return paths
def stable_package_paths(
root: pathlib.Path, stable_crates: list[str]
) -> list[tuple[str, pathlib.Path]]:
known_paths = workspace_member_paths(root)
result: list[tuple[str, pathlib.Path]] = []
for name in stable_crates:
if name not in known_paths:
raise RuntimeError(
f"release-plan.toml [stable] references unknown crate {name!r} "
"(no workspace package path)"
)
result.append((name, known_paths[name]))
return result
def validate_release_plan(root: pathlib.Path, tag_version: str | None = None) -> list[str]:
plan = sync_version.load_release_plan(root)
stable = plan["stable"] preview = plan["preview"]
stable_crates: list[str] = list(stable["crates"]) preview_crates: list[str] = list(preview["crates"]) stable_version: str = str(stable["version"])
if not stable_crates:
raise RuntimeError("release-plan.toml [stable] has an empty crates list")
seen_stable: set[str] = set()
for name in stable_crates:
if name in seen_stable:
raise RuntimeError(f"release-plan.toml [stable] has duplicate crate '{name}'")
seen_stable.add(name)
seen_preview: set[str] = set()
for name in preview_crates:
if name in seen_preview:
raise RuntimeError(f"release-plan.toml [preview] has duplicate crate '{name}'")
seen_preview.add(name)
overlap = seen_stable & seen_preview
if overlap:
raise RuntimeError(
f"release-plan.toml: crates appear in both stable and preview: {sorted(overlap)}"
)
if not SEMVER_RE.match(stable_version):
raise RuntimeError(
f"release-plan.toml [stable] version {stable_version!r} is not valid SemVer"
)
if tag_version is not None and tag_version != stable_version:
raise RuntimeError(
f"release-plan.toml [stable] version {stable_version!r} does not match "
f"release tag version {tag_version!r}"
)
known = workspace_member_names(root)
for name in stable_crates:
if name not in known:
raise RuntimeError(
f"release-plan.toml [stable] references unknown crate '{name}' "
f"(not a workspace member)"
)
for name in preview_crates:
if name not in known:
raise RuntimeError(
f"release-plan.toml [preview] references unknown crate '{name}' "
f"(not a workspace member)"
)
return stable_crates
def main() -> int:
parser = argparse.ArgumentParser(
description="Parse release-plan.toml and emit the Stable publish order."
)
parser.add_argument(
"--list-stable",
action="store_true",
help="Print the Stable crate list in publish order (one per line).",
)
parser.add_argument(
"--list-preview",
action="store_true",
help="Print the Preview crate list (for verification that Preview is not published).",
)
parser.add_argument(
"--list-stable-paths",
action="store_true",
help=(
"Print each Stable crate and its repository-relative package "
"directory, separated by a tab."
),
)
parser.add_argument(
"--tag",
type=str,
default=None,
help="Release tag (e.g. v1.0.0-rc.6) to validate against the Stable version.",
)
args = parser.parse_args()
root = pathlib.Path(__file__).resolve().parent.parent
tag_version: str | None = None
if args.tag is not None:
tag_version = args.tag.removeprefix("v")
try:
stable_crates = validate_release_plan(root, tag_version=tag_version)
except RuntimeError as error:
print(f"parse_release_plan: {error}", file=sys.stderr)
return 1
if args.list_stable:
for name in stable_crates:
print(name)
if args.list_stable_paths:
try:
package_paths = stable_package_paths(root, stable_crates)
except RuntimeError as error:
print(f"parse_release_plan: {error}", file=sys.stderr)
return 1
for name, path in package_paths:
print(f"{name}\t{path if path.parts else '.'}")
if args.list_preview:
plan = sync_version.load_release_plan(root)
for name in plan["preview"]["crates"]: print(name)
if not args.list_stable and not args.list_preview and not args.list_stable_paths:
print("parse_release_plan: validation passed.")
print(f" Stable version: {sync_version.load_release_plan(root)['stable']['version']}") print(f" Stable crates: {stable_crates}")
print(f" Preview crates: {sync_version.load_release_plan(root)['preview']['crates']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())