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
36 changes: 36 additions & 0 deletions .changeset/auto-derive-compat-versions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@adcp/sdk': patch
---

fix(version): auto-derive COMPATIBLE_ADCP_VERSIONS from ADCP_VERSION pin

The 3.0.x patch enumeration in `COMPATIBLE_ADCP_VERSIONS` was a hardcoded
array literal inside the `scripts/sync-version.ts` template. Every AdCP
patch bump needed someone to remember to append the new version to it.
The 3.0.9, 3.0.10, and 3.0.11 chore PRs all forgot — the list capped
at `3.0.8` even though `ADCP_VERSION` moved to `3.0.11`. Symptom:
`isCompatibleWith('3.0.11') === false` against the SDK's own pin.

Same root-cause class as the schema URL pinning drift surfaced by
`adcontextprotocol/adcp#4419` (BidMachine reports / "3.0.1 schemas" cited
against a 3.0.11 seller): a load-bearing version surface that depends on
human discipline at every patch bump.

Fix:

- `scripts/sync-version.ts` now derives the list dynamically from the
current `ADCP_VERSION`. Enumerates `3.0.0..3.0.<patch>` mechanically;
the bumper no longer has to remember anything.
- Fails closed when `ADCP_VERSION` falls outside the `3.0.x` range so a
future 3.1.x or 4.x bump forces the script to be extended (rather than
silently inheriting a stale enumeration). The compat surface for a
major/minor move is rarely mechanical — that's the right time to think.
- Adds `test/lib/compatible-versions-self-consistency.test.js` asserting
the regenerated list contains the current pin and fills the
`3.0.0..ADCP_VERSION` range without gaps. Future regressions (someone
reverting to hardcoded literals) fail loud at CI.

Does not address the deeper schema URL pinning drift in
`src/lib/testing/storyboard/validations.ts` (SDK-build-time `ADCP_VERSION`
used in cited schema URLs regardless of the agent's advertised version);
tracked separately at adcp-client#NNNN.
93 changes: 78 additions & 15 deletions scripts/sync-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,82 @@ function assertSafeVersion(value: string, source: string): void {
}
}

// Pre-3.0 / pre-stable version names. Kept as a separate constant so the
// 3.0.x patch enumeration below stays mechanical. Adding a future
// major/minor (3.1.x, 4.0.0-beta.1, etc.) requires updating this list AND
// the major/minor gate in `buildCompatibleVersions` — failing closed there
// is intentional, so a spec move forces a human to think about which
// historical versions stay in the compat surface.
const COMPATIBLE_PREFIX = ['v2.5', 'v2.6', 'v3', '3.0.0-beta.1', '3.0.0-beta.3'] as const;

/**
* Build the `COMPATIBLE_ADCP_VERSIONS` list dynamically from the current
* `ADCP_VERSION`. The bumper PR doesn't have to remember to append a new
* version literal — the script enumerates `3.0.0..3.0.<patch>` automatically.
*
* Fails loudly when the version isn't in the `3.0.x` range so a 3.1.x or
* 4.x bump can't silently inherit a stale enumeration: the human bumper has
* to extend this script to define the new range. That's the right behavior —
* a major/minor move likely also moves the compat surface in a non-mechanical
* way (e.g. dropping older 3.0.x once 3.1 is stable).
*
* Background: adcontextprotocol/adcp-client schema URL pinning drift. The
* 3.0.9 / 3.0.10 / 3.0.11 chore PRs all forgot to manually extend the
* `COMPATIBLE_ADCP_VERSIONS` array literal that previously lived inline in
* the template, so the compat surface capped at `3.0.8` across multiple
* patch bumps. Auto-deriving eliminates that drift class.
*/
// Sanity bound on the patch enumeration. The AdCP spec patch cadence is
// roughly weekly; even at 10× speed we won't see 3.0.500 in the lifetime
// of the 3.0.x series. A defensive cap turns a hostile or fat-fingered
// `ADCP_VERSION = '3.0.999999999'` into a clean build failure instead of
// a silent ~10⁹-string OOM during enumeration.
const MAX_PATCH_ENUMERATION = 500;

