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
34 changes: 34 additions & 0 deletions .github/workflows/node-matrix.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Multi-Node smoke test. Complements validate.yml (which pins to .nvmrc =
# Node 20) by exercising npm ci + npm test on the engine band declared in
Comment on lines +1 to +2
# package.json (>= 18). Catches breakage on newer Node before users hit it.
name: Node matrix

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v6
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm test
6 changes: 6 additions & 0 deletions .github/workflows/publish-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ jobs:
- name: Install
run: npm install --no-audit

- name: Verify version matches tag
working-directory: '.'
env:
GH_REF: ${{ github.ref }}
run: node scripts/check-versions.mjs --tag "$GH_REF"

- name: Pack (sanity check)
run: npm pack --dry-run

Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/publish-pysdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ jobs:
with:
python-version: '3.12'

- name: Set up Node (for version check)
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

- name: Verify version matches tag
working-directory: ${{ github.workspace }}
env:
GH_REF: ${{ github.ref }}
run: node scripts/check-versions.mjs --tag "$GH_REF"

- name: Install build tooling
run: python -m pip install --upgrade pip hatchling build twine

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
"badges": "node scripts/render-badges.mjs --registry registry.json --out site/badges",
"well-known": "node scripts/well-known.mjs --registry registry.json --out site/.well-known",
"sitemap": "node scripts/render-sitemap.mjs",
"test": "node --test 'scripts/__tests__/*.test.mjs'",
"test:coverage": "c8 --check-coverage --lines 90 --functions 90 --branches 80 node --test 'scripts/__tests__/*.test.mjs'",
"test": "bash -c 'shopt -s nullglob; node --test scripts/__tests__/*.test.mjs'",
"test:coverage": "bash -c 'shopt -s nullglob; c8 --check-coverage --lines 90 --functions 90 --branches 80 node --test scripts/__tests__/*.test.mjs'",
"check-versions": "node scripts/check-versions.mjs",
"smoke": "node scripts/sync.mjs --registry tests/registry-smoke.json --dry-run",
"smoke:browser": "playwright test --config tests/playwright/playwright.config.cjs"
},
Expand Down
96 changes: 96 additions & 0 deletions scripts/__tests__/check-versions.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Regression tests for the publish-time version-consistency guard.
// Ensures `tag-version match` and `malformed semver` are both caught
// before they reach npm/PyPI.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { check, readVersion, tagToVersion } from '../check-versions.mjs';

function fixtureRoot({ rootVersion = '0.2.0', cliVersion = '0.1.1', mcpVersion = '0.1.1', pysdkVersion = '0.1.0' } = {}) {
const root = mkdtempSync(join(tmpdir(), 'uq-vcheck-'));
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'r', version: rootVersion }));
mkdirSync(join(root, 'cli'));
writeFileSync(join(root, 'cli/package.json'), JSON.stringify({ name: 'c', version: cliVersion }));
mkdirSync(join(root, 'mcp'));
writeFileSync(join(root, 'mcp/package.json'), JSON.stringify({ name: 'm', version: mcpVersion }));
mkdirSync(join(root, 'python-sdk'));
writeFileSync(
join(root, 'python-sdk/pyproject.toml'),
`[project]\nname = "understand-quickly"\nversion = "${pysdkVersion}"\n`
);
return root;
}

test('check: all packages well-formed → ok', () => {
const root = fixtureRoot();
const { ok, errors } = check({ root });
assert.equal(errors.length, 0);
assert.equal(ok.length, 4);
});

test('check: malformed semver in cli → fails', () => {
const root = fixtureRoot({ cliVersion: 'not-a-version' });
const { errors } = check({ root });
assert.ok(errors.some(e => /cli.*malformed semver/.test(e)));
});

test('check: tag mismatch on cli → fails', () => {
const root = fixtureRoot({ cliVersion: '0.1.0' });
const { errors } = check({ root, tag: 'cli-v0.2.0' });
assert.ok(errors.some(e => /cli.*tag.*0\.2\.0.*0\.1\.0/.test(e)));
});

test('check: tag matches cli version → ok', () => {
const root = fixtureRoot({ cliVersion: '0.2.0' });
const { errors } = check({ root, tag: 'cli-v0.2.0' });
assert.equal(errors.length, 0);
});

test('check: tag for mcp does not invalidate cli', () => {
const root = fixtureRoot({ cliVersion: '0.1.1', mcpVersion: '0.1.1' });
const { errors } = check({ root, tag: 'mcp-v0.1.1' });
assert.equal(errors.length, 0);
});

test('check: pysdk version read from pyproject.toml', () => {
const root = fixtureRoot({ pysdkVersion: '0.3.0' });
const { errors } = check({ root, tag: 'pysdk-v0.3.0' });
assert.equal(errors.length, 0);
});

