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
127 changes: 119 additions & 8 deletions scripts/check-version-bump.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,42 @@
* everything repo-specific out of the manifests they inspect, so an edit to one
* belongs in all three. Nothing here may name a single repository.
*
* The version a change is measured against is the LAST PUBLISHED one, not the
* base branch's. Those differ whenever main already carries a bump that has not
* shipped, and reading the base branch instead made an unreleased version behave
* like a released one: the first consumer-visible change claimed the open slot
* and every later one in the same train was told to open another.
*
* Measured 2026-09-01 on agent-runtime. Two export-adding pull requests were
* open against a main that declared 0.190.0 while the registry's newest version
* was 0.189.0. Nothing could ship under 0.190.0 without a second bump, so #1065
* and #1066 were each refused, each for adding exports to a version no consumer
* could yet resolve, and both had to be merged with admin over a red gate. One
* unpublished bump can absorb every consumer-visible change until it ships,
* which is what a release train is for.
*
* A version that IS on the registry still demands its own bump. That is the
* defect this file exists for, and it is unchanged: 0.119.0 moved a peer floor
* under a version npm already held, and `publish.yml` skips a version already
* published, so re-tagging could never correct it.
*
* What "published" means here, in order:
*
* 1. The highest `v*` tag reachable from the base — the repository's own
* record of what it released, since `publish.yml` fires on that tag. Each
* package's released version is read from ITS manifest at that tag, so a
* workspace package with its own version line is never handed the root's.
* 2. The npm registry's `latest`, consulted only when no tag is reachable and
* only as a best effort. A registry that cannot be reached changes nothing.
* 3. Neither: fall back to the base branch's version, which is the behavior
* this check has always had. Unknown publication state must not weaken it.
*
* Usage: pnpm run check:version-bump
* PACKAGE_VERSION_BUMP_BASE base ref to compare against (default: the
* CI base branch, else origin/main, else main)
* PACKAGE_VERSION_BUMP_ROOT repository to inspect (default: this repo)
* PACKAGE_VERSION_BUMP_BASE base ref to compare against (default: the
* CI base branch, else origin/main, else main)
* PACKAGE_VERSION_BUMP_ROOT repository to inspect (default: this repo)
* PACKAGE_VERSION_BUMP_REGISTRY `0` skips the registry fallback entirely,
* for a hermetic or offline run
*/
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
Expand Down Expand Up @@ -340,6 +372,52 @@ const surfaceAtRef = (ref, manifestPath) => {
/** Rank a level, treating `lower` and `unorderable` as paying nothing. */
const rankOf = (level) => LEVEL_RANK[level] ?? 0

/** True when `candidate` is strictly higher than `reference`. */
const isHigher = (reference, candidate) => rankOf(versionBumpLevel(reference, candidate)) > 0

/**
* The newest `v*` tag reachable from `ref`, or `null`.
*
* Reachability is the point: a tag on a branch nobody merged describes nothing
* this base contains. Ordering is semantic, never lexical — `v0.99.0` sorts
* above `v0.100.0` as a string, and picking the wrong tag would compare against
* a version older than the one that actually shipped.
*/
const lastReleaseTag = (ref) => {
const tags = (git(['tag', '--list', 'v*', '--merged', ref], { allowFailure: true }) ?? '')
.split('\n')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
let newest = null
for (const tag of tags) {
if (newest === null || isHigher(newest.slice(1), tag.slice(1))) newest = tag
}
return newest
}

/**
* `latest` from the npm registry for one package, or `undefined`.
*
* Best effort by construction. This runs only when no release tag is reachable,
* and every failure — offline, private package, never published, a slow
* registry — returns `undefined` and leaves the check exactly as it was.
*/
const registryVersion = (name) => {
if (process.env.PACKAGE_VERSION_BUMP_REGISTRY === '0') return undefined
try {
const stdout = execFileSync('npm', ['view', name, 'version', '--silent'], {
encoding: 'utf8',
timeout: 5_000,
stdio: ['ignore', 'pipe', 'ignore'],
})
const version = stdout.trim()
return version.length > 0 ? version : undefined
} catch {
return undefined
}
}


const base = resolveBase()
if (base === null) {
process.stdout.write('No base ref to compare against; version-bump check does not apply.\n')
Expand All @@ -364,6 +442,20 @@ const headWorkspace = workspaceAtRef(head)
const baseManifests = manifestsAtRef(mergeBase, baseWorkspace)
const headManifests = manifestsAtRef(head, headWorkspace)

// What a consumer can ALREADY resolve, per package name. The base branch's
// version is not that: main routinely carries a bump that has not shipped, and
// one unpublished bump absorbs every consumer-visible change until it does.
const releaseTag = lastReleaseTag(mergeBase)
// Each package's released version comes from ITS OWN manifest at the tag, so a
// workspace package on a separate version line is never handed the root's.
const releasedManifests =
releaseTag === null ? null : manifestsAtRef(releaseTag, workspaceAtRef(releaseTag))
const releasedFrom = releaseTag === null ? 'the npm registry' : `${releaseTag}`
const releasedVersionOf = (name) =>
releasedManifests === null
? registryVersion(name)
: releasedManifests.get(name)?.manifest.version

const publishable = (entry) => entry !== undefined && entry.manifest.private !== true
const failures = []
const inspected = []
Expand Down Expand Up @@ -432,27 +524,38 @@ for (const name of names) {
continue
}

// Measure the bump against the last PUBLISHED version. When the base already
// carries a bump nobody can resolve yet, that bump pays for this change too;
// when the base's version is on the registry, it pays for nothing.
const releasedVersion = releasedVersionOf(name)
const absorbing =
releasedVersion !== undefined && isHigher(releasedVersion, baseEntry.manifest.version)
const comparisonVersion = absorbing ? releasedVersion : baseEntry.manifest.version

// The manifest rule asks only for a higher version; the export rule asks for a
// level. The stronger of the two governs.
const requiredLevel = (() => {
const forExports = requiredBumpLevel(severity, baseEntry.manifest.version)
const forExports = requiredBumpLevel(severity, comparisonVersion)
const forManifest = changes.length > 0 ? 'patch' : 'none'
return rankOf(forExports) >= rankOf(forManifest) ? forExports : forManifest
})()
const paidLevel = versionBumpLevel(baseEntry.manifest.version, headEntry.manifest.version)
const paidLevel = versionBumpLevel(comparisonVersion, headEntry.manifest.version)

if (rankOf(paidLevel) >= rankOf(requiredLevel)) {
inspected.push(
`${headEntry.path}: ${changes.length} manifest and ${exportLines.length} export change(s) ` +
`needing a ${requiredLevel} bump, paid for by ` +
`${baseEntry.manifest.version} -> ${headEntry.manifest.version} (${paidLevel})`,
`${comparisonVersion} -> ${headEntry.manifest.version} (${paidLevel})` +
(absorbing ? ` — ${comparisonVersion} is the last published version (${releasedFrom})` : ''),
)
continue
}
failures.push({
path: headEntry.path,
name,
baseVersion: baseEntry.manifest.version,
comparisonVersion,
absorbing,
headVersion: headEntry.manifest.version,
changes: [...changes, ...exportLines],
requiredLevel,
Expand All @@ -474,8 +577,10 @@ if (failures.length > 0) {
continue
}
const versionState = (() => {
if (failure.baseVersion === failure.headVersion) return `still declares ${failure.headVersion}`
const move = `moves ${failure.baseVersion} -> ${failure.headVersion}`
if (failure.comparisonVersion === failure.headVersion) {
return `still declares ${failure.headVersion}`
}
const move = `moves ${failure.comparisonVersion} -> ${failure.headVersion}`
return rankOf(failure.paidLevel) === 0
? `${move}, which is not higher`
: `${move}, only a ${failure.paidLevel} bump`
Expand All @@ -486,6 +591,12 @@ if (failures.length > 0) {
`${severityLabel === 'additive' ? 'an' : 'a'} ${severityLabel} change needing a ` +
`${failure.requiredLevel} bump:`,
)
if (failure.absorbing) {
lines.push(
` measured against the last published version ${failure.comparisonVersion} ` +
`(${releasedFrom}); the base declares ${failure.baseVersion}, which is not published yet`,
)
}
for (const change of failure.changes) lines.push(` ${change}`)
lines.push('')
}
Expand Down
182 changes: 182 additions & 0 deletions tests/version-bump-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,19 @@ async function check(root: string, base: string) {
...process.env,
PACKAGE_VERSION_BUMP_ROOT: root,
PACKAGE_VERSION_BUMP_BASE: base,
// Hermetic: a fixture with no release tag would otherwise fall back to the
// real registry, where these package names ARE published, and every
// fixture version would be measured against a stranger's release.
PACKAGE_VERSION_BUMP_REGISTRY: '0',
},
})
}

/** Mark a fixture commit as released, the way `publish.yml` marks a real one. */
async function tag(root: string, name: string): Promise<void> {
await git(root, 'tag', name)
}

afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
Expand Down Expand Up @@ -666,3 +675,176 @@ describe('a change to the exported symbols requires a version bump', () => {
})
})
})

