import argparse
import re
import shutil
import sys
import tempfile
from pathlib import Path
PACKAGE_VERSION_RE = re.compile(r'^version = "(.*)"', re.MULTILINE)
WORKSPACE_DEP_RE = re.compile(r'^(mermaid-[a-z]+) = \{.*version = "', re.MULTILINE)
DEP_VERSION_RE = re.compile(r'version = "([^"]*)"')
def manifests(root: Path) -> list[Path]:
return [root / "Cargo.toml"] + sorted((root / "crates").glob("*/Cargo.toml"))
def check_versions(root: Path, want: str) -> list[str]:
findings = []
for manifest in manifests(root):
if not manifest.is_file():
findings.append(f"{manifest}: missing")
continue
text = manifest.read_text(encoding="utf-8")
rel = manifest.relative_to(root).as_posix()
match = PACKAGE_VERSION_RE.search(text)
if match is None:
findings.append(f"{rel}: no `version = ` line")
elif match.group(1) != want:
findings.append(f"{rel}: package v{match.group(1)}, expected {want}")
for line in text.splitlines():
dep_match = WORKSPACE_DEP_RE.match(line)
if dep_match is None:
continue
versions = DEP_VERSION_RE.findall(line)
got = versions[-1]
if got != want:
findings.append(
f"{rel}: dependency {dep_match.group(1)} pinned to v{got}, "
f"expected {want}"
)
return findings
def extract_changelog_section(text: str, want: str) -> str:
header = f"## [{want}]"
out: list[str] = []
flag = False
for line in text.splitlines():
if line.startswith(header):
flag = True
continue
if flag and line.startswith("## "):
break
if flag:
out.append(line)
return "\n".join(out)
def check_changelog(root: Path, want: str) -> list[str]:
changelog = root / "CHANGELOG.md"
if not changelog.is_file():
return ["CHANGELOG.md: missing"]
section = extract_changelog_section(changelog.read_text(encoding="utf-8"), want)
if not section.strip():
return [
f"CHANGELOG.md: no `## [{want}]` section, or it is empty "
f"(the release would ship blank notes)"
]
return []
def check_compare_links(root: Path, want: str) -> list[str]:
changelog = root / "CHANGELOG.md"
if not changelog.is_file():
return ["CHANGELOG.md: missing"]
text = changelog.read_text(encoding="utf-8")
findings = []
if not re.search(rf"^\[{re.escape(want)}\]: ", text, re.MULTILINE):
findings.append(f"CHANGELOG.md: no `[{want}]:` compare link")
unreleased = rf"^\[Unreleased\]: .*compare/v{re.escape(want)}\.\.\.HEAD\s*$"
if not re.search(unreleased, text, re.MULTILINE):
findings.append(
f"CHANGELOG.md: `[Unreleased]:` does not point at v{want}...HEAD"
)
return findings
def workspace_crate_names(root: Path) -> list[str]:
names = []
for manifest in manifests(root):
if not manifest.is_file():
continue
match = re.search(
r'^name = "([^"]+)"', manifest.read_text(encoding="utf-8"), re.MULTILINE
)
if match:
names.append(match.group(1))
return names
def check_lockfile(root: Path, want: str) -> list[str]:
lock = root / "Cargo.lock"
if not lock.is_file():
return ["Cargo.lock: missing"]
text = lock.read_text(encoding="utf-8")
findings = []
for name in workspace_crate_names(root):
match = re.search(
rf'^name = "{re.escape(name)}"\nversion = "([^"]*)"', text, re.MULTILINE
)
if match is None:
findings.append(f"Cargo.lock: no entry for {name}")
elif match.group(1) != want:
findings.append(
f"Cargo.lock: {name} v{match.group(1)}, expected {want} "
f"(run `cargo update --workspace`)"
)
return findings
GATES = [
("versions (release.yml verify-version)", check_versions),
("CHANGELOG section extracts non-empty", check_changelog),
("CHANGELOG compare links", check_compare_links),
("Cargo.lock agrees", check_lockfile),
]
def run_gates(root: Path, want: str, quiet: bool = False) -> list[str]:
all_findings = []
for label, gate in GATES:
findings = gate(root, want)
all_findings.extend(findings)
if not quiet:
mark = "FAIL" if findings else "ok "
print(f" {mark} {label}")
for finding in findings:
print(f" {finding}")
return all_findings
FIXTURE_MANIFESTS = {
"Cargo.toml": (
'[package]\nname = "mermaid-cli"\nversion = "{v}"\n\n'
"[dependencies]\n"
'mermaid-domain = {{ path = "crates/mermaid-domain", version = "{v}" }}\n'
'mermaid-runtime = {{ path = "crates/mermaid-runtime", version = "{v}" }}\n'
),
"crates/mermaid-domain/Cargo.toml": (
'[package]\nname = "mermaid-domain"\nversion = "{v}"\n\n'
"[dependencies]\n"
'mermaid-runtime = {{ path = "../mermaid-runtime", version = "{v}" }}\n'
),
"crates/mermaid-runtime/Cargo.toml": (
'[package]\nname = "mermaid-runtime"\nversion = "{v}"\n'
),
}
FIXTURE_LOCK = (
"[[package]]\n"
'name = "mermaid-cli"\nversion = "{v}"\n\n'
"[[package]]\n"
'name = "mermaid-domain"\nversion = "{v}"\n\n'
"[[package]]\n"
'name = "mermaid-runtime"\nversion = "{v}"\n'
)
def write_fixture(root: Path, version: str, released: str | None) -> None:
for rel, template in FIXTURE_MANIFESTS.items():
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(template.format(v=version), encoding="utf-8")
(root / "Cargo.lock").write_text(FIXTURE_LOCK.format(v=version), encoding="utf-8")
sections = "## [Unreleased]\n\n"
if released:
sections += f"## [{released}] - 2026-01-01\n\n### Fixed\n\n- a real entry.\n\n"
sections += "## [0.0.9] - 2025-01-01\n\n### Fixed\n\n- a prior entry.\n\n"
links = f"[Unreleased]: https://example.invalid/compare/v{version}...HEAD\n"
if released:
links += f"[{released}]: https://example.invalid/compare/v0.0.9...v{released}\n"
links += "[0.0.9]: https://example.invalid/compare/v0.0.8...v0.0.9\n"
(root / "CHANGELOG.md").write_text(
f"# Changelog\n\n{sections}{links}", encoding="utf-8"
)
def self_test() -> int:
want = "0.23.0"
old = "0.22.0"
failures = []
tmp = Path(tempfile.mkdtemp(prefix="mermaid-preflight-selftest-"))
try:
bad = tmp / "bad"
bad.mkdir()
write_fixture(bad, version=old, released=old)
expected = {
check_versions: 6, check_changelog: 1,
check_compare_links: 2, check_lockfile: 3, }
for gate, count in expected.items():
findings = gate(bad, want)
if len(findings) != count:
failures.append(
f"{gate.__name__} on the un-bumped tree: expected {count} "
f"findings, got {len(findings)}: {findings}"
)
good = tmp / "good"
good.mkdir()
write_fixture(good, version=want, released=want)
for gate, _ in expected.items():
findings = gate(good, want)
if findings:
failures.append(
f"{gate.__name__} on the cut tree: expected no findings, "
f"got {findings}"
)
blank = tmp / "blank"
blank.mkdir()
write_fixture(blank, version=want, released=want)
changelog = blank / "CHANGELOG.md"
changelog.write_text(
changelog.read_text(encoding="utf-8").replace(
"### Fixed\n\n- a real entry.\n", " \n\t\n"
),
encoding="utf-8",
)
empty = check_changelog(blank, want)
if len(empty) != 1:
failures.append(
f"an empty `## [{want}]` section must be caught, got: {empty}"
)
partial = tmp / "partial"
partial.mkdir()
write_fixture(partial, version=want, released=want)
manifest = partial / "crates/mermaid-domain/Cargo.toml"
manifest.write_text(
manifest.read_text(encoding="utf-8").replace(
f'version = "{want}" }}', f'version = "{old}" }}'
),
encoding="utf-8",
)
stale = check_versions(partial, want)
if len(stale) != 1 or "mermaid-runtime" not in stale[0]:
failures.append(
f"a stale intra-workspace pin must be caught, got: {stale}"
)
finally:
shutil.rmtree(tmp, ignore_errors=True)
if failures:
print("release-readiness SELF-TEST FAILED:")
for failure in failures:
print(f" {failure}")
return 1
print("release-readiness self-test OK (gates fire on a bad tree, quiet on a cut one)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description="Verify this tree is ready to tag, before `git tag`."
)
parser.add_argument(
"version", nargs="?", help="target version, `0.23.0` or `v0.23.0`"
)
parser.add_argument(
"--self-test",
action="store_true",
help="prove the gates fail on an un-bumped fixture; needs no version",
)
args = parser.parse_args()
if args.self_test:
return self_test()
if not args.version:
parser.error("a version is required (or use --self-test)")
want = args.version.lstrip("v")
root = Path(__file__).resolve().parents[2]
print(f"release readiness for v{want} (in {root}):")
findings = run_gates(root, want)
if findings:
print(f"\nNOT READY to tag v{want}: {len(findings)} problem(s) above.")
print("Fix them, then re-run. Tagging now would fail release.yml after")
print("the GitHub release and its binaries had already shipped.")
return 1
print(f"\nready to tag v{want}.")
return 0
if __name__ == "__main__":
sys.exit(main())