Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,17 @@ jobs:
- name: Install
run: pnpm install --frozen-lockfile

- name: Version sync gate
run: pnpm version:check

- name: Lint
run: pnpm -r lint

- name: Build
run: pnpm -r build

- name: Test
run: pnpm -r test
run: pnpm test

openapi:
name: OpenAPI contract drift gate
Expand Down
43 changes: 37 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0.dev0
0.0.1.dev0
2 changes: 1 addition & 1 deletion frontend/annotator/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion frontend/ui-core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
93 changes: 80 additions & 13 deletions scripts/sync_versions.mjs
Original file line number Diff line number Diff line change
@@ -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"));
}
23 changes: 23 additions & 0 deletions tests/scripts/sync_versions.test.mjs
Original file line number Diff line number Diff line change
@@ -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()));
});
33 changes: 33 additions & 0 deletions tests/test_versioning.py
Original file line number Diff line number Diff line change
@@ -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
Loading