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..66851a0 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,31 @@ 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. 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 run set-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. + +`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..3cf6f1f 100644 --- a/okf-bundle/interfaces/packages.md +++ b/okf-bundle/interfaces/packages.md @@ -30,11 +30,19 @@ 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 -m 'chore(release): v%s' # bump every package, commit, tag v0.2.0 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`. The root `version` lifecycle script fans an +`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 - [Deployment construct](deployment.md) · [Run model](../schemas/run-model.md) for the diff --git a/package-lock.json b/package-lock.json index 2511a11..8496681 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/*" @@ -2727,12 +2727,12 @@ }, "packages/millwright-cdk": { "name": "@copperbox/millwright-cdk", - "version": "0.6.2", + "version": "0.7.0", "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.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.2", + "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.2", - "@copperbox/millwright-workflows": "^0.6.2", + "@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.2", + "version": "0.7.0", "license": "MIT", "engines": { "node": ">=20" @@ -2800,7 +2800,7 @@ }, "packages/millwright-workflows": { "name": "@copperbox/millwright-workflows", - "version": "0.6.2", + "version": "0.7.0", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index ad0b294..e47b8da 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", @@ -11,7 +11,9 @@ "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", + "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", diff --git a/packages/millwright-cdk/package.json b/packages/millwright-cdk/package.json index 9204181..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.2", + "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.2", - "@copperbox/millwright-state": "^0.6.2", - "@copperbox/millwright-workflows": "^0.6.2", + "@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 d811ac7..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.2'; +export const VERSION = '0.7.0'; /** * Highest run-model schemaVersion this control plane accepts. Synth fails 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/packages/millwright-cli/package.json b/packages/millwright-cli/package.json index 7bd81c8..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.2", + "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.2", - "@copperbox/millwright-workflows": "^0.6.2", + "@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 cf52011..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.2'; +export const VERSION = '0.7.0'; diff --git a/packages/millwright-state/package.json b/packages/millwright-state/package.json index 1039b19..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.2", + "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 870a7dc..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.2", + "version": "0.7.0", "description": "Millwright workflow definition library — the only install in watched repos", "license": "MIT", "repository": { diff --git a/scripts/set-version.mjs b/scripts/set-version.mjs index 448e58a..ceb06e1 100644 --- a/scripts/set-version.mjs +++ b/scripts/set-version.mjs @@ -1,22 +1,23 @@ #!/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 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'; -import { fileURLToPath } from 'node:url'; - -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const version = process.argv[2]; +import { fileURLToPath, pathToFileURL } from 'node:url'; -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 +25,122 @@ 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); } } - 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 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}`); + } + } + } + return { version, mismatches }; +} + +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. + */ +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; +} + +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 = resolveVersion(positional, process.env); + 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..25ce109 --- /dev/null +++ b/scripts/set-version.test.mjs @@ -0,0 +1,217 @@ +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'; +import { spawnSync } from 'node:child_process'; +import { applyVersion, checkLockstep, packageDirs, resolveVersion } 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']); + +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 }; + 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'); + 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'); + 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'); + 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'); + 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'); + 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'); + 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'); + 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';", + ); + }); +}); + +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']]) { + const result = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' }); + expect(result.status, args.join(' ')).toBe(1); + expect(result.stderr).toContain('Usage:'); + } + }); + + 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', () => { + 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'); + 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', () => { + 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; 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); + expect(result.stdout).toMatch(/lockstep at \d+\.\d+\.\d+/); + }); +});