From 620fc69cbff933fffa135ca5b1175071ecd6ec6c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 26 Aug 2026 14:23:40 -0700 Subject: [PATCH] improvement(cli): automate npm package version bumps --- .github/workflows/publish-sim-cli.yml | 21 +- .github/workflows/publish-sim-setup.yml | 5 +- package.json | 4 +- scripts/bump-npm-package-versions.test.ts | 71 ++++++ scripts/bump-npm-package-versions.ts | 259 ++++++++++++++++++++++ 5 files changed, 344 insertions(+), 16 deletions(-) create mode 100644 scripts/bump-npm-package-versions.test.ts create mode 100644 scripts/bump-npm-package-versions.ts diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml index f36e514ec69..95c74918a20 100644 --- a/.github/workflows/publish-sim-cli.yml +++ b/.github/workflows/publish-sim-cli.yml @@ -11,7 +11,7 @@ permissions: concurrency: group: publish-sim-cli-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: publish-npm: @@ -50,6 +50,9 @@ jobs: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} run: bun pm whoami + - name: Auto-bump package version + run: bun run bump:npm-packages sim-cli + - name: Run tests working-directory: packages/sim-cli run: bun run test @@ -115,20 +118,17 @@ jobs: tar -xzf "$PACKAGE_PATH" -C "$SMOKE_DIR" "$SMOKE_DIR/package/dist/index.js" --version - - name: Check if version already exists - id: version_check + - name: Verify version is unpublished working-directory: packages/sim-cli env: VERSION: ${{ steps.release.outputs.version }} run: | if bun pm view "sim@$VERSION" version > /dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" + echo "sim@$VERSION is already published. The automatic version bump did not produce a unique release." >&2 + exit 1 fi - name: Publish to npm - if: steps.version_check.outputs.exists == 'false' working-directory: packages/sim-cli env: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -136,14 +136,7 @@ jobs: run: bun publish --access public --tag "$NPM_TAG" --no-save - name: Summarize release - if: steps.version_check.outputs.exists == 'false' env: VERSION: ${{ steps.release.outputs.version }} NPM_TAG: ${{ steps.release.outputs.tag }} run: echo "Published sim@$VERSION with the '$NPM_TAG' tag." - - - name: Summarize skipped release - if: steps.version_check.outputs.exists == 'true' - env: - VERSION: ${{ steps.release.outputs.version }} - run: echo "Skipped sim@$VERSION because that version is already published." diff --git a/.github/workflows/publish-sim-setup.yml b/.github/workflows/publish-sim-setup.yml index e8d9f181b19..0594dee14b6 100644 --- a/.github/workflows/publish-sim-setup.yml +++ b/.github/workflows/publish-sim-setup.yml @@ -55,6 +55,9 @@ jobs: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} run: bun pm whoami + - name: Auto-bump package version + run: bun run bump:npm-packages sim-setup + - name: Check generated deployment config run: bun run deployment-config:check @@ -156,7 +159,7 @@ jobs: VERSION: ${{ steps.release.outputs.version }} run: | if bun pm view "sim-setup@$VERSION" version > /dev/null 2>&1; then - echo "sim-setup@$VERSION is already published. Bump packages/sim-setup/package.json before releasing another build." >&2 + echo "sim-setup@$VERSION is already published. The automatic version bump did not produce a unique release." >&2 exit 1 fi diff --git a/package.json b/package.json index 1b51b3735f1..04a796ef375 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "dev:sockets": "cd apps/realtime && bun run dev", "dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"", "dev:full:capped": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev:capped\" \"cd apps/realtime && bun run dev\"", - "test": "bun run test:setup && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:tool-request-boundary && bun run test:generators && turbo run test", + "test": "bun run test:setup && bun run test:npm-package-versions && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:tool-request-boundary && bun run test:generators && turbo run test", "test:setup": "bun run --cwd packages/sim-setup test", + "test:npm-package-versions": "bunx vitest run scripts/bump-npm-package-versions.test.ts", "test:icon-path-precision": "bunx vitest run scripts/check-icon-path-precision.test.ts", "test:tool-registry-boundary": "bunx vitest run scripts/check-tool-registry-boundary.test.ts", "test:tool-request-boundary": "bunx vitest run scripts/check-tool-request-boundary.test.ts", @@ -93,6 +94,7 @@ "library:covers": "bun run scripts/generate-library-covers.tsx", "library:covers:check": "bun run scripts/generate-library-covers.tsx --check", "skills:sync": "bun run scripts/sync-skills.ts", + "bump:npm-packages": "bun run scripts/bump-npm-package-versions.ts", "sim": "bun run packages/sim-cli/src/index.ts", "sim-setup": "bun run packages/sim-setup/src/index.ts", "agent-stream-docs:generate": "bun run scripts/sync-agent-stream-docs.ts", diff --git a/scripts/bump-npm-package-versions.test.ts b/scripts/bump-npm-package-versions.test.ts new file mode 100644 index 00000000000..c4948333b0a --- /dev/null +++ b/scripts/bump-npm-package-versions.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + resolveNextStableVersion, + updateLockfileWorkspaceVersion, +} from './bump-npm-package-versions' + +describe('resolveNextStableVersion', () => { + it('increments the highest published stable patch and ignores prereleases', () => { + expect( + resolveNextStableVersion('2.1.2', [ + '2.1.3-preview.10.1', + '1.9.9', + '2.1.2', + '2.1.3-dev.11.1', + '2.0.8', + ]) + ).toBe('2.1.3') + }) + + it('preserves a higher unpublished manifest version', () => { + expect(resolveNextStableVersion('3.0.0', ['2.1.2', '2.1.3-preview.10.1'])).toBe('3.0.0') + }) + + it('advances from the registry when the manifest is stale', () => { + expect(resolveNextStableVersion('1.5.0', ['2.0.0', '2.1.2'])).toBe('2.1.3') + }) + + it('fails on a prerelease manifest or a registry without a stable version', () => { + expect(() => resolveNextStableVersion('2.1.3-preview.1', ['2.1.2'])).toThrow( + "Manifest version must be a stable X.Y.Z version, got '2.1.3-preview.1'" + ) + expect(() => resolveNextStableVersion('2.1.3', ['2.1.3-preview.1'])).toThrow( + 'npm returned no published stable versions' + ) + }) +}) + +describe('updateLockfileWorkspaceVersion', () => { + const lockfile = `{ + "workspaces": { + "packages/sim-cli": { + "name": "sim", + "version": "2.1.2", + }, + "packages/sim-setup": { + "name": "sim-setup", + "version": "1.0.2", + }, + }, +}` + + it('updates only the selected workspace', () => { + const next = updateLockfileWorkspaceVersion( + lockfile, + 'packages/sim-cli', + 'sim', + '2.1.2', + '2.1.3' + ) + + expect(next).toContain('"version": "2.1.3"') + expect(next).toContain('"version": "1.0.2"') + expect(next).not.toContain('"version": "2.1.2"') + }) + + it('fails when the lockfile disagrees with the manifest', () => { + expect(() => + updateLockfileWorkspaceVersion(lockfile, 'packages/sim-cli', 'sim', '2.1.1', '2.1.3') + ).toThrow("bun.lock must contain exactly one sim@2.1.1 entry in 'packages/sim-cli'") + }) +}) diff --git a/scripts/bump-npm-package-versions.ts b/scripts/bump-npm-package-versions.ts new file mode 100644 index 00000000000..245cbca51e0 --- /dev/null +++ b/scripts/bump-npm-package-versions.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env bun + +import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const LOCKFILE_PATH = path.join(ROOT, 'bun.lock') +const STABLE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ + +interface PackageConfig { + readonly manifestPath: string + readonly name: string + readonly selector: string + readonly workspacePath: string +} + +interface PackageManifest extends Record { + name: string + version: string +} + +interface ParsedVersion { + major: number + minor: number + patch: number +} + +interface PlannedUpdate { + config: PackageConfig + currentVersion: string + manifest: PackageManifest + nextVersion: string +} + +const PACKAGE_CONFIGS = [ + { + manifestPath: path.join(ROOT, 'packages/sim-cli/package.json'), + name: 'sim', + selector: 'sim-cli', + workspacePath: 'packages/sim-cli', + }, + { + manifestPath: path.join(ROOT, 'packages/sim-setup/package.json'), + name: 'sim-setup', + selector: 'sim-setup', + workspacePath: 'packages/sim-setup', + }, +] as const satisfies readonly PackageConfig[] + +function parseStableVersion(version: string, source: string): ParsedVersion { + const match = STABLE_VERSION_PATTERN.exec(version) + if (!match) { + throw new Error(`${source} must be a stable X.Y.Z version, got '${version}'`) + } + + const parsed = { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + } + if (!Number.isSafeInteger(parsed.major)) { + throw new Error(`${source} major version exceeds JavaScript's safe integer range`) + } + if (!Number.isSafeInteger(parsed.minor)) { + throw new Error(`${source} minor version exceeds JavaScript's safe integer range`) + } + if (!Number.isSafeInteger(parsed.patch)) { + throw new Error(`${source} patch version exceeds JavaScript's safe integer range`) + } + return parsed +} + +function compareVersions(left: ParsedVersion, right: ParsedVersion): number { + if (left.major !== right.major) return left.major - right.major + if (left.minor !== right.minor) return left.minor - right.minor + return left.patch - right.patch +} + +function formatVersion(version: ParsedVersion): string { + return `${version.major}.${version.minor}.${version.patch}` +} + +/** + * Resolves the stable version a package should use for its next publish. + * + * A higher manifest version is an explicit release decision and is preserved. + * Otherwise the highest published stable version receives a patch increment. + * Prereleases never advance the stable line. + */ +export function resolveNextStableVersion( + manifestVersion: string, + publishedVersions: string[] +): string { + const manifest = parseStableVersion(manifestVersion, 'Manifest version') + const stableVersions = publishedVersions.flatMap((version) => { + if (typeof version !== 'string') { + throw new Error('npm returned a non-string package version') + } + return STABLE_VERSION_PATTERN.test(version) + ? [parseStableVersion(version, 'Published version')] + : [] + }) + + if (stableVersions.length === 0) { + throw new Error('npm returned no published stable versions') + } + + const latestPublished = stableVersions.reduce((latest, version) => + compareVersions(version, latest) > 0 ? version : latest + ) + if (compareVersions(manifest, latestPublished) > 0) return formatVersion(manifest) + + if (latestPublished.patch === Number.MAX_SAFE_INTEGER) { + throw new Error('Published patch version cannot be incremented safely') + } + return formatVersion({ ...latestPublished, patch: latestPublished.patch + 1 }) +} + +function readManifest(config: PackageConfig): PackageManifest { + const metadata: unknown = JSON.parse(readFileSync(config.manifestPath, 'utf8')) + if (metadata === null || typeof metadata !== 'object') { + throw new Error(`${config.workspacePath}/package.json must contain a JSON object`) + } + + const manifest = metadata as Record + if (manifest.name !== config.name) { + throw new Error( + `${config.workspacePath}/package.json must describe '${config.name}', got '${String(manifest.name)}'` + ) + } + if (typeof manifest.version !== 'string') { + throw new Error(`${config.workspacePath}/package.json is missing a string version`) + } + return manifest as PackageManifest +} + +function publishedVersions(packageName: string): string[] { + let output: string + try { + output = execFileSync('bun', ['pm', 'view', packageName, 'versions', '--json'], { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }) + } catch (error) { + throw new Error(`Could not read published versions for '${packageName}' from npm`, { + cause: error, + }) + } + + let metadata: unknown + try { + metadata = JSON.parse(output) + } catch (error) { + throw new Error(`npm returned invalid JSON for '${packageName}'`, { cause: error }) + } + if (!Array.isArray(metadata)) { + throw new Error(`npm did not return a version array for '${packageName}'`) + } + return metadata +} + +/** Updates one workspace version without touching an identically named version elsewhere. */ +export function updateLockfileWorkspaceVersion( + lockfile: string, + workspacePath: string, + packageName: string, + currentVersion: string, + nextVersion: string +): string { + const marker = ` "${workspacePath}": {` + const workspaceStart = lockfile.indexOf(marker) + if (workspaceStart === -1 || lockfile.indexOf(marker, workspaceStart + marker.length) !== -1) { + throw new Error(`bun.lock must contain exactly one '${workspacePath}' workspace entry`) + } + + const nextWorkspace = lockfile.indexOf('\n "packages/', workspaceStart + marker.length) + if (nextWorkspace === -1) { + throw new Error(`bun.lock has no workspace boundary after '${workspacePath}'`) + } + const workspace = lockfile.slice(workspaceStart, nextWorkspace) + const currentMetadata = ` "name": "${packageName}",\n "version": "${currentVersion}",` + const metadataIndex = workspace.indexOf(currentMetadata) + if ( + metadataIndex === -1 || + workspace.indexOf(currentMetadata, metadataIndex + currentMetadata.length) !== -1 + ) { + throw new Error( + `bun.lock must contain exactly one ${packageName}@${currentVersion} entry in '${workspacePath}'` + ) + } + + const nextWorkspaceContents = workspace.replace( + currentMetadata, + ` "name": "${packageName}",\n "version": "${nextVersion}",` + ) + return `${lockfile.slice(0, workspaceStart)}${nextWorkspaceContents}${lockfile.slice(nextWorkspace)}` +} + +function selectedPackages(args: string[]): readonly PackageConfig[] { + if (args.length === 0) return PACKAGE_CONFIGS + + const uniqueArgs = new Set(args) + if (uniqueArgs.size !== args.length) { + throw new Error('Package selectors must not be repeated') + } + + return args.map((selector) => { + const config = PACKAGE_CONFIGS.find((candidate) => candidate.selector === selector) + if (!config) { + throw new Error( + `Unknown package '${selector}'. Expected one of: ${PACKAGE_CONFIGS.map((candidate) => candidate.selector).join(', ')}` + ) + } + return config + }) +} + +function main(): void { + const configs = selectedPackages(process.argv.slice(2)) + const updates = configs.map((config) => { + const manifest = readManifest(config) + return { + config, + currentVersion: manifest.version, + manifest, + nextVersion: resolveNextStableVersion(manifest.version, publishedVersions(config.name)), + } + }) + + let nextLockfile = readFileSync(LOCKFILE_PATH, 'utf8') + for (const update of updates) { + nextLockfile = updateLockfileWorkspaceVersion( + nextLockfile, + update.config.workspacePath, + update.config.name, + update.currentVersion, + update.nextVersion + ) + } + + for (const update of updates) { + update.manifest.version = update.nextVersion + } + for (const update of updates) { + writeFileSync(update.config.manifestPath, `${JSON.stringify(update.manifest, null, 2)}\n`) + } + writeFileSync(LOCKFILE_PATH, nextLockfile) + + for (const update of updates) { + process.stdout.write( + `${update.config.name}: ${update.currentVersion} -> ${update.nextVersion}\n` + ) + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main()