Skill Tracer
Skills › BMAD Method › bmad › stamp_release.py

stamp_release.py

143 lines · skills/bmad/scripts/stamp_release.py at bd70c64

1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""Release version stamper for module repositories.
6
7Writes a human-supplied SemVer version into every module record: the
8`version` line inside the `[bmod]` table of each skills/*/bmod.toml that has
9one. Member skills carry no version and are not written. Each module
10repository's release runbook uses it to stamp releases and the next
11placeholder on `dev`.
13Before writing anything it runs the repository checks in
14validate_manifests.py beside it, the same ones pre-commit and CI run.
15`--check` runs only those checks and writes nothing.
17A file may carry keys and tables this script does not know. The runtime
18ignores them, so a release must not refuse them; they are left exactly as
19written. The version line is rewritten textually, so nothing else in a file
20moves, and each new file is parsed and compared before anything is written.
22After writing, the script re-reads every file and fails naming the offending
23path if anything is off.
25Usage:
26 uv run --python 3.11 skills/bmad/scripts/stamp_release.py 1.2.0 [--project-root <path>]
27 uv run --python 3.11 skills/bmad/scripts/stamp_release.py --check [--project-root <path>]
28"""
30from __future__ import annotations
32import argparse
33import importlib.util
34import sys
35import tomllib
36from pathlib import Path
38sys.dont_write_bytecode = True
41class StampError(Exception):
42 pass
45def load_validator():
46 """The repository checks, so a release and a commit can never be held to different rules."""
47 path = Path(__file__).resolve().parent / "validate_manifests.py"
48 spec = importlib.util.spec_from_file_location("bmad_validate_for_stamp", path)
49 if spec is None or spec.loader is None:
50 raise StampError(f"cannot load the repository checks at {path}")
51 module = importlib.util.module_from_spec(spec)
52 spec.loader.exec_module(module)
53 return module
56validator = load_validator()
57setup = validator.setup
60def validate_version(version: str) -> None:
61 problem = validator.version_problem(version)
62 if problem is not None:
63 raise StampError(problem)
66def collect_records(project_root: Path) -> list[Path]:
67 report = validator.check_repo(project_root)
68 if report.problems:
69 raise StampError("\n ".join(report.problems))
70 return list(report.records)
73def read_toml(path: Path, rel: str) -> tuple[str, dict[str, object]]:
74 try:
75 text = path.read_bytes().decode("utf-8")
76 return text, tomllib.loads(text)
77 except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
78 raise StampError(f"{rel}: cannot read bmod file: {error}") from error
81def verify_stamp(root: Path, records: list[Path], expected: dict[str, dict[str, object]]) -> None:
82 for record in records:
83 rel = record.relative_to(root).as_posix()
84 _, data = read_toml(record, rel)
85 if data != expected[rel]:
86 raise StampError(f"{rel}: stamping changed something other than the version")
87 collect_records(root)
90def run(project_root: Path, version: str) -> int:
91 try:
92 validate_version(version)
93 records = collect_records(project_root)
95 # Nothing is written if any file fails.
96 planned: list[tuple[Path, str]] = []
97 expected: dict[str, dict[str, object]] = {}
98 for record in records:
99 rel = record.relative_to(project_root).as_posix()
100 original, data = read_toml(record, rel)
101 try:
102 planned.append((record, validator.stamp_text(original, version)))
103 except ValueError as error:
104 raise StampError(f"{rel}: {error}") from error
105 expected[rel] = validator.with_version(data, version)
107 for path, content in planned:
108 try:
109 path.write_bytes(content.encode("utf-8"))
110 except OSError as error:
111 raise StampError(f"{path.relative_to(project_root).as_posix()}: cannot write: {error}") from error
112 verify_stamp(project_root, records, expected)
113 except StampError as error:
114 print(f"Error: {error}", file=sys.stderr)
115 return 1
117 print(f"Stamped version {version} into {len(planned)} files:")
118 for path, _ in planned:
119 print(f" {path.relative_to(project_root).as_posix()}")
120 return 0
123def main(argv: list[str] | None = None) -> int:
124 parser = argparse.ArgumentParser(description="Stamp a version into every module record.")
125 parser.add_argument("version", nargs="?", help='SemVer release version, e.g. "6.12.0"')
126 parser.add_argument("--check", action="store_true", help="run the repository checks and write nothing")
127 parser.add_argument(
128 "--project-root", type=Path, default=Path.cwd(), help="repository to stamp (default: the current directory)"
129 )
130 args = parser.parse_args(argv)
131 if args.check == (args.version is not None):
132 parser.error("give a version to stamp, or --check, but not both")
133 if args.check:
134 return validator.main(["--project-root", str(args.project_root)])
135 return run(args.project_root.resolve(), args.version)
138if __name__ == "__main__":
139 if sys.platform == "win32":
140 # Piped output on Windows defaults to a legacy code page, not UTF-8.
141 sys.stdout.reconfigure(encoding="utf-8")
142 sys.stderr.reconfigure(encoding="utf-8")
143 sys.exit(main())