diff --git a/.github/workflows/node-matrix.yml b/.github/workflows/node-matrix.yml new file mode 100644 index 0000000..a722546 --- /dev/null +++ b/.github/workflows/node-matrix.yml @@ -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 +# 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 diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 9249dbe..66e45f6 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -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 diff --git a/.github/workflows/publish-pysdk.yml b/.github/workflows/publish-pysdk.yml index 96c43c8..501850a 100644 --- a/.github/workflows/publish-pysdk.yml +++ b/.github/workflows/publish-pysdk.yml @@ -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 diff --git a/package.json b/package.json index 1aab6de..14901e5 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/__tests__/check-versions.test.mjs b/scripts/__tests__/check-versions.test.mjs new file mode 100644 index 0000000..d1f4bd5 --- /dev/null +++ b/scripts/__tests__/check-versions.test.mjs @@ -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); +}); diff --git a/scripts/check-versions.mjs b/scripts/check-versions.mjs new file mode 100644 index 0000000..70603f4 --- /dev/null +++ b/scripts/check-versions.mjs @@ -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 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(); +}