Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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

Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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',
Expand Down
23 changes: 21 additions & 2 deletions src/cli.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,10 @@ USAGE:
COMMANDS:
version --type <patch|minor|major|auto> [options]
Bump versions of all packages in lockstep


sync [options]
Realign packages that drifted out of lockstep to the highest version found

publish --tag <dist-tag> [options]
Publish all packages in dependency order

Expand All@@ -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
Expand All@@ -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
Expand All@@ -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.
Expand DownExpand Up@@ -159,6 +172,12 @@ async function main(): Promise<void> {

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']);
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ export type {
WorkspacePackage,
WorkspaceInfo,
PublishOptions,
SyncOptions,
VersionOptions,
CliOptions,
LockstepConfig,
Expand Down
178 changes: 155 additions & 23 deletions src/lockstep.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,7 @@ import type {
PackageJson,
PackageManager,
PublishOptions,
SyncOptions,
VersionOptions,
WorkspaceInfo,
WorkspacePackage
Expand DownExpand Up@@ -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")
Expand DownExpand Up@@ -434,23 +492,56 @@ 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;

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);
}
}
Expand All@@ -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);
Expand All@@ -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<void> {
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}.`);
}

/**
Expand Down
Loading
Loading