function buildCompatibleVersions(adcpVersion: string): string[] {
// Reject prerelease pins (e.g. `3.0.11-rc.1`) — enumerating
// `3.0.0..3.0.11` from a prerelease pin over-claims GA stability for
// the unreleased patch. The bumper for a prerelease should land the
// GA pin separately when it stabilizes.
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(adcpVersion);
if (!match) {
console.error(
`❌ ADCP_VERSION ${JSON.stringify(adcpVersion)} does not match the expected ` +
`major.minor.patch shape (no prerelease suffix). Update scripts/sync-version.ts ` +
`to define compat semantics if you intend to pin a prerelease.`
);
process.exit(1);
}
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
if (major !== 3 || minor !== 0) {
console.error(
`❌ ADCP_VERSION ${JSON.stringify(adcpVersion)} is outside the 3.0.x range this ` +
`script enumerates. Extend buildCompatibleVersions in scripts/sync-version.ts ` +
`to cover the new major/minor range, then re-run npm run sync-version.`
);
process.exit(1);
}
if (patch > MAX_PATCH_ENUMERATION) {
console.error(
`❌ ADCP_VERSION ${JSON.stringify(adcpVersion)} exceeds MAX_PATCH_ENUMERATION ` +
`(${MAX_PATCH_ENUMERATION}). If the spec has genuinely produced this many patches, ` +
`raise the constant in scripts/sync-version.ts deliberately.`
);
process.exit(1);
}
const range3_0_x: string[] = [];
for (let p = 0; p <= patch; p++) range3_0_x.push(`3.0.${p}`);
return [...COMPATIBLE_PREFIX, ...range3_0_x];
}