test('check: pysdk tag mismatch → fails', () => {
const root = fixtureRoot({ pysdkVersion: '0.3.0' });
const { errors } = check({ root, tag: 'pysdk-v0.4.0' });
assert.ok(errors.some(e => /pysdk.*tag.*0\.4\.0.*0\.3\.0/.test(e)));
});

test('check: refs/tags/ prefix tolerated', () => {
const root = fixtureRoot({ cliVersion: '0.5.0' });
const { errors } = check({ root, tag: 'refs/tags/cli-v0.5.0' });
assert.equal(errors.length, 0);
});

test('check: tag for unknown prefix is ignored', () => {
const root = fixtureRoot();
const { errors } = check({ root, tag: 'random-v9.9.9' });
assert.equal(errors.length, 0);
});

test('readVersion: returns null on missing file', () => {
assert.equal(readVersion({ path: '/nope/none.json' }), null);
});

test('readVersion: returns null on malformed JSON', () => {
const root = mkdtempSync(join(tmpdir(), 'uq-vcheck-'));
writeFileSync(join(root, 'p.json'), '{not json');
assert.equal(readVersion({ path: join(root, 'p.json') }), null);
});

test('tagToVersion: strips refs/tags/ + prefix', () => {
assert.equal(tagToVersion('refs/tags/cli-v0.1.2', 'cli-v'), '0.1.2');
assert.equal(tagToVersion('cli-v1.0.0', 'cli-v'), '1.0.0');
assert.equal(tagToVersion('mcp-v0.5.0', 'cli-v'), null);
});
98 changes: 98 additions & 0 deletions scripts/check-versions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Pre-publish version consistency check.
//
// Asserts that every published-or-publishable package in the repo has a
// well-formed semver version, and (when --tag <ref> is passed) that the
// tag's version matches the relevant package.json. Used by publish-cli /
// publish-mcp / publish-pysdk workflows as a regression guard so a tag
// like `cli-v0.2.0` can never publish a package whose `package.json` says
// `0.1.1`.
//
// Exit codes:
// 0 — all checks pass
// 1 — version mismatch or malformed semver

import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';

const SEMVER_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;

const PACKAGES = [
{ id: 'root', path: 'package.json', prefix: 'v', publishable: false },
{ id: 'cli', path: 'cli/package.json', prefix: 'cli-v', publishable: true },
{ id: 'mcp', path: 'mcp/package.json', prefix: 'mcp-v', publishable: true },
{ id: 'pysdk', path: 'python-sdk/pyproject.toml', prefix: 'pysdk-v', publishable: true, kind: 'pyproject' },
];

export function readVersion({ path, kind }) {
if (!existsSync(path)) return null;
const body = readFileSync(path, 'utf8');
if (kind === 'pyproject') {
// hatchling/poetry both write `version = "X.Y.Z"` at top level under [project]
const match = body.match(/^version\s*=\s*"([^"]+)"/m);
return match ? match[1] : null;
}
try {
return JSON.parse(body).version || null;
} catch {
return null;
}
}

export function tagToVersion(tag, prefix) {
if (!tag) return null;
// strip refs/tags/ if passed wholesale
const t = tag.replace(/^refs\/tags\//, '');
if (!t.startsWith(prefix)) return null;
return t.slice(prefix.length);
}

export function check({ tag = null, root = '.' } = {}) {
const errors = [];
const ok = [];

for (const pkg of PACKAGES) {
const path = resolve(root, pkg.path);
const version = readVersion({ path, kind: pkg.kind });

if (version == null) {
if (pkg.publishable) errors.push(`${pkg.id}: missing version field at ${pkg.path}`);
continue;
}
if (!SEMVER_RE.test(version)) {
errors.push(`${pkg.id}: malformed semver "${version}" at ${pkg.path}`);
continue;
}
ok.push({ id: pkg.id, version });

if (tag) {
const expected = tagToVersion(tag, pkg.prefix);
if (expected !== null && expected !== version) {
errors.push(
`${pkg.id}: tag "${tag}" implies version "${expected}" but ${pkg.path} says "${version}"`
);
}
}
}

return { ok, errors };
}

function main() {
const args = process.argv.slice(2);
const tagIdx = args.indexOf('--tag');
const tag = tagIdx >= 0 ? args[tagIdx + 1] : process.env.GITHUB_REF || null;

const { ok, errors } = check({ tag });

for (const { id, version } of ok) console.log(`${id}: ${version}`);
if (errors.length > 0) {
console.error('\nVERSION CHECK FAILED:');
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
console.log('\nversion check: OK');
}

if (import.meta.url === `file://${process.argv[1]}`) {
main();
}