#!/usr/bin/env python3 """Engine <-> npm package version-coupling gate. Every npm package that *ships the engine* (as opposed to an independently versioned SDK, or the decoupled editor extension) MUST carry the exact engine version. Otherwise a release silently skips publishing the stale package (npm "already exists" -> continue-on-error) and `npx`/Pi users quietly fall a version behind — the exact drift GH issue reports about the Pi extension came from. This gate is the *enforced* counterpart of the DEPLOY_CHECKLIST "Version bumped in ALL locations" step: it fails on every push/PR (CI) and blocks the release build, so the engine and its wrapper packages can never diverge. Source of truth: * engine version: rust/Cargo.toml [package] version (first `^version =`) Coupled packages (must equal the engine version): * packages/pi-lean-ctx/package.json — Pi Coding Agent extension * packages/lean-ctx-bin/package.json — npx/npm binary wrapper Deliberately excluded: * vscode-extension — decoupled cadence (vscode-v* tags, own workflow) * packages/node-lean-ctx and the cookbook SDKs — own semver, guarded by scripts/check-sdk-versions.py No third-party dependencies — standard library only. """ from __future__ import annotations import json import pathlib import re import sys ROOT = pathlib.Path(__file__).resolve().parent.parent # npm packages whose version must match the engine, relative to the repo root. COUPLED_PACKAGES = [ "packages/pi-lean-ctx/package.json", "packages/lean-ctx-bin/package.json", ] FAILURES: list[str] = [] def read(path: str) -> str: return (ROOT / path).read_text(encoding="utf-8") def engine_version() -> str: """The [package] version from the workspace root Cargo.toml (first `^version =`).""" m = re.search(r'^version\s*=\s*"([^"]+)"', read("rust/Cargo.toml"), re.M) if not m: sys.exit("FATAL: [package] version not found in rust/Cargo.toml") return m.group(1) def package_version(rel: str) -> str: return json.loads(read(rel))["version"] def main() -> int: engine = engine_version() print(f"engine version (rust/Cargo.toml): {engine}") for rel in COUPLED_PACKAGES: try: got = package_version(rel) except (OSError, KeyError, json.JSONDecodeError) as e: FAILURES.append(f"{rel}: could not read version ({e})") continue status = "ok" if got == engine else "DRIFT" print(f" {rel:<38} {got:<12} {status}") if got != engine: FAILURES.append( f"{rel} is {got} but the engine ships {engine} — bump it to {engine}" ) for f in FAILURES: print(f"::error title=Package version drift::{f}") if FAILURES: print( f"\n{len(FAILURES)} package(s) out of sync with engine {engine}. " "Bump every coupled package.json to the engine version before releasing." ) return 1 print(f"\nOK: all {len(COUPLED_PACKAGES)} coupled packages match engine {engine}") return 0 if __name__ == "__main__": sys.exit(main())