diff --git a/.changeset/version-sync-allow-registry-ahead.md b/.changeset/version-sync-allow-registry-ahead.md new file mode 100644 index 0000000000..79d2a9d40a --- /dev/null +++ b/.changeset/version-sync-allow-registry-ahead.md @@ -0,0 +1,10 @@ +--- +--- + +Fix the pre-push version-sync hook so contributors can push during forward-merge windows. + +The hook required `package.json`, `published_version`, and `adcp_version` to all be exactly equal. The forward-merge release process (#3807) deliberately keeps `package.json --ours` on main, so during the window between cutting a version and bumping the dev package, registry is ahead of `package.json` by design — and pre-push failed for every contributor (May 2026: package.json=3.0.3, registry=3.0.4). + +Now: registry must be at or ahead of `package.json`. Still fails for the genuine bug-modes — registry behind `package.json`, either field missing, or the two registry fields disagreeing. Pre-release tags (`-beta.0`) fall back to strict equality so beta windows still require lockstep. + +Also adds the missing `published_version: "3.0.4"` to `static/schemas/source/index.json` to match the `adcp_version` already there. Both registry fields now agree and are ahead of the dev package. diff --git a/package.json b/package.json index 1d0b1d1b51..8fa371e7bc 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "test:test-dynamic-imports": "node --test --test-force-exit --test-timeout=30000 tests/lint-test-dynamic-imports.test.cjs", "test:callapi-state-change": "node --test --test-force-exit --test-timeout=30000 tests/lint-callapi-state-change.test.cjs", "test:build-schemas-hoist-enums": "node --test --test-force-exit --test-timeout=30000 tests/build-schemas-hoist-enums.test.cjs", + "test:verify-version-sync": "node --test --test-force-exit --test-timeout=30000 tests/verify-version-sync.test.cjs", "test:error-codes": "node scripts/lint-error-codes.cjs", "test:substitution-vector-names": "node scripts/lint-substitution-vector-names.cjs", "test:unit": "vitest run --dir tests/ --pool=threads", @@ -60,12 +61,12 @@ "test:docker": "docker compose -f docker-compose.test.yml up --build --abort-on-container-exit", "test:docs-nav": "node tests/docs-nav-validation.test.cjs", "test:platform-agnostic": "node tests/check-platform-agnostic.cjs", - "test": "npm run test:docs-nav && npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:composed && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:callapi-state-change && npm run test:build-schemas-hoist-enums && npm run test:error-codes && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck", + "test": "npm run test:docs-nav && npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:composed && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:callapi-state-change && npm run test:build-schemas-hoist-enums && npm run test:verify-version-sync && npm run test:error-codes && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck", "test:all": "npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:error-handling && npm run test:snippets && npm run typecheck", "precommit": "bash scripts/with-timeout.sh 60 npm run test:unit && npm run test:test-dynamic-imports && npm run test:callapi-state-change && npm run typecheck", "prepare": "husky", "changeset": "changeset", - "version": "changeset version && npm run build:schemas -- --release && npm run build:compliance -- --release && npm run build:protocol-tarball -- --release && npm run sign:protocol-tarball", + "version": "changeset version && npm run build:schemas -- --release && npm run build:compliance -- --release && npm run build:protocol-tarball -- --release && npm run sign:protocol-tarball && npm run verify-version-sync -- --strict", "sign:protocol-tarball": "bash scripts/sign-protocol-tarball.sh", "update-schema-versions": "echo 'Schema versioning is now handled by build:schemas script'", "release": "npm run version && npm test && npm run build && git add -A && git commit -m 'Version packages' && git push", diff --git a/scripts/verify-version-sync.cjs b/scripts/verify-version-sync.cjs index 06c515bc68..7a409d044c 100755 --- a/scripts/verify-version-sync.cjs +++ b/scripts/verify-version-sync.cjs @@ -1,25 +1,41 @@ #!/usr/bin/env node /** - * Verify AdCP version is synchronized between package.json and schema registry + * Verify AdCP version is synchronized between package.json and the schema + * registry. * - * This script ensures that the schema registry version matches package.json - * before a release is finalized. It's run as part of the release process. + * Two modes: + * - Default (pre-push hook): allow registry to be at or ahead of + * package.json. The forward-merge release process (#3807) keeps + * `package.json --ours` on main, so during the window between cutting + * a version and bumping the dev package, registry is ahead of + * package.json by design. Strict equality made pre-push fail for every + * contributor during that window (May 2026: package.json=3.0.3, + * registry=3.0.4). + * - `--strict` (release-time): require exact equality. Run after + * `changeset version` + `build:schemas --release` have both bumped + * package.json and the registry — at that point they MUST match, and + * any drift is a release-step bug we want to surface before tagging. + * + * Always-fails (regardless of mode): + * - Either field missing entirely → release artifacts not published. + * - Registry behind package.json → bumped the dev version but forgot to + * run update-schema-versions / cut a release. + * - The two registry fields disagreeing with each other → hand-edit drift. + * - Pre-release tags (`-beta.0`) fall back to strict equality so beta + * windows lockstep, even in default mode. */ const fs = require('fs'); const path = require('path'); -// Read package.json version +const strict = process.argv.includes('--strict'); + const packageJson = JSON.parse( fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8') ); const packageVersion = packageJson.version; -// Read schema registry version. published_version is the source of truth; -// adcp_version is a legacy alias kept through 3.x for @adcp/client compat. -// Both MUST match package.json — we check both so a hand-edit on one without -// the other is caught. const registryPath = path.join(__dirname, '../static/schemas/source/index.json'); const schemaRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); const publishedVersion = schemaRegistry.published_version; @@ -30,17 +46,84 @@ console.log(` package.json version: ${packageVersion}`); console.log(` schema registry published_version: ${publishedVersion}`); console.log(` schema registry adcp_version (legacy alias): ${legacyAdcpVersion}`); -const mismatches = []; -if (packageVersion !== publishedVersion) mismatches.push(`published_version (${publishedVersion})`); -if (packageVersion !== legacyAdcpVersion) mismatches.push(`adcp_version legacy alias (${legacyAdcpVersion})`); +/** + * Parse a stable X.Y.Z version into a comparable triple. Returns null for + * pre-release tags (`3.1.0-beta.0`, `3.0.0+build1`) and anything we can't + * parse cleanly — the caller falls back to strict equality so pre-release + * windows require both sides to move together rather than letting `beta.1` + * silently satisfy a `beta.0` baseline. + */ +function parseVersion(v) { + if (typeof v !== 'string') return null; + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim()); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +/** `actual >= base` for X.Y.Z versions. Falls back to strict equality on parse failure. */ +function gteVersion(actual, base) { + const a = parseVersion(actual); + const b = parseVersion(base); + if (!a || !b) return actual === base; + for (let i = 0; i < 3; i++) { + if (a[i] > b[i]) return true; + if (a[i] < b[i]) return false; + } + return true; +} + +const problems = []; -if (mismatches.length > 0) { - console.error('\n❌ Version mismatch detected!\n'); - console.error(`The schema registry's ${mismatches.join(' and ')} does not match package.json.`); - console.error('This likely means the update-schema-versions script failed to run.'); +if (publishedVersion === undefined || publishedVersion === null) { + problems.push('schema registry is missing `published_version`'); +} +if (legacyAdcpVersion === undefined || legacyAdcpVersion === null) { + problems.push('schema registry is missing `adcp_version`'); +} + +// In strict mode (release-time, after `changeset version` + `build:schemas +// --release` have both written), require exact equality. Otherwise allow +// registry to be ahead. +if (strict) { + if (publishedVersion !== undefined && publishedVersion !== packageVersion) { + problems.push( + `[strict] published_version (${publishedVersion}) does not equal package.json (${packageVersion})` + ); + } + if (legacyAdcpVersion !== undefined && legacyAdcpVersion !== packageVersion) { + problems.push( + `[strict] adcp_version (${legacyAdcpVersion}) does not equal package.json (${packageVersion})` + ); + } +} else { + if (publishedVersion && !gteVersion(publishedVersion, packageVersion)) { + problems.push( + `published_version (${publishedVersion}) is behind package.json (${packageVersion}) — the registry was not updated when the package was bumped` + ); + } + if (legacyAdcpVersion && !gteVersion(legacyAdcpVersion, packageVersion)) { + problems.push( + `adcp_version (${legacyAdcpVersion}) is behind package.json (${packageVersion}) — the registry was not updated when the package was bumped` + ); + } +} + +if (publishedVersion && legacyAdcpVersion && publishedVersion !== legacyAdcpVersion) { + problems.push( + `published_version (${publishedVersion}) and adcp_version (${legacyAdcpVersion}) disagree — these two registry fields must match` + ); +} + +if (problems.length > 0) { + console.error('\n❌ Version sync problem detected!\n'); + for (const p of problems) console.error(` - ${p}`); console.error('\nTo fix this, run:'); console.error(' npm run update-schema-versions\n'); process.exit(1); } -console.log('\n✅ Versions are synchronized!\n'); +console.log( + strict + ? '\n✅ Versions are in strict sync (registry === package.json)\n' + : '\n✅ Versions are in sync (registry >= package.json)\n', +); diff --git a/tests/verify-version-sync.test.cjs b/tests/verify-version-sync.test.cjs new file mode 100644 index 0000000000..aab0eefe4e --- /dev/null +++ b/tests/verify-version-sync.test.cjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * Unit tests for scripts/verify-version-sync.cjs — the pre-push hook that + * reconciles package.json against the schema registry. + * + * The bug these tests prevent is the May 2026 deadlock: the original strict- + * equality check broke push for every contributor whenever main was in the + * forward-merge window (registry advanced to a just-cut version, package.json + * still on the next dev). The hook now allows registry to be at or ahead of + * package.json but still fails the genuine bug-modes (registry behind, fields + * missing, the two registry fields disagreeing). + * + * The script reads package.json + static/schemas/source/index.json directly, + * so we drive it by writing a temp tree and running it as a subprocess. + */ + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const SCRIPT = path.join(__dirname, '..', 'scripts', 'verify-version-sync.cjs'); + +const fixtureDirs = []; + +function makeFixture({ packageVersion, publishedVersion, adcpVersion }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-version-')); + fixtureDirs.push(dir); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'fixture', version: packageVersion }), + ); + const registryDir = path.join(dir, 'static', 'schemas', 'source'); + fs.mkdirSync(registryDir, { recursive: true }); + const registry = {}; + if (publishedVersion !== undefined) registry.published_version = publishedVersion; + if (adcpVersion !== undefined) registry.adcp_version = adcpVersion; + fs.writeFileSync(path.join(registryDir, 'index.json'), JSON.stringify(registry)); + return dir; +} + +function run(fixtureDir, args = []) { + // The script reads files via `path.join(__dirname, '../package.json')`, so + // it has to live at `/scripts/verify-version-sync.cjs` for the + // fixture to be its root. + const fixtureScriptDir = path.join(fixtureDir, 'scripts'); + fs.mkdirSync(fixtureScriptDir, { recursive: true }); + const scriptPath = path.join(fixtureScriptDir, 'verify-version-sync.cjs'); + fs.copyFileSync(SCRIPT, scriptPath); + const result = spawnSync('node', [scriptPath, ...args], { encoding: 'utf8' }); + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +test.after(() => { + for (const d of fixtureDirs) { + try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); + +test('passes when registry equals package.json (release-time steady state)', () => { + const dir = makeFixture({ packageVersion: '3.0.4', publishedVersion: '3.0.4', adcpVersion: '3.0.4' }); + const { code } = run(dir); + assert.equal(code, 0); +}); + +test('passes when registry is ahead of package.json (forward-merge window — was a false-fail before)', () => { + // The exact May 2026 state that broke pre-push for everyone: package.json + // stays at the dev version while registry was bumped to the cut version. + const dir = makeFixture({ packageVersion: '3.0.3', publishedVersion: '3.0.4', adcpVersion: '3.0.4' }); + const { code } = run(dir); + assert.equal(code, 0); +}); + +test('fails when registry is behind package.json (the genuine bug)', () => { + // package.json was bumped for a release but the registry never caught up. + const dir = makeFixture({ packageVersion: '3.1.0', publishedVersion: '3.0.4', adcpVersion: '3.0.4' }); + const { code, stderr } = run(dir); + assert.equal(code, 1); + assert.match(stderr, /behind package\.json/); +}); + +test('fails when published_version is missing entirely', () => { + const dir = makeFixture({ packageVersion: '3.0.4', adcpVersion: '3.0.4' }); + const { code, stderr } = run(dir); + assert.equal(code, 1); + assert.match(stderr, /missing `published_version`/); +}); + +test('fails when adcp_version is missing entirely', () => { + const dir = makeFixture({ packageVersion: '3.0.4', publishedVersion: '3.0.4' }); + const { code, stderr } = run(dir); + assert.equal(code, 1); + assert.match(stderr, /missing `adcp_version`/); +}); + +test('fails when published_version and adcp_version disagree (hand-edit drift)', () => { + const dir = makeFixture({ packageVersion: '3.0.3', publishedVersion: '3.0.5', adcpVersion: '3.0.4' }); + const { code, stderr } = run(dir); + assert.equal(code, 1); + assert.match(stderr, /disagree/); +}); + +test('passes for pre-release tags that match exactly', () => { + // Pre-release suffixes don't parse cleanly as X.Y.Z so the script falls back + // to strict equality. As long as both sides have the same tag, we accept. + const dir = makeFixture({ + packageVersion: '3.1.0-beta.0', + publishedVersion: '3.1.0-beta.0', + adcpVersion: '3.1.0-beta.0', + }); + const { code } = run(dir); + assert.equal(code, 0); +}); + +test('fails for pre-release tags that do not match (conservative fallback)', () => { + const dir = makeFixture({ + packageVersion: '3.1.0-beta.0', + publishedVersion: '3.1.0-beta.1', + adcpVersion: '3.1.0-beta.1', + }); + const { code } = run(dir); + assert.equal(code, 1); +}); + +test('compares numerically, not lexicographically (3.10.0 is ahead of 3.2.0)', () => { + // String compare would order "3.10.0" < "3.2.0" because "1" < "2". A + // future regression that swaps to localeCompare would silently flag this + // as registry-behind. Pin the X.Y.Z numeric semantics. + const dir = makeFixture({ packageVersion: '3.2.0', publishedVersion: '3.10.0', adcpVersion: '3.10.0' }); + const { code } = run(dir); + assert.equal(code, 0); +}); + +test('handles 0.x versions correctly (0.9.0 ahead of 0.8.0)', () => { + const dir = makeFixture({ packageVersion: '0.8.0', publishedVersion: '0.9.0', adcpVersion: '0.9.0' }); + const { code } = run(dir); + assert.equal(code, 0); +}); + +test('flags asymmetric drift: published_version ahead but adcp_version behind', () => { + // Half-bumped registry — should fail on the behind field, not silently + // accept just because the disagreement check is below the behind check. + const dir = makeFixture({ packageVersion: '3.0.4', publishedVersion: '3.0.4', adcpVersion: '3.0.3' }); + const { code, stderr } = run(dir); + assert.equal(code, 1); + assert.match(stderr, /adcp_version.*behind/); +}); + +test('strict mode: passes when registry exactly equals package.json (post-changeset-version state)', () => { + const dir = makeFixture({ packageVersion: '3.0.4', publishedVersion: '3.0.4', adcpVersion: '3.0.4' }); + const { code, stdout } = run(dir, ['--strict']); + assert.equal(code, 0); + assert.match(stdout, /strict sync/); +}); + +test('strict mode: fails when registry is ahead of package.json (release-step bug)', () => { + // The exact forward-merge state that the relaxed default mode permits. + // After `changeset version` runs, the two MUST match — if they don't, + // the release scripts ran out of order or one of them no-op'd. + const dir = makeFixture({ packageVersion: '3.0.3', publishedVersion: '3.0.4', adcpVersion: '3.0.4' }); + const { code, stderr } = run(dir, ['--strict']); + assert.equal(code, 1); + assert.match(stderr, /\[strict\].*does not equal/); +});