Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/version-sync-allow-registry-ahead.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
115 changes: 99 additions & 16 deletions scripts/verify-version-sync.cjs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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',
);
168 changes: 168 additions & 0 deletions tests/verify-version-sync.test.cjs
Original file line number Diff line number Diff line change
@@ -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 `<fixture>/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/);
});
Loading