from __future__ import annotations
import argparse
import json
import pathlib
import re
import subprocess
import sys
from typing import Any
SEMVER_RE = re.compile(
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
r"(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$"
)
def cargo_metadata(root: pathlib.Path) -> dict[str, Any]:
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 ValueError(f"cargo metadata failed:\n{result.stderr.strip()}")
return json.loads(result.stdout)
def load_release_plan(root: pathlib.Path) -> dict[str, dict[str, Any]]:
plan_path = root / "release-plan.toml"
if not plan_path.exists():
raise ValueError(f"release-plan.toml not found at {plan_path}")
text = plan_path.read_text(encoding="utf-8")
plan: dict[str, dict[str, Any]] = {}
current: str | None = None
in_crates = False
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if in_crates:
if line == "]":
in_crates = False
continue
for name in re.findall(r'"([^"]+)"', line):
assert current is not None
plan[current]["crates"].append(name)
continue
section = re.match(r"^\[(\w+)\]$", line)
if section:
current = section.group(1)
plan[current] = {"version": "", "crates": []}
continue
if current is None:
continue
version_match = re.match(r'^version\s*=\s*"([^"]+)"', line)
if version_match:
plan[current]["version"] = version_match.group(1)
continue
crates_match = re.match(r"^crates\s*=\s*\[(.*)\]$", line)
if crates_match:
inner = crates_match.group(1)
for name in re.findall(r'"([^"]+)"', inner):
plan[current]["crates"].append(name)
continue
crates_open = re.match(r"^crates\s*=\s*\[(.*)$", line)
if crates_open:
inner = crates_open.group(1).strip()
for name in re.findall(r'"([^"]+)"', inner):
plan[current]["crates"].append(name)
in_crates = True
for group in ("stable", "preview"):
if group not in plan:
raise ValueError(f"release-plan.toml missing [{group}] section")
if not plan[group]["version"]:
raise ValueError(f"release-plan.toml [{group}] has no version")
return plan
def project_version(pyproject: pathlib.Path) -> str:
contents = pyproject.read_text(encoding="utf-8")
project = re.search(r"(?ms)^\[project\]\s*(.*?)(?=^\[|\Z)", contents)
if not project:
raise ValueError(f"missing [project] table in {pyproject}")
version = re.search(r'^version\s*=\s*"([^"]+)"\s*$', project.group(1), re.M)
if not version:
raise ValueError(f"missing project.version in {pyproject}")
return version.group(1)
def validate_release(root: pathlib.Path, metadata: dict[str, Any]) -> str:
plan = load_release_plan(root)
stable_version = str(plan["stable"]["version"])
preview_version = str(plan["preview"]["version"])
stable_crates = set(plan["stable"]["crates"])
preview_crates = set(plan["preview"]["crates"])
if not SEMVER_RE.fullmatch(stable_version):
raise ValueError(
f"stable version {stable_version!r} is not valid SemVer"
)
if not SEMVER_RE.fullmatch(preview_version):
raise ValueError(
f"preview version {preview_version!r} is not valid SemVer"
)
workspace_ids = set(metadata["workspace_members"])
packages = [
package for package in metadata["packages"] if package["id"] in workspace_ids
]
if not packages:
raise ValueError("cargo metadata returned no workspace packages")
workspace_names = {package["name"] for package in packages}
for package in packages:
name = package["name"]
if name in stable_crates:
expected = stable_version
group = "stable"
elif name in preview_crates:
expected = preview_version
group = "preview"
else:
raise ValueError(
f"workspace crate {name!r} is not listed in release-plan.toml"
)
if package["version"] != expected:
raise ValueError(
f"{group} crate {name} is at {package['version']!r}, "
f"expected {expected!r}"
)
expected_req = f"^{stable_version}"
for package in packages:
for dependency in package.get("dependencies", []):
if not dependency.get("path"):
continue
dep_name = dependency["name"]
if dep_name not in workspace_names:
continue
if dep_name not in stable_crates:
raise ValueError(
f"{package['name']} depends on internal Preview crate "
f"{dep_name}; cross-Preview path deps are not supported"
)
actual_req = dependency.get("req")
if actual_req != expected_req:
raise ValueError(
f"{package['name']} requires local {dep_name} at "
f"{actual_req!r}, expected {expected_req!r}"
)
manifest_path = root / "models/example-default/manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
for field in ("version", "minRuntimeVersion"):
if manifest.get(field) != stable_version:
raise ValueError(
f"{manifest_path}:{field} is {manifest.get(field)!r}, "
f"expected {stable_version!r}"
)
pyproject_path = root / "crates/rill-ml-python/pyproject.toml"
python_version = project_version(pyproject_path)
if python_version != preview_version:
raise ValueError(
f"{pyproject_path}:project.version is {python_version!r}, "
f"expected preview {preview_version!r}"
)
changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8")
if not re.search(
rf"(?m)^## \[{re.escape(stable_version)}\] - \d{{4}}-\d{{2}}-\d{{2}}$",
changelog,
):
raise ValueError(
f"CHANGELOG.md has no dated release section for {stable_version}"
)
return stable_version
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd())
parser.add_argument("--github-output", type=pathlib.Path)
args = parser.parse_args()
try:
root = args.root.resolve()
version = validate_release(root, cargo_metadata(root))
except (OSError, ValueError, KeyError, json.JSONDecodeError) as error:
print(f"release version validation failed: {error}", file=sys.stderr)
return 1
tag = f"v{version}"
print(f"release version {version} is internally consistent ({tag})")
if args.github_output:
with args.github_output.open("a", encoding="utf-8") as output:
output.write(f"version={version}\n")
output.write(f"tag={tag}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())