diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d1e8272..628a2df1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,9 @@ jobs: - name: Install run: pnpm install --frozen-lockfile + - name: Version sync gate + run: pnpm version:check + - name: Lint run: pnpm -r lint @@ -57,7 +60,7 @@ jobs: run: pnpm -r build - name: Test - run: pnpm -r test + run: pnpm test openapi: name: OpenAPI contract drift gate diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04544cef..95dc5110 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,8 +18,9 @@ artifact is always the pip package). | Import contracts | `uv run lint-imports` | | Kernel type-safety (strict) | `uv run mypy src/visionset/kernel` | | Lint/format | `uv run ruff check .` / `uv run ruff format .` | -| Frontend build + tests | `pnpm -r build && pnpm -r test` | +| Frontend build + tests | `pnpm -r build && pnpm test` | | Frontend lint | `pnpm -r lint` | +| Version sync | `pnpm version:check` | | OpenAPI contract | `uv run python scripts/export_openapi.py` (commit the diff) | ## The two machine-enforced boundaries @@ -35,13 +36,43 @@ If a change fights either boundary, the change is wrong — not the boundary. ## Versioning -The repo-root `VERSION` file is the single source of truth (`0.1.0.dev0` style). +The repo-root `VERSION` file is the single source of truth, in PEP 440 form. Everything +else derives from it, in lockstep across the monorepo — the Python distribution and every +`frontend/*` package always carry the same version. -- **Python**: `pyproject.toml` reads it dynamically via hatchling's regex version source. -- **npm**: `pnpm version:sync` (root script) rewrites the `version` field of every - `frontend/*` package.json, converting to npm semver (`0.1.0.dev0` → `0.1.0-dev.0`). +- **Python**: `pyproject.toml` reads `VERSION` dynamically via hatchling's regex version + source; `visionset --version` prints it. +- **npm**: `pnpm version:sync` rewrites the `version` field of every `frontend/*` + package.json, translating PEP 440 to npm semver. `pnpm version:check` is the CI drift + gate — it fails if a package.json has fallen out of step with `VERSION`. -Never hand-edit a version anywhere else. +| PEP 440 (`VERSION`, PyPI) | npm semver | Used for | +| --- | --- | --- | +| `0.0.1.dev0` | `0.0.1-dev.0` | Ongoing development on `main` | +| `0.0.1a1` | `0.0.1-alpha.1` | Reserved; the alpha milestones are tags, not releases | +| `0.0.1b1` | `0.0.1-beta.1` | The first published beta | +| `0.0.1` | `0.0.1` | First stable release | + +Never hand-edit a version anywhere else — change `VERSION`, then run `pnpm version:sync`. + +### Tags and publishing + +The road to the beta is cut into six internal milestones. Each one ends with a **git tag +only**: + +``` +v0.0.1-alpha.1 … v0.0.1-alpha.5 git tags, never published to PyPI or npm +``` + +These mark milestone completion so the tree can be checked out and bisected. `VERSION` +stays at `0.0.1.dev0` throughout — the alpha tags do not bump it, because nothing is +being distributed. + +The first artifact anyone installs is the beta: bump `VERSION` to `0.0.1b1`, run +`pnpm version:sync`, tag `v0.0.1-beta.1`, and publish the wheel to PyPI (`0.0.1b1`) and, +if the packages are published at all, the frontend packages to npm (`0.0.1-beta.1`). +`0.0.1-beta` is *lower* than `0.1.0` in both version orderings, which is why `VERSION` +sits at `0.0.1.dev0` rather than the `0.1.0.dev0` the repo was bootstrapped with. ## Commits diff --git a/VERSION b/VERSION index 1821d33c..b732403d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0.dev0 +0.0.1.dev0 diff --git a/frontend/annotator/package.json b/frontend/annotator/package.json index 491cfda6..2b95a7ba 100644 --- a/frontend/annotator/package.json +++ b/frontend/annotator/package.json @@ -1,6 +1,6 @@ { "name": "@visionset/annotator", - "version": "0.1.0-dev.0", + "version": "0.0.1-dev.0", "description": "Headless annotation engine: interaction state machine + geometry + input in pure TypeScript, with replaceable render adapters (React first).", "license": "Apache-2.0", "type": "module", diff --git a/frontend/app/package.json b/frontend/app/package.json index 9b4a7894..07d2b1da 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -1,6 +1,6 @@ { "name": "@visionset/app", - "version": "0.1.0-dev.0", + "version": "0.0.1-dev.0", "description": "VisionSet OSS product shell. Private — never published to npm; the compiled bundle ships inside the Python wheel.", "private": true, "license": "Apache-2.0", diff --git a/frontend/ui-core/package.json b/frontend/ui-core/package.json index a1fe323f..6b796b15 100644 --- a/frontend/ui-core/package.json +++ b/frontend/ui-core/package.json @@ -1,6 +1,6 @@ { "name": "@visionset/ui-core", - "version": "0.1.0-dev.0", + "version": "0.0.1-dev.0", "description": "VisionSet domain components, design tokens, and the generated typed API client. UI primitives: Radix + lucide only.", "license": "Apache-2.0", "type": "module", diff --git a/openapi.json b/openapi.json index 9551fce2..4ec204ac 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "info": { "description": "REST surface of the VisionSet SDK. The committed openapi.json is the contract.", "title": "Robomous VisionSet API", - "version": "0.1.0.dev0" + "version": "0.0.1.dev0" }, "openapi": "3.1.0", "paths": { diff --git a/package.json b/package.json index 0c0b51c5..b6a466b9 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,11 @@ "description": "Root workspace for Robomous VisionSet. Version source of truth is the VERSION file.", "scripts": { "build": "pnpm -r build", - "test": "pnpm -r test", + "test": "pnpm -r test && pnpm test:scripts", + "test:scripts": "node --test \"tests/scripts/*.test.mjs\"", "lint": "pnpm -r lint", "version:sync": "node scripts/sync_versions.mjs", + "version:check": "node scripts/sync_versions.mjs --check", "bundle:static": "node scripts/bundle_static.mjs" }, "packageManager": "pnpm@10.30.2", diff --git a/scripts/sync_versions.mjs b/scripts/sync_versions.mjs index 60546255..8d48bea1 100644 --- a/scripts/sync_versions.mjs +++ b/scripts/sync_versions.mjs @@ -1,17 +1,84 @@ // Sync frontend package versions from the repo-root VERSION file (single source of truth). -// PEP 440 dev versions map to npm semver prereleases: 0.1.0.dev0 -> 0.1.0-dev.0 +// +// Usage: +// node scripts/sync_versions.mjs rewrite every frontend/*/package.json +// node scripts/sync_versions.mjs --check exit 1 if any is out of sync (CI drift gate) import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const pep440 = readFileSync(path.join(repoRoot, "VERSION"), "utf8").trim(); -const npmVersion = pep440.replace(/\.dev(\d+)$/, "-dev.$1"); - -for (const pkg of ["annotator", "ui-core", "app"]) { - const pkgJsonPath = path.join(repoRoot, "frontend", pkg, "package.json"); - const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8")); - pkgJson.version = npmVersion; - writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n"); - console.log(`${pkgJson.name} -> ${npmVersion}`); +import { fileURLToPath, pathToFileURL } from "node:url"; + +const PACKAGES = ["annotator", "ui-core", "app"]; + +const PEP440_PRERELEASE = { a: "alpha", b: "beta", rc: "rc", dev: "dev" }; + +/** + * Convert a PEP 440 version to its npm semver equivalent. + * + * 0.0.1.dev0 -> 0.0.1-dev.0 0.0.1b1 -> 0.0.1-beta.1 + * 0.0.1a2 -> 0.0.1-alpha.2 0.0.1rc1 -> 0.0.1-rc.1 + * 0.0.1 -> 0.0.1 + * + * Only the forms VisionSet actually publishes are supported; anything else is a + * mistake we want to hear about loudly rather than silently write into a package.json. + * + * @param {string} pep440 + * @returns {string} + */ +export function pep440ToNpm(pep440) { + const match = /^(\d+\.\d+\.\d+)(?:\.?(dev|a|b|rc)(\d+))?$/.exec(pep440); + if (!match) { + throw new Error( + `VERSION "${pep440}" is not a supported PEP 440 version ` + + `(expected X.Y.Z, X.Y.Z.devN, X.Y.ZaN, X.Y.ZbN or X.Y.ZrcN).`, + ); + } + const [, release, tag, number] = match; + return tag ? `${release}-${PEP440_PRERELEASE[tag]}.${number}` : release; +} + +/** @returns {string} the repo-root absolute path */ +export function repoRoot() { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +} + +/** @returns {string} the trimmed contents of the repo-root VERSION file */ +export function readVersion(root = repoRoot()) { + return readFileSync(path.join(root, "VERSION"), "utf8").trim(); +} + +function main(check) { + const root = repoRoot(); + const npmVersion = pep440ToNpm(readVersion(root)); + const stale = []; + + for (const pkg of PACKAGES) { + const pkgJsonPath = path.join(root, "frontend", pkg, "package.json"); + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8")); + + if (check) { + if (pkgJson.version !== npmVersion) { + stale.push(`${pkgJson.name}: ${pkgJson.version} (expected ${npmVersion})`); + } + continue; + } + + pkgJson.version = npmVersion; + writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n"); + console.log(`${pkgJson.name} -> ${npmVersion}`); + } + + if (check) { + if (stale.length > 0) { + console.error( + `Frontend versions are out of sync with VERSION:\n ${stale.join("\n ")}\n` + + `Run 'pnpm version:sync' and commit the result.`, + ); + process.exit(1); + } + console.log(`All frontend packages are at ${npmVersion}.`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.includes("--check")); } diff --git a/tests/scripts/sync_versions.test.mjs b/tests/scripts/sync_versions.test.mjs new file mode 100644 index 00000000..1d661e47 --- /dev/null +++ b/tests/scripts/sync_versions.test.mjs @@ -0,0 +1,23 @@ +// Run with: pnpm test:scripts (also part of the root `pnpm test`) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { pep440ToNpm, readVersion } from "../../scripts/sync_versions.mjs"; + +test("maps every PEP 440 form VisionSet publishes to npm semver", () => { + assert.equal(pep440ToNpm("0.0.1.dev0"), "0.0.1-dev.0"); + assert.equal(pep440ToNpm("0.0.1a1"), "0.0.1-alpha.1"); + assert.equal(pep440ToNpm("0.0.1b1"), "0.0.1-beta.1"); + assert.equal(pep440ToNpm("0.0.1rc2"), "0.0.1-rc.2"); + assert.equal(pep440ToNpm("0.0.1"), "0.0.1"); +}); + +test("rejects versions it cannot map instead of writing them into a package.json", () => { + for (const bad of ["0.0.1-beta.1", "1.0", "0.0.1.post1", "v0.0.1", ""]) { + assert.throws(() => pep440ToNpm(bad), /not a supported PEP 440 version/); + } +}); + +test("the committed VERSION file is mappable", () => { + assert.doesNotThrow(() => pep440ToNpm(readVersion())); +}); diff --git a/tests/test_versioning.py b/tests/test_versioning.py new file mode 100644 index 00000000..966ecfe7 --- /dev/null +++ b/tests/test_versioning.py @@ -0,0 +1,33 @@ +"""The repo-root VERSION file is the single source of truth; these tests keep it that way.""" + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from visionset import __version__ +from visionset.cli.main import app + +VERSION_FILE = Path(__file__).resolve().parents[1] / "VERSION" + +runner = CliRunner() + + +@pytest.fixture(scope="module") +def declared_version() -> str: + return VERSION_FILE.read_text(encoding="utf-8").strip() + + +def test_version_file_targets_the_beta_release_line(declared_version: str) -> None: + """`0.0.1-beta` is the release target, so VERSION must stay below it in PEP 440 order.""" + assert declared_version == "0.0.1.dev0" + + +def test_installed_distribution_reports_the_declared_version(declared_version: str) -> None: + assert __version__ == declared_version + + +def test_cli_version_flag_prints_the_declared_version(declared_version: str) -> None: + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert result.stdout.strip() == declared_version