#!/usr/bin/env python3
"""Update the Cargo workspace release version without resolving dependencies."""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
import package_native
ROOT_DIR = Path(__file__).resolve().parents[2]
TABLE_HEADER = re.compile(r"(?m)^[ \t]*\[(?P
[^\r\n]+)\][ \t]*(?:#[^\r\n]*)?\r?$")
PACKAGE_HEADER = re.compile(r"(?m)^\[\[package\]\][ \t]*\r?$")
VERSION_FILES = ("Cargo.toml", "Cargo.lock")
@dataclass(frozen=True)
class Change:
path: Path
before: bytes
after: bytes
def replace_version(text: str, version: str) -> str:
# Edit only the value so comments, quoting, and line endings survive.
pattern = r"(?m)^([ \t]*version[ \t]*=[ \t]*)([\"'])([^\"'\r\n]+)(\2)"
matches = list(re.finditer(pattern, text))
if len(matches) != 1:
raise ValueError("expected exactly one version assignment in the table")
match = matches[0]
return text[:match.start(3)] + version + text[match.end(3):]
def inherited_packages(root: Path, workspace: dict) -> set[str]:
excluded = {
path.resolve()
for pattern in workspace.get("exclude", [])
for path in root.glob(pattern)
}
names = set()
for pattern in workspace["members"]:
members = sorted(root.glob(pattern))
if not members:
raise ValueError(f"workspace member not found: {pattern}")
for member in members:
member = member.resolve()
if not member.is_relative_to(root):
raise ValueError(f"workspace member outside repository: {pattern}")
if any(member.is_relative_to(path) for path in excluded):
continue
manifest = tomllib.loads((member / "Cargo.toml").read_text(encoding="utf-8"))
package = manifest.get("package", {})
if package.get("version") == {"workspace": True}:
names.add(package["name"])
if not names:
raise ValueError("no packages inherit the workspace version")
return names
def update_lock(text: str, names: set[str], old: str, new: str) -> str:
headers = list(PACKAGE_HEADER.finditer(text))
result = [text[:headers[0].start()] if headers else text]
found = set()
for index, header in enumerate(headers):
end = headers[index + 1].start() if index + 1 < len(headers) else len(text)
block = text[header.start():end]
package = tomllib.loads(block)["package"][0]
name = package["name"]
if name in names and "source" not in package:
if name in found:
raise ValueError(f"duplicate local package in Cargo.lock: {name}")
if package["version"] not in {old, new}:
raise ValueError(f"unexpected Cargo.lock version for {name}: {package['version']}")
found.add(name)
block = replace_version(block, new)
result.append(block)
if missing := names - found:
raise ValueError(f"workspace packages missing from Cargo.lock: {', '.join(sorted(missing))}")
updated = "".join(result)
# Cargo qualifies dependency names with versions when names are ambiguous.
# Source-qualified references identify external packages and stay untouched.
for name in sorted(names):
updated = updated.replace(f'"{name} {old}"', f'"{name} {new}"')
tomllib.loads(updated)
return updated
def plan_changes(root: Path, raw_version: str) -> tuple[str, str, set[str], list[Change]]:
new = package_native.validate_version(raw_version)
root = root.resolve()
manifest_path = root / "Cargo.toml"
manifest_bytes = manifest_path.read_bytes()
manifest_text = manifest_bytes.decode("utf-8")
workspace = tomllib.loads(manifest_text)["workspace"]
old = workspace["package"]["version"]
names = inherited_packages(root, workspace)
headers = list(TABLE_HEADER.finditer(manifest_text))
for index, header in enumerate(headers):
if header.group("table") == "workspace.package":
end = headers[index + 1].start() if index + 1 < len(headers) else len(manifest_text)
table = replace_version(manifest_text[header.end():end], new)
updated_manifest = manifest_text[:header.end()] + table + manifest_text[end:]
break
else:
raise ValueError("[workspace.package] table not found")
if tomllib.loads(updated_manifest)["workspace"]["package"]["version"] != new:
raise ValueError("failed to update workspace version")
lock_path = root / "Cargo.lock"
lock_bytes = lock_path.read_bytes()
updated_lock = update_lock(lock_bytes.decode("utf-8"), names, old, new)
changes = [
Change(path, before, after.encode("utf-8"))
for path, before, after in (
(manifest_path, manifest_bytes, updated_manifest),
(lock_path, lock_bytes, updated_lock),
)
if before != after.encode("utf-8")
]
return old, new, names, changes
def git(root: Path, *args: str) -> str:
result = subprocess.run(
["git", *args], cwd=root, capture_output=True, text=True, encoding="utf-8", errors="replace"
)
if result.returncode:
raise ValueError(result.stderr.strip() or result.stdout.strip() or f"git {args[0]} failed")
return result.stdout.strip()
def check_commit_ready(root: Path) -> None:
if Path(git(root, "rev-parse", "--show-toplevel")).resolve() != root.resolve():
raise ValueError("version files must be at the Git repository root")
if not git(root, "branch", "--show-current"):
raise ValueError("--commit requires a branch; HEAD is detached")
for operation in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply"):
path = Path(git(root, "rev-parse", "--git-path", operation))
if (root / path).exists():
raise ValueError(f"finish the active Git operation before --commit: {operation}")
if git(root, "ls-files", "--unmerged"):
raise ValueError("resolve Git conflicts before --commit")
git(root, "ls-files", "--error-unmatch", "--", *VERSION_FILES)
if git(root, "status", "--porcelain=v1", "--", *VERSION_FILES):
raise ValueError("--commit requires clean Cargo.toml and Cargo.lock; commit existing edits first")
# Ask Git to validate the configured identity without changing configuration.
git(root, "var", "GIT_AUTHOR_IDENT")
git(root, "var", "GIT_COMMITTER_IDENT")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version", help="release SemVer, optionally prefixed with v")
parser.add_argument("--dry-run", action="store_true", help="preview changes without writing files")
parser.add_argument("--commit", action="store_true", help="commit only the version files after updating")
args = parser.parse_args(argv)
try:
old, new, names, changes = plan_changes(ROOT_DIR, args.version)
message = f"chore(release): bump version to {new}"
if args.commit and changes:
check_commit_ready(ROOT_DIR)
print(f"Workspace version: {old} -> {new} ({len(names)} inherited packages)")
for change in changes:
print(f"{'Would update' if args.dry_run else 'Update'} {change.path.relative_to(ROOT_DIR)}")
if not args.dry_run:
for change in changes:
if change.path.read_bytes() != change.before:
raise ValueError(f"file changed during version planning: {change.path.name}")
for change in changes:
change.path.write_bytes(change.after)
if args.commit and changes:
if args.dry_run:
print(f"Would commit: {message}")
else:
try:
# --only preserves unrelated staged changes in the user's index.
# Files retain their original line endings. With autocrlf off,
# diff --check otherwise treats the CR in CRLF as whitespace.
whitespace = git(
ROOT_DIR, "config", "--default", "trailing-space,space-before-tab",
"--get", "core.whitespace",
)
git(
ROOT_DIR, "-c", f"core.whitespace={whitespace},cr-at-eol",
"diff", "--check", "--", *VERSION_FILES,
)
git(ROOT_DIR, "commit", "--only", "-m", message, "--", *VERSION_FILES)
except (OSError, ValueError) as error:
raise ValueError(f"commit failed; version edits remain available for review: {error}") from error
print(f"Committed {git(ROOT_DIR, 'rev-parse', '--short', 'HEAD')}: {message}")
if not changes:
print("Versions are already up to date.")
except (OSError, ValueError, KeyError) as error:
print(f"error: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())