From 20ba772c73a5b2ada0ffd179523f66753d030c7b Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:03:55 +0000 Subject: [PATCH 01/10] chore: start ci-workflow From c9168f220b588590298a3491fdf87f85e6b34758 Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:11:04 +0000 Subject: [PATCH 02/10] fix(release): finish the 0.6.3 bump through set-version The v0.6.3 release commit only moved the root manifest and lockfile; all four workspace manifests, their internal ^0.6.2 ranges, and both embedded VERSION constants stayed at 0.6.2. That broke the cdk suite's lockstep test and is exactly the drift #35 guards against. Re-ran `npm run set-version -- 0.6.3` and `npm install --package-lock-only` so the tree agrees at 0.6.3 before the new check lands. Refs #35 --- package-lock.json | 18 +++++++++--------- packages/millwright-cdk/package.json | 8 ++++---- packages/millwright-cdk/src/version.ts | 2 +- packages/millwright-cli/package.json | 6 +++--- packages/millwright-cli/src/version.ts | 2 +- packages/millwright-state/package.json | 2 +- packages/millwright-workflows/package.json | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2511a11..f6d51a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2727,12 +2727,12 @@ }, "packages/millwright-cdk": { "name": "@copperbox/millwright-cdk", - "version": "0.6.2", + "version": "0.6.3", "license": "MIT", "dependencies": { - "@copperbox/millwright-cli": "^0.6.2", - "@copperbox/millwright-state": "^0.6.2", - "@copperbox/millwright-workflows": "^0.6.2", + "@copperbox/millwright-cli": "^0.6.3", + "@copperbox/millwright-state": "^0.6.3", + "@copperbox/millwright-workflows": "^0.6.3", "esbuild": "^0.28.0", "ssh2": "^1.17.0" }, @@ -2760,7 +2760,7 @@ }, "packages/millwright-cli": { "name": "@copperbox/millwright-cli", - "version": "0.6.2", + "version": "0.6.3", "license": "MIT", "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.1108.0", @@ -2773,8 +2773,8 @@ "@aws-sdk/client-sfn": "^3.1108.0", "@aws-sdk/client-ssm": "^3.700.0", "@aws-sdk/lib-dynamodb": "^3.1108.0", - "@copperbox/millwright-state": "^0.6.2", - "@copperbox/millwright-workflows": "^0.6.2", + "@copperbox/millwright-state": "^0.6.3", + "@copperbox/millwright-workflows": "^0.6.3", "commander": "^12.1.0", "ssh2": "^1.17.0", "typescript": "^5.7.0" @@ -2792,7 +2792,7 @@ }, "packages/millwright-state": { "name": "@copperbox/millwright-state", - "version": "0.6.2", + "version": "0.6.3", "license": "MIT", "engines": { "node": ">=20" @@ -2800,7 +2800,7 @@ }, "packages/millwright-workflows": { "name": "@copperbox/millwright-workflows", - "version": "0.6.2", + "version": "0.6.3", "license": "MIT", "engines": { "node": ">=20" diff --git a/packages/millwright-cdk/package.json b/packages/millwright-cdk/package.json index 9204181..2e0812c 100644 --- a/packages/millwright-cdk/package.json +++ b/packages/millwright-cdk/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-cdk", - "version": "0.6.2", + "version": "0.6.3", "description": "The Millwright CDK construct — deploys the millwright control plane into your AWS account", "license": "MIT", "repository": { @@ -23,9 +23,9 @@ "test": "vitest run" }, "dependencies": { - "@copperbox/millwright-cli": "^0.6.2", - "@copperbox/millwright-state": "^0.6.2", - "@copperbox/millwright-workflows": "^0.6.2", + "@copperbox/millwright-cli": "^0.6.3", + "@copperbox/millwright-state": "^0.6.3", + "@copperbox/millwright-workflows": "^0.6.3", "esbuild": "^0.28.0", "ssh2": "^1.17.0" }, diff --git a/packages/millwright-cdk/src/version.ts b/packages/millwright-cdk/src/version.ts index d811ac7..1918eb0 100644 --- a/packages/millwright-cdk/src/version.ts +++ b/packages/millwright-cdk/src/version.ts @@ -1,5 +1,5 @@ // Kept in lockstep with package.json by scripts/set-version.mjs — do not edit by hand. -export const VERSION = '0.6.2'; +export const VERSION = '0.6.3'; /** * Highest run-model schemaVersion this control plane accepts. Synth fails diff --git a/packages/millwright-cli/package.json b/packages/millwright-cli/package.json index 7bd81c8..b1aa535 100644 --- a/packages/millwright-cli/package.json +++ b/packages/millwright-cli/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-cli", - "version": "0.6.2", + "version": "0.6.3", "description": "millwright CLI — operate a millwright deployment from operator and developer machines", "license": "MIT", "repository": { @@ -33,8 +33,8 @@ "@aws-sdk/client-sfn": "^3.1108.0", "@aws-sdk/client-ssm": "^3.700.0", "@aws-sdk/lib-dynamodb": "^3.1108.0", - "@copperbox/millwright-state": "^0.6.2", - "@copperbox/millwright-workflows": "^0.6.2", + "@copperbox/millwright-state": "^0.6.3", + "@copperbox/millwright-workflows": "^0.6.3", "commander": "^12.1.0", "ssh2": "^1.17.0", "typescript": "^5.7.0" diff --git a/packages/millwright-cli/src/version.ts b/packages/millwright-cli/src/version.ts index cf52011..7d765b1 100644 --- a/packages/millwright-cli/src/version.ts +++ b/packages/millwright-cli/src/version.ts @@ -1,2 +1,2 @@ // Kept in lockstep with package.json by scripts/set-version.mjs — do not edit by hand. -export const VERSION = '0.6.2'; +export const VERSION = '0.6.3'; diff --git a/packages/millwright-state/package.json b/packages/millwright-state/package.json index 1039b19..4f43695 100644 --- a/packages/millwright-state/package.json +++ b/packages/millwright-state/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-state", - "version": "0.6.2", + "version": "0.6.3", "description": "Millwright's shared data-plane helpers — state/polling table item accessors, SSM config-plane paths, S3 layout", "license": "MIT", "repository": { diff --git a/packages/millwright-workflows/package.json b/packages/millwright-workflows/package.json index 870a7dc..ad0c6c5 100644 --- a/packages/millwright-workflows/package.json +++ b/packages/millwright-workflows/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-workflows", - "version": "0.6.2", + "version": "0.6.3", "description": "Millwright workflow definition library — the only install in watched repos", "license": "MIT", "repository": { From ced8a752f1b2491ae3acebf3dfe3bf92f74fba4c Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:11:04 +0000 Subject: [PATCH 03/10] ci: add verify workflow and version-lockstep check Closes #35. - .github/workflows/ci.yml: one `verify` job on pull requests and pushes to main, contents:read, cancel-in-progress per ref, Node 22 with npm cache. Steps mirror the root scripts: npm ci, typecheck, test, build, then `npm run check-version`. - scripts/set-version.mjs: split into exported `applyVersion` and `checkLockstep` with a guarded main. `--check` (no version argument) reads the root version and reports every workspace manifest version, every @copperbox/millwright-* range across all four dependency blocks, and every src/version.ts VERSION that disagrees, exiting 1 with the full list; `--check ` is a usage error. - package.json: `check-version` script wraps `set-version.mjs --check`. - scripts/set-version.test.mjs: fixture-tree tests for both functions and CLI argument handling, plus a --check run against this repo. - README and okf-bundle packages concept document the check. Notes: dispatch's ci.yml was not reachable from this environment, so the workflow follows the shape described in the issue. set-version does not touch package-lock.json; a release still needs `npm install --package-lock-only` after bumping, as the 0.6.3 realignment did. --- .github/workflows/ci.yml | 37 ++++++ README.md | 5 + okf-bundle/interfaces/packages.md | 5 + package.json | 3 +- scripts/set-version.mjs | 141 ++++++++++++++++----- scripts/set-version.test.mjs | 198 ++++++++++++++++++++++++++++++ 6 files changed, 358 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 scripts/set-version.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ee7690 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +# Mirrors the root package.json scripts step for step: keep the two in +# correspondence when adding a check to either side. +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - run: npm run typecheck + + - run: npm test + + - run: npm run build + + - name: Version lockstep + run: npm run check-version diff --git a/README.md b/README.md index d563660..0022b91 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,11 @@ npm run build npm publish --workspaces ``` +`npm run check-version` verifies the lockstep without changing anything: +every manifest on the root version, every internal dependency range at +`^`, every embedded `VERSION` constant matching. CI runs it alongside +typecheck, test, and build on every pull request and push to `main`. + ## Getting started (operators) ```sh diff --git a/okf-bundle/interfaces/packages.md b/okf-bundle/interfaces/packages.md index 9a47e89..3d80bdd 100644 --- a/okf-bundle/interfaces/packages.md +++ b/okf-bundle/interfaces/packages.md @@ -35,6 +35,11 @@ npm run build npm publish --workspaces ``` +`npm run check-version` (`scripts/set-version.mjs --check`) asserts the lockstep without writing: +every manifest on the root version, every `@copperbox/millwright-*` range at `^`, every +`src/version.ts` `VERSION` matching. `.github/workflows/ci.yml` runs typecheck, test, build, and +this check on pull requests and pushes to `main`; releases stay manual. + ## Related - [Deployment construct](deployment.md) · [Run model](../schemas/run-model.md) for the diff --git a/package.json b/package.json index ad0b294..72a60d4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "build": "npm run build --workspace @copperbox/millwright-state --workspace @copperbox/millwright-workflows --workspace @copperbox/millwright-cdk --workspace @copperbox/millwright-cli", "typecheck": "npm run typecheck --workspaces", "test": "vitest run", - "set-version": "node scripts/set-version.mjs" + "set-version": "node scripts/set-version.mjs", + "check-version": "node scripts/set-version.mjs --check" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/scripts/set-version.mjs b/scripts/set-version.mjs index 448e58a..8e89ef5 100644 --- a/scripts/set-version.mjs +++ b/scripts/set-version.mjs @@ -1,22 +1,16 @@ #!/usr/bin/env node -// Lockstep version bump: all three packages (and the root) always share one -// version, and the embedded src/version.ts constants track package.json. +// Lockstep version bump: all four packages (and the root) always share one +// version, every internal @copperbox/millwright-* dependency range is +// ^, and the embedded src/version.ts constants track package.json. // -// npm run set-version -- 0.2.0 +// npm run set-version -- 0.2.0 # bump everything to 0.2.0 +// npm run set-version -- --check # verify the tree is in lockstep (CI) import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const version = process.argv[2]; - -if (!version || !/^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/.test(version)) { - console.error('Usage: npm run set-version -- '); - process.exit(1); -} - -const packageDirs = [ +export const packageDirs = [ '.', 'packages/millwright-state', 'packages/millwright-workflows', @@ -24,24 +18,111 @@ const packageDirs = [ 'packages/millwright-cli', ]; -for (const dir of packageDirs) { - const manifestPath = join(root, dir, 'package.json'); - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - manifest.version = version; - for (const block of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { - for (const dep of Object.keys(manifest[block] ?? {})) { - if (dep.startsWith('@copperbox/millwright-')) manifest[block][dep] = `^${version}`; +const dependencyBlocks = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']; +const internalScope = '@copperbox/millwright-'; +const versionPattern = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/; +const versionConstant = /export const VERSION = '([^']*)';/; + +function readManifest(root, dir) { + return JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')); +} + +function versionTsPath(root, dir) { + const path = join(root, dir, 'src', 'version.ts'); + return existsSync(path) ? path : undefined; +} + +/** + * Rewrites every manifest and embedded VERSION constant under `root` to + * `version`. Returns one log line per manifest touched. + */ +export function applyVersion(root, version) { + const log = []; + for (const dir of packageDirs) { + const manifest = readManifest(root, dir); + manifest.version = version; + for (const block of dependencyBlocks) { + for (const dep of Object.keys(manifest[block] ?? {})) { + if (dep.startsWith(internalScope)) manifest[block][dep] = `^${version}`; + } + } + writeFileSync(join(root, dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); + log.push(`${manifest.name} -> ${version}`); + + const versionTs = versionTsPath(root, dir); + if (versionTs) { + const source = readFileSync(versionTs, 'utf8').replace( + versionConstant, + `export const VERSION = '${version}';`, + ); + writeFileSync(versionTs, source); + } + } + return log; +} + +/** + * Compares every manifest and embedded VERSION constant under `root` against + * the root manifest's version. Returns the root version and one line per + * mismatch; an empty list means the tree is in lockstep. + */ +export function checkLockstep(root) { + const { version } = readManifest(root, '.'); + const mismatches = []; + for (const dir of packageDirs) { + const manifestPath = join(dir, 'package.json'); + const manifest = readManifest(root, dir); + if (manifest.version !== version) { + mismatches.push(`${manifestPath}: version is ${manifest.version}, expected ${version}`); + } + for (const block of dependencyBlocks) { + for (const [dep, range] of Object.entries(manifest[block] ?? {})) { + if (dep.startsWith(internalScope) && range !== `^${version}`) { + mismatches.push(`${manifestPath}: ${block}.${dep} is ${range}, expected ^${version}`); + } + } + } + + const versionTs = versionTsPath(root, dir); + if (versionTs) { + const found = readFileSync(versionTs, 'utf8').match(versionConstant)?.[1]; + if (found !== version) { + mismatches.push(`${join(dir, 'src', 'version.ts')}: VERSION is ${found ?? 'missing'}, expected ${version}`); + } } } - writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - console.log(`${manifest.name} -> ${version}`); - - const versionTs = join(root, dir, 'src', 'version.ts'); - if (existsSync(versionTs)) { - const source = readFileSync(versionTs, 'utf8').replace( - /export const VERSION = '[^']*';/, - `export const VERSION = '${version}';`, - ); - writeFileSync(versionTs, source); + return { version, mismatches }; +} + +function usage() { + console.error('Usage: npm run set-version -- '); + console.error(' npm run set-version -- --check'); + process.exit(1); +} + +function main(args) { + const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + const check = args.includes('--check'); + const positional = args.filter((arg) => arg !== '--check'); + if (positional.length > 1 || (check && positional.length > 0)) usage(); + + if (check) { + const { version, mismatches } = checkLockstep(root); + if (mismatches.length > 0) { + console.error(`Version lockstep broken (root is ${version}):`); + for (const line of mismatches) console.error(` ${line}`); + console.error('Run `npm run set-version -- ` to realign every package.'); + process.exit(1); + } + console.log(`All packages in lockstep at ${version}.`); + return; } + + const version = positional[0]; + if (!version || !versionPattern.test(version)) usage(); + for (const line of applyVersion(root, version)) console.log(line); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)); } diff --git a/scripts/set-version.test.mjs b/scripts/set-version.test.mjs new file mode 100644 index 0000000..99f2dbf --- /dev/null +++ b/scripts/set-version.test.mjs @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { applyVersion, checkLockstep, packageDirs } from './set-version.mjs'; + +const script = join(import.meta.dirname, 'set-version.mjs'); + +const names = { + '.': 'millwright', + 'packages/millwright-state': '@copperbox/millwright-state', + 'packages/millwright-workflows': '@copperbox/millwright-workflows', + 'packages/millwright-cdk': '@copperbox/millwright-cdk', + 'packages/millwright-cli': '@copperbox/millwright-cli', +}; + +const internalDeps = { + 'packages/millwright-cdk': { + dependencies: ['@copperbox/millwright-state', '@copperbox/millwright-workflows'], + devDependencies: ['@copperbox/millwright-cli'], + }, + 'packages/millwright-cli': { + dependencies: ['@copperbox/millwright-state', '@copperbox/millwright-workflows'], + }, +}; + +const withVersionTs = new Set(['packages/millwright-cdk', 'packages/millwright-cli']); + +/** Builds a minimal lockstep tree at `version` and returns its root. */ +function fixture(version) { + const root = mkdtempSync(join(tmpdir(), 'set-version-')); + for (const dir of packageDirs) { + mkdirSync(join(root, dir), { recursive: true }); + const manifest = { name: names[dir], version }; + for (const [block, deps] of Object.entries(internalDeps[dir] ?? {})) { + manifest[block] = Object.fromEntries(deps.map((dep) => [dep, `^${version}`])); + } + manifest.devDependencies = { ...(manifest.devDependencies ?? {}), typescript: '^5.7.0' }; + writeFileSync(join(root, dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); + if (withVersionTs.has(dir)) { + mkdirSync(join(root, dir, 'src'), { recursive: true }); + writeFileSync( + join(root, dir, 'src', 'version.ts'), + `// Kept in lockstep with package.json by scripts/set-version.mjs — do not edit by hand.\nexport const VERSION = '${version}';\n`, + ); + } + } + return root; +} + +function readManifest(root, dir) { + return JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')); +} + +function writeManifest(root, dir, manifest) { + writeFileSync(join(root, dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); +} + +describe('checkLockstep', () => { + it('reports no mismatches for a tree in lockstep', () => { + const root = fixture('0.6.3'); + try { + expect(checkLockstep(root)).toEqual({ version: '0.6.3', mismatches: [] }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('names a package whose version drifted from the root', () => { + const root = fixture('0.6.3'); + try { + const manifest = readManifest(root, 'packages/millwright-cli'); + manifest.version = '0.6.2'; + writeManifest(root, 'packages/millwright-cli', manifest); + + const { mismatches } = checkLockstep(root); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toContain('packages/millwright-cli/package.json'); + expect(mismatches[0]).toContain('0.6.2'); + expect(mismatches[0]).toContain('0.6.3'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('names an internal dependency range that is not ^', () => { + const root = fixture('0.6.3'); + try { + const manifest = readManifest(root, 'packages/millwright-cdk'); + manifest.devDependencies['@copperbox/millwright-cli'] = '^0.6.2'; + writeManifest(root, 'packages/millwright-cdk', manifest); + + const { mismatches } = checkLockstep(root); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toContain('packages/millwright-cdk/package.json'); + expect(mismatches[0]).toContain('devDependencies'); + expect(mismatches[0]).toContain('@copperbox/millwright-cli'); + expect(mismatches[0]).toContain('^0.6.2'); + expect(mismatches[0]).toContain('^0.6.3'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('ignores external dependency ranges', () => { + const root = fixture('0.6.3'); + try { + const manifest = readManifest(root, 'packages/millwright-cdk'); + manifest.devDependencies.typescript = '^0.0.1'; + writeManifest(root, 'packages/millwright-cdk', manifest); + expect(checkLockstep(root).mismatches).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('names an embedded VERSION constant that drifted', () => { + const root = fixture('0.6.3'); + try { + writeFileSync( + join(root, 'packages/millwright-cdk/src/version.ts'), + "export const VERSION = '0.6.2';\n", + ); + + const { mismatches } = checkLockstep(root); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toContain('packages/millwright-cdk/src/version.ts'); + expect(mismatches[0]).toContain('0.6.2'); + expect(mismatches[0]).toContain('0.6.3'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('lists every mismatch rather than stopping at the first', () => { + const root = fixture('0.6.3'); + try { + for (const dir of ['packages/millwright-state', 'packages/millwright-workflows']) { + const manifest = readManifest(root, dir); + manifest.version = '0.6.2'; + writeManifest(root, dir, manifest); + } + writeFileSync(join(root, 'packages/millwright-cli/src/version.ts'), "export const VERSION = '0.5.0';\n"); + + const { mismatches } = checkLockstep(root); + expect(mismatches).toHaveLength(3); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('applyVersion', () => { + it('leaves a drifted tree in lockstep at the new version', () => { + const root = fixture('0.6.2'); + try { + const manifest = readManifest(root, 'packages/millwright-cli'); + manifest.version = '0.5.0'; + writeManifest(root, 'packages/millwright-cli', manifest); + + applyVersion(root, '0.6.3'); + + expect(checkLockstep(root)).toEqual({ version: '0.6.3', mismatches: [] }); + expect(readManifest(root, 'packages/millwright-cdk').dependencies['@copperbox/millwright-state']).toBe('^0.6.3'); + expect(readFileSync(join(root, 'packages/millwright-cli/src/version.ts'), 'utf8')).toContain( + "export const VERSION = '0.6.3';", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('command line', () => { + it('rejects a missing or malformed version', () => { + for (const args of [[], ['1.2']]) { + const result = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' }); + expect(result.status, args.join(' ')).toBe(1); + expect(result.stderr).toContain('Usage:'); + } + }); + + it('rejects --check combined with a version', () => { + const result = spawnSync(process.execPath, [script, '--check', '1.2.3'], { encoding: 'utf8' }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage:'); + }); + + it('passes --check against this repository', () => { + // The real tree is the one CI guards; the cdk suite pins the same + // invariant, so a drift here fails both. + const result = spawnSync(process.execPath, [script, '--check'], { encoding: 'utf8' }); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/lockstep at \d+\.\d+\.\d+/); + }); +}); From a02fe0ff12f97998c7287509cf160299e6a42f59 Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:17:23 +0000 Subject: [PATCH 04/10] chore(release): v0.7.0 (minor) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f6d51a3..87534e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "millwright", - "version": "0.6.3", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "millwright", - "version": "0.6.3", + "version": "0.7.0", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 72a60d4..4edbb9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "millwright", - "version": "0.6.3", + "version": "0.7.0", "private": true, "description": "Millwright monorepo — polling-driven CI/CD in your own AWS account", "license": "MIT", From 79c5e2cfa57897a5b187896a0a1ac6c4c8517abb Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:26:05 +0000 Subject: [PATCH 05/10] fix(release): finish the 0.7.0 bump through set-version --- package-lock.json | 18 +++++++++--------- packages/millwright-cdk/package.json | 8 ++++---- packages/millwright-cdk/src/version.ts | 2 +- packages/millwright-cli/package.json | 6 +++--- packages/millwright-cli/src/version.ts | 2 +- packages/millwright-state/package.json | 2 +- packages/millwright-workflows/package.json | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 87534e8..8496681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2727,12 +2727,12 @@ }, "packages/millwright-cdk": { "name": "@copperbox/millwright-cdk", - "version": "0.6.3", + "version": "0.7.0", "license": "MIT", "dependencies": { - "@copperbox/millwright-cli": "^0.6.3", - "@copperbox/millwright-state": "^0.6.3", - "@copperbox/millwright-workflows": "^0.6.3", + "@copperbox/millwright-cli": "^0.7.0", + "@copperbox/millwright-state": "^0.7.0", + "@copperbox/millwright-workflows": "^0.7.0", "esbuild": "^0.28.0", "ssh2": "^1.17.0" }, @@ -2760,7 +2760,7 @@ }, "packages/millwright-cli": { "name": "@copperbox/millwright-cli", - "version": "0.6.3", + "version": "0.7.0", "license": "MIT", "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.1108.0", @@ -2773,8 +2773,8 @@ "@aws-sdk/client-sfn": "^3.1108.0", "@aws-sdk/client-ssm": "^3.700.0", "@aws-sdk/lib-dynamodb": "^3.1108.0", - "@copperbox/millwright-state": "^0.6.3", - "@copperbox/millwright-workflows": "^0.6.3", + "@copperbox/millwright-state": "^0.7.0", + "@copperbox/millwright-workflows": "^0.7.0", "commander": "^12.1.0", "ssh2": "^1.17.0", "typescript": "^5.7.0" @@ -2792,7 +2792,7 @@ }, "packages/millwright-state": { "name": "@copperbox/millwright-state", - "version": "0.6.3", + "version": "0.7.0", "license": "MIT", "engines": { "node": ">=20" @@ -2800,7 +2800,7 @@ }, "packages/millwright-workflows": { "name": "@copperbox/millwright-workflows", - "version": "0.6.3", + "version": "0.7.0", "license": "MIT", "engines": { "node": ">=20" diff --git a/packages/millwright-cdk/package.json b/packages/millwright-cdk/package.json index 2e0812c..ff3a169 100644 --- a/packages/millwright-cdk/package.json +++ b/packages/millwright-cdk/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-cdk", - "version": "0.6.3", + "version": "0.7.0", "description": "The Millwright CDK construct — deploys the millwright control plane into your AWS account", "license": "MIT", "repository": { @@ -23,9 +23,9 @@ "test": "vitest run" }, "dependencies": { - "@copperbox/millwright-cli": "^0.6.3", - "@copperbox/millwright-state": "^0.6.3", - "@copperbox/millwright-workflows": "^0.6.3", + "@copperbox/millwright-cli": "^0.7.0", + "@copperbox/millwright-state": "^0.7.0", + "@copperbox/millwright-workflows": "^0.7.0", "esbuild": "^0.28.0", "ssh2": "^1.17.0" }, diff --git a/packages/millwright-cdk/src/version.ts b/packages/millwright-cdk/src/version.ts index 1918eb0..a3545c8 100644 --- a/packages/millwright-cdk/src/version.ts +++ b/packages/millwright-cdk/src/version.ts @@ -1,5 +1,5 @@ // Kept in lockstep with package.json by scripts/set-version.mjs — do not edit by hand. -export const VERSION = '0.6.3'; +export const VERSION = '0.7.0'; /** * Highest run-model schemaVersion this control plane accepts. Synth fails diff --git a/packages/millwright-cli/package.json b/packages/millwright-cli/package.json index b1aa535..9aae3a6 100644 --- a/packages/millwright-cli/package.json +++ b/packages/millwright-cli/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-cli", - "version": "0.6.3", + "version": "0.7.0", "description": "millwright CLI — operate a millwright deployment from operator and developer machines", "license": "MIT", "repository": { @@ -33,8 +33,8 @@ "@aws-sdk/client-sfn": "^3.1108.0", "@aws-sdk/client-ssm": "^3.700.0", "@aws-sdk/lib-dynamodb": "^3.1108.0", - "@copperbox/millwright-state": "^0.6.3", - "@copperbox/millwright-workflows": "^0.6.3", + "@copperbox/millwright-state": "^0.7.0", + "@copperbox/millwright-workflows": "^0.7.0", "commander": "^12.1.0", "ssh2": "^1.17.0", "typescript": "^5.7.0" diff --git a/packages/millwright-cli/src/version.ts b/packages/millwright-cli/src/version.ts index 7d765b1..88a9716 100644 --- a/packages/millwright-cli/src/version.ts +++ b/packages/millwright-cli/src/version.ts @@ -1,2 +1,2 @@ // Kept in lockstep with package.json by scripts/set-version.mjs — do not edit by hand. -export const VERSION = '0.6.3'; +export const VERSION = '0.7.0'; diff --git a/packages/millwright-state/package.json b/packages/millwright-state/package.json index 4f43695..813e381 100644 --- a/packages/millwright-state/package.json +++ b/packages/millwright-state/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-state", - "version": "0.6.3", + "version": "0.7.0", "description": "Millwright's shared data-plane helpers — state/polling table item accessors, SSM config-plane paths, S3 layout", "license": "MIT", "repository": { diff --git a/packages/millwright-workflows/package.json b/packages/millwright-workflows/package.json index ad0c6c5..f52ef08 100644 --- a/packages/millwright-workflows/package.json +++ b/packages/millwright-workflows/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-workflows", - "version": "0.6.3", + "version": "0.7.0", "description": "Millwright workflow definition library — the only install in watched repos", "license": "MIT", "repository": { From 9213e16ffbdbf2a9cd9cde9d78ec811a53da4e35 Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:26:05 +0000 Subject: [PATCH 06/10] build: fan out npm version through set-version and drop duplicate lockstep test The version lifecycle script runs set-version with the new version, refreshes the lockfile, and stages the workspace manifests so npm version produces a tree that already passes check-version. The cdk suite's lockstep block asserted a subset of the same invariant from a second hand-maintained list; scripts/set-version.test.mjs now owns it. --- package.json | 3 ++- .../millwright-cdk/test/millwright.test.ts | 22 ------------------- scripts/set-version.test.mjs | 4 ++-- 3 files changed, 4 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 4edbb9d..d57086c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "typecheck": "npm run typecheck --workspaces", "test": "vitest run", "set-version": "node scripts/set-version.mjs", - "check-version": "node scripts/set-version.mjs --check" + "check-version": "node scripts/set-version.mjs --check", + "version": "node scripts/set-version.mjs $npm_package_version && npm install --package-lock-only && git add -A packages package-lock.json" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/millwright-cdk/test/millwright.test.ts b/packages/millwright-cdk/test/millwright.test.ts index 02d94ea..65fb8d8 100644 --- a/packages/millwright-cdk/test/millwright.test.ts +++ b/packages/millwright-cdk/test/millwright.test.ts @@ -3,12 +3,6 @@ import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; import * as iam from 'aws-cdk-lib/aws-iam'; import { describe, expect, it } from 'vitest'; import { Boundary, Millwright, MillwrightProps, SUPPORTED_SCHEMA_VERSION, VERSION } from '../src'; -import cdkPkg from '../package.json'; -import rootPkg from '../../../package.json'; -import cliPkg from '../../millwright-cli/package.json'; -import statePkg from '../../millwright-state/package.json'; -import workflowsPkg from '../../millwright-workflows/package.json'; -import { VERSION as CLI_VERSION } from '../../millwright-cli/src/version'; const BOUNDARY_ARN = 'arn:aws:iam::123456789012:policy/team-boundary'; @@ -166,19 +160,3 @@ describe('run executor wiring', () => { expect(millwright.buildProject.projectName).toBe(millwright.runExecutor.buildProjectName); }); }); - -describe('lockstep version', () => { - it('keeps the embedded VERSION constants in sync with their manifests', () => { - expect(VERSION).toBe(cdkPkg.version); - expect(CLI_VERSION).toBe(cliPkg.version); - }); - - it('keeps every workspace manifest on the root version', () => { - // A hand-edited bump that skips `npm run set-version` moves some subset - // of the five manifests; pinning all four workspaces to the root catches - // any divergence, not just the cdk one. - for (const pkg of [cdkPkg, cliPkg, statePkg, workflowsPkg]) { - expect(pkg.version, pkg.name).toBe(rootPkg.version); - } - }); -}); diff --git a/scripts/set-version.test.mjs b/scripts/set-version.test.mjs index 99f2dbf..fffdb45 100644 --- a/scripts/set-version.test.mjs +++ b/scripts/set-version.test.mjs @@ -188,8 +188,8 @@ describe('command line', () => { }); it('passes --check against this repository', () => { - // The real tree is the one CI guards; the cdk suite pins the same - // invariant, so a drift here fails both. + // The real tree is the one CI guards; this test is the only place the + // invariant is asserted, so a drift anywhere in the tree fails here. const result = spawnSync(process.execPath, [script, '--check'], { encoding: 'utf8' }); expect(result.stderr).toBe(''); expect(result.status).toBe(0); From 8e2b6137ea6d30b55b733b6c481b795ba8ad55cb Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:39:33 +0000 Subject: [PATCH 07/10] build: read npm_package_version in set-version and document npm version The root version lifecycle script passed $npm_package_version as an argument, which only expands under a POSIX shell; on Windows npm runs scripts through cmd.exe and set-version received the literal string, so npm version aborted. The script now takes no argument under the lifecycle and reads process.env.npm_package_version when npm_lifecycle_event is 'version', which npm sets on every platform. README.md and okf-bundle/interfaces/packages.md still showed the bare set-version release path that leaves package-lock.json behind, and packages.md claimed releases stay manual. Both now document npm version as the bump command and name the lockfile step for the bare path. --- README.md | 10 ++++++++-- okf-bundle/interfaces/packages.md | 5 +++-- package.json | 2 +- scripts/set-version.mjs | 22 ++++++++++++++++++++-- scripts/set-version.test.mjs | 17 +++++++++++++++++ 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0022b91..2ad8f0f 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,20 @@ npm test npm run build ``` -Releases bump every package to one version, then publish them all: +Releases bump every package to one version, then publish them all. `npm version` +fans the bump out through `scripts/set-version.mjs` (workspace manifests, internal +ranges, embedded `VERSION` constants) and refreshes the lockfile: ```sh -npm run set-version -- 0.2.0 +npm version 0.2.0 npm run build npm publish --workspaces ``` +To bump without `npm version`, run `npm run set-version -- 0.2.0` followed by +`npm install --package-lock-only`; `check-version` does not inspect the lockfile, +so a bare `set-version` leaves `package-lock.json` behind unnoticed. + `npm run check-version` verifies the lockstep without changing anything: every manifest on the root version, every internal dependency range at `^`, every embedded `VERSION` constant matching. CI runs it alongside diff --git a/okf-bundle/interfaces/packages.md b/okf-bundle/interfaces/packages.md index 3d80bdd..32509c4 100644 --- a/okf-bundle/interfaces/packages.md +++ b/okf-bundle/interfaces/packages.md @@ -30,7 +30,7 @@ between "millwright's own deployment is CDK" and "your workflows are not CDK". ```sh npm install && npm run typecheck && npm test && npm run build -npm run set-version -- 0.2.0 # bump every package to one version +npm version 0.2.0 # bump every package to one version npm run build npm publish --workspaces ``` @@ -38,7 +38,8 @@ npm publish --workspaces `npm run check-version` (`scripts/set-version.mjs --check`) asserts the lockstep without writing: every manifest on the root version, every `@copperbox/millwright-*` range at `^`, every `src/version.ts` `VERSION` matching. `.github/workflows/ci.yml` runs typecheck, test, build, and -this check on pull requests and pushes to `main`; releases stay manual. +this check on pull requests and pushes to `main`. The root `version` lifecycle script fans an +`npm version` bump through `set-version.mjs` and refreshes the lockfile; publishing stays manual. ## Related diff --git a/package.json b/package.json index d57086c..02cbf43 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test": "vitest run", "set-version": "node scripts/set-version.mjs", "check-version": "node scripts/set-version.mjs --check", - "version": "node scripts/set-version.mjs $npm_package_version && npm install --package-lock-only && git add -A packages package-lock.json" + "version": "node scripts/set-version.mjs && npm install --package-lock-only && git add -A packages package-lock.json" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/scripts/set-version.mjs b/scripts/set-version.mjs index 8e89ef5..14708db 100644 --- a/scripts/set-version.mjs +++ b/scripts/set-version.mjs @@ -3,8 +3,15 @@ // version, every internal @copperbox/millwright-* dependency range is // ^, and the embedded src/version.ts constants track package.json. // -// npm run set-version -- 0.2.0 # bump everything to 0.2.0 +// npm version 0.2.0 # bump everything to 0.2.0 (via the +// # root `version` lifecycle script) +// npm run set-version -- 0.2.0 # the same bump without npm version // npm run set-version -- --check # verify the tree is in lockstep (CI) +// +// Under `npm version` the script takes no argument: npm exports the new +// version as npm_package_version for every lifecycle script on every +// platform, whereas `$npm_package_version` in package.json only expands +// under a POSIX shell (cmd.exe hands it over as a literal). import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; @@ -97,9 +104,20 @@ export function checkLockstep(root) { function usage() { console.error('Usage: npm run set-version -- '); console.error(' npm run set-version -- --check'); + console.error(' npm version (reads npm_package_version)'); process.exit(1); } +/** + * The version to apply: the positional argument, or, when running as npm's + * `version` lifecycle script, the freshly bumped npm_package_version. + */ +function resolveVersion(positional, env) { + if (positional.length > 0) return positional[0]; + if (env.npm_lifecycle_event === 'version') return env.npm_package_version; + return undefined; +} + function main(args) { const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const check = args.includes('--check'); @@ -118,7 +136,7 @@ function main(args) { return; } - const version = positional[0]; + const version = resolveVersion(positional, process.env); if (!version || !versionPattern.test(version)) usage(); for (const line of applyVersion(root, version)) console.log(line); } diff --git a/scripts/set-version.test.mjs b/scripts/set-version.test.mjs index fffdb45..8a30949 100644 --- a/scripts/set-version.test.mjs +++ b/scripts/set-version.test.mjs @@ -181,6 +181,23 @@ describe('command line', () => { } }); + it('ignores npm_package_version outside the version lifecycle', () => { + const env = { ...process.env, npm_package_version: '1.2.3' }; + delete env.npm_lifecycle_event; + const result = spawnSync(process.execPath, [script], { encoding: 'utf8', env }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage:'); + }); + + it('validates npm_package_version under the version lifecycle', () => { + // A malformed value must be rejected before anything is written; a valid + // one would rewrite this checkout, so only the failure path is exercised. + const env = { ...process.env, npm_lifecycle_event: 'version', npm_package_version: 'not-a-version' }; + const result = spawnSync(process.execPath, [script], { encoding: 'utf8', env }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage:'); + }); + it('rejects --check combined with a version', () => { const result = spawnSync(process.execPath, [script, '--check', '1.2.3'], { encoding: 'utf8' }); expect(result.status).toBe(1); From 7006e246a5767d7aa5baeb81afdd2ef83f849c83 Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:49:43 +0000 Subject: [PATCH 08/10] build: stage only tracked files in the version lifecycle and test the npm_package_version path git add -u keeps untracked files under packages/ out of the release commit that npm version creates and tags. resolveVersion is exported and covered by a unit test plus a spawn test against a fixture copy of the script, so dropping the lifecycle fallback now fails the suite. README and packages.md say that npm version commits, tags v, and needs a clean tree, and show the -m form that keeps the chore(release) commit convention. --- README.md | 10 ++++++++-- okf-bundle/interfaces/packages.md | 6 ++++-- package.json | 2 +- scripts/set-version.mjs | 2 +- scripts/set-version.test.mjs | 33 +++++++++++++++++++++++++++---- 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2ad8f0f..66851a0 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,20 @@ npm run build Releases bump every package to one version, then publish them all. `npm version` fans the bump out through `scripts/set-version.mjs` (workspace manifests, internal -ranges, embedded `VERSION` constants) and refreshes the lockfile: +ranges, embedded `VERSION` constants) and refreshes the lockfile. It also creates +a release commit and a `v` tag, and refuses to run on a dirty tree, so +commit or stash first. Pass `-m` to keep the `chore(release): vX.Y.Z` commit +convention: ```sh -npm version 0.2.0 +npm version 0.2.0 -m 'chore(release): v%s' npm run build npm publish --workspaces ``` +Add `--no-git-tag-version` to skip the commit and tag; the bump is then left +staged for you to commit yourself. + To bump without `npm version`, run `npm run set-version -- 0.2.0` followed by `npm install --package-lock-only`; `check-version` does not inspect the lockfile, so a bare `set-version` leaves `package-lock.json` behind unnoticed. diff --git a/okf-bundle/interfaces/packages.md b/okf-bundle/interfaces/packages.md index 32509c4..3cf6f1f 100644 --- a/okf-bundle/interfaces/packages.md +++ b/okf-bundle/interfaces/packages.md @@ -30,7 +30,7 @@ between "millwright's own deployment is CDK" and "your workflows are not CDK". ```sh npm install && npm run typecheck && npm test && npm run build -npm version 0.2.0 # bump every package to one version +npm version 0.2.0 -m 'chore(release): v%s' # bump every package, commit, tag v0.2.0 npm run build npm publish --workspaces ``` @@ -39,7 +39,9 @@ npm publish --workspaces every manifest on the root version, every `@copperbox/millwright-*` range at `^`, every `src/version.ts` `VERSION` matching. `.github/workflows/ci.yml` runs typecheck, test, build, and this check on pull requests and pushes to `main`. The root `version` lifecycle script fans an -`npm version` bump through `set-version.mjs` and refreshes the lockfile; publishing stays manual. +`npm version` bump through `set-version.mjs` and refreshes the lockfile. `npm version` then +commits and tags `v`, and it refuses to run on a dirty tree; `--no-git-tag-version` +leaves the bump staged without a commit or tag. Publishing stays manual. ## Related diff --git a/package.json b/package.json index 02cbf43..278da6b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test": "vitest run", "set-version": "node scripts/set-version.mjs", "check-version": "node scripts/set-version.mjs --check", - "version": "node scripts/set-version.mjs && npm install --package-lock-only && git add -A packages package-lock.json" + "version": "node scripts/set-version.mjs && npm install --package-lock-only && git add -u packages package-lock.json" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/scripts/set-version.mjs b/scripts/set-version.mjs index 14708db..ceb06e1 100644 --- a/scripts/set-version.mjs +++ b/scripts/set-version.mjs @@ -112,7 +112,7 @@ function usage() { * The version to apply: the positional argument, or, when running as npm's * `version` lifecycle script, the freshly bumped npm_package_version. */ -function resolveVersion(positional, env) { +export function resolveVersion(positional, env) { if (positional.length > 0) return positional[0]; if (env.npm_lifecycle_event === 'version') return env.npm_package_version; return undefined; diff --git a/scripts/set-version.test.mjs b/scripts/set-version.test.mjs index 8a30949..a9b7dab 100644 --- a/scripts/set-version.test.mjs +++ b/scripts/set-version.test.mjs @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; -import { applyVersion, checkLockstep, packageDirs } from './set-version.mjs'; +import { applyVersion, checkLockstep, packageDirs, resolveVersion } from './set-version.mjs'; const script = join(import.meta.dirname, 'set-version.mjs'); @@ -172,6 +172,16 @@ describe('applyVersion', () => { }); }); +describe('resolveVersion', () => { + it('reads npm_package_version only under the version lifecycle', () => { + const env = { npm_lifecycle_event: 'version', npm_package_version: '1.2.3' }; + expect(resolveVersion([], env)).toBe('1.2.3'); + expect(resolveVersion([], { ...env, npm_lifecycle_event: 'set-version' })).toBeUndefined(); + expect(resolveVersion([], { npm_package_version: '1.2.3' })).toBeUndefined(); + expect(resolveVersion(['0.9.0'], env)).toBe('0.9.0'); + }); +}); + describe('command line', () => { it('rejects a missing or malformed version', () => { for (const args of [[], ['1.2']]) { @@ -190,14 +200,29 @@ describe('command line', () => { }); it('validates npm_package_version under the version lifecycle', () => { - // A malformed value must be rejected before anything is written; a valid - // one would rewrite this checkout, so only the failure path is exercised. const env = { ...process.env, npm_lifecycle_event: 'version', npm_package_version: 'not-a-version' }; const result = spawnSync(process.execPath, [script], { encoding: 'utf8', env }); expect(result.status).toBe(1); expect(result.stderr).toContain('Usage:'); }); + it('applies npm_package_version under the version lifecycle', () => { + // The script resolves its tree from its own location, so a copy inside a + // fixture rewrites the fixture rather than this checkout. + const root = fixture('0.6.3'); + try { + mkdirSync(join(root, 'scripts')); + copyFileSync(script, join(root, 'scripts', 'set-version.mjs')); + const env = { ...process.env, npm_lifecycle_event: 'version', npm_package_version: '0.7.0' }; + const result = spawnSync(process.execPath, [join(root, 'scripts', 'set-version.mjs')], { encoding: 'utf8', env }); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(checkLockstep(root)).toEqual({ version: '0.7.0', mismatches: [] }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('rejects --check combined with a version', () => { const result = spawnSync(process.execPath, [script, '--check', '1.2.3'], { encoding: 'utf8' }); expect(result.status).toBe(1); From c5b0ec12142a5d68af8365cff17a77086dc257ed Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:56:33 +0000 Subject: [PATCH 09/10] build: stage the root manifest in the version lifecycle --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 278da6b..e47b8da 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test": "vitest run", "set-version": "node scripts/set-version.mjs", "check-version": "node scripts/set-version.mjs --check", - "version": "node scripts/set-version.mjs && npm install --package-lock-only && git add -u packages package-lock.json" + "version": "node scripts/set-version.mjs && npm install --package-lock-only && git add -u package.json packages package-lock.json" }, "devDependencies": { "@types/node": "^22.10.0", From 3b3c80c76baa084c50c1a1ba7f7355deba11fbcd Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:03:17 +0000 Subject: [PATCH 10/10] test(set-version): clean up fixtures with a tmpdir registry Replace the per-test try/finally rmSync blocks with the module-level tmpdirs registry and afterEach hook the other temp-dir tests use, so a fixture is removed even when a test creates it outside its own guard. --- scripts/set-version.test.mjs | 161 +++++++++++++++-------------------- 1 file changed, 69 insertions(+), 92 deletions(-) diff --git a/scripts/set-version.test.mjs b/scripts/set-version.test.mjs index a9b7dab..25ce109 100644 --- a/scripts/set-version.test.mjs +++ b/scripts/set-version.test.mjs @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -27,9 +27,18 @@ const internalDeps = { const withVersionTs = new Set(['packages/millwright-cdk', 'packages/millwright-cli']); +const tmpdirs = []; + +afterEach(() => { + for (const dir of tmpdirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + /** Builds a minimal lockstep tree at `version` and returns its root. */ function fixture(version) { const root = mkdtempSync(join(tmpdir(), 'set-version-')); + tmpdirs.push(root); for (const dir of packageDirs) { mkdirSync(join(root, dir), { recursive: true }); const manifest = { name: names[dir], version }; @@ -60,115 +69,87 @@ function writeManifest(root, dir, manifest) { describe('checkLockstep', () => { it('reports no mismatches for a tree in lockstep', () => { const root = fixture('0.6.3'); - try { - expect(checkLockstep(root)).toEqual({ version: '0.6.3', mismatches: [] }); - } finally { - rmSync(root, { recursive: true, force: true }); - } + expect(checkLockstep(root)).toEqual({ version: '0.6.3', mismatches: [] }); }); it('names a package whose version drifted from the root', () => { const root = fixture('0.6.3'); - try { - const manifest = readManifest(root, 'packages/millwright-cli'); - manifest.version = '0.6.2'; - writeManifest(root, 'packages/millwright-cli', manifest); - - const { mismatches } = checkLockstep(root); - expect(mismatches).toHaveLength(1); - expect(mismatches[0]).toContain('packages/millwright-cli/package.json'); - expect(mismatches[0]).toContain('0.6.2'); - expect(mismatches[0]).toContain('0.6.3'); - } finally { - rmSync(root, { recursive: true, force: true }); - } + const manifest = readManifest(root, 'packages/millwright-cli'); + manifest.version = '0.6.2'; + writeManifest(root, 'packages/millwright-cli', manifest); + + const { mismatches } = checkLockstep(root); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toContain('packages/millwright-cli/package.json'); + expect(mismatches[0]).toContain('0.6.2'); + expect(mismatches[0]).toContain('0.6.3'); }); it('names an internal dependency range that is not ^', () => { const root = fixture('0.6.3'); - try { - const manifest = readManifest(root, 'packages/millwright-cdk'); - manifest.devDependencies['@copperbox/millwright-cli'] = '^0.6.2'; - writeManifest(root, 'packages/millwright-cdk', manifest); - - const { mismatches } = checkLockstep(root); - expect(mismatches).toHaveLength(1); - expect(mismatches[0]).toContain('packages/millwright-cdk/package.json'); - expect(mismatches[0]).toContain('devDependencies'); - expect(mismatches[0]).toContain('@copperbox/millwright-cli'); - expect(mismatches[0]).toContain('^0.6.2'); - expect(mismatches[0]).toContain('^0.6.3'); - } finally { - rmSync(root, { recursive: true, force: true }); - } + const manifest = readManifest(root, 'packages/millwright-cdk'); + manifest.devDependencies['@copperbox/millwright-cli'] = '^0.6.2'; + writeManifest(root, 'packages/millwright-cdk', manifest); + + const { mismatches } = checkLockstep(root); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toContain('packages/millwright-cdk/package.json'); + expect(mismatches[0]).toContain('devDependencies'); + expect(mismatches[0]).toContain('@copperbox/millwright-cli'); + expect(mismatches[0]).toContain('^0.6.2'); + expect(mismatches[0]).toContain('^0.6.3'); }); it('ignores external dependency ranges', () => { const root = fixture('0.6.3'); - try { - const manifest = readManifest(root, 'packages/millwright-cdk'); - manifest.devDependencies.typescript = '^0.0.1'; - writeManifest(root, 'packages/millwright-cdk', manifest); - expect(checkLockstep(root).mismatches).toEqual([]); - } finally { - rmSync(root, { recursive: true, force: true }); - } + const manifest = readManifest(root, 'packages/millwright-cdk'); + manifest.devDependencies.typescript = '^0.0.1'; + writeManifest(root, 'packages/millwright-cdk', manifest); + expect(checkLockstep(root).mismatches).toEqual([]); }); it('names an embedded VERSION constant that drifted', () => { const root = fixture('0.6.3'); - try { - writeFileSync( - join(root, 'packages/millwright-cdk/src/version.ts'), - "export const VERSION = '0.6.2';\n", - ); - - const { mismatches } = checkLockstep(root); - expect(mismatches).toHaveLength(1); - expect(mismatches[0]).toContain('packages/millwright-cdk/src/version.ts'); - expect(mismatches[0]).toContain('0.6.2'); - expect(mismatches[0]).toContain('0.6.3'); - } finally { - rmSync(root, { recursive: true, force: true }); - } + writeFileSync( + join(root, 'packages/millwright-cdk/src/version.ts'), + "export const VERSION = '0.6.2';\n", + ); + + const { mismatches } = checkLockstep(root); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toContain('packages/millwright-cdk/src/version.ts'); + expect(mismatches[0]).toContain('0.6.2'); + expect(mismatches[0]).toContain('0.6.3'); }); it('lists every mismatch rather than stopping at the first', () => { const root = fixture('0.6.3'); - try { - for (const dir of ['packages/millwright-state', 'packages/millwright-workflows']) { - const manifest = readManifest(root, dir); - manifest.version = '0.6.2'; - writeManifest(root, dir, manifest); - } - writeFileSync(join(root, 'packages/millwright-cli/src/version.ts'), "export const VERSION = '0.5.0';\n"); - - const { mismatches } = checkLockstep(root); - expect(mismatches).toHaveLength(3); - } finally { - rmSync(root, { recursive: true, force: true }); + for (const dir of ['packages/millwright-state', 'packages/millwright-workflows']) { + const manifest = readManifest(root, dir); + manifest.version = '0.6.2'; + writeManifest(root, dir, manifest); } + writeFileSync(join(root, 'packages/millwright-cli/src/version.ts'), "export const VERSION = '0.5.0';\n"); + + const { mismatches } = checkLockstep(root); + expect(mismatches).toHaveLength(3); }); }); describe('applyVersion', () => { it('leaves a drifted tree in lockstep at the new version', () => { const root = fixture('0.6.2'); - try { - const manifest = readManifest(root, 'packages/millwright-cli'); - manifest.version = '0.5.0'; - writeManifest(root, 'packages/millwright-cli', manifest); + const manifest = readManifest(root, 'packages/millwright-cli'); + manifest.version = '0.5.0'; + writeManifest(root, 'packages/millwright-cli', manifest); - applyVersion(root, '0.6.3'); + applyVersion(root, '0.6.3'); - expect(checkLockstep(root)).toEqual({ version: '0.6.3', mismatches: [] }); - expect(readManifest(root, 'packages/millwright-cdk').dependencies['@copperbox/millwright-state']).toBe('^0.6.3'); - expect(readFileSync(join(root, 'packages/millwright-cli/src/version.ts'), 'utf8')).toContain( - "export const VERSION = '0.6.3';", - ); - } finally { - rmSync(root, { recursive: true, force: true }); - } + expect(checkLockstep(root)).toEqual({ version: '0.6.3', mismatches: [] }); + expect(readManifest(root, 'packages/millwright-cdk').dependencies['@copperbox/millwright-state']).toBe('^0.6.3'); + expect(readFileSync(join(root, 'packages/millwright-cli/src/version.ts'), 'utf8')).toContain( + "export const VERSION = '0.6.3';", + ); }); }); @@ -210,17 +191,13 @@ describe('command line', () => { // The script resolves its tree from its own location, so a copy inside a // fixture rewrites the fixture rather than this checkout. const root = fixture('0.6.3'); - try { - mkdirSync(join(root, 'scripts')); - copyFileSync(script, join(root, 'scripts', 'set-version.mjs')); - const env = { ...process.env, npm_lifecycle_event: 'version', npm_package_version: '0.7.0' }; - const result = spawnSync(process.execPath, [join(root, 'scripts', 'set-version.mjs')], { encoding: 'utf8', env }); - expect(result.stderr).toBe(''); - expect(result.status).toBe(0); - expect(checkLockstep(root)).toEqual({ version: '0.7.0', mismatches: [] }); - } finally { - rmSync(root, { recursive: true, force: true }); - } + mkdirSync(join(root, 'scripts')); + copyFileSync(script, join(root, 'scripts', 'set-version.mjs')); + const env = { ...process.env, npm_lifecycle_event: 'version', npm_package_version: '0.7.0' }; + const result = spawnSync(process.execPath, [join(root, 'scripts', 'set-version.mjs')], { encoding: 'utf8', env }); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(checkLockstep(root)).toEqual({ version: '0.7.0', mismatches: [] }); }); it('rejects --check combined with a version', () => {