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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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', () => { From 7ea8cabc3e8015e1def661ec1dd706c239ca8dc7 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Mon, 7 Sep 2026 20:29:13 -0700 Subject: [PATCH 11/12] test(cdk): share one cloud-assembly outdir across construct tests and raise the vitest timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Template.fromStack over the Millwright construct bundles ten Lambdas with esbuild. With a bare new App() each test picks a fresh outdir, so aws-cdk-lib's process-wide asset cache never hits and every test rebundles from scratch — 5–11 s per test on the CI runner, past vitest's 5 s default. testApp() pins one outdir per worker process so the first synth in a file bundles and later synths reuse it (data-stores.test.ts: ~21 s → ~4.5 s locally). The first synth per file still takes several seconds on a loaded runner, so testTimeout is now 30 s. --- .../decisions/cdk-tests-share-one-outdir.md | 51 +++++++++++++++++++ okf-bundle/decisions/index.md | 1 + okf-bundle/log.md | 3 ++ .../millwright-cdk/test/build-project.test.ts | 5 +- .../millwright-cdk/test/data-stores.test.ts | 7 +-- .../millwright-cdk/test/event-bus.test.ts | 5 +- .../test/launcher-construct.test.ts | 5 +- .../millwright-cdk/test/millwright.test.ts | 11 ++-- .../test/poller-construct.test.ts | 5 +- .../test/reporter-construct.test.ts | 5 +- .../test/run-executor-construct.test.ts | 5 +- .../millwright-cdk/test/shim-assets.test.ts | 5 +- .../test/step-events-writer-construct.test.ts | 5 +- .../millwright-cdk/test/support/test-app.ts | 26 ++++++++++ .../test/sweep-construct.test.ts | 5 +- .../test/synth-job-construct.test.ts | 5 +- vitest.config.ts | 7 +++ 17 files changed, 128 insertions(+), 28 deletions(-) create mode 100644 okf-bundle/decisions/cdk-tests-share-one-outdir.md create mode 100644 packages/millwright-cdk/test/support/test-app.ts diff --git a/okf-bundle/decisions/cdk-tests-share-one-outdir.md b/okf-bundle/decisions/cdk-tests-share-one-outdir.md new file mode 100644 index 0000000..6b60af9 --- /dev/null +++ b/okf-bundle/decisions/cdk-tests-share-one-outdir.md @@ -0,0 +1,51 @@ +--- +type: decision +title: CDK construct tests share one cloud-assembly outdir +description: Why construct tests build their App with test/support/test-app.ts + instead of new App(), and why vitest's timeout is 30 s. +tags: + - millwright + - testing + - cdk +timestamp: 2026-09-08T03:23:12.874Z +--- + +Every `Template.fromStack(...)` over the `Millwright` construct synthesizes a full stack, and that +stack bundles **ten `NodejsFunction` Lambdas with esbuild** (poller, launcher, sweep, three synth-job +functions, two run-executor functions, reporter, step-events writer) plus the synth tooling bundle +from `millwright-cli`. One synth costs roughly 1.5 s on a fast workstation and 5–11 s on a loaded +GitHub Actions runner — past vitest's 5 s default per-test budget. That is what made the first CI +workflow run (PR #36) fail with timeouts in `data-stores`, `millwright` and `run-executor-construct`. + +## What was decided + +- Construct tests build their App with `testApp()` from `packages/millwright-cdk/test/support/test-app.ts`, + never a bare `new App()`. The helper pins one cloud-assembly outdir per worker process. +- The root `vitest.config.ts` sets `testTimeout: 30_000`. + +## Why the shared outdir works + +`aws-cdk-lib`'s `AssetStaging` keeps a **process-wide cache** keyed on (outdir, source path, bundling +options). A bare `new App()` picks a fresh temp outdir every time, so the cache never hits and every +test in a file re-runs all the esbuild bundles. With one outdir per process the first synth in a file +bundles and every later synth reuses it: `data-stores.test.ts` dropped from ~21 s to ~4.5 s locally. + +The outdir must be **per process**, not shared across vitest workers: each App writes +`manifest.json` and `Test.template.json` into it, and concurrent workers would race on those files. + +## Why not `CDK_OUTDIR` + +Setting the env var would reach every `new App()` without touching tests, but `App` treats a set +`CDK_OUTDIR` as a request for `autoSynth`, registering a `beforeExit` listener per App. Hundreds of +Apps per run means listener-leak warnings and exit-time synths of half-built trees from the +"throws at construct time" tests. + +## Why not disable bundling + +The `aws:cdk:bundling-stacks` context can skip bundling entirely, but the tests would then stop +exercising the real bundling configuration (workspace aliases, entry points, formats). + +## Citations + +[1] [test-app.ts](../../packages/millwright-cdk/test/support/test-app.ts) +[2] [vitest.config.ts](../../vitest.config.ts) diff --git a/okf-bundle/decisions/index.md b/okf-bundle/decisions/index.md index 3e63f63..e9becbe 100644 --- a/okf-bundle/decisions/index.md +++ b/okf-bundle/decisions/index.md @@ -3,6 +3,7 @@ # Concepts * [BatchGetBuilds is authoritative for terminal job state](batchgetbuilds-authoritative.md) +* [CDK construct tests share one cloud-assembly outdir](cdk-tests-share-one-outdir.md) - Why construct tests build their App with test/support/test-app.ts instead of new App(), and why vitest's timeout is 30 s. * [Caught-timeout wake instead of task heartbeats](no-heartbeat-wake.md) * [Why polling instead of webhooks](no-webhooks.md) * [The poller is non-VPC](non-vpc-poller.md) diff --git a/okf-bundle/log.md b/okf-bundle/log.md index c062eaf..b2089a0 100644 --- a/okf-bundle/log.md +++ b/okf-bundle/log.md @@ -1,5 +1,8 @@ # Update Log +## 2026-09-08 +* Record why CDK construct tests share one outdir and why vitest's timeout is 30 s + ## 2026-08-13 * **Update**: Updated [Deferred and out of scope for v1](/deferred-and-out-of-scope.md). diff --git a/packages/millwright-cdk/test/build-project.test.ts b/packages/millwright-cdk/test/build-project.test.ts index 1a5ff25..c092da8 100644 --- a/packages/millwright-cdk/test/build-project.test.ts +++ b/packages/millwright-cdk/test/build-project.test.ts @@ -1,12 +1,13 @@ -import { App, Stack } from 'aws-cdk-lib'; +import { Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { describe, expect, it } from 'vitest'; import { BuildProject } from '../src'; +import { testApp } from './support/test-app'; function synth(): { buildProject: BuildProject; template: Template } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const buildProject = new BuildProject(stack, 'BuildProject', { deploymentName: 'ci', artifactBucket: new s3.Bucket(stack, 'Artifacts'), diff --git a/packages/millwright-cdk/test/data-stores.test.ts b/packages/millwright-cdk/test/data-stores.test.ts index 8bbbbbf..1af8f70 100644 --- a/packages/millwright-cdk/test/data-stores.test.ts +++ b/packages/millwright-cdk/test/data-stores.test.ts @@ -1,7 +1,8 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; import { describe, expect, it } from 'vitest'; import { Millwright, MillwrightProps } from '../src'; +import { testApp } from './support/test-app'; const BOUNDARY_ARN = 'arn:aws:iam::123456789012:policy/team-boundary'; @@ -10,7 +11,7 @@ function templateFor(props: Partial = {}): { millwright: Millwright; template: Template; } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const millwright = new Millwright(stack, 'Millwright', { permissionsBoundary: BOUNDARY_ARN, ...props, @@ -169,7 +170,7 @@ describe('build log group (C17)', () => { }); it('rejects retention day counts CloudWatch does not support', () => { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); expect( () => new Millwright(stack, 'Millwright', { diff --git a/packages/millwright-cdk/test/event-bus.test.ts b/packages/millwright-cdk/test/event-bus.test.ts index 7ddee0e..144b1c6 100644 --- a/packages/millwright-cdk/test/event-bus.test.ts +++ b/packages/millwright-cdk/test/event-bus.test.ts @@ -1,13 +1,14 @@ -import { App, Stack } from 'aws-cdk-lib'; +import { Stack } from 'aws-cdk-lib'; import { Template } from 'aws-cdk-lib/assertions'; import { describe, expect, it } from 'vitest'; import { MillwrightEventBus } from '../src'; +import { testApp } from './support/test-app'; function synth(deploymentName = 'millwright'): { bus: MillwrightEventBus; template: Template; } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const bus = new MillwrightEventBus(stack, 'EventBus', { deploymentName }); return { bus, template: Template.fromStack(stack) }; } diff --git a/packages/millwright-cdk/test/launcher-construct.test.ts b/packages/millwright-cdk/test/launcher-construct.test.ts index 792d124..9fdcc34 100644 --- a/packages/millwright-cdk/test/launcher-construct.test.ts +++ b/packages/millwright-cdk/test/launcher-construct.test.ts @@ -1,12 +1,13 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { describe, expect, it } from 'vitest'; import { Launcher, MillwrightEventBus } from '../src'; +import { testApp } from './support/test-app'; function synth(): { launcher: Launcher; template: Template } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const bus = new MillwrightEventBus(stack, 'EventBus', { deploymentName: 'ci' }); const launcher = new Launcher(stack, 'Launcher', { deploymentName: 'ci', diff --git a/packages/millwright-cdk/test/millwright.test.ts b/packages/millwright-cdk/test/millwright.test.ts index 65fb8d8..3a54258 100644 --- a/packages/millwright-cdk/test/millwright.test.ts +++ b/packages/millwright-cdk/test/millwright.test.ts @@ -1,26 +1,27 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; 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 { testApp } from './support/test-app'; const BOUNDARY_ARN = 'arn:aws:iam::123456789012:policy/team-boundary'; function stackWith(props: MillwrightProps): { stack: Stack; millwright: Millwright } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); return { stack, millwright: new Millwright(stack, 'Millwright', props) }; } describe('permissionsBoundary', () => { it('throws at construct time when absent', () => { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); expect(() => new Millwright(stack, 'Millwright', {} as MillwrightProps)).toThrow( /permissions boundary/i, ); }); it('throws on a value that is neither an ARN nor Boundary.NONE', () => { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); expect( () => new Millwright(stack, 'Millwright', { permissionsBoundary: 'team-boundary' }), ).toThrow(/managed policy ARN or Boundary\.NONE/); @@ -107,7 +108,7 @@ describe('manifest parameter', () => { it('rejects deployment names that cannot namespace SSM paths', () => { for (const bad of ['Millwright', 'has space', '-leading', 'a/b', '']) { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); expect( () => new Millwright(stack, 'Millwright', { diff --git a/packages/millwright-cdk/test/poller-construct.test.ts b/packages/millwright-cdk/test/poller-construct.test.ts index ee4036c..a5a319a 100644 --- a/packages/millwright-cdk/test/poller-construct.test.ts +++ b/packages/millwright-cdk/test/poller-construct.test.ts @@ -1,16 +1,17 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as kms from 'aws-cdk-lib/aws-kms'; import { describe, expect, it } from 'vitest'; import { MillwrightEventBus, Poller } from '../src'; +import { testApp } from './support/test-app'; function synth(pollCadence = Duration.minutes(1)): { poller: Poller; stack: Stack; template: Template; } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const bus = new MillwrightEventBus(stack, 'EventBus', { deploymentName: 'ci' }); const poller = new Poller(stack, 'Poller', { deploymentName: 'ci', diff --git a/packages/millwright-cdk/test/reporter-construct.test.ts b/packages/millwright-cdk/test/reporter-construct.test.ts index 0687d64..1e2287b 100644 --- a/packages/millwright-cdk/test/reporter-construct.test.ts +++ b/packages/millwright-cdk/test/reporter-construct.test.ts @@ -1,4 +1,4 @@ -import { App, Stack } from 'aws-cdk-lib'; +import { Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as kms from 'aws-cdk-lib/aws-kms'; @@ -6,9 +6,10 @@ import { describe, expect, it } from 'vitest'; import { Reporter } from '../src'; import { checkStateKey } from '@copperbox/millwright-state'; import { coordinatesFromStreamRecords } from '../src/runtime/reporter/handler'; +import { testApp } from './support/test-app'; function synth(): { reporter: Reporter; template: Template } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const reporter = new Reporter(stack, 'Reporter', { deploymentName: 'ci', stateTable: new dynamodb.Table(stack, 'StateTable', { diff --git a/packages/millwright-cdk/test/run-executor-construct.test.ts b/packages/millwright-cdk/test/run-executor-construct.test.ts index 5354dc3..9b50415 100644 --- a/packages/millwright-cdk/test/run-executor-construct.test.ts +++ b/packages/millwright-cdk/test/run-executor-construct.test.ts @@ -1,12 +1,13 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { describe, expect, it } from 'vitest'; import { RunExecutor } from '../src'; +import { testApp } from './support/test-app'; function synth(): { executor: RunExecutor; template: Template } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const executor = new RunExecutor(stack, 'RunExecutor', { deploymentName: 'ci', stateTable: new dynamodb.Table(stack, 'StateTable', { diff --git a/packages/millwright-cdk/test/shim-assets.test.ts b/packages/millwright-cdk/test/shim-assets.test.ts index 1a50ac4..f60ad50 100644 --- a/packages/millwright-cdk/test/shim-assets.test.ts +++ b/packages/millwright-cdk/test/shim-assets.test.ts @@ -4,12 +4,13 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; -import { App, Stack } from 'aws-cdk-lib'; +import { Stack } from 'aws-cdk-lib'; import { Template } from 'aws-cdk-lib/assertions'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import cdkPkg from '../package.json'; import { ShimAssets, stageShimDelivery } from '../src'; +import { testApp } from './support/test-app'; const sh = promisify(execFile); @@ -104,7 +105,7 @@ describe('release build', () => { describe('shim assets construct (C13)', () => { it('deploys the staged delivery to the artifact bucket under control/shim/', () => { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); new ShimAssets(stack, 'ShimAssets', { deploymentName: 'ci', artifactBucket: new s3.Bucket(stack, 'Artifacts'), diff --git a/packages/millwright-cdk/test/step-events-writer-construct.test.ts b/packages/millwright-cdk/test/step-events-writer-construct.test.ts index 86875d1..6d7e193 100644 --- a/packages/millwright-cdk/test/step-events-writer-construct.test.ts +++ b/packages/millwright-cdk/test/step-events-writer-construct.test.ts @@ -1,11 +1,12 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import { describe, expect, it } from 'vitest'; import { MillwrightEventBus, StepEventsWriter } from '../src'; +import { testApp } from './support/test-app'; function synth(deploymentName = 'ci'): Template { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const bus = new MillwrightEventBus(stack, 'EventBus', { deploymentName }); const table = new dynamodb.Table(stack, 'StateTable', { partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING }, diff --git a/packages/millwright-cdk/test/support/test-app.ts b/packages/millwright-cdk/test/support/test-app.ts new file mode 100644 index 0000000..0ec1ac4 --- /dev/null +++ b/packages/millwright-cdk/test/support/test-app.ts @@ -0,0 +1,26 @@ +import { mkdtempSync, realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { App, AppProps } from 'aws-cdk-lib'; + +/** + * A CDK App for construct tests, with one cloud-assembly outdir shared by every + * App in this worker process. + * + * Synthesizing the Millwright construct bundles ten Lambdas with esbuild (plus + * the synth tooling bundle), which costs seconds per synth and blew vitest's + * 5 s per-test budget on the CI runner. aws-cdk-lib's AssetStaging keeps a + * process-wide cache keyed on (outdir, source, bundling options), but a bare + * `new App()` picks a fresh temp outdir every time, so nothing ever hits it. + * Pinning the outdir per process means the first synth in a test file does the + * bundling and every later synth reuses it. + * + * The outdir is not removed afterwards: a bare `new App()` also leaves its temp + * dir behind, once per App, so this strictly reduces the litter. + */ +let outdir: string | undefined; + +export function testApp(props: AppProps = {}): App { + outdir ??= mkdtempSync(join(realpathSync(tmpdir()), 'millwright-cdk-test-')); + return new App({ outdir, ...props }); +} diff --git a/packages/millwright-cdk/test/sweep-construct.test.ts b/packages/millwright-cdk/test/sweep-construct.test.ts index 1e00fe7..6b1bd66 100644 --- a/packages/millwright-cdk/test/sweep-construct.test.ts +++ b/packages/millwright-cdk/test/sweep-construct.test.ts @@ -1,13 +1,14 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import { describe, expect, it } from 'vitest'; import { Sweep } from '../src'; +import { testApp } from './support/test-app'; const RUN_EXECUTOR_ARN = 'arn:aws:states:eu-west-1:123456789012:stateMachine:ci-run-executor'; function synth(deploymentName = 'ci'): Template { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const table = new dynamodb.Table(stack, 'StateTable', { partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING }, sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING }, diff --git a/packages/millwright-cdk/test/synth-job-construct.test.ts b/packages/millwright-cdk/test/synth-job-construct.test.ts index e276052..5cf9957 100644 --- a/packages/millwright-cdk/test/synth-job-construct.test.ts +++ b/packages/millwright-cdk/test/synth-job-construct.test.ts @@ -1,8 +1,9 @@ -import { App, Stack } from 'aws-cdk-lib'; +import { Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import { beforeAll, describe, expect, it } from 'vitest'; import { Millwright } from '../src/millwright'; import { SYNTH_IMAGE } from '../src/synth-image'; +import { testApp } from './support/test-app'; const BOUNDARY_ARN = 'arn:aws:iam::123456789012:policy/boundary'; @@ -10,7 +11,7 @@ let millwright: Millwright; let template: Template; beforeAll(() => { - const app = new App(); + const app = testApp(); const stack = new Stack(app, 'Test'); millwright = new Millwright(stack, 'Millwright', { permissionsBoundary: BOUNDARY_ARN }); template = Template.fromStack(stack); diff --git a/vitest.config.ts b/vitest.config.ts index 430d7c1..9f3cc45 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,13 @@ import { defineConfig } from 'vitest/config'; // Resolve workspace-internal imports to sources so tests run on a clean // checkout, without a prior `npm run build` producing each package's dist. export default defineConfig({ + // The millwright-cdk construct tests synthesize real stacks, and each first + // synth in a file bundles ten Lambdas with esbuild (see + // packages/millwright-cdk/test/support/test-app.ts). That is several + // seconds on a loaded CI runner — well past vitest's 5 s default. + test: { + testTimeout: 30_000, + }, resolve: { alias: { '@copperbox/millwright-state': path.resolve( From cbc17b137aab367e04efb5fcf06f7d0e9311ec86 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Mon, 7 Sep 2026 20:32:50 -0700 Subject: [PATCH 12/12] test(cli): wait for the container to start before cancelling the local run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ctrl-C test slept a fixed 40 ms before firing cancel, but localRun runs several real git subprocesses before it registers its cancel handler. Under full-suite load those took longer than 40 ms, the cancel arrived before the handler existed and was dropped, and the blocked fake build never ended — a timeout every time locally, passing only when the file ran alone. Wait on the fake executor's next start() instead, which the run only reaches after registering the handler. --- packages/millwright-cli/test/local-run.test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/millwright-cli/test/local-run.test.ts b/packages/millwright-cli/test/local-run.test.ts index 5cdcce2..8f21504 100644 --- a/packages/millwright-cli/test/local-run.test.ts +++ b/packages/millwright-cli/test/local-run.test.ts @@ -73,11 +73,17 @@ interface JobPlan { */ class FakeExecutor implements Executor { readonly started: LocalJobSpec[] = []; + private startWaiters: ((spec: LocalJobSpec) => void)[] = []; constructor(private readonly plans: Readonly> = {}) {} async preflight(): Promise {} + /** Resolves when the next job container is started. */ + nextStart(): Promise { + return new Promise((resolve) => this.startWaiters.push(resolve)); + } + private emit(spec: LocalJobSpec, status: string, extra: Record = {}): void { fs.mkdirSync(path.dirname(spec.eventsFile), { recursive: true }); const detail = { @@ -96,6 +102,9 @@ class FakeExecutor implements Executor { start(spec: LocalJobSpec): LocalExecution { this.started.push(spec); + const waiters = this.startWaiters; + this.startWaiters = []; + for (const waiter of waiters) waiter(spec); const attempt = this.started.filter((s) => s.job === spec.job).length; const plan = this.plans[spec.job] ?? {}; let stopRequested = false; @@ -262,9 +271,13 @@ describe('millwright run — the local host', () => { const executor = new FakeExecutor({ build: { block: true } }); const h = harness(root, executor); + const started = executor.nextStart(); const run = localRun(h.deps, { workflow: 'ci' }); - // Let the build container start, then interrupt. - await new Promise((resolve) => setTimeout(resolve, 40)); + // Interrupt once the build container has started. A fixed sleep raced the + // run's setup (several real git subprocesses precede cancel registration): + // under load the cancel arrived before the handler existed, was dropped, + // and the blocked build never ended. + await started; h.cancel(); const result = await run;