// Generate version.ts file with library and AdCP versions
function generateVersionFile(libraryVersion: string, adcpVersion: string): void {
assertSafeVersion(libraryVersion, 'package.json version');
assertSafeVersion(adcpVersion, 'ADCP_VERSION');
const compatibleVersions = buildCompatibleVersions(adcpVersion);
const compatibleVersionsLiteral = compatibleVersions.map(v => ` '${v}',`).join('\n');
const versionFilePath = path.join(__dirname, '../src/lib/version.ts');
const versionContent = `// Generated version information
// This file is auto-generated by sync-version.ts
Expand All @@ -99,23 +171,14 @@ export const ADCP_VERSION = '${adcpVersion}';
export const ADCP_MAJOR_VERSION = 3;

/**
* AdCP versions this library maintains backward compatibility with
* AdCP versions this library maintains backward compatibility with.
*
* Auto-derived from \`ADCP_VERSION\` by scripts/sync-version.ts — every
* \`3.0.0\` through the current pin is enumerated. Do not edit this list
* by hand; bumping the AdCP pin via \`npm run sync-version\` extends it.
*/
export const COMPATIBLE_ADCP_VERSIONS = [
'v2.5',
'v2.6',
'v3',
'3.0.0-beta.1',
'3.0.0-beta.3',
'3.0.0',
'3.0.1',
'3.0.2',
'3.0.3',
'3.0.4',
'3.0.5',
'3.0.6',
'3.0.7',
'3.0.8',
${compatibleVersionsLiteral}
] as const;

/**
Expand Down
15 changes: 11 additions & 4 deletions src/lib/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
/**
* AdCP SDK library version
*/
export const LIBRARY_VERSION = '6.19.1';
export const LIBRARY_VERSION = '7.0.0';

/**
* AdCP specification version this library is built for
Expand All @@ -19,7 +19,11 @@ export const ADCP_VERSION = '3.0.11';
export const ADCP_MAJOR_VERSION = 3;

/**
* AdCP versions this library maintains backward compatibility with
* AdCP versions this library maintains backward compatibility with.
*
* Auto-derived from `ADCP_VERSION` by scripts/sync-version.ts — every
* `3.0.0` through the current pin is enumerated. Do not edit this list
* by hand; bumping the AdCP pin via `npm run sync-version` extends it.
*/
export const COMPATIBLE_ADCP_VERSIONS = [
'v2.5',
Expand All @@ -36,6 +40,9 @@ export const COMPATIBLE_ADCP_VERSIONS = [
'3.0.6',
'3.0.7',
'3.0.8',
'3.0.9',
'3.0.10',
'3.0.11',
] as const;

/**
Expand All @@ -52,10 +59,10 @@ export type AdcpVersion = (typeof COMPATIBLE_ADCP_VERSIONS)[number];
* Full version information
*/
export const VERSION_INFO = {
library: '6.19.1',
library: '7.0.0',
adcp: '3.0.11',
compatibleVersions: COMPATIBLE_ADCP_VERSIONS,
generatedAt: '2026-05-11T14:19:39.941Z',
generatedAt: '2026-05-12T01:00:50.163Z',
} as const;

/**
Expand Down
64 changes: 64 additions & 0 deletions test/lib/compatible-versions-self-consistency.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Self-consistency check: the auto-derived COMPATIBLE_ADCP_VERSIONS list
* (scripts/sync-version.ts) MUST contain the current ADCP_VERSION pin.
*
* Background: the 3.0.9 / 3.0.10 / 3.0.11 chore PRs forgot to manually
* append the new patch to the hardcoded array literal that previously
* lived in the version.ts template — capping the compat surface at
* 3.0.8 even though ADCP_VERSION moved to 3.0.11. Tied to the
* SDK-build-time schema URL pinning drift surfaced by
* adcontextprotocol/adcp#4419. The script is now auto-derived; this
* test locks the invariant so a future regression (e.g. someone
* reverting the auto-derive to the old hardcoded shape) fails loud at
* CI rather than slipping out as schema URL drift on the next release.
*/

const { describe, test } = require('node:test');
const assert = require('node:assert/strict');

const { ADCP_VERSION, COMPATIBLE_ADCP_VERSIONS, isCompatibleWith } = require('../../dist/lib/version.js');

describe('COMPATIBLE_ADCP_VERSIONS self-consistency', () => {
test('includes the current ADCP_VERSION pin', () => {
assert.ok(
COMPATIBLE_ADCP_VERSIONS.includes(ADCP_VERSION),
`ADCP_VERSION=${ADCP_VERSION} is not present in COMPATIBLE_ADCP_VERSIONS=` +
`[${COMPATIBLE_ADCP_VERSIONS.join(', ')}]. ` +
`Run \`npm run sync-version\` to regenerate src/lib/version.ts so the ` +
`compat list extends through the current pin.`
);
});

test('isCompatibleWith returns true for the current ADCP_VERSION', () => {
assert.equal(isCompatibleWith(ADCP_VERSION), true);
});

test('enumerates every 3.0.x patch from .0 up through the current pin', () => {
const match = /^3\.0\.(\d+)$/.exec(ADCP_VERSION);
if (!match) return; // pre-release or future major/minor — separate gate
const patch = Number(match[1]);
for (let p = 0; p <= patch; p++) {
const v = `3.0.${p}`;
assert.ok(
COMPATIBLE_ADCP_VERSIONS.includes(v),
`Expected ${v} in COMPATIBLE_ADCP_VERSIONS — auto-derivation should fill the ` +
`3.0.0..${ADCP_VERSION} range without gaps.`
);
}
});

test('preserves the pre-3.0 legacy aliases (COMPATIBLE_PREFIX)', () => {
// Belt-and-suspenders against a regression that drops legacy entries
// while keeping 3.0.x intact (e.g. a future "clean up old aliases" PR
// that loses them silently). Catches that class without coupling to
// the exact prefix membership — adopters that pinned `v2.5` /
// `v2.6` / `v3` legacy aliases must keep matching.
for (const legacy of ['v2.5', 'v2.6', 'v3', '3.0.0-beta.1', '3.0.0-beta.3']) {
assert.ok(
COMPATIBLE_ADCP_VERSIONS.includes(legacy),
`Legacy alias ${legacy} dropped from COMPATIBLE_ADCP_VERSIONS — would break ` +
`adopters who pinned the alias as their adcpVersion option.`
);
}
});
});
Loading