/**
* The version a change is measured against is the LAST PUBLISHED one, not the
* base branch's.
*
* Reading the base branch instead made an unreleased version behave like a
* released one. Measured 2026-09-01 on agent-runtime: main declared 0.190.0
* while the registry's newest was 0.189.0, and two export-adding pull requests
* open against that main were each refused for adding exports to a version no
* consumer could yet resolve. One unpublished bump absorbs every consumer-visible
* change until it ships; a published version still buys nothing.
*/
describe('the baseline is the last published version, not the base branch', () => {
/** Release `v1.0.0`, then put an unshipped `1.1.0` on the base. */
async function repoWithUnpublishedBump(): Promise<{ root: string; base: string }> {
const root = await createRepo()
await tag(root, 'v1.0.0')
await writeManifests(root, {
version: '1.1.0',
evalPeer: '>=0.140.1 <0.141.0',
knowledgeCatalog: '7.0.4',
benchVersion: '0.4.9',
})
await commit(root, 'prepare 1.1.0')
return { root, base: (await git(root, 'rev-parse', 'HEAD')).trim() }
}

it('accepts added exports when the base carries a bump that has not shipped', async () => {
const { root, base } = await repoWithUnpublishedBump()
await writeSurface(root, '.', '@tangle-network/agent-runtime', {
'.': { runAgent: 'value', AgentSpec: 'type', supervise: 'value' },
})
await commit(root, 'add an export under the unshipped 1.1.0')

// 1.1.0 is not on the registry, so it is still paying for its own contents.
const result = await check(root, base)
expect(result.stdout).toContain('paid for by 1.0.0 -> 1.1.0 (minor)')
expect(result.stdout).toContain('1.0.0 is the last published version (v1.0.0)')
})

it('accepts a SECOND change under the same unshipped bump — the release-train case', async () => {
const { root, base } = await repoWithUnpublishedBump()
// The first change already landed on the base; this is the one that used to
// be told to open a version of its own.
await writeSurface(root, '.', '@tangle-network/agent-runtime', {
'.': { runAgent: 'value', AgentSpec: 'type', supervise: 'value' },
})
await commit(root, 'first export-adding change')
const secondBase = (await git(root, 'rev-parse', 'HEAD')).trim()
await writeSurface(root, '.', '@tangle-network/agent-runtime', {
'.': { runAgent: 'value', AgentSpec: 'type', supervise: 'value', runGraph: 'value' },
})
await commit(root, 'second export-adding change')

await expect(check(root, secondBase)).resolves.toMatchObject({
stdout: expect.stringContaining('paid for by 1.0.0 -> 1.1.0 (minor)'),
})
})

it('still refuses added exports when the base version IS published', async () => {
const root = await createRepo()
await tag(root, 'v1.0.0')
const base = (await git(root, 'rev-parse', 'HEAD')).trim()
await writeSurface(root, '.', '@tangle-network/agent-runtime', {
'.': { runAgent: 'value', AgentSpec: 'type', supervise: 'value' },
})
await commit(root, 'add an export under the published 1.0.0')

// The defect this file exists for is unchanged: npm already holds 1.0.0, and
// publish.yml skips a version already on the registry.
const failure = await check(root, base).catch((error) => error)
expect(failure.stderr).toContain('still declares 1.0.0')
expect(failure.stderr).toContain('export added: . supervise')
})

it('names the published baseline in the refusal when the base is ahead but still short', async () => {
const root = await createRepo()
await tag(root, 'v1.0.0')
await writeManifests(root, {
version: '1.0.1',
evalPeer: '>=0.140.1 <0.141.0',
knowledgeCatalog: '7.0.4',
benchVersion: '0.4.9',
})
await commit(root, 'prepare 1.0.1')
const base = (await git(root, 'rev-parse', 'HEAD')).trim()
// A 1.x package needs a MINOR for an addition, and the unshipped bump is only
// a patch, so the absorbing case applies and still falls short.
await writeSurface(root, '.', '@tangle-network/agent-runtime', {
'.': { runAgent: 'value', AgentSpec: 'type', supervise: 'value' },
})
await commit(root, 'add an export under the unshipped 1.0.1')

const failure = await check(root, base).catch((error) => error)
expect(failure.stderr).toContain('moves 1.0.0 -> 1.0.1, only a patch bump')
expect(failure.stderr).toContain(
'measured against the last published version 1.0.0 (v1.0.0); the base declares 1.0.1, ' +
'which is not published yet',
)
})

it('passes a change with no consumer surface, published or not', async () => {
const { root, base } = await repoWithUnpublishedBump()
await writeFile(join(root, 'source.ts'), 'export const value = 2\n')
await commit(root, 'change only source')

await expect(check(root, base)).resolves.toMatchObject({
stdout: expect.stringContaining('consumer surface unchanged at 1.1.0'),
})
})

it('orders release tags semantically, so v0.100.0 outranks v0.99.0', async () => {
const root = await createRepo()
await writeManifests(root, {
version: '0.99.0',
evalPeer: '>=0.140.1 <0.141.0',
knowledgeCatalog: '7.0.4',
benchVersion: '0.4.9',
})
await commit(root, 'release 0.99.0')
await tag(root, 'v0.99.0')
await writeManifests(root, {
version: '0.100.0',
evalPeer: '>=0.140.1 <0.141.0',
knowledgeCatalog: '7.0.4',
benchVersion: '0.4.9',
})
await commit(root, 'release 0.100.0')
await tag(root, 'v0.100.0')
await writeManifests(root, {
version: '0.101.0',
evalPeer: '>=0.140.1 <0.141.0',
knowledgeCatalog: '7.0.4',
benchVersion: '0.4.9',
})
await commit(root, 'prepare 0.101.0')
const base = (await git(root, 'rev-parse', 'HEAD')).trim()
await writeSurface(root, '.', '@tangle-network/agent-runtime', {
'.': { runAgent: 'value', AgentSpec: 'type', supervise: 'value' },
})
await commit(root, 'add an export')

// Lexically v0.99.0 is the larger string. Picking it would measure against a
// version two releases stale.
await expect(check(root, base)).resolves.toMatchObject({
stdout: expect.stringContaining('paid for by 0.100.0 -> 0.101.0'),
})
})

it('reads each package own released version at the tag, never the root one', async () => {
const root = await createRepo()
await tag(root, 'v1.0.0')
// The root ships an unpublished bump; bench does not move at all.
await writeManifests(root, {
version: '1.1.0',
evalPeer: '>=0.140.1 <0.141.0',
knowledgeCatalog: '7.0.4',
benchVersion: '0.4.9',
})
await commit(root, 'prepare 1.1.0')
const base = (await git(root, 'rev-parse', 'HEAD')).trim()
await writeSurface(root, 'bench', '@tangle-network/agent-bench', {
'.': { runBench: 'value', runSuite: 'value' },
})
await commit(root, 'add a bench export')

// bench released 0.4.9 and still declares 0.4.9, so the root's unshipped
// 1.1.0 pays nothing for it.
const failure = await check(root, base).catch((error) => error)
expect(failure.stderr).toContain('bench/package.json (@tangle-network/agent-bench)')
expect(failure.stderr).toContain('still declares 0.4.9')
})
})