diff --git a/README.md b/README.md index 379369f..c760ff2 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A comprehensive monorepo package management tool that maintains synchronized ver ## Features - **Lockstep Versioning**: All packages maintain the same version number +- **Drift Recovery**: `sync` realigns packages that fell out of lockstep to the highest version found - **Dependency-Aware Publishing**: Uses topological sorting to publish dependencies first - **Branch-Based Dist-Tags**: Automatic prefixing based on git branch - **Conventional Commits**: Automatic version detection from commit messages @@ -47,6 +48,9 @@ lockstep version --type patch # Automatically determine version from conventional commits lockstep version --type auto +# Realign packages that drifted out of lockstep to the highest version +lockstep sync + # Publish all packages to latest lockstep publish --tag latest @@ -93,6 +97,31 @@ lockstep version --type patch --no-changelog By default a version bump also generates changelog and release-notes files and folds them into the release commit. See [AI Changelog & Release Notes](#ai-changelog--release-notes) below. +### Sync Command + +Realigns packages that have drifted out of lockstep by setting every package — and its internal +dependency ranges — to the **highest** version found across the workspace. This is the recovery +path for when `version`, `changelog`, or `publish` fail because the packages no longer share a +single version. + +```bash +lockstep sync [options] +``` + +Unlike `version`, `sync` mints no new version and touches no git state: it rewrites `package.json` +files only, leaving the commit and tag to you. When the workspace is already uniform it reports so +and changes nothing. The highest version is chosen by numeric semver precedence, so `1.10.0` +correctly wins over `1.9.0`. + +**Options:** +- `--dry-run` - Print which packages would change; write nothing + +**Examples:** +```bash +lockstep sync +lockstep sync --dry-run +``` + ### Changelog Command Generates a per-package `CHANGELOG.md` and a root `RELEASE_NOTES.md` for the current release, @@ -274,6 +303,9 @@ await lockstep.version({ noGitCommit: false }); +// Realign drifted packages to the highest version found (rewrites package.json only) +await lockstep.syncVersions({ dryRun: false }); + // Publish all packages await lockstep.publish({ tag: 'latest', diff --git a/src/cli.ts b/src/cli.ts index 41a6cef..b31022c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,7 +45,10 @@ USAGE: COMMANDS: version --type [options] Bump versions of all packages in lockstep - + + sync [options] + Realign packages that drifted out of lockstep to the highest version found + publish --tag [options] Publish all packages in dependency order @@ -61,6 +64,9 @@ VERSION OPTIONS: --no-git-commit Skip git commit and tag operations --no-changelog Skip AI changelog generation during the bump +SYNC OPTIONS: + --dry-run Print which packages would change; write nothing + CHANGELOG OPTIONS: --dry-run Print what would be generated; write nothing --verbose Print detailed progress and token usage @@ -78,7 +84,10 @@ EXAMPLES: lockstep version --type major --no-git-commit lockstep version --type auto lockstep version --type auto --ci - + + lockstep sync + lockstep sync --dry-run + lockstep publish --tag latest lockstep publish --tag alpha lockstep publish --tag beta --dry @@ -99,6 +108,10 @@ NOTES: • Lockstep versioning: All packages maintain the same version number + • lockstep sync recovers from version drift: it sets every package (and its internal + dependency ranges) to the highest version found. It rewrites package.json files only — + no git commit, tag, or changelog — so you review and commit the result yourself. + • --provenance: Generates npm provenance attestations. Requires a supported CI (GitHub Actions or GitLab CI) with id-token permission, and a "repository" field in each package.json. Outside supported CI it is skipped with a warning. @@ -159,6 +172,12 @@ async function main(): Promise { await lockstep.version({ type, skipCi, noGitCommit, noChangelog }); + } else if (cmd === 'sync') { + // Handle sync command - realign drifted packages to the highest version found + const dryRun = Boolean(opts['dry-run']); + + await lockstep.syncVersions({ dryRun }); + } else if (cmd === 'changelog') { // Handle changelog command const dryRun = Boolean(opts['dry-run']); diff --git a/src/index.ts b/src/index.ts index c6825d2..dcc4bff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export type { WorkspacePackage, WorkspaceInfo, PublishOptions, + SyncOptions, VersionOptions, CliOptions, LockstepConfig, diff --git a/src/lockstep.ts b/src/lockstep.ts index b6721ca..605f5ac 100644 --- a/src/lockstep.ts +++ b/src/lockstep.ts @@ -22,6 +22,7 @@ import type { PackageJson, PackageManager, PublishOptions, + SyncOptions, VersionOptions, WorkspaceInfo, WorkspacePackage @@ -254,6 +255,63 @@ export class Lockstep { return `${major}.${minor}.${patch}`; } + /** + * Compares two semantic versions by precedence. + * + * The three numeric fields are compared as integers, so `1.10.0` correctly outranks `1.9.0` — + * a plain string comparison would get this backwards. A normal release outranks its own + * pre-release (`1.0.0` > `1.0.0-alpha`, per semver); when both carry a pre-release and their + * numeric cores are equal, the pre-release identifiers are compared as plain strings. That last + * step is a deliberate simplification: it orders the common cases (`alpha` < `beta`) without a + * full dot-separated precedence walk, which lockstep's uniform versions never need. + * + * @param a - First version string + * @param b - Second version string + * @returns Negative if a precedes b, positive if a follows b, zero if equal in precedence + * @throws Error if either argument is not a valid semver version + * + * @example + * lockstep.semverCompare("1.10.0", "1.9.0"); // > 0 (10 is greater than 9 numerically) + */ + semverCompare(a: string, b: string): number { + const parse = (v: string): { nums: number[]; pre: string } => { + const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); + if (!m) throw new Error(`Not a semver version: ${v}`); + return { nums: [+m[1], +m[2], +m[3]], pre: m[4] ?? "" }; + }; + + const pa = parse(a); + const pb = parse(b); + + for (let i = 0; i < 3; i++) { + if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] - pb.nums[i]; + } + + // Equal numeric cores: a missing pre-release ranks above any present one. + if (pa.pre === pb.pre) return 0; + if (pa.pre === "") return 1; + if (pb.pre === "") return -1; + return pa.pre < pb.pre ? -1 : 1; + } + + /** + * Returns the highest-precedence version from a non-empty list, using {@link semverCompare}. + * @param versions - Version strings to compare (at least one) + * @returns The greatest version by semver precedence + * @throws Error if the list is empty or any entry is not a valid semver version + * + * @example + * lockstep.highestVersion(["1.0.0", "1.10.0", "1.9.0"]); // "1.10.0" + */ + highestVersion(versions: string[]): string { + if (versions.length === 0) { + throw new Error("highestVersion requires at least one version"); + } + // Seed the reduction with the first entry and compare every element, so an invalid version + // anywhere — including a lone entry — is rejected by semverCompare rather than passed through. + return versions.reduce((max, v) => (this.semverCompare(v, max) > 0 ? v : max), versions[0]); + } + /** * Preserves the version range operator when updating dependency versions * @param oldRange - Original version range (e.g., "^1.2.3", "~1.2.3") @@ -434,15 +492,49 @@ export class Lockstep { const current = this.ensureAllSameVersion(packages); const next = this.semverBump(current, actualType); - // Create set for quick internal package lookup + this.applyVersion(packages, next); + + // Generate the changelog into the release commit, unless opted out. A changelog failure + // must never abort a release that already succeeded, so any error degrades to a warning. + if (!noChangelog) { + try { + await this.changelog({}); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.warn(`Changelog generation failed, continuing release: ${detail}`); + } + } + + // Perform git operations unless explicitly skipped + if (!noGitCommit) { + execSync(`git add .`, { cwd: this.config.root, stdio: "inherit" }); + const commitMessage = `chore(release): v${next}${skipCi ? " [skip ci]" : ""}`; + execSync(`git commit -m "${commitMessage}"`, { cwd: this.config.root, stdio: "inherit" }); + execSync(`git tag v${next}`, { cwd: this.config.root, stdio: "inherit" }); + + console.log(`\nAll packages bumped to v${next} and tagged.`); + } else { + console.log(`\nAll packages bumped to v${next}. Git commit and tag skipped.`); + } + } + + /** + * Writes a single target version across every workspace package and the repository-root + * package.json, rewriting internal cross-dependency ranges to match while preserving each + * range's operator. Shared by the lockstep bump and the drift-recovery sync so both apply a + * version identically. + * @param packages - The workspace packages to rewrite + * @param next - The version string to apply everywhere + */ + protected applyVersion(packages: WorkspacePackage[], next: string): void { + // Internal package names drive which dependency ranges get rewritten; external dependencies + // are left untouched so only the monorepo's own cross-references move in lockstep. const internalNames = new Set(packages.map((p) => p.name)); - // Update version in all packages and their internal dependencies for (const p of packages) { const pkg = p.data; pkg.version = next; - // Update internal dependency versions for (const field of DEP_FIELDS) { const deps = pkg[field]; if (!deps) continue; @@ -450,7 +542,6 @@ export class Lockstep { for (const [dep, range] of Object.entries(deps)) { if (!internalNames.has(dep)) continue; if (typeof range !== "string") continue; - // Update internal dependency version while preserving range operator deps[dep] = this.preserveOperator(range, next); } } @@ -459,7 +550,8 @@ export class Lockstep { console.log(`✔ ${p.name} -> ${next}`); } - // Update root package.json version if it exists + // A monorepo's workspace-root package.json is not part of `packages`; keep it in step so its + // version never drifts from the packages it aggregates. const rootPkgPath = path.join(this.config.root, "package.json"); if (exists(rootPkgPath)) { const rootPkg = readJSON(rootPkgPath); @@ -469,29 +561,69 @@ export class Lockstep { console.log(`✔ root -> ${next}`); } } + } - // Generate the changelog into the release commit, unless opted out. A changelog failure - // must never abort a release that already succeeded, so any error degrades to a warning. - if (!noChangelog) { - try { - await this.changelog({}); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - console.warn(`Changelog generation failed, continuing release: ${detail}`); - } + /** + * Realigns every package to the highest version found, recovering a workspace whose packages + * have drifted out of lockstep. + * + * Unlike {@link version} this mints no new version and touches no git state: it scans all + * packages (and the repository-root package.json), selects the greatest version by semver + * precedence, and rewrites every package plus its internal dependency ranges to that version. + * When the workspace is already uniform it reports so and writes nothing. This is the recovery + * path for the "all packages share a version" invariant that {@link version}, changelog + * generation, and {@link publish} all depend on. + * + * @param options - Dry-run switch to preview the change set without writing + * + * @example + * await lockstep.syncVersions(); // realign every package to the highest version + * await lockstep.syncVersions({ dryRun: true }); // preview which packages would change + */ + async syncVersions(options: SyncOptions = {}): Promise { + const { dryRun = false } = options; + + const { packages } = this.buildWorkspace(); + if (packages.length === 0) { + console.log("No packages found; nothing to sync."); + return; } - // Perform git operations unless explicitly skipped - if (!noGitCommit) { - execSync(`git add .`, { cwd: this.config.root, stdio: "inherit" }); - const commitMessage = `chore(release): v${next}${skipCi ? " [skip ci]" : ""}`; - execSync(`git commit -m "${commitMessage}"`, { cwd: this.config.root, stdio: "inherit" }); - execSync(`git tag v${next}`, { cwd: this.config.root, stdio: "inherit" }); + // A single-package repo discovers its root as the package itself; a monorepo keeps a + // separate aggregator root outside `packages`. Only that separate root needs folding into + // the candidate set and drift check — otherwise the root is already covered by `packages`. + const rootPkgPath = path.join(this.config.root, "package.json"); + const rootIsPackage = packages.some((p) => p.pkgPath === rootPkgPath); - console.log(`\nAll packages bumped to v${next} and tagged.`); - } else { - console.log(`\nAll packages bumped to v${next}. Git commit and tag skipped.`); + let rootVersion: string | undefined; + if (!rootIsPackage && exists(rootPkgPath)) { + const v = readJSON(rootPkgPath).version; + if (v) rootVersion = v; } + + const candidates = packages.map((p) => p.version); + if (rootVersion) candidates.push(rootVersion); + + const target = this.highestVersion(candidates); + + const behind = packages.filter((p) => p.version !== target); + const rootBehind = rootVersion !== undefined && rootVersion !== target; + + if (behind.length === 0 && !rootBehind) { + console.log(`All packages already in sync at v${target}.`); + return; + } + + if (dryRun) { + console.log(`[dry-run] Would sync to v${target}:`); + for (const p of behind) console.log(` ${p.name}: ${p.version} -> ${target}`); + if (rootBehind) console.log(` (root package.json): ${rootVersion} -> ${target}`); + return; + } + + console.log(`Syncing all packages to the highest version found: v${target}`); + this.applyVersion(packages, target); + console.log(`\nAll packages synced to v${target}.`); } /** diff --git a/src/sync.impl.test.ts b/src/sync.impl.test.ts new file mode 100644 index 0000000..408bbb5 --- /dev/null +++ b/src/sync.impl.test.ts @@ -0,0 +1,66 @@ +/** + * Implementation tests for the sync command's version-precedence internals. + * + * `semverCompare` and `highestVersion` are pure helpers underpinning drift recovery; these cover + * their ordering rules and boundary conditions directly, without a workspace fixture. + */ + +import { describe, expect, it } from 'vitest'; +import { Lockstep } from './lockstep.js'; + +describe('semverCompare', () => { + const ls = new Lockstep(); + + it('should return a positive number when the first version is higher', () => { + expect(ls.semverCompare('2.0.0', '1.9.9')).toBeGreaterThan(0); + }); + + it('should return a negative number when the first version is lower', () => { + expect(ls.semverCompare('1.0.0', '1.0.1')).toBeLessThan(0); + }); + + it('should return zero for versions equal in precedence', () => { + expect(ls.semverCompare('1.2.3', '1.2.3')).toBe(0); + }); + + it('should compare the minor field numerically, not lexically', () => { + // 1.9.0 vs 1.10.0: lexically "9" > "10", numerically 10 > 9 — the numeric order must win. + expect(ls.semverCompare('1.10.0', '1.9.0')).toBeGreaterThan(0); + }); + + it('should rank a normal release above its own pre-release', () => { + expect(ls.semverCompare('1.0.0', '1.0.0-alpha')).toBeGreaterThan(0); + }); + + it('should throw when either argument is not valid semver', () => { + expect(() => ls.semverCompare('1.0', '1.0.0')).toThrow('Not a semver version'); + }); +}); + +describe('highestVersion', () => { + const ls = new Lockstep(); + + it('should return the greatest version by numeric precedence', () => { + expect(ls.highestVersion(['1.0.0', '1.10.0', '1.9.0'])).toBe('1.10.0'); + }); + + it('should return the sole element for a single-item list', () => { + expect(ls.highestVersion(['3.4.5'])).toBe('3.4.5'); + }); + + it('should prefer a normal release over a competing pre-release of the same core', () => { + expect(ls.highestVersion(['2.0.0-rc.1', '2.0.0'])).toBe('2.0.0'); + }); + + it('should be independent of input order', () => { + expect(ls.highestVersion(['1.9.0', '1.10.0'])).toBe(ls.highestVersion(['1.10.0', '1.9.0'])); + }); + + it('should throw for an empty list', () => { + expect(() => ls.highestVersion([])).toThrow('at least one'); + }); + + it('should throw when any entry is not valid semver', () => { + expect(() => ls.highestVersion(['1.0.0', 'nope'])).toThrow('Not a semver version'); + }); +}); diff --git a/src/sync.spec.test.ts b/src/sync.spec.test.ts new file mode 100644 index 0000000..9aac1fa --- /dev/null +++ b/src/sync.spec.test.ts @@ -0,0 +1,152 @@ +/** + * Specification tests for the `sync` command — version-drift reconciliation. + * + * lockstep requires every package to share one version; the moment they drift, `version`, + * `changelog`, and `publish` all fail. `sync` is the recovery path: it finds the highest version + * present and realigns every package (and its internal dependency ranges) to it, rewriting + * package.json files only — no git commit, tag, or changelog. These tests derive from that + * contract over real temp git fixtures. + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Lockstep } from './lockstep.js'; + +const dirs: string[] = []; +afterEach(() => { + for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); +}); + +function git(dir: string, cmd: string): void { + execSync(`git ${cmd}`, { cwd: dir, stdio: 'pipe' }); +} + +/** + * A two-package monorepo (`@scope/a`, `@scope/b` where b depends on a) plus a private aggregator + * root, each at the caller-chosen version, committed once so git side effects are observable. + */ +function makeDriftedMonorepo(versions: { root?: string; a: string; b: string }): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lockstep-sync-')); + dirs.push(dir); + git(dir, 'init -q'); + git(dir, 'config user.email t@e.com'); + git(dir, 'config user.name T'); + git(dir, 'config commit.gpgsign false'); + + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'root', version: versions.root ?? '1.0.0', private: true }, null, 2) + ); + fs.mkdirSync(path.join(dir, 'packages', 'a'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'packages', 'a', 'package.json'), + JSON.stringify({ name: '@scope/a', version: versions.a }, null, 2) + ); + fs.mkdirSync(path.join(dir, 'packages', 'b'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'packages', 'b', 'package.json'), + JSON.stringify({ name: '@scope/b', version: versions.b, dependencies: { '@scope/a': `^${versions.a}` } }, null, 2) + ); + + git(dir, 'add -A'); + git(dir, 'commit -q -m "chore: init"'); + return dir; +} + +/** Reads a package.json relative to the repo root (use '.' for the root package). */ +function readPkg(dir: string, rel: string): any { + return JSON.parse(fs.readFileSync(path.join(dir, rel, 'package.json'), 'utf8')); +} + +function readVersion(dir: string, rel: string): string { + return readPkg(dir, rel).version; +} + +/** A comparable snapshot of every version and the internal dependency range in the fixture. */ +function snapshotVersions(dir: string): Record { + return { + root: readVersion(dir, '.'), + a: readVersion(dir, 'packages/a'), + b: readVersion(dir, 'packages/b'), + bDependsOnA: readPkg(dir, 'packages/b').dependencies['@scope/a'] + }; +} + +describe('sync command — version reconciliation', () => { + // Drifted packages must all move up to the highest version present. + it('should set all packages to the highest version when versions have drifted', async () => { + const dir = makeDriftedMonorepo({ a: '1.0.0', b: '1.2.0' }); + await new Lockstep({ root: dir }).syncVersions(); + expect(readVersion(dir, 'packages/a')).toBe('1.2.0'); + expect(readVersion(dir, 'packages/b')).toBe('1.2.0'); + }); + + // "Highest" is decided by numeric semver precedence, not lexical order: 1.10.0 is newer than + // 1.9.0 even though the string "1.10.0" sorts before "1.9.0". + it('should pick the highest version by numeric precedence, not string order', async () => { + const dir = makeDriftedMonorepo({ a: '1.9.0', b: '1.10.0' }); + await new Lockstep({ root: dir }).syncVersions(); + expect(readVersion(dir, 'packages/a')).toBe('1.10.0'); + expect(readVersion(dir, 'packages/b')).toBe('1.10.0'); + }); + + // Internal cross-dependency ranges follow the synced version, keeping their range operator. + it('should update internal dependency ranges to the highest version, preserving operators', async () => { + const dir = makeDriftedMonorepo({ a: '1.0.0', b: '1.2.0' }); + await new Lockstep({ root: dir }).syncVersions(); + expect(readPkg(dir, 'packages/b').dependencies['@scope/a']).toBe('^1.2.0'); + }); + + // The workspace-root package.json is kept in step even though it is not part of `packages`. + it('should update the root package.json to the highest version', async () => { + const dir = makeDriftedMonorepo({ root: '1.0.0', a: '1.0.0', b: '1.2.0' }); + await new Lockstep({ root: dir }).syncVersions(); + expect(readVersion(dir, '.')).toBe('1.2.0'); + }); + + // Even when every package already agrees, a lagging aggregator root is brought up to match. + it('should bring a lagging root up when all packages already agree', async () => { + const dir = makeDriftedMonorepo({ root: '1.0.0', a: '1.2.0', b: '1.2.0' }); + await new Lockstep({ root: dir }).syncVersions(); + expect(readVersion(dir, '.')).toBe('1.2.0'); + }); + + // An already-uniform workspace is a no-op: nothing on disk changes. + it('should leave files unchanged when all packages already share a version', async () => { + const dir = makeDriftedMonorepo({ root: '2.0.0', a: '2.0.0', b: '2.0.0' }); + const before = snapshotVersions(dir); + await new Lockstep({ root: dir }).syncVersions(); + expect(snapshotVersions(dir)).toEqual(before); + }); + + // sync rewrites files only; it must never create a commit or a tag. + it('should not create any git commit or tag (files-only)', async () => { + const dir = makeDriftedMonorepo({ a: '1.0.0', b: '1.2.0' }); + const commitsBefore = execSync('git rev-list --count HEAD', { cwd: dir }).toString().trim(); + + await new Lockstep({ root: dir }).syncVersions(); + + const commitsAfter = execSync('git rev-list --count HEAD', { cwd: dir }).toString().trim(); + expect(commitsAfter).toBe(commitsBefore); + expect(execSync('git tag', { cwd: dir }).toString().trim()).toBe(''); + // The edits live in the working tree, proving sync wrote the files but did not commit them. + expect(execSync('git status --porcelain', { cwd: dir }).toString()).toContain('package.json'); + }); + + // dry-run previews the change set without touching any file. + it('should write nothing in dry-run mode', async () => { + const dir = makeDriftedMonorepo({ a: '1.0.0', b: '1.2.0' }); + await new Lockstep({ root: dir }).syncVersions({ dryRun: true }); + expect(readVersion(dir, 'packages/a')).toBe('1.0.0'); + expect(readVersion(dir, 'packages/b')).toBe('1.2.0'); + }); + + // A non-semver version anywhere is a hard error, never a silent mis-sort. + it('should throw when a package has a non-semver version', async () => { + const dir = makeDriftedMonorepo({ a: 'not-a-version', b: '1.2.0' }); + await expect(new Lockstep({ root: dir }).syncVersions()).rejects.toThrow('Not a semver version'); + }); +}); diff --git a/src/types.ts b/src/types.ts index 2556b7c..e1dd720 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,6 +66,14 @@ export interface PublishOptions { provenance?: boolean; } +/** + * Options for the sync command + */ +export interface SyncOptions { + /** Preview which packages would change without writing any files */ + dryRun?: boolean; +} + /** * Options for the version command */ @@ -113,4 +121,4 @@ export type DependencyField = export type PackageManager = 'npm' | 'yarn' | 'pnpm'; /** CLI command types */ -export type Command = 'version' | 'publish' | 'help'; +export type Command = 'version' | 'sync' | 'changelog' | 'publish' | 'help';