From 0115e05a97a5b12568d9c04956293deaac4cc3ab Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 1 Aug 2026 17:11:13 -0600 Subject: [PATCH] ci(release): fail a consumer-visible change that ships no version bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twice in two days a change reached main declaring a version the registry already held: v0.118.0's bump was never tagged, and 0.119.0's agent-eval peer floor moved from >=0.139.2 to >=0.140.1 with no bump at all. publish.yml skips a version already on the registry, so neither could be corrected by re-tagging and neither turned a build red. Nothing asserted that a consumer-visible change arrives with a version bump. check:version-bump compares every publishable manifest against the merge base and fails when any field npm copies into the published tarball moved while version stood still. version is not part of the compared surface — it is the payment. catalog: specifiers are resolved through pnpm-workspace.yaml first. That indirection is how the drift stayed invisible: "@tangle-network/agent-knowledge": "catalog:" is byte-identical across the change that moved the installed version 7.0.3 -> 7.0.4, so no manifest diff shows anything. Resolving it also covers bench, whose published 0.4.9 still carries agent-eval 0.135.2 against a catalog that now says 0.140.1. Wired into the ci job beside check:publish-workflow. That job's checkout gains fetch-depth: 0 so the merge base exists, and the step passes the pull request's base sha. A named base that will not resolve is an error, never a silent pass. tests/version-bump-check.test.ts holds the calibration: seven cases over a throwaway git repo covering both rejection directions, the byte-identical catalog move, and the two shapes that must NOT fire — a source-only edit and a devDependency/script change. --- .github/workflows/ci.yml | 11 + CHANGELOG.md | 2 + package.json | 1 + scripts/check-version-bump.mjs | Bin 0 -> 16303 bytes tests/version-bump-check.test.ts | 448 +++++++++++++++++++++++++++++++ 5 files changed, 462 insertions(+) create mode 100644 scripts/check-version-bump.mjs create mode 100644 tests/version-bump-check.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdc7b6a3..854f4334 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # check:version-bump diffs this branch's package manifests against the + # merge base, so the base commit has to be in the clone. + fetch-depth: 0 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -38,6 +41,14 @@ jobs: - name: Check publish credential isolation run: pnpm run check:publish-workflow + # GITHUB_BASE_REF is the base BRANCH. The webhook's frozen + # pull_request.base.sha is deliberately not used: the checked-out merge ref + # is recomputed against the current base tip, so the frozen sha can name a + # commit that is no longer the merge parent, and a release merged in the + # meantime would pay for this pull request's unpaid change. + - name: Check consumer-visible changes carry a version bump + run: pnpm run check:version-bump + - name: Lint (biome) run: pnpm run lint diff --git a/CHANGELOG.md b/CHANGELOG.md index f942ba7b..652d7253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- `pnpm run check:version-bump` (new, in the `ci` job) fails any change that alters a consumer-visible package surface without raising that package's version. Compared: every field npm copies into the published manifest — `exports`, `files`, `bin`, `directories`, `engines`, `typesVersions`, `dependencies`, `peerDependencies`, install-lifecycle `scripts`, `private` — plus the `pnpm-workspace.yaml` catalog pins those resolve through. A `catalog:` specifier is compared by what it RESOLVES to, because a byte-identical `"catalog:"` hiding a moved version is exactly how 0.119.0 shipped its peer range twice. Packages are keyed by name, not path, so relocating one still compares against what that name already published. + ## 0.120.0 ### The runtime's own supervision journal is readable again diff --git a/package.json b/package.json index ab7409ff..2163c27e 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "check:testing-fixture": "tsx scripts/generate-agent-improvement-proposal-fixtures.ts --check", "check:skills": "node scripts/check-skills.mjs", "check:publish-workflow": "node scripts/check-publish-workflow.mjs", + "check:version-bump": "node scripts/check-version-bump.mjs", "release:prepare": "node scripts/prepare-release.mjs", "verify:static-imports": "node scripts/verify-static-imports.mjs", "verify:edge-tool-loop": "node scripts/verify-edge-tool-loop.mjs", diff --git a/scripts/check-version-bump.mjs b/scripts/check-version-bump.mjs new file mode 100644 index 0000000000000000000000000000000000000000..1559509ea17c2db0daacb6800875a104db15546a GIT binary patch literal 16303 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 literal 0 HcmV?d00001 diff --git a/tests/version-bump-check.test.ts b/tests/version-bump-check.test.ts new file mode 100644 index 00000000..a09163ab --- /dev/null +++ b/tests/version-bump-check.test.ts @@ -0,0 +1,448 @@ +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' + +const execFileAsync = promisify(execFile) +const roots: string[] = [] + +/** + * A throwaway repository with the two shapes that matter: a root manifest whose + * peer ranges a consumer resolves against, and a workspace package that reaches + * its dependency versions through a `catalog:` pin. + */ +async function createRepo(): Promise { + const root = await mkdtemp(join(tmpdir(), 'agent-runtime-version-bump-')) + roots.push(root) + await git(root, 'init', '--quiet', '--initial-branch=main') + await mkdir(join(root, 'bench')) + await writeManifests(root, { + version: '1.0.0', + evalPeer: '>=0.140.1 <0.141.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await writeFile(join(root, 'source.ts'), 'export const value = 1\n') + await commit(root, 'base') + return root +} + +async function writeManifests( + root: string, + spec: { version: string; evalPeer: string; knowledgeCatalog: string; benchVersion: string }, +): Promise { + await writeFile( + join(root, 'package.json'), + `${JSON.stringify( + { + name: '@tangle-network/agent-runtime', + version: spec.version, + exports: { '.': './dist/index.js' }, + files: ['dist', 'README.md'], + dependencies: { '@tangle-network/agent-knowledge': 'catalog:' }, + peerDependencies: { '@tangle-network/agent-eval': spec.evalPeer }, + devDependencies: { vitest: '^4.1.10' }, + scripts: { build: 'tsdown' }, + }, + null, + 2, + )}\n`, + ) + await writeFile( + join(root, 'bench', 'package.json'), + `${JSON.stringify( + { + name: '@tangle-network/agent-bench', + version: spec.benchVersion, + dependencies: { '@tangle-network/agent-knowledge': 'catalog:' }, + }, + null, + 2, + )}\n`, + ) + await writeFile( + join(root, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - bench', + '', + 'catalog:', + ` '@tangle-network/agent-knowledge': ${spec.knowledgeCatalog}`, + '', + ].join('\n'), + ) +} + +async function git(root: string, ...args: string[]): Promise { + // -c core.hooksPath keeps a developer's global hooks out of the fixture; the + // fixture identity is local so it never depends on machine git config. + const { stdout } = await execFileAsync( + 'git', + ['-C', root, '-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', ...args], + { env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' } }, + ) + return stdout +} + +async function commit(root: string, message: string): Promise { + await git(root, 'add', '-A') + await git( + root, + '-c', + 'user.email=t@t.dev', + '-c', + 'user.name=T', + 'commit', + '--quiet', + '-m', + message, + ) + return (await git(root, 'rev-parse', 'HEAD')).trim() +} + +async function check(root: string, base: string) { + return execFileAsync(process.execPath, ['scripts/check-version-bump.mjs'], { + cwd: process.cwd(), + env: { + ...process.env, + AGENT_RUNTIME_VERSION_BUMP_ROOT: root, + AGENT_RUNTIME_VERSION_BUMP_BASE: base, + }, + }) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('consumer-visible change requires a version bump', () => { + it('rejects a peer range move that keeps the same version', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeManifests(root, { + version: '1.0.0', + evalPeer: '>=0.141.0 <0.142.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await commit(root, 'move the peer range') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining( + 'peerDependencies.@tangle-network/agent-eval: ">=0.140.1 <0.141.0" -> ">=0.141.0 <0.142.0"', + ), + }) + }) + + it('accepts the same move once the version pays for it', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeManifests(root, { + version: '1.1.0', + evalPeer: '>=0.141.0 <0.142.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await commit(root, 'move the peer range and bump') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('paid for by 1.0.0 -> 1.1.0'), + }) + }) + + it('rejects a catalog pin move even though every manifest is byte-identical', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeManifests(root, { + version: '1.0.0', + evalPeer: '>=0.140.1 <0.141.0', + knowledgeCatalog: '7.0.5', + benchVersion: '0.4.9', + }) + await commit(root, 'move only the catalog pin') + + // This is the shape that shipped 0.119.0 twice: the specifier string never + // changes, so a manifest diff shows nothing while the resolved version moves. + expect((await git(root, 'diff', base, 'HEAD', '--', 'package.json')).trim()).toBe('') + const failure = await check(root, base).catch((error) => error) + expect(failure.stderr).toContain( + 'dependencies.@tangle-network/agent-knowledge: "7.0.4" -> "7.0.5"', + ) + // Every publishable package that resolves through the pin, not just the root. + expect(failure.stderr).toContain('bench/package.json (@tangle-network/agent-bench)') + }) + + it('does not fire on an ordinary source-only change', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeFile(join(root, 'source.ts'), 'export const value = 2\n') + await commit(root, 'edit source') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged at 1.0.0'), + }) + }) + + it('does not fire on a devDependency or script change', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:package.json').then((raw) => raw)) + manifest.devDependencies.vitest = '^4.2.0' + manifest.scripts.lint = 'biome check src' + await writeFile(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await commit(root, 'bump a devDependency and add a script') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged at 1.0.0'), + }) + }) + + it('fails closed when a catalog specifier resolves to nothing', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeFile( + join(root, 'pnpm-workspace.yaml'), + ['packages:', ' - bench', '', 'catalog: {}', ''].join('\n'), + ) + await commit(root, 'drop the catalog entry the dependency points at') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('has no catalog entry for it'), + }) + }) + + it('fails closed when the named base cannot be resolved', async () => { + const root = await createRepo() + + await expect(check(root, '0000000000000000000000000000000000000000')).rejects.toMatchObject({ + stderr: expect.stringContaining('cannot resolve the requested base commit'), + }) + }) + + it('fails closed on a CI event that should have had a base branch', async () => { + const root = await createRepo() + await writeFile(join(root, 'source.ts'), 'export const value = 2\n') + await commit(root, 'edit source') + + await expect( + execFileAsync(process.execPath, ['scripts/check-version-bump.mjs'], { + cwd: process.cwd(), + env: { + ...process.env, + AGENT_RUNTIME_VERSION_BUMP_ROOT: root, + AGENT_RUNTIME_VERSION_BUMP_BASE: '', + GITHUB_BASE_REF: '', + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_NAME: 'merge_group', + }, + }), + ).rejects.toMatchObject({ + stderr: expect.stringContaining('with no base branch to compare against'), + }) + }) + + it('rejects a downgrade, which lands on a version the registry may already hold', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await writeManifests(root, { + version: '0.9.0', + evalPeer: '>=0.141.0 <0.142.0', + knowledgeCatalog: '7.0.4', + benchVersion: '0.4.9', + }) + await commit(root, 'move the peer range and lower the version') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('moves 1.0.0 -> 0.9.0, which is not higher'), + }) + }) + + it('rejects an install-lifecycle script that would run on a consumer machine', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:package.json')) + manifest.scripts.postinstall = 'node ./dist/postinstall.js' + await writeFile(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await commit(root, 'add a postinstall script') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('installScripts'), + }) + }) + + it('rejects a typesVersions change', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:package.json')) + manifest.typesVersions = { '*': { '*': ['dist/*'] } } + await writeFile(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await commit(root, 'add typesVersions') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('typesVersions'), + }) + }) + + it('compares a moved package against the name it already published, not a new path', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + await mkdir(join(root, 'packages', 'bench'), { recursive: true }) + const manifest = JSON.parse(await git(root, 'show', 'HEAD:bench/package.json')) + manifest.peerDependencies = { '@tangle-network/agent-eval': '>=0.141.0 <0.142.0' } + await writeFile( + join(root, 'packages', 'bench', 'package.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + ) + await rm(join(root, 'bench'), { recursive: true }) + await writeFile( + join(root, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - packages/*', + '', + 'catalog:', + " '@tangle-network/agent-knowledge': 7.0.4", + '', + ].join('\n'), + ) + await commit(root, 'move bench and change its peers') + + // Relocating a directory must not buy a free pass on an already-published name. + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('@tangle-network/agent-bench'), + }) + }) + + it('rejects marking a published package private without a version bump', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:bench/package.json')) + manifest.private = true + await writeFile(join(root, 'bench', 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await commit(root, 'mark bench private') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('private'), + }) + }) + + it('does not fire when a dependency moves between a catalog and an identical literal pin', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:package.json')) + manifest.dependencies['@tangle-network/agent-knowledge'] = '7.0.4' + await writeFile(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await commit(root, 'inline the catalog pin at the same version') + + // The tarball is byte-identical; only the authoring style moved. + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged'), + }) + }) + + it('does not fire when files is reordered', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:package.json')) + manifest.files = ['README.md', 'dist'] + await writeFile(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await commit(root, 'reorder files') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged'), + }) + }) + + it('does not police a directory the workspace excludes', async () => { + const root = await createRepo() + await writeFile( + join(root, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - bench', + ' - fixtures/*', + ' - "!fixtures/scratch"', + '', + 'catalog:', + " '@tangle-network/agent-knowledge': 7.0.4", + '', + ].join('\n'), + ) + await mkdir(join(root, 'fixtures', 'scratch'), { recursive: true }) + await writeFile( + join(root, 'fixtures', 'scratch', 'package.json'), + `${JSON.stringify({ name: 'scratch-fixture', version: '1.0.0', files: ['a'] }, null, 2)}\n`, + ) + await commit(root, 'add an excluded fixture package') + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + + await writeFile( + join(root, 'fixtures', 'scratch', 'package.json'), + `${JSON.stringify({ name: 'scratch-fixture', version: '1.0.0', files: ['a', 'b'] }, null, 2)}\n`, + ) + await commit(root, 'edit the excluded fixture') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged at 1.0.0'), + }) + }) + + it('reaches a package nested deeper than one level under a ** pattern', async () => { + const root = await createRepo() + await writeFile( + join(root, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - bench', + ' - packages/**', + '', + 'catalog:', + " '@tangle-network/agent-knowledge': 7.0.4", + '', + ].join('\n'), + ) + await mkdir(join(root, 'packages', 'group', 'nested'), { recursive: true }) + await writeFile( + join(root, 'packages', 'group', 'nested', 'package.json'), + `${JSON.stringify( + { name: '@tangle-network/nested', version: '1.0.0', peerDependencies: { react: '>=18' } }, + null, + 2, + )}\n`, + ) + await commit(root, 'add a nested workspace package') + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + + await writeFile( + join(root, 'packages', 'group', 'nested', 'package.json'), + `${JSON.stringify( + { name: '@tangle-network/nested', version: '1.0.0', peerDependencies: { react: '>=19' } }, + null, + 2, + )}\n`, + ) + await commit(root, 'move the nested package peer range') + + await expect(check(root, base)).rejects.toMatchObject({ + stderr: expect.stringContaining('@tangle-network/nested'), + }) + }) + + it('survives a workspace file that does not exist', async () => { + const root = await createRepo() + const base = (await git(root, 'rev-parse', 'HEAD')).trim() + const manifest = JSON.parse(await git(root, 'show', 'HEAD:package.json')) + // The catalog is gone, so nothing may resolve through it any more. + manifest.dependencies['@tangle-network/agent-knowledge'] = '7.0.4' + await writeFile(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await rm(join(root, 'pnpm-workspace.yaml')) + await rm(join(root, 'bench'), { recursive: true }) + await commit(root, 'drop the workspace file') + + await expect(check(root, base)).resolves.toMatchObject({ + stdout: expect.stringContaining('consumer surface unchanged'), + }) + }) +})