Files
hermes-relay/scripts/check-version-tracks.py

242 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Report and validate Hermes-Relay monorepo release tracks.
The repo ships three independently versioned artifacts:
- Android app: ``android-v*`` tags, source in ``gradle/libs.versions.toml``.
- Plugin: ``server-v*`` tags, source in ``pyproject.toml``.
- CLI+UI: ``desktop-v*`` tags, source in ``desktop/package.json``.
This script gives release prep one command that checks the sources without
forcing unrelated artifacts to share the same SemVer.
"""
from __future__ import annotations
import argparse
import json
import pathlib
import re
import sys
import tomllib
from dataclasses import dataclass
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$")
@dataclass(frozen=True)
class Track:
name: str
version: str
source: str
tag: str
details: str = ""
def _read_text(rel_path: str) -> str:
return (REPO_ROOT / rel_path).read_text(encoding="utf-8")
def _read_json(rel_path: str) -> object:
return json.loads(_read_text(rel_path))
def _regex_version(rel_path: str, pattern: str) -> str:
match = re.search(pattern, _read_text(rel_path), re.MULTILINE)
if not match:
raise ValueError(f"{rel_path} has no parseable version")
return match.group(1)
def _json_version(rel_path: str) -> str:
data = _read_json(rel_path)
if not isinstance(data, dict) or not isinstance(data.get("version"), str):
raise ValueError(f"{rel_path} has no parseable version")
return data["version"]
def _package_lock_root_version() -> str:
data = _read_json("plugin/dashboard/package-lock.json")
if not isinstance(data, dict) or not isinstance(data.get("packages"), dict):
raise ValueError("plugin/dashboard/package-lock.json has no packages object")
root_pkg = data["packages"].get("")
if not isinstance(root_pkg, dict) or not isinstance(root_pkg.get("version"), str):
raise ValueError('plugin/dashboard/package-lock.json packages[""] has no version')
return root_pkg["version"]
def _desktop_package_lock_versions() -> tuple[str, str]:
data = _read_json("desktop/package-lock.json")
if not isinstance(data, dict) or not isinstance(data.get("version"), str):
raise ValueError("desktop/package-lock.json has no root version")
packages = data.get("packages")
if not isinstance(packages, dict):
raise ValueError("desktop/package-lock.json has no packages object")
root_pkg = packages.get("")
if not isinstance(root_pkg, dict) or not isinstance(root_pkg.get("version"), str):
raise ValueError('desktop/package-lock.json packages[""] has no version')
return data["version"], root_pkg["version"]
def _android_track(errors: list[str]) -> Track:
data = tomllib.loads(_read_text("gradle/libs.versions.toml"))
versions = data.get("versions")
if not isinstance(versions, dict):
raise ValueError("gradle/libs.versions.toml has no [versions] table")
version = str(versions.get("appVersionName", ""))
code = str(versions.get("appVersionCode", ""))
if not SEMVER_RE.match(version):
errors.append(f"android version is not SemVer: {version!r}")
if not code.isdigit():
errors.append(f"android versionCode is not numeric: {code!r}")
return Track(
name="android",
version=version,
source="gradle/libs.versions.toml",
tag=f"android-v{version}",
details=f"versionCode {code}",
)
def _plugin_track(errors: list[str]) -> Track:
pyproject = tomllib.loads(_read_text("pyproject.toml"))
project = pyproject.get("project")
if not isinstance(project, dict):
raise ValueError("pyproject.toml has no [project] table")
version = str(project.get("version", ""))
versions = [
("pyproject.toml", version),
(
"plugin/relay/__init__.py",
_regex_version("plugin/relay/__init__.py", r'^__version__\s*=\s*"([^"]+)"'),
),
("plugin/plugin.yaml", _regex_version("plugin/plugin.yaml", r"^version:\s*([^\s#]+)")),
("plugin/dashboard/manifest.json", _json_version("plugin/dashboard/manifest.json")),
("plugin/dashboard/package.json", _json_version("plugin/dashboard/package.json")),
("plugin/dashboard/package-lock.json", _json_version("plugin/dashboard/package-lock.json")),
('plugin/dashboard/package-lock.json packages[""]', _package_lock_root_version()),
]
if not SEMVER_RE.match(version):
errors.append(f"plugin version is not SemVer: {version!r}")
for source, found in versions:
if found != version:
errors.append(f"plugin version mismatch: {source} has {found}, expected {version}")
return Track(
# Stable machine-readable identifier; the public surface name is Plugin.
name="server",
version=version,
source="pyproject.toml",
tag=f"server-v{version}",
details="plugin + dashboard metadata",
)
def _cli_track(errors: list[str]) -> Track:
version = _json_version("desktop/package.json")
generated = _regex_version("desktop/src/version.ts", r'^export const VERSION = "([^"]+)" as const')
package_lock, package_lock_root = _desktop_package_lock_versions()
versions = [
("desktop/src/version.ts", generated),
("desktop/package-lock.json", package_lock),
('desktop/package-lock.json packages[""]', package_lock_root),
(
"desktop/tray/Cargo.toml",
_regex_version(
"desktop/tray/Cargo.toml",
r'^version\s*=\s*"([^"]+)"',
),
),
(
"desktop/tray/Cargo.lock",
_regex_version(
"desktop/tray/Cargo.lock",
r'\[\[package\]\]\s*\nname\s*=\s*"hermes-relay-tray"\s*\nversion\s*=\s*"([^"]+)"',
),
),
]
if not SEMVER_RE.match(version):
errors.append(f"CLI+UI version is not SemVer: {version!r}")
for source, found in versions:
if found != version:
errors.append(
f"CLI+UI version mismatch: {source} has {found}, expected {version}; "
"run npm run sync:version in desktop/"
)
return Track(
# Stable machine-readable identifier; the public surface name is CLI+UI.
name="desktop",
version=version,
source="desktop/package.json",
tag=f"desktop-v{version}",
details="CLI + tray metadata",
)
def collect_tracks() -> tuple[list[Track], list[str]]:
errors: list[str] = []
tracks = [
_android_track(errors),
_plugin_track(errors),
_cli_track(errors),
]
return tracks, errors
def _print_table(tracks: list[Track]) -> None:
headers = ("track", "version", "source", "tag", "details")
# Keep JSON track identifiers stable while presenting the current public
# surface names to operators.
display_names = {"server": "Plugin", "desktop": "CLI+UI"}
rows = [
(display_names.get(t.name, t.name), t.version, t.source, t.tag, t.details)
for t in tracks
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
print(" ".join(header.ljust(widths[index]) for index, header in enumerate(headers)))
print(" ".join("-" * width for width in widths))
for row in rows:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
def main() -> int:
parser = argparse.ArgumentParser(description="Validate monorepo release track versions.")
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
args = parser.parse_args()
try:
tracks, errors = collect_tracks()
except Exception as exc:
print(f"version track check failed: {exc}", file=sys.stderr)
return 1
if args.json:
print(
json.dumps(
{
"tracks": [track.__dict__ for track in tracks],
"ok": not errors,
"errors": errors,
},
indent=2,
)
)
else:
_print_table(tracks)
if not errors:
print("\nrelease track versions are internally consistent")
if errors:
for error in errors:
print(error, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())