From 9d14e5840a751ab556a0edd1b33858295739a9ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 17:10:06 +0000 Subject: [PATCH 01/18] fix(version): sync Cargo.lock on every Cargo.toml bump path The #496 lock sync only ran from updateCargoFiles, so crates bumped through version.groups (grouped and ungrouped) or the per-package processor left their own entry in the committed Cargo.lock at the old version. The sync now runs inside updateCargoVersion, the one function every version-step Cargo.toml write goes through. The sync also stops shelling out to `cargo update --workspace --offline`, which exits 101 on a runner with an empty CARGO_HOME (no cached crates.io index) and needs a Rust toolchain the release job may not have. Core's new syncCargoLockVersion edits the lock text the way cargo does for a path package's version change: the entry's version line plus any "name old-version" dependency references. Registry and git entries are never touched, and a lock where the edit would change how cargo tells same-named packages apart is left alone with a warning. Publish's prepare-stage sync uses the same helper. Closes #650 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- packages/core/src/cargo.ts | 123 +++++++- packages/core/src/index.ts | 2 +- packages/core/test/unit/cargo.spec.ts | 191 ++++++++++++- packages/publish/src/stages/prepare.ts | 2 +- packages/publish/src/utils/cargo.ts | 41 +-- .../publish/test/unit/stages/prepare.spec.ts | 42 ++- packages/version/src/cargo/cargoHandler.ts | 6 + packages/version/src/cargo/cargoLock.ts | 69 +++-- .../version/src/core/versionStrategies.ts | 17 +- .../test/unit/cargo/cargoHandler.spec.ts | 5 + .../version/test/unit/cargo/cargoLock.spec.ts | 270 +++++++++++++++--- test/integration/cargo-lock-sync.spec.ts | 96 ++++--- 12 files changed, 687 insertions(+), 177 deletions(-) diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index 1895e4d1..bd2c2d36 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -11,8 +11,8 @@ function isWorkspaceManifest(cargoTomlPath: string): boolean { } /** - * Find the `Cargo.lock` that governs a crate at `startDir` — the one `cargo update --workspace` - * rewrites — by walking up from `startDir`: + * Find the `Cargo.lock` that governs a crate at `startDir` — the one cargo resolves the crate + * into — by walking up from `startDir`: * * - If an ancestor `Cargo.toml` declares a `[workspace]`, that's the workspace root and its * `Cargo.lock` is authoritative. A workspace member's own (stray) lock is ignored by cargo, so we @@ -43,3 +43,122 @@ export function findCargoLockfile(startDir: string): string | undefined { } return nearestLock; } + +interface CargoLockEntry { + name: string; + version: string; + /** Index of the entry's `version = "…"` line. */ + versionLine: number; + /** Registry and git packages carry a `source`; path and workspace packages don't. */ + hasSource: boolean; +} + +/** Read the `[[package]]` entries of a Cargo.lock split into lines, keeping each line's position. */ +function readCargoLockEntries(lines: string[]): CargoLockEntry[] { + const entries: CargoLockEntry[] = []; + let current: { name?: string; version?: string; versionLine?: number; hasSource: boolean } | undefined; + const closeEntry = (): void => { + if (current?.name !== undefined && current.version !== undefined && current.versionLine !== undefined) { + entries.push({ + name: current.name, + version: current.version, + versionLine: current.versionLine, + hasSource: current.hasSource, + }); + } + }; + + for (const [index, line] of lines.entries()) { + if (line.startsWith('[')) { + closeEntry(); + current = /^\[\[package\]\]\s*$/.test(line) ? { hasSource: false } : undefined; + continue; + } + const field = current ? /^(name|version|source) = "([^"]*)"\s*$/.exec(line) : null; + if (!current || !field) { + continue; + } + if (field[1] === 'name') { + current.name = field[2]; + } else if (field[1] === 'version') { + current.version = field[2]; + current.versionLine = index; + } else { + current.hasSource = true; + } + } + closeEntry(); + return entries; +} + +/** + * Point a path or workspace crate's own `[[package]]` entry in Cargo.lock content at `version`, making + * the edit cargo itself makes for a version change: the entry's `version` line, plus every + * `" "` dependency reference. Cargo only writes the version into a reference when + * the lock holds more than one version of that name — otherwise dependents list the bare name, which + * needs no edit. That encoding is the same in every lockfile format, and cargo never adds a source to + * a reference to a path package. Registry and git entries of the same name are never touched. + * + * Returns the updated content (identical when the entry is already at `version`), or `undefined` when + * the lock has no path entry for `crateName`. Throws rather than guess when the lock holds the name + * more than once in a way this edit would change cargo's encoding of: several path entries, or another + * entry at the old or new version (those references carry a source that would have to be added or + * dropped). + */ +export function setCargoLockVersion(lockContent: string, crateName: string, version: string): string | undefined { + const lines = lockContent.split('\n'); + const sameName = readCargoLockEntries(lines).filter((entry) => entry.name === crateName); + const own = sameName.filter((entry) => !entry.hasSource); + if (own.length === 0) { + return undefined; + } + if (own.length > 1) { + throw new Error(`Cargo.lock has ${own.length} path entries named ${crateName}`); + } + + const [entry] = own; + if (entry.version === version) { + return lockContent; + } + const clash = sameName.find((other) => other !== entry && [entry.version, version].includes(other.version)); + if (clash) { + throw new Error( + `Cargo.lock also has ${crateName} ${clash.version} from another source; run \`cargo update --workspace\` to sync it`, + ); + } + + lines[entry.versionLine] = lines[entry.versionLine].replace(`"${entry.version}"`, `"${version}"`); + return lines.join('\n').replaceAll(`"${crateName} ${entry.version}"`, `"${crateName} ${version}"`); +} + +/** + * Sync the crate's own entry in the committed Cargo.lock that governs `crateDir` (see + * {@link findCargoLockfile}) to `version`, by editing the lock text directly with + * {@link setCargoLockVersion}. Nothing runs cargo, so this needs no Rust toolchain, registry index or + * network. Writes nothing under `dryRun`. + * + * Returns the lock path and whether its content changed, or `undefined` when no committed lock lists + * the crate as a path package. Throws when the lock can't be read or written, or can't be edited + * unambiguously. + */ +export function syncCargoLockVersion( + crateDir: string, + crateName: string, + version: string, + dryRun = false, +): { lockPath: string; changed: boolean } | undefined { + const lockPath = findCargoLockfile(crateDir); + if (!lockPath) { + return undefined; + } + const content = fs.readFileSync(lockPath, 'utf8'); + const updated = setCargoLockVersion(content, crateName, version); + if (updated === undefined) { + return undefined; + } + const changed = updated !== content; + if (changed && !dryRun) { + fs.writeFileSync(lockPath, updated); + } + return { lockPath, changed }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a3b89e88..f0cd485f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export { findCargoLockfile } from './cargo.js'; +export { findCargoLockfile, syncCargoLockVersion } from './cargo.js'; export { type ChangelogRefsMode, escapeChangelogMentions, diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index bd2da50d..8b58e9cd 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { findCargoLockfile } from '../../src/cargo.js'; +import { findCargoLockfile, setCargoLockVersion, syncCargoLockVersion } from '../../src/cargo.js'; describe('findCargoLockfile', () => { const tmpDirs: string[] = []; @@ -77,3 +77,192 @@ describe('findCargoLockfile', () => { expect(findCargoLockfile(member)).toBeUndefined(); }); }); + +const REGISTRY = 'registry+https://github.com/rust-lang/crates.io-index'; +const GIT = 'git+https://github.com/example/dupe#9a1327a6d3f8bc80a0f02fe01325c7828d587668'; + +/** Join lock lines the way cargo writes them: LF endings and a trailing newline. */ +const lock = (...lines: string[]): string => `${lines.join('\n')}\n`; + +describe('setCargoLockVersion', () => { + it.each([3, 4])('should bump only the crate’s own entry in a v%i lock', (format) => { + const before = lock( + '# This file is automatically @generated by Cargo.', + '# It is not intended for manual editing.', + `version = ${format}`, + '', + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + 'dependencies = [', + ' "core",', + ']', + '', + '[[package]]', + 'name = "core"', + 'version = "1.4.0"', + ); + + expect(setCargoLockVersion(before, 'core', '1.5.0')).toBe(before.replace('version = "1.4.0"', 'version = "1.5.0"')); + }); + + it('should rewrite versioned references when the lock holds two versions of the name', () => { + // Byte-for-byte what `cargo update --workspace` writes after bumping the path crate `dupe`. + const before = lock( + 'version = 4', + '', + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + 'dependencies = [', + ' "dupe 0.9.0",', + ' "dupe 1.0.0",', + ']', + '', + '[[package]]', + 'name = "dupe"', + 'version = "0.9.0"', + `source = "${GIT}"`, + '', + '[[package]]', + 'name = "dupe"', + 'version = "1.0.0"', + ); + const after = lock( + 'version = 4', + '', + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + 'dependencies = [', + ' "dupe 0.9.0",', + ' "dupe 1.1.0",', + ']', + '', + '[[package]]', + 'name = "dupe"', + 'version = "0.9.0"', + `source = "${GIT}"`, + '', + '[[package]]', + 'name = "dupe"', + 'version = "1.1.0"', + ); + + expect(setCargoLockVersion(before, 'dupe', '1.1.0')).toBe(after); + }); + + it('should rewrite path references in a v1 lock without touching registry references or checksums', () => { + // v1 locks always spell out the version, and add the source for registry packages. + const before = lock( + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + 'dependencies = [', + ' "core 1.4.0",', + ` "serde 1.0.200 (${REGISTRY})",`, + ']', + '', + '[[package]]', + 'name = "core"', + 'version = "1.4.0"', + '', + '[[package]]', + 'name = "serde"', + 'version = "1.0.200"', + `source = "${REGISTRY}"`, + '', + '[metadata]', + `"checksum serde 1.0.200 (${REGISTRY})" = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f"`, + ); + + expect(setCargoLockVersion(before, 'core', '2.0.0')).toBe( + before.replace('"core 1.4.0"', '"core 2.0.0"').replace('version = "1.4.0"', 'version = "2.0.0"'), + ); + }); + + it('should return undefined when the crate is only in the lock as a registry package', () => { + const before = lock( + 'version = 3', + '', + '[[package]]', + 'name = "serde"', + 'version = "1.0.200"', + `source = "${REGISTRY}"`, + ); + + expect(setCargoLockVersion(before, 'serde', '1.0.201')).toBeUndefined(); + }); + + it('should return the content unchanged when the entry is already at the version', () => { + const before = lock('version = 3', '', '[[package]]', 'name = "core"', 'version = "1.5.0"'); + + expect(setCargoLockVersion(before, 'core', '1.5.0')).toBe(before); + }); + + it('should keep CRLF line endings', () => { + const before = 'version = 3\r\n\r\n[[package]]\r\nname = "core"\r\nversion = "1.4.0"\r\n'; + + expect(setCargoLockVersion(before, 'core', '1.5.0')).toBe(before.replace('1.4.0', '1.5.0')); + }); + + it.each([ + ['the new version', '2.0.0'], + ['the old version', '1.4.0'], + ])('should throw when another source holds the name at %s', (_label, otherVersion) => { + const before = lock( + 'version = 4', + '', + '[[package]]', + 'name = "core"', + 'version = "1.4.0"', + '', + '[[package]]', + 'name = "core"', + `version = "${otherVersion}"`, + `source = "${REGISTRY}"`, + ); + + expect(() => setCargoLockVersion(before, 'core', '2.0.0')).toThrow(/from another source/); + }); +}); + +describe('syncCargoLockVersion', () => { + const tmpDirs: string[] = []; + + function workspaceWithLock(): { root: string; member: string; lockPath: string } { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-sync-'))); + tmpDirs.push(root); + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["crates/core"]\n'); + const member = path.join(root, 'crates', 'core'); + fs.mkdirSync(member, { recursive: true }); + const lockPath = path.join(root, 'Cargo.lock'); + fs.writeFileSync(lockPath, lock('version = 4', '', '[[package]]', 'name = "core"', 'version = "1.4.0"')); + return { root, member, lockPath }; + } + + afterEach(() => { + for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true }); + tmpDirs.length = 0; + }); + + it('should write the bumped entry to the workspace-root lock', () => { + const { member, lockPath } = workspaceWithLock(); + + expect(syncCargoLockVersion(member, 'core', '1.5.0')).toEqual({ lockPath, changed: true }); + expect(fs.readFileSync(lockPath, 'utf8')).toContain('version = "1.5.0"'); + }); + + it('should report the change without writing under dry-run', () => { + const { member, lockPath } = workspaceWithLock(); + + expect(syncCargoLockVersion(member, 'core', '1.5.0', true)).toEqual({ lockPath, changed: true }); + expect(fs.readFileSync(lockPath, 'utf8')).toContain('version = "1.4.0"'); + }); + + it('should return undefined when no committed lock lists the crate', () => { + const { member } = workspaceWithLock(); + + expect(syncCargoLockVersion(member, 'other', '1.5.0')).toBeUndefined(); + }); +}); diff --git a/packages/publish/src/stages/prepare.ts b/packages/publish/src/stages/prepare.ts index bef27d3a..65a4cfc1 100644 --- a/packages/publish/src/stages/prepare.ts +++ b/packages/publish/src/stages/prepare.ts @@ -79,7 +79,7 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { updateCargoVersion(cargoPath, update.newVersion); debug(`Updated ${cargoPath} to version ${update.newVersion}`); - const lockPath = await syncCargoLockfile(pkgDir); + const lockPath = syncCargoLockfile(pkgDir); if (lockPath) lockfiles.add(lockPath); } diff --git a/packages/publish/src/utils/cargo.ts b/packages/publish/src/utils/cargo.ts index f10b5996..79ef1c08 100644 --- a/packages/publish/src/utils/cargo.ts +++ b/packages/publish/src/utils/cargo.ts @@ -2,46 +2,35 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { CargoManifest } from '@releasekit/config'; import { parseCargoToml } from '@releasekit/config'; -import { findCargoLockfile, warn } from '@releasekit/core'; +import { syncCargoLockVersion, warn } from '@releasekit/core'; import * as TOML from 'smol-toml'; import { createPublishError, PublishErrorCode } from '../errors/index.js'; -import { execCommand } from './exec.js'; export type { CargoManifest }; export { parseCargoToml }; /** - * Refresh a crate's `Cargo.lock` self-version entry after its `Cargo.toml` was bumped, so the - * committed lock doesn't drift. `cargo update --workspace --offline` moves only workspace - * members' own lock entries to match their manifests — registry dependencies are never re-resolved. + * Sync a crate's own entry in its committed `Cargo.lock` to the version now in its `Cargo.toml`, after + * the manifest was bumped, so the committed lock doesn't drift. Uses the same direct lock edit as the + * version step (core's `syncCargoLockVersion`), so no Rust toolchain is needed. * - * Returns the lock path (so the caller can stage it) or `undefined` when there's no committed lock, - * or the refresh fails. A failure is logged, never thrown: a lockfile fix-up must not abort a publish + * Returns the lock path (so the caller can stage it) or `undefined` when no committed lock lists the + * crate, or the sync fails. A failure is logged, never thrown: a lockfile fix-up must not abort a publish * that would otherwise succeed. This mirrors the version step's sync (which covers the standing-PR * flow's `git add -A`); doing it here too keeps the direct-commit flow self-sufficient rather than * depending on the version step having run and succeeded first. */ -export async function syncCargoLockfile(crateDir: string): Promise { - const lockPath = findCargoLockfile(crateDir); - if (!lockPath) return undefined; - +export function syncCargoLockfile(crateDir: string): string | undefined { + const cargoPath = path.join(crateDir, 'Cargo.toml'); try { - await execCommand('cargo', ['update', '--workspace', '--offline'], { - cwd: crateDir, - label: `cargo update --workspace --offline (${path.basename(crateDir)})`, - }); - return lockPath; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - warn( - `cargo not found on PATH — Cargo.lock at ${lockPath} may drift from the bumped version. ` + - 'Install the Rust toolchain in the release environment, or disable cargo handling.', - ); - } else { - const stderr = (error as { stderr?: string }).stderr; - const detail = stderr?.trim() || (error instanceof Error ? error.message : String(error)); - warn(`Failed to refresh Cargo.lock at ${lockPath}: ${detail}. The committed lock may drift from the bump.`); + const { name, version } = parseCargoToml(cargoPath).package ?? {}; + if (!name || typeof version !== 'string') { + return undefined; } + return syncCargoLockVersion(crateDir, name, version)?.lockPath; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + warn(`Failed to sync Cargo.lock for ${cargoPath}: ${detail}. The committed lock may drift from the bump.`); return undefined; } } diff --git a/packages/publish/test/unit/stages/prepare.spec.ts b/packages/publish/test/unit/stages/prepare.spec.ts index e0529064..b8450673 100644 --- a/packages/publish/test/unit/stages/prepare.spec.ts +++ b/packages/publish/test/unit/stages/prepare.spec.ts @@ -6,11 +6,8 @@ import { getDefaultConfig } from '../../../src/config.js'; import { runPrepareStage } from '../../../src/stages/prepare.js'; import type { PipelineContext } from '../../../src/types.js'; -// Don't shell out to a real cargo for the lockfile sync — assert the staging behaviour, not cargo. -vi.mock('../../../src/utils/exec.js', async () => { - const actual = await vi.importActual('../../../src/utils/exec.js'); - return { ...actual, execCommand: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }) }; -}); +/** A committed lock listing the path crate `foo` at `version`. */ +const fooLock = (version: string): string => `version = 4\n\n[[package]]\nname = "foo"\nversion = "${version}"\n`; function createContext(overrides?: Partial): PipelineContext { return { @@ -144,13 +141,41 @@ describe('prepare stage', () => { expect(fs.existsSync(path.join(pkgDir, 'LICENSE'))).toBe(false); }); - it('should stage the workspace Cargo.lock alongside a bumped crate', async () => { + it('should sync and stage the workspace Cargo.lock alongside a bumped crate', async () => { const dir = createTmpDir(); + fs.writeFileSync(path.join(dir, 'Cargo.toml'), '[workspace]\nmembers = ["crates/foo"]\n'); const crateDir = path.join(dir, 'crates', 'foo'); fs.mkdirSync(crateDir, { recursive: true }); fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); const lockPath = path.join(dir, 'Cargo.lock'); - fs.writeFileSync(lockPath, ''); + fs.writeFileSync(lockPath, fooLock('1.0.0')); + + const config = getDefaultConfig(); + config.cargo.enabled = true; + const ctx = createContext({ + cwd: dir, + config, + input: { + dryRun: false, + updates: [{ packageName: 'foo', newVersion: '1.1.0', filePath: 'crates/foo/Cargo.toml' }], + changelogs: [], + tags: [], + }, + }); + + await runPrepareStage(ctx); + + expect(fs.readFileSync(lockPath, 'utf-8')).toBe(fooLock('1.1.0')); + expect(ctx.additionalFiles).toContain(lockPath); + }); + + it('should still stage a Cargo.lock the version step already synced', async () => { + const dir = createTmpDir(); + const crateDir = path.join(dir, 'crates', 'foo'); + fs.mkdirSync(crateDir, { recursive: true }); + fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.1.0"\n'); + const lockPath = path.join(crateDir, 'Cargo.lock'); + fs.writeFileSync(lockPath, fooLock('1.1.0')); const config = getDefaultConfig(); config.cargo.enabled = true; @@ -175,7 +200,7 @@ describe('prepare stage', () => { const crateDir = path.join(dir, 'crates', 'foo'); fs.mkdirSync(crateDir, { recursive: true }); fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); - fs.writeFileSync(path.join(dir, 'Cargo.lock'), ''); + fs.writeFileSync(path.join(dir, 'Cargo.lock'), fooLock('1.0.0')); const config = getDefaultConfig(); config.cargo.enabled = true; @@ -203,6 +228,7 @@ describe('prepare stage', () => { await runPrepareStage(ctx); + expect(fs.readFileSync(path.join(dir, 'Cargo.lock'), 'utf-8')).toBe(fooLock('1.0.0')); expect(ctx.additionalFiles ?? []).not.toContain(path.join(dir, 'Cargo.lock')); }); diff --git a/packages/version/src/cargo/cargoHandler.ts b/packages/version/src/cargo/cargoHandler.ts index 4d802bb0..ee8e2deb 100644 --- a/packages/version/src/cargo/cargoHandler.ts +++ b/packages/version/src/cargo/cargoHandler.ts @@ -9,6 +9,7 @@ import { isCargoToml, parseCargoToml } from '@releasekit/config'; import * as TOML from 'smol-toml'; import { addPackageUpdate, recordPendingWrite } from '../utils/jsonOutput.js'; import { log } from '../utils/logging.js'; +import { syncCargoLockfile } from './cargoLock.js'; export { isCargoToml }; @@ -58,6 +59,9 @@ export function getCargoInfo(cargoPath: string): CargoInfo { /** * Update a Cargo.toml file with a new version * Preserves comments and formatting as much as possible + * + * Then syncs the crate's own entry in its committed Cargo.lock. Every version-step Cargo.toml bump + * goes through here, so no strategy can bump a crate and leave its lock behind. */ export function updateCargoVersion(cargoPath: string, version: string, dryRun = false): void { try { @@ -95,4 +99,6 @@ export function updateCargoVersion(cargoPath: string, version: string, dryRun = } throw error; } + + syncCargoLockfile(cargoPath, dryRun); } diff --git a/packages/version/src/cargo/cargoLock.ts b/packages/version/src/cargo/cargoLock.ts index ee200155..9ea286dc 100644 --- a/packages/version/src/cargo/cargoLock.ts +++ b/packages/version/src/cargo/cargoLock.ts @@ -1,53 +1,50 @@ -import { execFileSync } from 'node:child_process'; import path from 'node:path'; -import { findCargoLockfile } from '@releasekit/core'; +import { parseCargoToml } from '@releasekit/config'; +import { findCargoLockfile, syncCargoLockVersion } from '@releasekit/core'; import { log } from '../utils/logging.js'; /** - * Sync a crate's `Cargo.lock` self-version entry after its `Cargo.toml` version was bumped, so a - * committed lock doesn't drift from the manifest (which breaks `cargo build --locked`/`--frozen` and - * leaks a spurious lock diff into later builds). The bump only rewrites `Cargo.toml`, so without this - * the lock's own `[[package]]` entry stays at the old version. + * Sync a crate's own `[[package]]` entry in its committed `Cargo.lock` to the version now in its + * `Cargo.toml`, so the lock doesn't drift from the manifest (which breaks `cargo build --locked`/ + * `--frozen` and leaks a spurious lock diff into later builds). The edit is core's + * `syncCargoLockVersion`, which never runs cargo. We never create a lock that wasn't already committed. * - * The refresh is surgical and offline: `cargo update --workspace --offline` moves only the workspace - * members' own lock entries to match their manifests, never re-resolving registry dependencies, so it - * can't reintroduce a dependency-cascade. We never create a lock that wasn't already committed. - * - * Returns the lock path when it was refreshed (so the caller can stage it), or `undefined` when there - * is no committed lock, under dry-run, or when cargo is unavailable / the refresh fails. A failure is - * logged but never thrown: the version bump itself already succeeded and must not be blocked by a - * lockfile fix-up — surfacing the drift loudly is enough. + * Returns the lock path when it lists the crate at the manifest's version (so the caller can stage it), + * or `undefined` when no committed lock lists the crate, under dry-run (the manifest hasn't been written + * yet), or when the sync fails. A failure is logged but never thrown: the version bump itself already + * succeeded and must not be blocked by a lockfile fix-up — surfacing the drift loudly is enough. */ export function syncCargoLockfile(cargoTomlPath: string, dryRun = false): string | undefined { const crateDir = path.dirname(cargoTomlPath); - const lockPath = findCargoLockfile(crateDir); - if (!lockPath) { - log(`No Cargo.lock found above ${crateDir}; nothing to sync`, 'debug'); - return undefined; - } - if (dryRun) { - log(`[DRY RUN] Would refresh Cargo.lock at ${lockPath}`, 'info'); + const lockPath = findCargoLockfile(crateDir); + if (lockPath) { + log(`[DRY RUN] Would sync Cargo.lock at ${lockPath}`, 'info'); + } return undefined; } try { - execFileSync('cargo', ['update', '--workspace', '--offline'], { cwd: crateDir, stdio: 'pipe' }); - log(`Synced Cargo.lock at ${lockPath}`, 'success'); - return lockPath; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT') { - log( - `cargo not found on PATH — Cargo.lock at ${lockPath} may drift from the bumped version. ` + - 'Install the Rust toolchain in the release environment, or set version.cargo.enabled to false.', - 'warning', - ); - } else { - const stderr = (error as { stderr?: Buffer | string }).stderr; - const detail = stderr ? stderr.toString().trim() : error instanceof Error ? error.message : String(error); - log(`Failed to refresh Cargo.lock at ${lockPath}: ${detail}`, 'warning'); + const { name, version } = parseCargoToml(cargoTomlPath).package ?? {}; + if (!name || typeof version !== 'string') { + log(`No package name and version in ${cargoTomlPath}; Cargo.lock not synced`, 'debug'); + return undefined; + } + const result = syncCargoLockVersion(crateDir, name, version); + if (!result) { + log(`No committed Cargo.lock lists ${name}; nothing to sync`, 'debug'); + return undefined; } + if (result.changed) { + log(`Synced Cargo.lock at ${result.lockPath} (${name} ${version})`, 'success'); + } + return result.lockPath; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + log( + `Failed to sync Cargo.lock for ${cargoTomlPath}: ${detail}. The committed lock may drift from the bump.`, + 'warning', + ); return undefined; } } diff --git a/packages/version/src/core/versionStrategies.ts b/packages/version/src/core/versionStrategies.ts index 836c565e..5fbb9eb3 100644 --- a/packages/version/src/core/versionStrategies.ts +++ b/packages/version/src/core/versionStrategies.ts @@ -7,7 +7,6 @@ import * as path from 'node:path'; import type { Package } from '@manypkg/get-packages'; import type { VersionChangelogEntry } from '@releasekit/core'; import { shouldMatchPackageTargets, shouldProcessPackage as shouldProcessPackageUtil } from '@releasekit/core'; -import { syncCargoLockfile } from '../cargo/cargoLock.js'; import { extractChangelogEntriesFromCommits } from '../changelog/commitParser.js'; import { BaseVersionError } from '../errors/baseError.js'; import { StrictReachableError } from '../errors/strictReachableError.js'; @@ -88,29 +87,21 @@ function updateCargoFiles( const cargoPaths = cargoConfig?.paths; - // After rewriting a Cargo.toml version, sync its Cargo.lock self-entry so the committed lock - // doesn't drift. The returned lock path is staged alongside the manifest; deduped because - // crates in one workspace share a single workspace-root lock. - const stageCargo = (cargoTomlPath: string): void => { - updatePackageVersion(cargoTomlPath, version, dryRun); - updatedFiles.push(cargoTomlPath); - const lockPath = syncCargoLockfile(cargoTomlPath, dryRun); - if (lockPath && !updatedFiles.includes(lockPath)) updatedFiles.push(lockPath); - }; - if (cargoPaths && cargoPaths.length > 0) { // If paths are specified, only include those Cargo.toml files for (const cargoPath of cargoPaths) { const resolvedCargoPath = resolveConfinedManifestPath(repoRoot, packageDir, cargoPath, 'Cargo.toml'); if (fs.existsSync(resolvedCargoPath)) { - stageCargo(resolvedCargoPath); + updatePackageVersion(resolvedCargoPath, version, dryRun); + updatedFiles.push(resolvedCargoPath); } } } else { // Default behaviour: check for Cargo.toml in the root package directory const cargoTomlPath = path.join(packageDir, 'Cargo.toml'); if (fs.existsSync(cargoTomlPath)) { - stageCargo(cargoTomlPath); + updatePackageVersion(cargoTomlPath, version, dryRun); + updatedFiles.push(cargoTomlPath); } } diff --git a/packages/version/test/unit/cargo/cargoHandler.spec.ts b/packages/version/test/unit/cargo/cargoHandler.spec.ts index d367120e..cab17b80 100644 --- a/packages/version/test/unit/cargo/cargoHandler.spec.ts +++ b/packages/version/test/unit/cargo/cargoHandler.spec.ts @@ -3,11 +3,13 @@ import path from 'node:path'; import * as TOML from 'smol-toml'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getCargoInfo, isCargoToml, updateCargoVersion } from '../../../src/cargo/cargoHandler.js'; +import { syncCargoLockfile } from '../../../src/cargo/cargoLock.js'; import * as jsonOutput from '../../../src/utils/jsonOutput.js'; import * as logging from '../../../src/utils/logging.js'; // Mock dependencies vi.mock('node:fs'); +vi.mock('../../../src/cargo/cargoLock.js'); vi.mock('../../../src/utils/jsonOutput.js'); vi.mock('../../../src/utils/logging.js'); vi.mock('smol-toml', () => ({ @@ -150,6 +152,7 @@ describe('Cargo Handler', () => { // Should still track the update and log expect(jsonOutput.addPackageUpdate).toHaveBeenCalledWith('test-package', '2.0.0', mockCargoPath); expect(logging.log).toHaveBeenCalledWith(expect.stringContaining('[DRY RUN] Would update'), 'success'); + expect(syncCargoLockfile).toHaveBeenCalledWith(mockCargoPath, true); }); it('should write to disk when dryRun is false', () => { @@ -160,6 +163,7 @@ describe('Cargo Handler', () => { expect(TOML.stringify).toHaveBeenCalled(); expect(fs.writeFileSync).toHaveBeenCalledWith(mockCargoPath, 'mocked stringified TOML'); expect(jsonOutput.addPackageUpdate).toHaveBeenCalledWith('test-package', '2.0.0', mockCargoPath); + expect(syncCargoLockfile).toHaveBeenCalledWith(mockCargoPath, false); }); it('should handle errors when updating Cargo.toml', () => { @@ -170,6 +174,7 @@ describe('Cargo Handler', () => { expect(() => updateCargoVersion(mockCargoPath, '2.0.0')).toThrow(); expect(logging.log).toHaveBeenCalledWith(expect.stringContaining('Failed to update Cargo.toml at'), 'error'); + expect(syncCargoLockfile).not.toHaveBeenCalled(); }); it('should throw an error if package name is not found', () => { diff --git a/packages/version/test/unit/cargo/cargoLock.spec.ts b/packages/version/test/unit/cargo/cargoLock.spec.ts index 49b3f5ed..f11739ad 100644 --- a/packages/version/test/unit/cargo/cargoLock.spec.ts +++ b/packages/version/test/unit/cargo/cargoLock.spec.ts @@ -1,72 +1,248 @@ -import { execFileSync } from 'node:child_process'; +/** + * Cargo.lock sync across every path that bumps a Cargo.toml (#496, #650). Each test drives a real + * strategy over a real Cargo workspace on disk — only git, tag lookups and the version calculation are + * mocked — and checks the committed lock that the standing-PR flow stages with `git add -A`. + */ +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; -import { findCargoLockfile } from '@releasekit/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { syncCargoLockfile } from '../../../src/cargo/cargoLock.js'; -import { log } from '../../../src/utils/logging.js'; +import type { Package, Tool } from '@manypkg/get-packages'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as commitParser from '../../../src/changelog/commitParser.js'; +import { createGroupStrategy } from '../../../src/core/groupStrategy.js'; +import * as calculator from '../../../src/core/versionCalculator.js'; +import type { PackagesWithRoot } from '../../../src/core/versionEngine.js'; +import { createAsyncStrategy, createSingleStrategy, createSyncStrategy } from '../../../src/core/versionStrategies.js'; +import * as gitTags from '../../../src/git/tagsAndBranches.js'; +import * as tagVerification from '../../../src/git/tagVerification.js'; +import type { Config } from '../../../src/types.js'; +import * as logging from '../../../src/utils/logging.js'; + +vi.mock('../../../src/git/tagsAndBranches.js'); +vi.mock('../../../src/git/tagVerification.js'); +vi.mock('../../../src/core/versionCalculator.js'); +vi.mock('../../../src/changelog/commitParser.js'); +vi.mock('../../../src/utils/logging.js'); + +const REGISTRY = 'registry+https://github.com/rust-lang/crates.io-index'; + +/** A lock as cargo writes it for the fixture workspace: alpha depends on beta (path) and serde (registry). */ +function renderLock(versions: { alpha: string; beta: string }): string { + return [ + '# This file is automatically @generated by Cargo.', + '# It is not intended for manual editing.', + 'version = 3', + '', + '[[package]]', + 'name = "alpha"', + `version = "${versions.alpha}"`, + 'dependencies = [', + ' "beta",', + ' "serde",', + ']', + '', + '[[package]]', + 'name = "beta"', + `version = "${versions.beta}"`, + '', + '[[package]]', + 'name = "serde"', + 'version = "1.0.200"', + `source = "${REGISTRY}"`, + 'checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f"', + '', + ].join('\n'); +} + +function writeCrate(dir: string, name: string, version: string, extra = ''): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'Cargo.toml'), + `[package]\nname = "${name}"\nversion = "${version}"\nedition = "2021"\n${extra}`, + ); +} + +function crate(root: string, name: string): Package { + return { + dir: path.join(root, 'crates', name), + relativeDir: `crates/${name}`, + packageJson: { name, version: '1.0.0' }, + } as unknown as Package; +} + +function workspace(root: string, packages: Package[]): PackagesWithRoot { + return { root, rootDir: root, tool: 'cargo' as unknown as Tool, packages } as unknown as PackagesWithRoot; +} + +const baseConfig = (overrides: Partial = {}): Config => + ({ + sync: false, + preset: 'conventional', + packages: [], + tagTemplate: '${prefix}${version}', + versionPrefix: 'v', + ...overrides, + }) as Config; + +/** Make `calculateVersion` bump exactly the named packages. */ +function bump(next: Record): void { + vi.mocked(calculator.calculateVersion).mockImplementation(async (_config, options) => next[options.name ?? ''] ?? ''); +} + +describe('Cargo.lock sync on Cargo.toml bumps', () => { + let root: string; + let lockPath: string; + + const readLock = (): string => fs.readFileSync(lockPath, 'utf8'); + const lockWarnings = (): string[] => + vi + .mocked(logging.log) + .mock.calls.filter(([message, level]) => level === 'warning' && String(message).includes('Cargo.lock')) + .map(([message]) => String(message)); + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(gitTags.getLatestTag).mockResolvedValue(''); + vi.mocked(gitTags.getLatestTagForPackage).mockResolvedValue(''); + vi.mocked(tagVerification.verifyTag, { partial: true }).mockResolvedValue({ exists: true, reachable: true }); + vi.mocked(commitParser.extractChangelogEntriesFromCommits).mockResolvedValue([]); + vi.mocked(commitParser.extractRepoLevelChangelogEntries).mockResolvedValue([]); + + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-paths-'))); + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["crates/alpha", "crates/beta"]\n'); + writeCrate( + path.join(root, 'crates', 'alpha'), + 'alpha', + '1.0.0', + '\n[dependencies]\nbeta = { path = "../beta" }\nserde = "1"\n', + ); + writeCrate(path.join(root, 'crates', 'beta'), 'beta', '1.0.0'); + lockPath = path.join(root, 'Cargo.lock'); + fs.writeFileSync(lockPath, renderLock({ alpha: '1.0.0', beta: '1.0.0' })); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('should sync the lock for crates bumped through a version group', async () => { + bump({ alpha: '1.1.0' }); + const strategy = createGroupStrategy( + baseConfig({ groups: { rust: { packages: ['alpha', 'beta'], sync: 'fixed' } } }), + ); + + await strategy(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); + + expect(readLock()).toBe(renderLock({ alpha: '1.1.0', beta: '1.1.0' })); + expect(lockWarnings()).toEqual([]); + }); + + it('should sync the lock for an ungrouped crate when version groups are configured', async () => { + bump({ beta: '1.0.1' }); + const strategy = createGroupStrategy(baseConfig({ groups: { app: { packages: ['alpha'], sync: 'fixed' } } })); -vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })); -vi.mock('@releasekit/core', () => ({ findCargoLockfile: vi.fn() })); -vi.mock('../../../src/utils/logging.js', () => ({ log: vi.fn() })); + await strategy(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); -const crateDir = path.join('/repo', 'crates', 'foo'); -const cargoToml = path.join(crateDir, 'Cargo.toml'); -const lockPath = path.join('/repo', 'Cargo.lock'); + expect(readLock()).toBe(renderLock({ alpha: '1.0.0', beta: '1.0.1' })); + }); + + it('should sync the lock for a crate bumped by the per-package (async) strategy', async () => { + bump({ beta: '2.0.0' }); + + await createAsyncStrategy(baseConfig())(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); + + expect(readLock()).toBe(renderLock({ alpha: '1.0.0', beta: '2.0.0' })); + }); -describe('syncCargoLockfile', () => { - afterEach(() => vi.clearAllMocks()); + it('should sync the lock for a Cargo.toml found through cargo.paths (Tauri layout)', async () => { + // A JS package whose crate lives in src-tauri/ with its own committed lock, outside any workspace. + const appDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-tauri-'))); + const crateLock = path.join(appDir, 'src-tauri', 'Cargo.lock'); + fs.writeFileSync(path.join(appDir, 'package.json'), '{ "name": "desktop", "version": "0.4.0" }\n'); + writeCrate(path.join(appDir, 'src-tauri'), 'desktop', '0.4.0'); + fs.writeFileSync(crateLock, 'version = 4\n\n[[package]]\nname = "desktop"\nversion = "0.4.0"\n'); + const app = { dir: appDir, relativeDir: '.', packageJson: { name: 'desktop', version: '0.4.0' } } as unknown; + bump({ desktop: '0.5.0' }); + + try { + await createAsyncStrategy(baseConfig({ cargo: { enabled: true, paths: ['src-tauri'] } }))( + workspace(appDir, [app as Package]), + ); + expect(fs.readFileSync(crateLock, 'utf8')).toBe( + 'version = 4\n\n[[package]]\nname = "desktop"\nversion = "0.5.0"\n', + ); + } finally { + fs.rmSync(appDir, { recursive: true, force: true }); + } + }); - it('should run an offline workspace-scoped cargo update and return the lock path', () => { - vi.mocked(findCargoLockfile).mockReturnValue(lockPath); + it('should sync the lock for a single-package release', async () => { + bump({ beta: '1.2.0' }); - const result = syncCargoLockfile(cargoToml); + await createSingleStrategy(baseConfig())(workspace(root, [crate(root, 'beta')])); - expect(result).toBe(lockPath); - expect(execFileSync).toHaveBeenCalledWith('cargo', ['update', '--workspace', '--offline'], { - cwd: crateDir, - stdio: 'pipe', - }); + expect(readLock()).toBe(renderLock({ alpha: '1.0.0', beta: '1.2.0' })); }); - it('should skip the refresh and not run cargo under dry-run', () => { - vi.mocked(findCargoLockfile).mockReturnValue(lockPath); + it('should sync the lock for every crate in a sync release', async () => { + bump({ alpha: '1.3.0' }); - const result = syncCargoLockfile(cargoToml, true); + await createSyncStrategy(baseConfig({ sync: true }))(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); + + expect(readLock()).toBe(renderLock({ alpha: '1.3.0', beta: '1.3.0' })); + }); - expect(result).toBeUndefined(); - expect(execFileSync).not.toHaveBeenCalled(); + it('should sync the lock without a Rust toolchain or a crates.io index', async () => { + // A fresh CI runner: no cargo on PATH and an empty CARGO_HOME. Nothing may shell out to cargo. + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rk-no-cargo-')); + vi.stubEnv('PATH', emptyDir); + vi.stubEnv('CARGO_HOME', emptyDir); + bump({ alpha: '1.1.0' }); + + try { + await createGroupStrategy(baseConfig({ groups: { rust: { packages: ['alpha'], sync: 'fixed' } } }))( + workspace(root, [crate(root, 'alpha'), crate(root, 'beta')]), + ); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(emptyDir, { recursive: true, force: true }); + } + + expect(readLock()).toBe(renderLock({ alpha: '1.1.0', beta: '1.0.0' })); + expect(lockWarnings()).toEqual([]); }); - it('should do nothing when there is no committed lock above the crate', () => { - vi.mocked(findCargoLockfile).mockReturnValue(undefined); + it('should leave the lock untouched under dry-run', async () => { + bump({ alpha: '1.1.0' }); - const result = syncCargoLockfile(cargoToml); + await createGroupStrategy( + baseConfig({ dryRun: true, groups: { rust: { packages: ['alpha', 'beta'], sync: 'fixed' } } }), + )(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); - expect(result).toBeUndefined(); - expect(execFileSync).not.toHaveBeenCalled(); + expect(readLock()).toBe(renderLock({ alpha: '1.0.0', beta: '1.0.0' })); }); - it('should warn and return undefined when cargo is not on PATH', () => { - vi.mocked(findCargoLockfile).mockReturnValue(lockPath); - vi.mocked(execFileSync).mockImplementation(() => { - throw Object.assign(new Error('spawn cargo ENOENT'), { code: 'ENOENT' }); - }); + it('should not create a lock when none is committed', async () => { + fs.rmSync(lockPath); + bump({ alpha: '1.1.0' }); - const result = syncCargoLockfile(cargoToml); + await createAsyncStrategy(baseConfig())(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); - expect(result).toBeUndefined(); - expect(log).toHaveBeenCalledWith(expect.stringContaining('cargo not found on PATH'), 'warning'); + expect(fs.existsSync(lockPath)).toBe(false); + expect(fs.readFileSync(path.join(root, 'crates', 'alpha', 'Cargo.toml'), 'utf8')).toContain('version = "1.1.0"'); }); - it('should warn with the cargo error and return undefined when the refresh fails', () => { - vi.mocked(findCargoLockfile).mockReturnValue(lockPath); - vi.mocked(execFileSync).mockImplementation(() => { - throw Object.assign(new Error('exit 101'), { stderr: Buffer.from('error: version conflict') }); - }); + it('should warn and still bump Cargo.toml when the lock cannot be synced unambiguously', async () => { + // A registry copy of beta at the target version: cargo would have to add a source to beta's + // references, so the sync refuses to guess and leaves the lock for `cargo update` to fix. + const ambiguous = `${renderLock({ alpha: '1.0.0', beta: '1.0.0' }).replace(' "beta",', ' "beta 1.0.0",')}\n[[package]]\nname = "beta"\nversion = "2.0.0"\nsource = "${REGISTRY}"\n`; + fs.writeFileSync(lockPath, ambiguous); + bump({ beta: '2.0.0' }); - const result = syncCargoLockfile(cargoToml); + await createAsyncStrategy(baseConfig())(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); - expect(result).toBeUndefined(); - expect(log).toHaveBeenCalledWith(expect.stringContaining('error: version conflict'), 'warning'); + expect(readLock()).toBe(ambiguous); + expect(fs.readFileSync(path.join(root, 'crates', 'beta', 'Cargo.toml'), 'utf8')).toContain('version = "2.0.0"'); + expect(lockWarnings()).toEqual([expect.stringContaining('Failed to sync Cargo.lock')]); }); }); diff --git a/test/integration/cargo-lock-sync.spec.ts b/test/integration/cargo-lock-sync.spec.ts index b1970a17..ec81520d 100644 --- a/test/integration/cargo-lock-sync.spec.ts +++ b/test/integration/cargo-lock-sync.spec.ts @@ -1,13 +1,13 @@ /** - * Integration test: Cargo.lock self-version sync (#496) against REAL cargo. + * Integration test: Cargo.lock self-version sync (#496, #650) checked against REAL cargo. * - * Unit tests mock cargo, so they can't prove the load-bearing claim — that - * `cargo update --workspace --offline` syncs a workspace member's self-version in the lock WITHOUT - * re-resolving other members/dependencies. This runs real cargo on a real workspace to verify exactly - * that. Skipped automatically where cargo isn't on PATH (CI installs a Rust toolchain for this job). + * The version step edits the lock text directly instead of running cargo, so the load-bearing claim is + * that the edit is exactly the one cargo makes. This bumps a workspace member, syncs the lock with + * releasekit, and compares it byte-for-byte with what `cargo update --workspace` writes for the same + * bump. Skipped automatically where cargo isn't on PATH (CI installs a Rust toolchain for this job). */ import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { syncCargoLockfile } from '@releasekit/version'; @@ -22,58 +22,70 @@ function cargoAvailable(): boolean { } } -/** The version on a crate's `[[package]]` entry in a Cargo.lock. */ -function lockVersion(lockContent: string, crate: string): string | undefined { - const block = lockContent.split('[[package]]').find((b) => new RegExp(`^\\s*name = "${crate}"\\s*$`, 'm').test(b)); - return block?.match(/^\s*version = "([^"]+)"/m)?.[1]; +function writeCrate(dir: string, name: string, version: string, deps = ''): void { + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync( + join(dir, 'Cargo.toml'), + `[package]\nname = "${name}"\nversion = "${version}"\nedition = "2021"\n${deps}`, + ); + writeFileSync(join(dir, 'src', 'lib.rs'), '// test crate\n'); } describe.skipIf(!cargoAvailable())('syncCargoLockfile (real cargo)', () => { let dir: string; + let ws: string; + + // A private CARGO_HOME keeps the test hermetic: everything resolves from path and local git sources. + const cargo = (cwd: string, ...args: string[]): void => { + execFileSync('cargo', args, { cwd, stdio: 'pipe', env: { ...process.env, CARGO_HOME: join(dir, 'cargo-home') } }); + }; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'rk-cargo-lock-int-')); - // Two-member workspace: crate-a depends on crate-b by path. Bumping crate-a must move only its - // own lock entry — crate-b and the a→b dependency edge stay put (no dependency re-resolution). - writeFileSync(join(dir, 'Cargo.toml'), '[workspace]\nmembers = ["crate-a", "crate-b"]\nresolver = "2"\n'); - for (const [name, deps] of [ - ['crate-a', '\n[dependencies]\ncrate-b = { path = "../crate-b", version = "0.1.0" }\n'], - ['crate-b', ''], - ] as const) { - const crateDir = join(dir, name); - mkdirSync(join(crateDir, 'src'), { recursive: true }); - writeFileSync( - join(crateDir, 'Cargo.toml'), - `[package]\nname = "${name}"\nversion = "0.1.0"\nedition = "2021"\n${deps}`, - ); - writeFileSync(join(crateDir, 'src', 'lib.rs'), '// test crate\n'); - } - // Generate a real, valid committed lock at the stale versions (offline — only path deps). - execFileSync('cargo', ['generate-lockfile', '--offline'], { cwd: dir, stdio: 'ignore' }); + + // A git crate with the same name as a workspace member puts two versions of `crate-b` in the lock, + // so cargo spells out versions in references to it ("crate-b 0.1.0") — the encoding a bump must edit. + const gitCrate = join(dir, 'git-crate-b'); + writeCrate(gitCrate, 'crate-b', '0.0.9'); + const git = (...args: string[]) => execFileSync('git', args, { cwd: gitCrate, stdio: 'ignore' }); + git('init', '-q'); + git('add', '-A'); + git('-c', 'user.name=test', '-c', 'user.email=test@example.com', 'commit', '-qm', 'init'); + + ws = join(dir, 'ws'); + mkdirSync(ws); + writeFileSync(join(ws, 'Cargo.toml'), '[workspace]\nmembers = ["crate-a", "crate-b"]\nresolver = "2"\n'); + writeCrate( + join(ws, 'crate-a'), + 'crate-a', + '0.1.0', + `\n[dependencies]\ncrate-b = { path = "../crate-b" }\nold-b = { package = "crate-b", git = "file://${gitCrate}" }\n`, + ); + writeCrate(join(ws, 'crate-b'), 'crate-b', '0.1.0'); + cargo(ws, 'generate-lockfile'); }); afterEach(() => rmSync(dir, { recursive: true, force: true })); - it('should sync the bumped crate’s self-version in the workspace-root lock, leaving others untouched', () => { - // Bump crate-a's manifest (what the version step does before calling the sync). - writeFileSync( - join(dir, 'crate-a', 'Cargo.toml'), - '[package]\nname = "crate-a"\nversion = "0.2.0"\nedition = "2021"\n\n[dependencies]\ncrate-b = { path = "../crate-b", version = "0.1.0" }\n', - ); + it.each(['crate-a', 'crate-b'])('should write the same lock cargo writes when %s is bumped', (crate) => { + const expected = join(dir, 'expected'); + cpSync(ws, expected, { recursive: true }); + for (const root of [ws, expected]) { + const manifest = join(root, crate, 'Cargo.toml'); + writeFileSync(manifest, readFileSync(manifest, 'utf-8').replace('version = "0.1.0"', 'version = "0.2.0"')); + } + cargo(expected, 'update', '--workspace', '--offline'); - const lockPath = syncCargoLockfile(join(dir, 'crate-a', 'Cargo.toml')); + const lockPath = syncCargoLockfile(join(ws, crate, 'Cargo.toml')); - expect(lockPath).toBe(join(dir, 'Cargo.lock')); - const lock = readFileSync(join(dir, 'Cargo.lock'), 'utf-8'); - expect(lockVersion(lock, 'crate-a')).toBe('0.2.0'); // self-version synced - expect(lockVersion(lock, 'crate-b')).toBe('0.1.0'); // other member untouched - expect(lock).toContain('crate-b'); // a→b dependency edge preserved (no re-resolution) + expect(lockPath).toBe(join(ws, 'Cargo.lock')); + expect(readFileSync(lockPath as string, 'utf-8')).toBe(readFileSync(join(expected, 'Cargo.lock'), 'utf-8')); }); it('should not create a lock when none is committed', () => { - rmSync(join(dir, 'Cargo.lock')); - const lockPath = syncCargoLockfile(join(dir, 'crate-a', 'Cargo.toml')); + rmSync(join(ws, 'Cargo.lock')); + const lockPath = syncCargoLockfile(join(ws, 'crate-a', 'Cargo.toml')); expect(lockPath).toBeUndefined(); - expect(() => readFileSync(join(dir, 'Cargo.lock'))).toThrow(); + expect(() => readFileSync(join(ws, 'Cargo.lock'))).toThrow(); }); }); From 0254166c66c8330bc6ce4ef72e80cb366577aa42 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 17:44:14 +0000 Subject: [PATCH 02/18] fix(version): sync every committed Cargo.lock that records a bumped crate #650 now asks that no committed Cargo.lock needs a follow-up sync PR after a release. - Locks that use a crate through a path dependency are synced too: for each bumped crate, every Cargo.lock tracked by git with a sourceless entry of that name is updated, leaving registry and git entries of the name alone. The crate's own lock is still synced even when untracked. The tracked locks are listed once per run through the git seam (new Git.lsFiles). - updateCargoVersion records each bump and VersionEngine.run syncs the locks once the strategy is done, so every strategy is covered, a lock shared by several crates is rewritten once, and every edit is worked out before the first write. - A lock that can't be synced fails the run with CargoLockSyncError (CARGO_LOCK_SYNC_ERROR, VERSION_ERROR exit family) instead of warning. A dry run works out the same edits, so it fails too, and records them as pending writes that release's flush writes with the manifests. - Publish's prepare stage syncs and stages every such lock, so the direct-commit flow commits fixture locks as well. It stays non-fatal: in the standing-PR flow it runs after the release PR has merged. - The real-cargo integration test runs `releasekit version` with no cargo on PATH and an empty CARGO_HOME for version.groups, the per-package processor and updateCargoFiles, and checks that `cargo metadata --locked` accepts every such lock unchanged. The syncCargoLockfile export of @releasekit/version (added for the integration test in #498) is removed: the sync is now per run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/rust.md | 5 + packages/core/src/cargo.ts | 105 +++++-- packages/core/src/envelope.ts | 1 + packages/core/src/index.ts | 9 +- packages/core/test/unit/cargo.spec.ts | 126 ++++++-- packages/git/src/fake.ts | 9 + packages/git/src/git.ts | 10 + packages/git/src/index.ts | 1 + packages/git/src/types.ts | 9 + packages/git/test/unit/fake.spec.ts | 7 + packages/git/test/unit/git.spec.ts | 10 + packages/publish/src/stages/prepare.ts | 25 +- packages/publish/src/utils/cargo.ts | 48 +-- .../publish/test/unit/stages/prepare.spec.ts | 65 ++++ packages/version/src/cargo/cargoHandler.ts | 10 +- packages/version/src/cargo/cargoLock.ts | 83 +++--- packages/version/src/core/versionEngine.ts | 5 + .../version/src/errors/cargoLockSyncError.ts | 14 + packages/version/src/index.ts | 1 - .../test/unit/cargo/cargoHandler.spec.ts | 16 +- .../version/test/unit/cargo/cargoLock.spec.ts | 279 ++++++++++++------ test/integration/cargo-lock-sync.spec.ts | 206 ++++++++++--- 22 files changed, 772 insertions(+), 272 deletions(-) create mode 100644 packages/version/src/errors/cargoLockSyncError.ts diff --git a/docs/rust.md b/docs/rust.md index 3aefbcd2..49b0f486 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -9,6 +9,7 @@ See [configuration.md](./configuration.md) for the full config reference. ## What's supported - `Cargo.toml` version bumping driven by conventional commits +- `Cargo.lock` sync on every bump: each committed lock that lists a bumped crate as a path package (the crate's own lock, and those of apps that use it by path) is updated in the same release commit, without running cargo - Per-crate git tags (via `version.packageSpecificTags`) - Publishing to crates.io with `cargo publish` - Workspace dependency ordering via topological sort (Kahn's algorithm) so path-dependent crates are published in the correct sequence @@ -207,6 +208,10 @@ jobs: ## Edge cases and troubleshooting +**`Failed to sync Cargo.lock: can't set to in `** + +releasekit edits each lock's text the way cargo would, and fails the run rather than leave a lock behind. The edit is refused when that lock also lists the crate from a registry or git source at the old or the new version, because cargo would then have to change how it tells the two apart. Sync that lock with cargo yourself (`cargo update` with the new version in `Cargo.toml`) and commit it, or drop the second source of the crate. + **`error: failed to verify package tarball`** `cargo publish` runs a pre-publish verification step that builds the crate from the packaged tarball. This can fail in some environments (missing system libraries, conflicting feature flags, etc.). As a workaround, set `publish.cargo.noVerify: true` to pass `--no-verify` — but note that this skips all pre-publish checks, so it is best treated as a temporary measure rather than a permanent setting. diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index bd2c2d36..523b72de 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -113,7 +113,7 @@ export function setCargoLockVersion(lockContent: string, crateName: string, vers return undefined; } if (own.length > 1) { - throw new Error(`Cargo.lock has ${own.length} path entries named ${crateName}`); + throw new Error(`it has ${own.length} path entries named ${crateName}`); } const [entry] = own; @@ -122,43 +122,90 @@ export function setCargoLockVersion(lockContent: string, crateName: string, vers } const clash = sameName.find((other) => other !== entry && [entry.version, version].includes(other.version)); if (clash) { - throw new Error( - `Cargo.lock also has ${crateName} ${clash.version} from another source; run \`cargo update --workspace\` to sync it`, - ); + throw new Error(`it also has ${crateName} ${clash.version} from another source`); } lines[entry.versionLine] = lines[entry.versionLine].replace(`"${entry.version}"`, `"${version}"`); return lines.join('\n').replaceAll(`"${crateName} ${entry.version}"`, `"${crateName} ${version}"`); } +/** The part of `@releasekit/git`'s `Git` seam that lists tracked files (core doesn't depend on it). */ +export interface TrackedFileLister { + isRepository(cwd?: string): Promise; + lsFiles(opts?: { paths?: string[]; cwd?: string }): Promise; +} + /** - * Sync the crate's own entry in the committed Cargo.lock that governs `crateDir` (see - * {@link findCargoLockfile}) to `version`, by editing the lock text directly with - * {@link setCargoLockVersion}. Nothing runs cargo, so this needs no Rust toolchain, registry index or - * network. Writes nothing under `dryRun`. - * - * Returns the lock path and whether its content changed, or `undefined` when no committed lock lists - * the crate as a path package. Throws when the lock can't be read or written, or can't be edited - * unambiguously. + * Every Cargo.lock tracked by git anywhere in the repository containing `cwd`, as absolute paths — + * empty outside a git repository. Untracked and ignored locks are left out. */ -export function syncCargoLockVersion( - crateDir: string, - crateName: string, - version: string, - dryRun = false, -): { lockPath: string; changed: boolean } | undefined { - const lockPath = findCargoLockfile(crateDir); - if (!lockPath) { - return undefined; +export async function findCommittedCargoLockfiles(git: TrackedFileLister, cwd: string): Promise { + if (!(await git.isRepository(cwd))) { + return []; } - const content = fs.readFileSync(lockPath, 'utf8'); - const updated = setCargoLockVersion(content, crateName, version); - if (updated === undefined) { - return undefined; + const tracked = await git.lsFiles({ paths: [':(top,glob)**/Cargo.lock'], cwd }); + return tracked.map((lockPath) => path.resolve(cwd, lockPath)); +} + +/** A crate whose new version the Cargo.lock files that record it must follow. */ +export interface CargoLockBump { + cargoTomlPath: string; + name: string; + version: string; +} + +/** A Cargo.lock that lists at least one bumped crate as a path package, with the content it must have. */ +export interface CargoLockUpdate { + lockPath: string; + content: string; + changed: boolean; +} + +/** + * Work out the content of every Cargo.lock that records a bumped crate, by editing the lock text with + * {@link setCargoLockVersion} — nothing runs cargo, so no Rust toolchain, registry index or network is + * needed. The candidates are `committedLocks` (every tracked lock, which covers apps that use a crate + * through a path dependency) plus each crate's own governing lock ({@link findCargoLockfile}), which is + * synced even when it isn't tracked. A lock gets every bump it lists, so a lock shared by several bumped + * crates is rewritten once; locks that list none are left out. + * + * Nothing is written: the caller writes the changed content. Throws, naming the lock, when a lock can't + * be read or edited unambiguously, so no lock is written unless every lock can be. + */ +export function planCargoLockUpdates(bumps: CargoLockBump[], committedLocks: string[]): CargoLockUpdate[] { + const lockPaths = new Set(committedLocks.map((lockPath) => path.resolve(lockPath))); + for (const bump of bumps) { + const ownLock = findCargoLockfile(path.dirname(bump.cargoTomlPath)); + if (ownLock) { + lockPaths.add(ownLock); + } } - const changed = updated !== content; - if (changed && !dryRun) { - fs.writeFileSync(lockPath, updated); + + const updates: CargoLockUpdate[] = []; + for (const lockPath of lockPaths) { + // Tracked but deleted in the work tree: nothing to sync. + if (!fs.existsSync(lockPath)) { + continue; + } + const original = fs.readFileSync(lockPath, 'utf8'); + let content = original; + let listed = false; + for (const bump of bumps) { + let next: string | undefined; + try { + next = setCargoLockVersion(content, bump.name, bump.version); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`can't set ${bump.name} to ${bump.version} in ${lockPath}: ${detail}`); + } + if (next !== undefined) { + listed = true; + content = next; + } + } + if (listed) { + updates.push({ lockPath, content, changed: content !== original }); + } } - return { lockPath, changed }; + return updates; } diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 2e1cb23c..73b61973 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -73,6 +73,7 @@ const CODE_TO_EXIT: Record = { GIT_ERROR: EXIT_CODES.GIT_ERROR, VERSION_ERROR: EXIT_CODES.VERSION_ERROR, PUBLISH_ERROR: EXIT_CODES.PUBLISH_ERROR, + CARGO_LOCK_SYNC_ERROR: EXIT_CODES.VERSION_ERROR, }; /** Map any thrown value to its process exit code, defaulting to GENERAL_ERROR. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f0cd485f..5ed52ce7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,11 @@ -export { findCargoLockfile, syncCargoLockVersion } from './cargo.js'; +export { + type CargoLockBump, + type CargoLockUpdate, + findCargoLockfile, + findCommittedCargoLockfiles, + planCargoLockUpdates, + type TrackedFileLister, +} from './cargo.js'; export { type ChangelogRefsMode, escapeChangelogMentions, diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index 8b58e9cd..554d4972 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -1,8 +1,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { findCargoLockfile, setCargoLockVersion, syncCargoLockVersion } from '../../src/cargo.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + findCargoLockfile, + findCommittedCargoLockfiles, + planCargoLockUpdates, + setCargoLockVersion, +} from '../../src/cargo.js'; describe('findCargoLockfile', () => { const tmpDirs: string[] = []; @@ -227,18 +232,37 @@ describe('setCargoLockVersion', () => { }); }); -describe('syncCargoLockVersion', () => { +describe('planCargoLockUpdates', () => { const tmpDirs: string[] = []; - function workspaceWithLock(): { root: string; member: string; lockPath: string } { - const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-sync-'))); + /** A workspace whose root lock lists `core`, plus a separate app whose own lock uses `core` by path. */ + function repoWithLocks(): { root: string; manifest: string; ownLock: string; appLock: string } { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-plan-'))); tmpDirs.push(root); - fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["crates/core"]\n'); + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["crates/*"]\n'); const member = path.join(root, 'crates', 'core'); fs.mkdirSync(member, { recursive: true }); - const lockPath = path.join(root, 'Cargo.lock'); - fs.writeFileSync(lockPath, lock('version = 4', '', '[[package]]', 'name = "core"', 'version = "1.4.0"')); - return { root, member, lockPath }; + const manifest = path.join(member, 'Cargo.toml'); + fs.writeFileSync(manifest, '[package]\nname = "core"\nversion = "1.5.0"\n'); + const ownLock = path.join(root, 'Cargo.lock'); + fs.writeFileSync(ownLock, lock('version = 4', '', '[[package]]', 'name = "core"', 'version = "1.4.0"')); + fs.mkdirSync(path.join(root, 'app')); + const appLock = path.join(root, 'app', 'Cargo.lock'); + fs.writeFileSync( + appLock, + lock( + 'version = 4', + '', + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + '', + '[[package]]', + 'name = "core"', + 'version = "1.4.0"', + ), + ); + return { root, manifest, ownLock, appLock }; } afterEach(() => { @@ -246,23 +270,85 @@ describe('syncCargoLockVersion', () => { tmpDirs.length = 0; }); - it('should write the bumped entry to the workspace-root lock', () => { - const { member, lockPath } = workspaceWithLock(); + it('should plan the crate’s own lock and every committed lock that lists it, writing nothing', () => { + const { root, manifest, ownLock, appLock } = repoWithLocks(); + const unrelated = path.join(root, 'other.lock'); + fs.writeFileSync(unrelated, lock('version = 4', '', '[[package]]', 'name = "other"', 'version = "1.0.0"')); + + const updates = planCargoLockUpdates( + [{ cargoTomlPath: manifest, name: 'core', version: '1.5.0' }], + [appLock, unrelated], + ); + + expect(updates).toEqual([ + { lockPath: appLock, content: fs.readFileSync(appLock, 'utf8').replace('1.4.0', '1.5.0'), changed: true }, + { lockPath: ownLock, content: fs.readFileSync(ownLock, 'utf8').replace('1.4.0', '1.5.0'), changed: true }, + ]); + expect(fs.readFileSync(ownLock, 'utf8')).toContain('version = "1.4.0"'); + }); + + it('should apply every bump a shared lock lists in one update', () => { + const { root, manifest, appLock } = repoWithLocks(); + + const updates = planCargoLockUpdates( + [ + { cargoTomlPath: path.join(root, 'app', 'Cargo.toml'), name: 'app', version: '0.2.0' }, + { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }, + ], + [appLock], + ); - expect(syncCargoLockVersion(member, 'core', '1.5.0')).toEqual({ lockPath, changed: true }); - expect(fs.readFileSync(lockPath, 'utf8')).toContain('version = "1.5.0"'); + expect(updates.find((update) => update.lockPath === appLock)?.content).toBe( + lock( + 'version = 4', + '', + '[[package]]', + 'name = "app"', + 'version = "0.2.0"', + '', + '[[package]]', + 'name = "core"', + 'version = "1.5.0"', + ), + ); }); - it('should report the change without writing under dry-run', () => { - const { member, lockPath } = workspaceWithLock(); + it('should report a lock already at the new version as unchanged, and skip a tracked lock missing from disk', () => { + const { root, manifest, ownLock } = repoWithLocks(); + fs.writeFileSync(ownLock, lock('version = 4', '', '[[package]]', 'name = "core"', 'version = "1.5.0"')); + + const updates = planCargoLockUpdates( + [{ cargoTomlPath: manifest, name: 'core', version: '1.5.0' }], + [path.join(root, 'deleted', 'Cargo.lock')], + ); + + expect(updates).toEqual([{ lockPath: ownLock, content: fs.readFileSync(ownLock, 'utf8'), changed: false }]); + }); + + it('should name the lock when an edit cannot be made unambiguously', () => { + const { manifest, appLock } = repoWithLocks(); + fs.appendFileSync(appLock, lock('', '[[package]]', 'name = "core"', 'version = "1.5.0"', `source = "${REGISTRY}"`)); + + expect(() => + planCargoLockUpdates([{ cargoTomlPath: manifest, name: 'core', version: '1.5.0' }], [appLock]), + ).toThrow(`can't set core to 1.5.0 in ${appLock}: it also has core 1.5.0 from another source`); + }); +}); + +describe('findCommittedCargoLockfiles', () => { + it('should list tracked locks across the whole repository as absolute paths', async () => { + const lsFiles = vi.fn(async () => ['../Cargo.lock', 'app/Cargo.lock']); + + const locks = await findCommittedCargoLockfiles({ isRepository: async () => true, lsFiles }, '/repo/crates'); - expect(syncCargoLockVersion(member, 'core', '1.5.0', true)).toEqual({ lockPath, changed: true }); - expect(fs.readFileSync(lockPath, 'utf8')).toContain('version = "1.4.0"'); + expect(locks).toEqual(['/repo/Cargo.lock', '/repo/crates/app/Cargo.lock']); + expect(lsFiles).toHaveBeenCalledWith({ paths: [':(top,glob)**/Cargo.lock'], cwd: '/repo/crates' }); }); - it('should return undefined when no committed lock lists the crate', () => { - const { member } = workspaceWithLock(); + it('should return no locks outside a git repository', async () => { + const lsFiles = vi.fn(async () => []); - expect(syncCargoLockVersion(member, 'other', '1.5.0')).toBeUndefined(); + expect(await findCommittedCargoLockfiles({ isRepository: async () => false, lsFiles }, '/tmp/x')).toEqual([]); + expect(lsFiles).not.toHaveBeenCalled(); }); }); diff --git a/packages/git/src/fake.ts b/packages/git/src/fake.ts index f0fccb7c..5acdd867 100644 --- a/packages/git/src/fake.ts +++ b/packages/git/src/fake.ts @@ -7,6 +7,7 @@ import type { GitForEachRefOptions, GitListTagsOptions, GitLogOptions, + GitLsFilesOptions, GitPushOptions, GitStatusOptions, GitTagOptions, @@ -73,6 +74,8 @@ export interface FakeGitSeed { commitCounts?: Record; /** Working-tree status text returned by `status`. Defaults to empty (clean). */ status?: string; + /** Paths returned by `lsFiles` (the seam doesn't match pathspecs, so callers seed final paths). */ + trackedFiles?: string[]; } function ancestryHas(ancestors: FakeGitSeed['ancestors'], ancestor: string, ref: string): boolean { @@ -102,6 +105,7 @@ export class FakeGit implements Git { private readonly refLines: string[]; private readonly commitCounts: Record; private statusText: string; + private readonly trackedFiles: string[]; // Recorded mutations, for assertions. readonly added: string[][] = []; @@ -131,6 +135,7 @@ export class FakeGit implements Git { this.refLines = [...(seed.refLines ?? [])]; this.commitCounts = seed.commitCounts ?? {}; this.statusText = seed.status ?? ''; + this.trackedFiles = [...(seed.trackedFiles ?? [])]; } // — Queries — @@ -186,6 +191,10 @@ export class FakeGit implements Git { return [...(this.diffNames[commitSha] ?? [])]; } + async lsFiles(_opts: GitLsFilesOptions = {}): Promise { + return [...this.trackedFiles]; + } + async forEachRef(_opts: GitForEachRefOptions): Promise { return [...this.refLines]; } diff --git a/packages/git/src/git.ts b/packages/git/src/git.ts index 468ffac4..41e60cc2 100644 --- a/packages/git/src/git.ts +++ b/packages/git/src/git.ts @@ -10,6 +10,7 @@ import type { GitForEachRefOptions, GitListTagsOptions, GitLogOptions, + GitLsFilesOptions, GitPushOptions, GitStatusOptions, GitTagOptions, @@ -227,6 +228,15 @@ export class GitCli implements Git { return splitLines(stdout); } + async lsFiles(opts: GitLsFilesOptions = {}): Promise { + const args = ['ls-files', '-z']; + if (opts.paths && opts.paths.length > 0) { + args.push('--', ...opts.paths); + } + const { stdout } = await this.exec(args, { cwd: this.cwd(opts.cwd) }); + return stdout.split('\0').filter((file) => file.length > 0); + } + async forEachRef(opts: GitForEachRefOptions): Promise { // `format`/`sort` are flag values by design; `pattern` is a positional ref, so guard it like // every other user-controlled positional (a leading-`-` pattern would be read as an option). diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index da3454c2..cca06dd8 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -18,6 +18,7 @@ export type { GitForEachRefOptions, GitListTagsOptions, GitLogOptions, + GitLsFilesOptions, GitPushOptions, GitStatusOptions, GitTagOptions, diff --git a/packages/git/src/types.ts b/packages/git/src/types.ts index bfbf9f00..864332c5 100644 --- a/packages/git/src/types.ts +++ b/packages/git/src/types.ts @@ -98,6 +98,13 @@ export interface GitForEachRefOptions { cwd?: string; } +/** Options for {@link Git.lsFiles}. */ +export interface GitLsFilesOptions { + /** Pathspecs to match (passed after `--`); magic such as `:(top,glob)` is allowed. */ + paths?: string[]; + cwd?: string; +} + /** Options for {@link Git.status}. */ export interface GitStatusOptions { /** Use `--porcelain` machine-readable output. */ @@ -131,6 +138,8 @@ export interface Git { log(opts: GitLogOptions): Promise; /** File paths changed by `commitSha` (`diff-tree --no-commit-id --name-only -r`). */ diffTreeNames(commitSha: string, cwd?: string): Promise; + /** Tracked file paths matching `paths` (`ls-files -z`), relative to `cwd` as git prints them. */ + lsFiles(opts?: GitLsFilesOptions): Promise; /** One formatted line per matching ref (`for-each-ref`), empties dropped. */ forEachRef(opts: GitForEachRefOptions): Promise; /** Whether `branch` exists on `remote` (`ls-remote --exit-code --heads`). */ diff --git a/packages/git/test/unit/fake.spec.ts b/packages/git/test/unit/fake.spec.ts index 027dd04b..22a4f343 100644 --- a/packages/git/test/unit/fake.spec.ts +++ b/packages/git/test/unit/fake.spec.ts @@ -77,6 +77,13 @@ describe('FakeGit queries', () => { expect(await git.diffTreeNames('sha2')).toEqual([]); }); + it('should return seeded tracked files', async () => { + expect(await createFakeGit({ trackedFiles: ['Cargo.lock'] }).lsFiles({ paths: ['*.lock'] })).toEqual([ + 'Cargo.lock', + ]); + expect(await createFakeGit().lsFiles()).toEqual([]); + }); + it('should return seeded for-each-ref lines', async () => { const git = createFakeGit({ refLines: ['v1 sha1', 'v2 sha2'] }); expect(await git.forEachRef({ format: '%(refname)' })).toEqual(['v1 sha1', 'v2 sha2']); diff --git a/packages/git/test/unit/git.spec.ts b/packages/git/test/unit/git.spec.ts index edff51cc..5690e5cf 100644 --- a/packages/git/test/unit/git.spec.ts +++ b/packages/git/test/unit/git.spec.ts @@ -163,6 +163,16 @@ describe('GitCli queries', () => { expect(calls[0].args).toEqual(['diff-tree', '--no-commit-id', '--name-only', '-r', 'sha1']); }); + it('should list tracked files NUL-separated, passing pathspecs after --', async () => { + const { run, calls } = makeRunner({ stdout: '../Cargo.lock\0app/Cargo.lock\0' }); + expect(await new GitCli(run).lsFiles({ paths: [':(top,glob)**/Cargo.lock'], cwd: '/r/crates' })).toEqual([ + '../Cargo.lock', + 'app/Cargo.lock', + ]); + expect(calls[0].args).toEqual(['ls-files', '-z', '--', ':(top,glob)**/Cargo.lock']); + expect(calls[0].options.cwd).toBe('/r/crates'); + }); + it('should run for-each-ref with format, and append sort/pattern when given', async () => { const { run, calls } = makeRunner({ stdout: 'v1 sha1\nv2 sha2\n' }); expect( diff --git a/packages/publish/src/stages/prepare.ts b/packages/publish/src/stages/prepare.ts index 65a4cfc1..31aaf360 100644 --- a/packages/publish/src/stages/prepare.ts +++ b/packages/publish/src/stages/prepare.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import { debug, info, isPathWithinRoot } from '@releasekit/core'; import { createPublishError, PublishErrorCode } from '../errors/index.js'; import type { PipelineContext } from '../types.js'; -import { syncCargoLockfile, updateCargoVersion } from '../utils/cargo.js'; +import { syncCargoLockfiles, updateCargoVersion } from '../utils/cargo.js'; export async function runPrepareStage(ctx: PipelineContext): Promise { const { input, config, cliOptions, cwd } = ctx; @@ -57,12 +57,12 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { // Update Cargo.toml versions for cargo packages if (config.cargo.enabled) { - // Cargo.lock files to stage alongside the manifests, deduped — crates in one workspace - // share a single workspace-root lock. We re-sync the lock here (rather than trust the version - // step) so the direct-commit flow — git-commit stages an explicit file list, not `git add -A` — - // is self-sufficient and correct even run in isolation. The standing-PR flow stages the lock via - // its own `git add -A`, with git-commit skipped, so this re-sync is a harmless no-op there. - const lockfiles = new Set(); + // Cargo.lock files to stage alongside the manifests. We re-sync the locks here (rather than trust + // the version step) so the direct-commit flow — git-commit stages an explicit file list, not + // `git add -A` — commits every lock that records a bumped crate, fixture apps' locks included, and + // is correct even run in isolation. The standing-PR flow stages the locks via its own `git add -A`, + // with git-commit skipped, so this re-sync is a harmless no-op there. + const bumps: Array<{ cargoTomlPath: string; version: string }> = []; for (const update of input.updates) { const pkgDir = path.dirname(path.resolve(cwd, update.filePath)); const cargoPath = path.join(pkgDir, 'Cargo.toml'); @@ -78,15 +78,12 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { updateCargoVersion(cargoPath, update.newVersion); debug(`Updated ${cargoPath} to version ${update.newVersion}`); - - const lockPath = syncCargoLockfile(pkgDir); - if (lockPath) lockfiles.add(lockPath); + bumps.push({ cargoTomlPath: cargoPath, version: update.newVersion }); } - if (lockfiles.size > 0) { - const existing = new Set(ctx.additionalFiles ?? []); - for (const lock of lockfiles) existing.add(lock); - ctx.additionalFiles = [...existing]; + const lockfiles = await syncCargoLockfiles(cwd, bumps); + if (lockfiles.length > 0) { + ctx.additionalFiles = [...new Set([...(ctx.additionalFiles ?? []), ...lockfiles])]; } } } diff --git a/packages/publish/src/utils/cargo.ts b/packages/publish/src/utils/cargo.ts index 79ef1c08..0360087e 100644 --- a/packages/publish/src/utils/cargo.ts +++ b/packages/publish/src/utils/cargo.ts @@ -1,8 +1,8 @@ import * as fs from 'node:fs'; -import * as path from 'node:path'; import type { CargoManifest } from '@releasekit/config'; import { parseCargoToml } from '@releasekit/config'; -import { syncCargoLockVersion, warn } from '@releasekit/core'; +import { findCommittedCargoLockfiles, planCargoLockUpdates, warn } from '@releasekit/core'; +import { createGitCli, type Git } from '@releasekit/git'; import * as TOML from 'smol-toml'; import { createPublishError, PublishErrorCode } from '../errors/index.js'; @@ -10,28 +10,40 @@ export type { CargoManifest }; export { parseCargoToml }; /** - * Sync a crate's own entry in its committed `Cargo.lock` to the version now in its `Cargo.toml`, after - * the manifest was bumped, so the committed lock doesn't drift. Uses the same direct lock edit as the - * version step (core's `syncCargoLockVersion`), so no Rust toolchain is needed. + * Sync every Cargo.lock that records a crate this publish bumped — its own lock and the tracked locks + * of apps that use it through a path dependency — with the same direct lock edit as the version step + * (core's `planCargoLockUpdates`), so no Rust toolchain is needed. * - * Returns the lock path (so the caller can stage it) or `undefined` when no committed lock lists the - * crate, or the sync fails. A failure is logged, never thrown: a lockfile fix-up must not abort a publish - * that would otherwise succeed. This mirrors the version step's sync (which covers the standing-PR - * flow's `git add -A`); doing it here too keeps the direct-commit flow self-sufficient rather than - * depending on the version step having run and succeeded first. + * Returns every lock that lists a bumped crate, changed or not, so the caller stages all of them: the + * version step has usually written them already, and they still belong in the release commit. A + * failure is logged, never thrown. In the standing-PR flow this runs after the release PR has merged, + * and a lock fix-up must not strand a release that is already on the base branch; an unsyncable lock + * fails the version step instead, before anything is committed. */ -export function syncCargoLockfile(crateDir: string): string | undefined { - const cargoPath = path.join(crateDir, 'Cargo.toml'); +export async function syncCargoLockfiles( + cwd: string, + bumps: Array<{ cargoTomlPath: string; version: string }>, + git: Git = createGitCli(), +): Promise { + if (bumps.length === 0) { + return []; + } try { - const { name, version } = parseCargoToml(cargoPath).package ?? {}; - if (!name || typeof version !== 'string') { - return undefined; + const lockBumps = bumps.flatMap(({ cargoTomlPath, version }) => { + const name = parseCargoToml(cargoTomlPath).package?.name; + return name ? [{ cargoTomlPath, name, version }] : []; + }); + const updates = planCargoLockUpdates(lockBumps, await findCommittedCargoLockfiles(git, cwd)); + for (const { lockPath, content, changed } of updates) { + if (changed) { + fs.writeFileSync(lockPath, content); + } } - return syncCargoLockVersion(crateDir, name, version)?.lockPath; + return updates.map(({ lockPath }) => lockPath); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - warn(`Failed to sync Cargo.lock for ${cargoPath}: ${detail}. The committed lock may drift from the bump.`); - return undefined; + warn(`Failed to sync Cargo.lock files: ${detail}. Committed locks may drift from the bump.`); + return []; } } diff --git a/packages/publish/test/unit/stages/prepare.spec.ts b/packages/publish/test/unit/stages/prepare.spec.ts index b8450673..0b96a0fa 100644 --- a/packages/publish/test/unit/stages/prepare.spec.ts +++ b/packages/publish/test/unit/stages/prepare.spec.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -169,6 +170,70 @@ describe('prepare stage', () => { expect(ctx.additionalFiles).toContain(lockPath); }); + it('should sync and stage a committed fixture-app lock that uses the crate by path', async () => { + const dir = createTmpDir(); + const crateDir = path.join(dir, 'crates', 'foo'); + fs.mkdirSync(crateDir, { recursive: true }); + fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); + const ownLock = path.join(crateDir, 'Cargo.lock'); + fs.writeFileSync(ownLock, fooLock('1.0.0')); + const appLock = path.join(dir, 'fixtures', 'app', 'Cargo.lock'); + fs.mkdirSync(path.dirname(appLock), { recursive: true }); + fs.writeFileSync(appLock, `${fooLock('1.0.0')}\n[[package]]\nname = "app"\nversion = "0.1.0"\n`); + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + + const config = getDefaultConfig(); + config.cargo.enabled = true; + const ctx = createContext({ + cwd: dir, + config, + input: { + dryRun: false, + updates: [{ packageName: 'foo', newVersion: '1.1.0', filePath: 'crates/foo/Cargo.toml' }], + changelogs: [], + tags: [], + }, + }); + + await runPrepareStage(ctx); + + expect(fs.readFileSync(ownLock, 'utf-8')).toBe(fooLock('1.1.0')); + expect(fs.readFileSync(appLock, 'utf-8')).toBe( + `${fooLock('1.1.0')}\n[[package]]\nname = "app"\nversion = "0.1.0"\n`, + ); + expect(ctx.additionalFiles).toEqual(expect.arrayContaining([ownLock, appLock])); + }); + + it('should not fail the publish when a lock cannot be synced', async () => { + const dir = createTmpDir(); + const crateDir = path.join(dir, 'crates', 'foo'); + fs.mkdirSync(crateDir, { recursive: true }); + fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); + const lockPath = path.join(crateDir, 'Cargo.lock'); + const ambiguous = `${fooLock('1.0.0')}\n[[package]]\nname = "foo"\nversion = "1.1.0"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\n`; + fs.writeFileSync(lockPath, ambiguous); + + const config = getDefaultConfig(); + config.cargo.enabled = true; + const ctx = createContext({ + cwd: dir, + config, + input: { + dryRun: false, + updates: [{ packageName: 'foo', newVersion: '1.1.0', filePath: 'crates/foo/Cargo.toml' }], + changelogs: [], + tags: [], + }, + }); + + await runPrepareStage(ctx); + + expect(fs.readFileSync(path.join(crateDir, 'Cargo.toml'), 'utf-8')).toContain('version = "1.1.0"'); + expect(fs.readFileSync(lockPath, 'utf-8')).toBe(ambiguous); + expect(ctx.additionalFiles ?? []).not.toContain(lockPath); + }); + it('should still stage a Cargo.lock the version step already synced', async () => { const dir = createTmpDir(); const crateDir = path.join(dir, 'crates', 'foo'); diff --git a/packages/version/src/cargo/cargoHandler.ts b/packages/version/src/cargo/cargoHandler.ts index ee8e2deb..39d1668a 100644 --- a/packages/version/src/cargo/cargoHandler.ts +++ b/packages/version/src/cargo/cargoHandler.ts @@ -9,7 +9,7 @@ import { isCargoToml, parseCargoToml } from '@releasekit/config'; import * as TOML from 'smol-toml'; import { addPackageUpdate, recordPendingWrite } from '../utils/jsonOutput.js'; import { log } from '../utils/logging.js'; -import { syncCargoLockfile } from './cargoLock.js'; +import { recordCargoBump } from './cargoLock.js'; export { isCargoToml }; @@ -60,8 +60,9 @@ export function getCargoInfo(cargoPath: string): CargoInfo { * Update a Cargo.toml file with a new version * Preserves comments and formatting as much as possible * - * Then syncs the crate's own entry in its committed Cargo.lock. Every version-step Cargo.toml bump - * goes through here, so no strategy can bump a crate and leave its lock behind. + * Also records the bump for the Cargo.lock sync that `VersionEngine.run` makes once the strategy is + * done. Every version-step Cargo.toml bump goes through here, so no strategy can bump a crate and + * leave the locks that record it behind. */ export function updateCargoVersion(cargoPath: string, version: string, dryRun = false): void { try { @@ -90,6 +91,7 @@ export function updateCargoVersion(cargoPath: string, version: string, dryRun = // Track update for JSON output addPackageUpdate(packageName, version, cargoPath); + recordCargoBump({ cargoTomlPath: cargoPath, name: packageName, version }); log(`${dryRun ? '[DRY RUN] Would update' : 'Updated'} Cargo.toml at ${cargoPath} to version ${version}`, 'success'); } catch (error) { @@ -99,6 +101,4 @@ export function updateCargoVersion(cargoPath: string, version: string, dryRun = } throw error; } - - syncCargoLockfile(cargoPath, dryRun); } diff --git a/packages/version/src/cargo/cargoLock.ts b/packages/version/src/cargo/cargoLock.ts index 9ea286dc..8bdc7ff6 100644 --- a/packages/version/src/cargo/cargoLock.ts +++ b/packages/version/src/cargo/cargoLock.ts @@ -1,50 +1,57 @@ -import path from 'node:path'; -import { parseCargoToml } from '@releasekit/config'; -import { findCargoLockfile, syncCargoLockVersion } from '@releasekit/core'; +import fs from 'node:fs'; +import { type CargoLockBump, findCommittedCargoLockfiles, planCargoLockUpdates } from '@releasekit/core'; +import { createGitCli, type Git } from '@releasekit/git'; +import { CargoLockSyncError } from '../errors/cargoLockSyncError.js'; +import { recordPendingWrite } from '../utils/jsonOutput.js'; import { log } from '../utils/logging.js'; +const recordedBumps: CargoLockBump[] = []; + +/** Note a `Cargo.toml` version bump for the Cargo.lock sync at the end of the run. */ +export function recordCargoBump(bump: CargoLockBump): void { + recordedBumps.push(bump); +} + +/** Drop bumps recorded outside a version run, so a run never syncs another run's bumps. */ +export function clearCargoBumps(): void { + recordedBumps.length = 0; +} + /** - * Sync a crate's own `[[package]]` entry in its committed `Cargo.lock` to the version now in its - * `Cargo.toml`, so the lock doesn't drift from the manifest (which breaks `cargo build --locked`/ - * `--frozen` and leaks a spurious lock diff into later builds). The edit is core's - * `syncCargoLockVersion`, which never runs cargo. We never create a lock that wasn't already committed. + * Sync every Cargo.lock that records a crate bumped since the last sync, so no committed lock is left + * at the old version (which breaks `cargo build --locked` and leaks a lock diff into later builds). The + * candidates are every Cargo.lock tracked by git — apps that use a crate through a path dependency + * have their own — plus each crate's own lock; see core's `planCargoLockUpdates`. We never create a lock. * - * Returns the lock path when it lists the crate at the manifest's version (so the caller can stage it), - * or `undefined` when no committed lock lists the crate, under dry-run (the manifest hasn't been written - * yet), or when the sync fails. A failure is logged but never thrown: the version bump itself already - * succeeded and must not be blocked by a lockfile fix-up — surfacing the drift loudly is enough. + * Under dry-run the edits are still worked out, so an unsyncable lock fails the dry run too, and are + * recorded as pending writes (flushed with the manifests by `release`) instead of written. + * + * Returns every lock that lists a bumped crate, changed or not, so a caller can stage them. Throws a + * {@link CargoLockSyncError} when a lock can't be read, edited or written; every edit is worked out + * before the first write, so a lock that can't be edited leaves all of them untouched. */ -export function syncCargoLockfile(cargoTomlPath: string, dryRun = false): string | undefined { - const crateDir = path.dirname(cargoTomlPath); - if (dryRun) { - const lockPath = findCargoLockfile(crateDir); - if (lockPath) { - log(`[DRY RUN] Would sync Cargo.lock at ${lockPath}`, 'info'); - } - return undefined; +export async function syncCargoLockfiles(cwd: string, dryRun: boolean, git: Git = createGitCli()): Promise { + const bumps = recordedBumps.splice(0); + if (bumps.length === 0) { + return []; } try { - const { name, version } = parseCargoToml(cargoTomlPath).package ?? {}; - if (!name || typeof version !== 'string') { - log(`No package name and version in ${cargoTomlPath}; Cargo.lock not synced`, 'debug'); - return undefined; - } - const result = syncCargoLockVersion(crateDir, name, version); - if (!result) { - log(`No committed Cargo.lock lists ${name}; nothing to sync`, 'debug'); - return undefined; - } - if (result.changed) { - log(`Synced Cargo.lock at ${result.lockPath} (${name} ${version})`, 'success'); + const updates = planCargoLockUpdates(bumps, await findCommittedCargoLockfiles(git, cwd)); + for (const { lockPath, content, changed } of updates) { + if (!changed) { + continue; + } + if (dryRun) { + recordPendingWrite(lockPath, content); + log(`[DRY RUN] Would sync Cargo.lock at ${lockPath}`, 'info'); + } else { + fs.writeFileSync(lockPath, content); + log(`Synced Cargo.lock at ${lockPath}`, 'success'); + } } - return result.lockPath; + return updates.map(({ lockPath }) => lockPath); } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - log( - `Failed to sync Cargo.lock for ${cargoTomlPath}: ${detail}. The committed lock may drift from the bump.`, - 'warning', - ); - return undefined; + throw new CargoLockSyncError(error instanceof Error ? error.message : String(error)); } } diff --git a/packages/version/src/core/versionEngine.ts b/packages/version/src/core/versionEngine.ts index e775bd4b..cfbb43bc 100644 --- a/packages/version/src/core/versionEngine.ts +++ b/packages/version/src/core/versionEngine.ts @@ -6,6 +6,7 @@ import { filterPackagesByConfig, parseCargoToml, parsePubspec } from '@releaseki import { isPrivatePackageJson, shouldMatchPackageTargets } from '@releasekit/core'; import { minimatch } from 'minimatch'; import * as yaml from 'yaml'; +import { clearCargoBumps, syncCargoLockfiles } from '../cargo/cargoLock.js'; import { GitError } from '../errors/gitError.js'; import { createVersionError, VersionError, VersionErrorCode } from '../errors/versionError.js'; import { getCommitsLength, getLatestTag, getLatestTagForPackage } from '../git/tagsAndBranches.js'; @@ -603,8 +604,12 @@ export class VersionEngine { */ public async run(packages: PackagesWithRoot, targets: string[] = []): Promise { try { + clearCargoBumps(); // Execute the strategy function await this.currentStrategy(packages, targets); + // Every strategy records its Cargo.toml bumps (updateCargoVersion); syncing the locks that + // record those crates here, in one pass, covers all strategies and fails the run on an unsyncable lock. + await syncCargoLockfiles(packages.root ?? cwd(), this.config.dryRun ?? false); // Once the strategy has produced the updates, tag each one's role: explicit (group-expanded) // targets keep the override; derived prerequisites carry their own bump and record the // target(s) they were pulled in for. Only when prerequisites were actually derived. diff --git a/packages/version/src/errors/cargoLockSyncError.ts b/packages/version/src/errors/cargoLockSyncError.ts new file mode 100644 index 00000000..05d701a0 --- /dev/null +++ b/packages/version/src/errors/cargoLockSyncError.ts @@ -0,0 +1,14 @@ +import { VersionError } from './versionError.js'; + +/** + * Thrown when a Cargo.lock that records a bumped crate can't be synced to the crate's new version. + * It fails the run on purpose: a lock left at the old version breaks every later `cargo --locked` run, + * and a warning is how that drift used to go unnoticed. + */ +export class CargoLockSyncError extends VersionError { + constructor(details: string) { + super(`Failed to sync Cargo.lock: ${details}`, 'CARGO_LOCK_SYNC_ERROR', [ + 'If the lock also lists the crate from a registry or git source, sync it with cargo (`cargo update` with the new version in Cargo.toml) and commit it, or drop the second source', + ]); + } +} diff --git a/packages/version/src/index.ts b/packages/version/src/index.ts index 89bcac23..7600fa09 100644 --- a/packages/version/src/index.ts +++ b/packages/version/src/index.ts @@ -1,7 +1,6 @@ // Re-export public API export type { VersionOutput } from '@releasekit/core'; -export { syncCargoLockfile } from './cargo/cargoLock.js'; export { extractChangelogEntriesFromCommits } from './changelog/commitParser.js'; export { createVersionCommand } from './command.js'; export { loadConfig } from './config.js'; diff --git a/packages/version/test/unit/cargo/cargoHandler.spec.ts b/packages/version/test/unit/cargo/cargoHandler.spec.ts index cab17b80..b9b1932d 100644 --- a/packages/version/test/unit/cargo/cargoHandler.spec.ts +++ b/packages/version/test/unit/cargo/cargoHandler.spec.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import * as TOML from 'smol-toml'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getCargoInfo, isCargoToml, updateCargoVersion } from '../../../src/cargo/cargoHandler.js'; -import { syncCargoLockfile } from '../../../src/cargo/cargoLock.js'; +import { recordCargoBump } from '../../../src/cargo/cargoLock.js'; import * as jsonOutput from '../../../src/utils/jsonOutput.js'; import * as logging from '../../../src/utils/logging.js'; @@ -152,7 +152,11 @@ describe('Cargo Handler', () => { // Should still track the update and log expect(jsonOutput.addPackageUpdate).toHaveBeenCalledWith('test-package', '2.0.0', mockCargoPath); expect(logging.log).toHaveBeenCalledWith(expect.stringContaining('[DRY RUN] Would update'), 'success'); - expect(syncCargoLockfile).toHaveBeenCalledWith(mockCargoPath, true); + expect(recordCargoBump).toHaveBeenCalledWith({ + cargoTomlPath: mockCargoPath, + name: 'test-package', + version: '2.0.0', + }); }); it('should write to disk when dryRun is false', () => { @@ -163,7 +167,11 @@ describe('Cargo Handler', () => { expect(TOML.stringify).toHaveBeenCalled(); expect(fs.writeFileSync).toHaveBeenCalledWith(mockCargoPath, 'mocked stringified TOML'); expect(jsonOutput.addPackageUpdate).toHaveBeenCalledWith('test-package', '2.0.0', mockCargoPath); - expect(syncCargoLockfile).toHaveBeenCalledWith(mockCargoPath, false); + expect(recordCargoBump).toHaveBeenCalledWith({ + cargoTomlPath: mockCargoPath, + name: 'test-package', + version: '2.0.0', + }); }); it('should handle errors when updating Cargo.toml', () => { @@ -174,7 +182,7 @@ describe('Cargo Handler', () => { expect(() => updateCargoVersion(mockCargoPath, '2.0.0')).toThrow(); expect(logging.log).toHaveBeenCalledWith(expect.stringContaining('Failed to update Cargo.toml at'), 'error'); - expect(syncCargoLockfile).not.toHaveBeenCalled(); + expect(recordCargoBump).not.toHaveBeenCalled(); }); it('should throw an error if package name is not found', () => { diff --git a/packages/version/test/unit/cargo/cargoLock.spec.ts b/packages/version/test/unit/cargo/cargoLock.spec.ts index f11739ad..1ae8b9e9 100644 --- a/packages/version/test/unit/cargo/cargoLock.spec.ts +++ b/packages/version/test/unit/cargo/cargoLock.spec.ts @@ -1,22 +1,24 @@ /** - * Cargo.lock sync across every path that bumps a Cargo.toml (#496, #650). Each test drives a real - * strategy over a real Cargo workspace on disk — only git, tag lookups and the version calculation are - * mocked — and checks the committed lock that the standing-PR flow stages with `git add -A`. + * Cargo.lock sync across every path that bumps a Cargo.toml (#496, #650). Each test runs a real + * strategy through `VersionEngine.run` over a real git work tree on disk — only tag lookups, commit + * parsing and the version calculation are mocked — and checks the locks the standing-PR flow stages + * with `git add -A`: the crates' own workspace lock and a fixture app's lock that uses them by path. */ +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import type { Package, Tool } from '@manypkg/get-packages'; +import { EXIT_CODES, exitCodeForError } from '@releasekit/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as commitParser from '../../../src/changelog/commitParser.js'; -import { createGroupStrategy } from '../../../src/core/groupStrategy.js'; import * as calculator from '../../../src/core/versionCalculator.js'; -import type { PackagesWithRoot } from '../../../src/core/versionEngine.js'; -import { createAsyncStrategy, createSingleStrategy, createSyncStrategy } from '../../../src/core/versionStrategies.js'; +import { type PackagesWithRoot, VersionEngine } from '../../../src/core/versionEngine.js'; +import type { StrategyType } from '../../../src/core/versionStrategies.js'; import * as gitTags from '../../../src/git/tagsAndBranches.js'; import * as tagVerification from '../../../src/git/tagVerification.js'; import type { Config } from '../../../src/types.js'; -import * as logging from '../../../src/utils/logging.js'; +import { enableJsonOutput, flushPendingWrites } from '../../../src/utils/jsonOutput.js'; vi.mock('../../../src/git/tagsAndBranches.js'); vi.mock('../../../src/git/tagVerification.js'); @@ -25,9 +27,12 @@ vi.mock('../../../src/changelog/commitParser.js'); vi.mock('../../../src/utils/logging.js'); const REGISTRY = 'registry+https://github.com/rust-lang/crates.io-index'; +const CHECKSUM = 'checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f"'; -/** A lock as cargo writes it for the fixture workspace: alpha depends on beta (path) and serde (registry). */ -function renderLock(versions: { alpha: string; beta: string }): string { +type Versions = { alpha: string; beta: string }; + +/** The workspace lock as cargo writes it: alpha depends on beta (path) and serde (registry). */ +function workspaceLock(versions: Versions): string { return [ '# This file is automatically @generated by Cargo.', '# It is not intended for manual editing.', @@ -49,7 +54,50 @@ function renderLock(versions: { alpha: string; beta: string }): string { 'name = "serde"', 'version = "1.0.200"', `source = "${REGISTRY}"`, - 'checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f"', + CHECKSUM, + '', + ].join('\n'); +} + +/** + * The fixture app's lock: it uses alpha by path and also the published alpha 0.9.0, so cargo spells + * out versions in references to alpha, and the registry entry must never move. + */ +function appLock(versions: Versions): string { + return [ + 'version = 4', + '', + '[[package]]', + 'name = "alpha"', + 'version = "0.9.0"', + `source = "${REGISTRY}"`, + CHECKSUM, + '', + '[[package]]', + 'name = "alpha"', + `version = "${versions.alpha}"`, + 'dependencies = [', + ' "beta",', + ' "serde",', + ']', + '', + '[[package]]', + 'name = "beta"', + `version = "${versions.beta}"`, + '', + '[[package]]', + 'name = "fixture-app"', + 'version = "0.1.0"', + 'dependencies = [', + ' "alpha 0.9.0",', + ` "alpha ${versions.alpha}",`, + ']', + '', + '[[package]]', + 'name = "serde"', + 'version = "1.0.200"', + `source = "${REGISTRY}"`, + CHECKSUM, '', ].join('\n'); } @@ -89,16 +137,22 @@ function bump(next: Record): void { vi.mocked(calculator.calculateVersion).mockImplementation(async (_config, options) => next[options.name ?? ''] ?? ''); } +/** Run one strategy the way the version CLI and the release flows do: through `VersionEngine.run`. */ +async function runVersion(strategy: StrategyType, config: Partial, packages: PackagesWithRoot): Promise { + const engine = new VersionEngine(baseConfig(config)); + engine.setStrategy(strategy); + await engine.run(packages); +} + +const git = (cwd: string, ...args: string[]): string => execFileSync('git', args, { cwd, encoding: 'utf8' }); + describe('Cargo.lock sync on Cargo.toml bumps', () => { let root: string; - let lockPath: string; + let ownLock: string; + let fixtureLock: string; - const readLock = (): string => fs.readFileSync(lockPath, 'utf8'); - const lockWarnings = (): string[] => - vi - .mocked(logging.log) - .mock.calls.filter(([message, level]) => level === 'warning' && String(message).includes('Cargo.lock')) - .map(([message]) => String(message)); + const read = (file: string): string => fs.readFileSync(file, 'utf8'); + const both = (): Package[] => [crate(root, 'alpha'), crate(root, 'beta')]; beforeEach(() => { vi.resetAllMocks(); @@ -109,7 +163,8 @@ describe('Cargo.lock sync on Cargo.toml bumps', () => { vi.mocked(commitParser.extractRepoLevelChangelogEntries).mockResolvedValue([]); root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-paths-'))); - fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["crates/alpha", "crates/beta"]\n'); + fs.mkdirSync(path.join(root, 'crates')); + fs.writeFileSync(path.join(root, 'crates', 'Cargo.toml'), '[workspace]\nmembers = ["alpha", "beta"]\n'); writeCrate( path.join(root, 'crates', 'alpha'), 'alpha', @@ -117,45 +172,60 @@ describe('Cargo.lock sync on Cargo.toml bumps', () => { '\n[dependencies]\nbeta = { path = "../beta" }\nserde = "1"\n', ); writeCrate(path.join(root, 'crates', 'beta'), 'beta', '1.0.0'); - lockPath = path.join(root, 'Cargo.lock'); - fs.writeFileSync(lockPath, renderLock({ alpha: '1.0.0', beta: '1.0.0' })); - }); + ownLock = path.join(root, 'crates', 'Cargo.lock'); + fs.writeFileSync(ownLock, workspaceLock({ alpha: '1.0.0', beta: '1.0.0' })); - afterEach(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - it('should sync the lock for crates bumped through a version group', async () => { - bump({ alpha: '1.1.0' }); - const strategy = createGroupStrategy( - baseConfig({ groups: { rust: { packages: ['alpha', 'beta'], sync: 'fixed' } } }), + writeCrate( + path.join(root, 'fixtures', 'app'), + 'fixture-app', + '0.1.0', + 'publish = false\n\n[workspace]\n\n[dependencies]\nalpha = { path = "../../crates/alpha" }\nalpha-old = { package = "alpha", version = "0.9" }\n', ); + fixtureLock = path.join(root, 'fixtures', 'app', 'Cargo.lock'); + fs.writeFileSync(fixtureLock, appLock({ alpha: '1.0.0', beta: '1.0.0' })); - await strategy(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); - - expect(readLock()).toBe(renderLock({ alpha: '1.1.0', beta: '1.1.0' })); - expect(lockWarnings()).toEqual([]); + git(root, 'init', '-q'); + git(root, 'add', '-A'); }); - it('should sync the lock for an ungrouped crate when version groups are configured', async () => { - bump({ beta: '1.0.1' }); - const strategy = createGroupStrategy(baseConfig({ groups: { app: { packages: ['alpha'], sync: 'fixed' } } })); - - await strategy(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); - - expect(readLock()).toBe(renderLock({ alpha: '1.0.0', beta: '1.0.1' })); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); }); - it('should sync the lock for a crate bumped by the per-package (async) strategy', async () => { - bump({ beta: '2.0.0' }); - - await createAsyncStrategy(baseConfig())(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); - - expect(readLock()).toBe(renderLock({ alpha: '1.0.0', beta: '2.0.0' })); - }); + it.each<[string, StrategyType, Partial, 'alpha' | 'both', Versions]>([ + [ + 'a version group', + 'group', + { groups: { rust: { packages: ['alpha', 'beta'], sync: 'fixed' } } }, + 'both', + { alpha: '1.1.0', beta: '1.1.0' }, + ], + [ + 'the ungrouped path in group mode', + 'group', + { groups: { other: { packages: ['beta'], sync: 'fixed' } } }, + 'both', + { alpha: '1.1.0', beta: '1.0.0' }, + ], + ['the per-package processor', 'async', {}, 'both', { alpha: '1.1.0', beta: '1.0.0' }], + ['updateCargoFiles in a sync release', 'sync', { sync: true }, 'both', { alpha: '1.1.0', beta: '1.1.0' }], + ['updateCargoFiles in a single-package release', 'single', {}, 'alpha', { alpha: '1.1.0', beta: '1.0.0' }], + ])( + 'should sync every lock that records a crate bumped through %s', + async (_label, strategy, config, scope, expected) => { + bump({ alpha: '1.1.0' }); + const packages = scope === 'both' ? both() : [crate(root, 'alpha')]; + + await runVersion(strategy, config, workspace(root, packages)); + + expect(read(ownLock)).toBe(workspaceLock(expected)); + expect(read(fixtureLock)).toBe(appLock(expected)); + expect(git(root, 'diff', '--name-only', '--', '*.lock')).toBe('crates/Cargo.lock\nfixtures/app/Cargo.lock\n'); + }, + ); it('should sync the lock for a Cargo.toml found through cargo.paths (Tauri layout)', async () => { - // A JS package whose crate lives in src-tauri/ with its own committed lock, outside any workspace. + // A JS package whose crate lives in src-tauri/ with its own committed lock. const appDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-tauri-'))); const crateLock = path.join(appDir, 'src-tauri', 'Cargo.lock'); fs.writeFileSync(path.join(appDir, 'package.json'), '{ "name": "desktop", "version": "0.4.0" }\n'); @@ -165,84 +235,97 @@ describe('Cargo.lock sync on Cargo.toml bumps', () => { bump({ desktop: '0.5.0' }); try { - await createAsyncStrategy(baseConfig({ cargo: { enabled: true, paths: ['src-tauri'] } }))( + await runVersion( + 'async', + { cargo: { enabled: true, paths: ['src-tauri'] } }, workspace(appDir, [app as Package]), ); - expect(fs.readFileSync(crateLock, 'utf8')).toBe( - 'version = 4\n\n[[package]]\nname = "desktop"\nversion = "0.5.0"\n', - ); + expect(read(crateLock)).toBe('version = 4\n\n[[package]]\nname = "desktop"\nversion = "0.5.0"\n'); } finally { fs.rmSync(appDir, { recursive: true, force: true }); } }); - it('should sync the lock for a single-package release', async () => { - bump({ beta: '1.2.0' }); + it('should sync the locks without a Rust toolchain or a crates.io index', async () => { + // A fresh CI runner: git on PATH but no cargo, and an empty CARGO_HOME. Nothing may shell out to cargo. + const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'rk-no-cargo-')); + const gitDir = (process.env.PATH ?? '').split(path.delimiter).find((dir) => fs.existsSync(path.join(dir, 'git'))); + fs.symlinkSync(path.join(gitDir as string, 'git'), path.join(bin, 'git')); + vi.stubEnv('PATH', bin); + vi.stubEnv('CARGO_HOME', bin); + bump({ alpha: '1.1.0' }); - await createSingleStrategy(baseConfig())(workspace(root, [crate(root, 'beta')])); + try { + await runVersion('group', { groups: { rust: { packages: ['alpha'], sync: 'fixed' } } }, workspace(root, both())); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(bin, { recursive: true, force: true }); + } - expect(readLock()).toBe(renderLock({ alpha: '1.0.0', beta: '1.2.0' })); + expect(read(ownLock)).toBe(workspaceLock({ alpha: '1.1.0', beta: '1.0.0' })); + expect(read(fixtureLock)).toBe(appLock({ alpha: '1.1.0', beta: '1.0.0' })); }); - it('should sync the lock for every crate in a sync release', async () => { - bump({ alpha: '1.3.0' }); + it('should leave the locks on disk under dry-run and hand the edits to the release flush', async () => { + // `release` runs the version step as a dry run with JSON output, then flushes the pending writes. + enableJsonOutput(true); + bump({ alpha: '1.1.0' }); - await createSyncStrategy(baseConfig({ sync: true }))(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); + await runVersion('async', { dryRun: true }, workspace(root, both())); - expect(readLock()).toBe(renderLock({ alpha: '1.3.0', beta: '1.3.0' })); + expect(read(ownLock)).toBe(workspaceLock({ alpha: '1.0.0', beta: '1.0.0' })); + expect(read(fixtureLock)).toBe(appLock({ alpha: '1.0.0', beta: '1.0.0' })); + flushPendingWrites(); + expect(read(ownLock)).toBe(workspaceLock({ alpha: '1.1.0', beta: '1.0.0' })); + expect(read(fixtureLock)).toBe(appLock({ alpha: '1.1.0', beta: '1.0.0' })); }); - it('should sync the lock without a Rust toolchain or a crates.io index', async () => { - // A fresh CI runner: no cargo on PATH and an empty CARGO_HOME. Nothing may shell out to cargo. - const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rk-no-cargo-')); - vi.stubEnv('PATH', emptyDir); - vi.stubEnv('CARGO_HOME', emptyDir); - bump({ alpha: '1.1.0' }); + it('should fail the run, writing no lock, when a committed lock cannot be synced', async () => { + // A registry copy of beta at the target version: cargo would have to add a source to beta's + // references, so the sync refuses to guess. + const ambiguous = `${appLock({ alpha: '1.0.0', beta: '1.0.0' }).replace(' "beta",', ' "beta 1.0.0",')}\n[[package]]\nname = "beta"\nversion = "2.0.0"\nsource = "${REGISTRY}"\n`; + fs.writeFileSync(fixtureLock, ambiguous); + bump({ beta: '2.0.0' }); - try { - await createGroupStrategy(baseConfig({ groups: { rust: { packages: ['alpha'], sync: 'fixed' } } }))( - workspace(root, [crate(root, 'alpha'), crate(root, 'beta')]), - ); - } finally { - vi.unstubAllEnvs(); - fs.rmSync(emptyDir, { recursive: true, force: true }); - } + const run = runVersion('async', {}, workspace(root, both())); - expect(readLock()).toBe(renderLock({ alpha: '1.1.0', beta: '1.0.0' })); - expect(lockWarnings()).toEqual([]); + await expect(run).rejects.toMatchObject({ code: 'CARGO_LOCK_SYNC_ERROR' }); + expect(exitCodeForError(await run.catch((error: unknown) => error))).toBe(EXIT_CODES.VERSION_ERROR); + expect(read(ownLock)).toBe(workspaceLock({ alpha: '1.0.0', beta: '1.0.0' })); + expect(read(fixtureLock)).toBe(ambiguous); }); - it('should leave the lock untouched under dry-run', async () => { - bump({ alpha: '1.1.0' }); - - await createGroupStrategy( - baseConfig({ dryRun: true, groups: { rust: { packages: ['alpha', 'beta'], sync: 'fixed' } } }), - )(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); + it('should fail a dry run on a lock that cannot be synced, before anything is written', async () => { + fs.writeFileSync( + fixtureLock, + `${read(fixtureLock)}\n[[package]]\nname = "beta"\nversion = "2.0.0"\nsource = "${REGISTRY}"\n`, + ); + bump({ beta: '2.0.0' }); - expect(readLock()).toBe(renderLock({ alpha: '1.0.0', beta: '1.0.0' })); + await expect(runVersion('async', { dryRun: true }, workspace(root, both()))).rejects.toMatchObject({ + code: 'CARGO_LOCK_SYNC_ERROR', + }); }); - it('should not create a lock when none is committed', async () => { - fs.rmSync(lockPath); + it('should sync a crate’s own lock even when untracked, but leave other untracked locks alone', async () => { + git(root, 'rm', '-q', '--cached', 'crates/Cargo.lock', 'fixtures/app/Cargo.lock'); bump({ alpha: '1.1.0' }); - await createAsyncStrategy(baseConfig())(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); + await runVersion('async', {}, workspace(root, both())); - expect(fs.existsSync(lockPath)).toBe(false); - expect(fs.readFileSync(path.join(root, 'crates', 'alpha', 'Cargo.toml'), 'utf8')).toContain('version = "1.1.0"'); + expect(read(ownLock)).toBe(workspaceLock({ alpha: '1.1.0', beta: '1.0.0' })); + expect(read(fixtureLock)).toBe(appLock({ alpha: '1.0.0', beta: '1.0.0' })); }); - it('should warn and still bump Cargo.toml when the lock cannot be synced unambiguously', async () => { - // A registry copy of beta at the target version: cargo would have to add a source to beta's - // references, so the sync refuses to guess and leaves the lock for `cargo update` to fix. - const ambiguous = `${renderLock({ alpha: '1.0.0', beta: '1.0.0' }).replace(' "beta",', ' "beta 1.0.0",')}\n[[package]]\nname = "beta"\nversion = "2.0.0"\nsource = "${REGISTRY}"\n`; - fs.writeFileSync(lockPath, ambiguous); - bump({ beta: '2.0.0' }); + it('should not create a lock when none is committed', async () => { + fs.rmSync(ownLock); + fs.rmSync(fixtureLock); + bump({ alpha: '1.1.0' }); - await createAsyncStrategy(baseConfig())(workspace(root, [crate(root, 'alpha'), crate(root, 'beta')])); + await runVersion('async', {}, workspace(root, both())); - expect(readLock()).toBe(ambiguous); - expect(fs.readFileSync(path.join(root, 'crates', 'beta', 'Cargo.toml'), 'utf8')).toContain('version = "2.0.0"'); - expect(lockWarnings()).toEqual([expect.stringContaining('Failed to sync Cargo.lock')]); + expect(fs.existsSync(ownLock)).toBe(false); + expect(fs.existsSync(fixtureLock)).toBe(false); + expect(read(path.join(root, 'crates', 'alpha', 'Cargo.toml'))).toContain('version = "1.1.0"'); }); }); diff --git a/test/integration/cargo-lock-sync.spec.ts b/test/integration/cargo-lock-sync.spec.ts index ec81520d..4af9b294 100644 --- a/test/integration/cargo-lock-sync.spec.ts +++ b/test/integration/cargo-lock-sync.spec.ts @@ -1,18 +1,21 @@ /** - * Integration test: Cargo.lock self-version sync (#496, #650) checked against REAL cargo. + * Integration test: Cargo.lock sync (#496, #650) checked against REAL cargo. * - * The version step edits the lock text directly instead of running cargo, so the load-bearing claim is - * that the edit is exactly the one cargo makes. This bumps a workspace member, syncs the lock with - * releasekit, and compares it byte-for-byte with what `cargo update --workspace` writes for the same - * bump. Skipped automatically where cargo isn't on PATH (CI installs a Rust toolchain for this job). + * Each test runs `releasekit version` (the built CLI) with no cargo on PATH and an empty CARGO_HOME, + * as on a fresh CI runner, and then asks real cargo whether the locks it left are right: cargo must + * accept every lock that records a bumped crate under `--locked` without changing it, and the edit + * must be byte-for-byte the one `cargo update --workspace` makes. Skipped automatically where cargo + * isn't on PATH (CI installs a Rust toolchain for this job). */ -import { execFileSync } from 'node:child_process'; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { syncCargoLockfile } from '@releasekit/version'; +import { delimiter, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +const VERSION_CLI = fileURLToPath(new URL('../../packages/version/dist/cli.js', import.meta.url)); + function cargoAvailable(): boolean { try { execFileSync('cargo', ['--version'], { stdio: 'ignore' }); @@ -22,37 +25,161 @@ function cargoAvailable(): boolean { } } -function writeCrate(dir: string, name: string, version: string, deps = ''): void { +function writeCrate(dir: string, name: string, version: string, extra = ''): void { mkdirSync(join(dir, 'src'), { recursive: true }); writeFileSync( join(dir, 'Cargo.toml'), - `[package]\nname = "${name}"\nversion = "${version}"\nedition = "2021"\n${deps}`, + `[package]\nname = "${name}"\nversion = "${version}"\nedition = "2021"\n${extra}`, ); writeFileSync(join(dir, 'src', 'lib.rs'), '// test crate\n'); } -describe.skipIf(!cargoAvailable())('syncCargoLockfile (real cargo)', () => { +function git(cwd: string, ...args: string[]): void { + execFileSync('git', ['-c', 'user.name=test', '-c', 'user.email=test@example.com', ...args], { cwd, stdio: 'ignore' }); +} + +/** Versions of the sourceless (path or workspace) `[[package]]` entries named `name` in a lock. */ +function pathEntryVersions(lockContent: string, name: string): string[] { + return lockContent + .split('[[package]]') + .filter((entry) => entry.includes(`name = "${name}"\n`) && !/^source = /m.test(entry)) + .map((entry) => /^version = "([^"]+)"/m.exec(entry)?.[1] ?? ''); +} + +function crateVersion(manifest: string): string { + return /^version = "([^"]+)"/m.exec(readFileSync(manifest, 'utf-8'))?.[1] ?? ''; +} + +describe.skipIf(!cargoAvailable())('Cargo.lock sync (real cargo)', () => { let dir: string; - let ws: string; + let emptyCargoHome: string; + let warmCargoHome: string; - // A private CARGO_HOME keeps the test hermetic: everything resolves from path and local git sources. - const cargo = (cwd: string, ...args: string[]): void => { - execFileSync('cargo', args, { cwd, stdio: 'pipe', env: { ...process.env, CARGO_HOME: join(dir, 'cargo-home') } }); + const cargo = (cwd: string, cargoHome: string, ...args: string[]): void => { + execFileSync('cargo', args, { cwd, stdio: 'pipe', env: { ...process.env, CARGO_HOME: cargoHome } }); }; + /** Commit the repo, tag every crate at its current version, then land a `feat:` change in `changedCrate`. */ + function commitWithChange(repo: string, crates: string[], changedCrate: string): void { + // Workspace discovery starts from a root package.json, as in a JS monorepo that ships crates. + writeFileSync(join(repo, 'package.json'), '{ "name": "rk-fixture", "private": true }\n'); + git(repo, 'init', '-q'); + git(repo, 'add', '-A'); + git(repo, 'commit', '-qm', 'chore: init'); + for (const name of crates) { + git(repo, 'tag', `${name}@v0.1.0`); + } + writeFileSync(join(changedCrate, 'src', 'lib.rs'), '// a new feature\n'); + git(repo, 'commit', '-qam', 'feat: add a feature'); + } + + /** Run `releasekit version` the way a fresh CI runner would: git on PATH, no cargo, an empty CARGO_HOME. */ + function releasekitVersion(repo: string, versionConfig: Record): string { + writeFileSync( + join(repo, 'releasekit.config.json'), + JSON.stringify({ + version: { + preset: 'angular', + sync: false, + versionPrefix: 'v', + tagTemplate: '${packageName}@v${version}', + packageSpecificTags: true, + ...versionConfig, + }, + }), + ); + const bin = join(dir, 'bin'); + if (!existsSync(bin)) { + mkdirSync(bin); + const gitDir = (process.env.PATH ?? '').split(delimiter).find((entry) => existsSync(join(entry, 'git'))); + symlinkSync(join(gitDir as string, 'git'), join(bin, 'git')); + } + const result = spawnSync(process.execPath, [VERSION_CLI], { + cwd: repo, + encoding: 'utf-8', + env: { ...process.env, PATH: bin, CARGO_HOME: emptyCargoHome }, + }); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + return result.stdout; + } + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'rk-cargo-lock-int-')); + emptyCargoHome = join(dir, 'empty-cargo-home'); + mkdirSync(emptyCargoHome); + warmCargoHome = join(dir, 'warm-cargo-home'); + }); - // A git crate with the same name as a workspace member puts two versions of `crate-b` in the lock, - // so cargo spells out versions in references to it ("crate-b 0.1.0") — the encoding a bump must edit. + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it.each([ + [ + 'version.groups', + { groups: { rust: { packages: ['rk-core', 'rk-cli'], sync: 'fixed' } } }, + 'Using version-groups strategy', + ], + ['the per-package processor', {}, 'Using async versioning strategy'], + ['updateCargoFiles', { sync: true }, 'Using sync versioning strategy'], + ])( + 'should leave every lock that records a crate bumped through %s accepted by cargo --locked', + (_path, config, strategyLog) => { + // A Cargo workspace with its own lock, plus a fixture app (its own workspace and lock) that uses + // the workspace's crates by path, as webdriverio/desktop-mobile's Tauri and Dioxus apps do. + const repo = join(dir, 'repo'); + mkdirSync(join(repo, 'crates'), { recursive: true }); + writeFileSync(join(repo, 'crates', 'Cargo.toml'), '[workspace]\nmembers = ["core", "cli"]\nresolver = "2"\n'); + writeCrate(join(repo, 'crates', 'core'), 'rk-core', '0.1.0'); + writeCrate(join(repo, 'crates', 'cli'), 'rk-cli', '0.1.0', '\n[dependencies]\nrk-core = { path = "../core" }\n'); + writeCrate( + join(repo, 'fixtures', 'app'), + 'fixture-app', + '0.1.0', + 'publish = false\n\n[workspace]\n\n[dependencies]\nrk-cli = { path = "../../crates/cli" }\n', + ); + const manifests = [join(repo, 'crates', 'Cargo.toml'), join(repo, 'fixtures', 'app', 'Cargo.toml')]; + for (const manifest of manifests) { + cargo(repo, emptyCargoHome, 'generate-lockfile', '--offline', '--manifest-path', manifest); + } + commitWithChange(repo, ['rk-core', 'rk-cli'], join(repo, 'crates', 'core')); + + expect(releasekitVersion(repo, config)).toContain(strategyLog); + + const bumped = ['core', 'cli'] + .map((crate) => ({ name: `rk-${crate}`, version: crateVersion(join(repo, 'crates', crate, 'Cargo.toml')) })) + .filter(({ version }) => version !== '0.1.0'); + expect(bumped.map(({ name }) => name)).toContain('rk-core'); + for (const manifest of manifests) { + const lockPath = join(manifest, '..', 'Cargo.lock'); + const lock = readFileSync(lockPath, 'utf-8'); + for (const { name, version } of bumped) { + expect(pathEntryVersions(lock, name), `${name} in ${lockPath}`).toEqual([version]); + } + cargo( + repo, + emptyCargoHome, + 'metadata', + '--locked', + '--offline', + '--format-version', + '1', + '--manifest-path', + manifest, + ); + expect(readFileSync(lockPath, 'utf-8')).toBe(lock); + } + }, + ); + + it('should write the same lock cargo writes', () => { + // A git crate named like a workspace member puts two versions of `crate-b` in the lock, so cargo + // spells out versions in references to it ("crate-b 0.1.0") — the encoding a bump must edit. const gitCrate = join(dir, 'git-crate-b'); writeCrate(gitCrate, 'crate-b', '0.0.9'); - const git = (...args: string[]) => execFileSync('git', args, { cwd: gitCrate, stdio: 'ignore' }); - git('init', '-q'); - git('add', '-A'); - git('-c', 'user.name=test', '-c', 'user.email=test@example.com', 'commit', '-qm', 'init'); + git(gitCrate, 'init', '-q'); + git(gitCrate, 'add', '-A'); + git(gitCrate, 'commit', '-qm', 'init'); - ws = join(dir, 'ws'); + const ws = join(dir, 'ws'); mkdirSync(ws); writeFileSync(join(ws, 'Cargo.toml'), '[workspace]\nmembers = ["crate-a", "crate-b"]\nresolver = "2"\n'); writeCrate( @@ -62,30 +189,21 @@ describe.skipIf(!cargoAvailable())('syncCargoLockfile (real cargo)', () => { `\n[dependencies]\ncrate-b = { path = "../crate-b" }\nold-b = { package = "crate-b", git = "file://${gitCrate}" }\n`, ); writeCrate(join(ws, 'crate-b'), 'crate-b', '0.1.0'); - cargo(ws, 'generate-lockfile'); - }); - - afterEach(() => rmSync(dir, { recursive: true, force: true })); - - it.each(['crate-a', 'crate-b'])('should write the same lock cargo writes when %s is bumped', (crate) => { + cargo(ws, warmCargoHome, 'generate-lockfile'); + commitWithChange(ws, ['crate-a', 'crate-b'], join(ws, 'crate-b')); const expected = join(dir, 'expected'); cpSync(ws, expected, { recursive: true }); - for (const root of [ws, expected]) { - const manifest = join(root, crate, 'Cargo.toml'); - writeFileSync(manifest, readFileSync(manifest, 'utf-8').replace('version = "0.1.0"', 'version = "0.2.0"')); - } - cargo(expected, 'update', '--workspace', '--offline'); - const lockPath = syncCargoLockfile(join(ws, crate, 'Cargo.toml')); + releasekitVersion(ws, {}); - expect(lockPath).toBe(join(ws, 'Cargo.lock')); - expect(readFileSync(lockPath as string, 'utf-8')).toBe(readFileSync(join(expected, 'Cargo.lock'), 'utf-8')); - }); - - it('should not create a lock when none is committed', () => { - rmSync(join(ws, 'Cargo.lock')); - const lockPath = syncCargoLockfile(join(ws, 'crate-a', 'Cargo.toml')); - expect(lockPath).toBeUndefined(); - expect(() => readFileSync(join(ws, 'Cargo.lock'))).toThrow(); + expect(crateVersion(join(ws, 'crate-b', 'Cargo.toml'))).toBe('0.2.0'); + // Give cargo the same manifest versions releasekit wrote (it may bump dependents too). + for (const member of ['crate-a', 'crate-b']) { + const manifest = join(expected, member, 'Cargo.toml'); + const version = crateVersion(join(ws, member, 'Cargo.toml')); + writeFileSync(manifest, readFileSync(manifest, 'utf-8').replace('version = "0.1.0"', `version = "${version}"`)); + } + cargo(expected, warmCargoHome, 'update', '--workspace', '--offline'); + expect(readFileSync(join(ws, 'Cargo.lock'), 'utf-8')).toBe(readFileSync(join(expected, 'Cargo.lock'), 'utf-8')); }); }); From 7fcf94540721074a29ad88abb25347b66c630750 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 17:44:47 +0000 Subject: [PATCH 03/18] fix(core): keep cargo's order when a lock bump passes another version Cargo orders same-name [[package]] entries by semver and a package's dependency references by the version as a plain string, so "dupe 10.0.0" sorts before "dupe 2.0.0" (checked with cargo 1.97). Rewriting a path crate's version in place left both in their old order whenever the new version passed a same-named registry or git entry, and the next cargo run would reorder the lock. setCargoLockVersion now moves the edited entry, and the run of references around each edited reference, back to cargo's order. The real-cargo byte-for-byte test bumps crate-b from 0.1.0 to 0.2.0 past git copies at 0.1.5 (entry order) and 0.10.0 (reference order). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- packages/core/src/cargo.ts | 133 +++++++++++++++++++++-- packages/core/test/unit/cargo.spec.ts | 42 +++++++ test/integration/cargo-lock-sync.spec.ts | 23 ++-- 3 files changed, 180 insertions(+), 18 deletions(-) diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index 523b72de..65f12979 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -47,6 +47,10 @@ export function findCargoLockfile(startDir: string): string | undefined { interface CargoLockEntry { name: string; version: string; + /** Index of the entry's `[[package]]` line. */ + start: number; + /** Index of the entry's last non-blank line. */ + end: number; /** Index of the entry's `version = "…"` line. */ versionLine: number; /** Registry and git packages carry a `source`; path and workspace packages don't. */ @@ -56,12 +60,14 @@ interface CargoLockEntry { /** Read the `[[package]]` entries of a Cargo.lock split into lines, keeping each line's position. */ function readCargoLockEntries(lines: string[]): CargoLockEntry[] { const entries: CargoLockEntry[] = []; - let current: { name?: string; version?: string; versionLine?: number; hasSource: boolean } | undefined; + let current: (Partial & { start: number; end: number; hasSource: boolean }) | undefined; const closeEntry = (): void => { if (current?.name !== undefined && current.version !== undefined && current.versionLine !== undefined) { entries.push({ name: current.name, version: current.version, + start: current.start, + end: current.end, versionLine: current.versionLine, hasSource: current.hasSource, }); @@ -71,19 +77,22 @@ function readCargoLockEntries(lines: string[]): CargoLockEntry[] { for (const [index, line] of lines.entries()) { if (line.startsWith('[')) { closeEntry(); - current = /^\[\[package\]\]\s*$/.test(line) ? { hasSource: false } : undefined; + current = /^\[\[package\]\]\s*$/.test(line) ? { start: index, end: index, hasSource: false } : undefined; continue; } - const field = current ? /^(name|version|source) = "([^"]*)"\s*$/.exec(line) : null; - if (!current || !field) { + if (!current) { continue; } - if (field[1] === 'name') { + if (line.trim() !== '') { + current.end = index; + } + const field = /^(name|version|source) = "([^"]*)"\s*$/.exec(line); + if (field?.[1] === 'name') { current.name = field[2]; - } else if (field[1] === 'version') { + } else if (field?.[1] === 'version') { current.version = field[2]; current.versionLine = index; - } else { + } else if (field?.[1] === 'source') { current.hasSource = true; } } @@ -91,13 +100,106 @@ function readCargoLockEntries(lines: string[]): CargoLockEntry[] { return entries; } +/** Split a version into its numeric core, pre-release identifiers and build metadata. */ +function parseVersion(version: string): { core: number[]; pre: string[]; build: string } { + const plus = version.indexOf('+'); + const withoutBuild = plus === -1 ? version : version.slice(0, plus); + const dash = withoutBuild.indexOf('-'); + return { + core: (dash === -1 ? withoutBuild : withoutBuild.slice(0, dash)).split('.').map(Number), + pre: dash === -1 ? [] : withoutBuild.slice(dash + 1).split('.'), + build: plus === -1 ? '' : version.slice(plus + 1), + }; +} + +const compareStrings = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +/** + * Semver precedence, which is how cargo orders same-name `[[package]]` entries: numeric core, then a + * pre-release sorts before the release, identifier by identifier (numeric ones numerically and before + * alphanumeric ones). Build metadata only breaks ties. + */ +function compareVersions(a: string, b: string): number { + const left = parseVersion(a); + const right = parseVersion(b); + for (let i = 0; i < 3; i++) { + if (left.core[i] !== right.core[i]) { + return left.core[i] - right.core[i]; + } + } + if (left.pre.length === 0 || right.pre.length === 0) { + return right.pre.length - left.pre.length || compareStrings(left.build, right.build); + } + for (let i = 0; i < Math.min(left.pre.length, right.pre.length); i++) { + const [x, y] = [left.pre[i], right.pre[i]]; + const [xNumeric, yNumeric] = [/^\d+$/.test(x), /^\d+$/.test(y)]; + if (xNumeric && yNumeric && x !== y) { + return x.length - y.length || compareStrings(x, y); + } + if (xNumeric !== yNumeric) { + return xNumeric ? -1 : 1; + } + if (x !== y) { + return compareStrings(x, y); + } + } + return left.pre.length - right.pre.length || compareStrings(left.build, right.build); +} + +/** + * Put same-name `[[package]]` entries back in cargo's order (by version) after one of them changed + * version. They sit next to each other, so the entries are permuted while the blank lines between + * them stay put. A lock not laid out that way is left as it is. + */ +function sortSameNameEntries(lines: string[], group: Array<{ entry: CargoLockEntry; version: string }>): void { + const gaps = group.slice(1).map(({ entry }, i) => lines.slice(group[i].entry.end + 1, entry.start)); + if (gaps.some((gap) => gap.some((line) => line.trim() !== ''))) { + return; + } + const blocks = group.map(({ entry }) => lines.slice(entry.start, entry.end + 1)); + const order = group.map((_, i) => i).sort((a, b) => compareVersions(group[a].version, group[b].version)); + const region = order.flatMap((blockIndex, slot) => [...(slot === 0 ? [] : gaps[slot - 1]), ...blocks[blockIndex]]); + const first = group[0].entry.start; + lines.splice(first, group[group.length - 1].entry.end + 1 - first, ...region); +} + +/** + * Put the run of `name` references around each `" "` dependency line back in cargo's + * order. Cargo sorts a package's dependencies by name and then by the version as a string, so + * `"foo 10.0.0"` sorts before `"foo 2.0.0"`. + */ +function sortReferenceRuns(lines: string[], name: string, version: string): void { + const referenceVersion = (line: string): string | undefined => { + const reference = /^\s*"([^" ]+) ([^" ]+)[^"]*",?\s*$/.exec(line); + return reference?.[1] === name ? reference[2] : undefined; + }; + for (let i = 0; i < lines.length; i++) { + if (referenceVersion(lines[i]) !== version) { + continue; + } + let first = i; + while (first > 0 && referenceVersion(lines[first - 1]) !== undefined) { + first--; + } + let last = i; + while (last + 1 < lines.length && referenceVersion(lines[last + 1]) !== undefined) { + last++; + } + const run = lines.slice(first, last + 1); + const sorted = [...run].sort((a, b) => compareStrings(referenceVersion(a) ?? '', referenceVersion(b) ?? '')); + lines.splice(first, run.length, ...sorted); + i = last; + } +} + /** * Point a path or workspace crate's own `[[package]]` entry in Cargo.lock content at `version`, making * the edit cargo itself makes for a version change: the entry's `version` line, plus every * `" "` dependency reference. Cargo only writes the version into a reference when * the lock holds more than one version of that name — otherwise dependents list the bare name, which * needs no edit. That encoding is the same in every lockfile format, and cargo never adds a source to - * a reference to a path package. Registry and git entries of the same name are never touched. + * a reference to a path package. Registry and git entries of the same name are never touched, but + * when the new version passes one of theirs, the entries and references are put back in cargo's order. * * Returns the updated content (identical when the entry is already at `version`), or `undefined` when * the lock has no path entry for `crateName`. Throws rather than guess when the lock holds the name @@ -126,7 +228,20 @@ export function setCargoLockVersion(lockContent: string, crateName: string, vers } lines[entry.versionLine] = lines[entry.versionLine].replace(`"${entry.version}"`, `"${version}"`); - return lines.join('\n').replaceAll(`"${crateName} ${entry.version}"`, `"${crateName} ${version}"`); + if (sameName.length > 1) { + sortSameNameEntries( + lines, + sameName.map((other) => ({ entry: other, version: other === entry ? version : other.version })), + ); + } + const updated = lines + .join('\n') + .replaceAll(`"${crateName} ${entry.version}"`, `"${crateName} ${version}"`) + .split('\n'); + if (sameName.length > 1) { + sortReferenceRuns(updated, crateName, version); + } + return updated.join('\n'); } /** The part of `@releasekit/git`'s `Git` seam that lists tracked files (core doesn't depend on it). */ diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index 554d4972..d5b20439 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -157,6 +157,48 @@ describe('setCargoLockVersion', () => { expect(setCargoLockVersion(before, 'dupe', '1.1.0')).toBe(after); }); + it('should keep cargo’s order when the new version passes same-named entries from other sources', () => { + // Byte-for-byte what `cargo update --workspace` writes after bumping the path crate `dupe` from + // 1.0.0 to 3.0.0: entries are ordered by semver, references by the version as a string. + const source = (repo: string) => + `source = "git+https://example.com/${repo}#9a1327a6d3f8bc80a0f02fe01325c7828d587668"`; + const entry = (version: string, ...rest: string[]) => [ + '', + '[[package]]', + 'name = "dupe"', + `version = "${version}"`, + ...rest, + ]; + const app = (...references: string[]) => [ + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + 'dependencies = [', + ...references.map((reference) => ` "dupe ${reference}",`), + ']', + ]; + const before = lock( + 'version = 4', + '', + ...app('1.0.0', '1.0.0-beta.1', '10.0.0', '2.0.0'), + ...entry('1.0.0-beta.1', source('pre')), + ...entry('1.0.0'), + ...entry('2.0.0', source('two')), + ...entry('10.0.0', source('ten')), + ); + const after = lock( + 'version = 4', + '', + ...app('1.0.0-beta.1', '10.0.0', '2.0.0', '3.0.0'), + ...entry('1.0.0-beta.1', source('pre')), + ...entry('2.0.0', source('two')), + ...entry('3.0.0'), + ...entry('10.0.0', source('ten')), + ); + + expect(setCargoLockVersion(before, 'dupe', '3.0.0')).toBe(after); + }); + it('should rewrite path references in a v1 lock without touching registry references or checksums', () => { // v1 locks always spell out the version, and add the source for registry packages. const before = lock( diff --git a/test/integration/cargo-lock-sync.spec.ts b/test/integration/cargo-lock-sync.spec.ts index 4af9b294..6f8a20e0 100644 --- a/test/integration/cargo-lock-sync.spec.ts +++ b/test/integration/cargo-lock-sync.spec.ts @@ -170,14 +170,19 @@ describe.skipIf(!cargoAvailable())('Cargo.lock sync (real cargo)', () => { }, ); - it('should write the same lock cargo writes', () => { - // A git crate named like a workspace member puts two versions of `crate-b` in the lock, so cargo - // spells out versions in references to it ("crate-b 0.1.0") — the encoding a bump must edit. - const gitCrate = join(dir, 'git-crate-b'); - writeCrate(gitCrate, 'crate-b', '0.0.9'); - git(gitCrate, 'init', '-q'); - git(gitCrate, 'add', '-A'); - git(gitCrate, 'commit', '-qm', 'init'); + it('should write the same lock cargo writes, also when the bump passes other versions of the crate', () => { + // Git crates named like a workspace member put several versions of `crate-b` in the lock, so cargo + // spells out versions in references to it ("crate-b 0.1.0"). Bumping crate-b from 0.1.0 to 0.2.0 + // passes 0.1.5 in entry order (semver) and 0.10.0 in reference order (plain string), so the edit + // also has to put both back in cargo's order. + const gitDeps = ['0.0.9', '0.1.5', '0.10.0'].map((version) => { + const gitCrate = join(dir, `git-crate-b-${version}`); + writeCrate(gitCrate, 'crate-b', version); + git(gitCrate, 'init', '-q'); + git(gitCrate, 'add', '-A'); + git(gitCrate, 'commit', '-qm', 'init'); + return `b-${version.replaceAll('.', '-')} = { package = "crate-b", git = "file://${gitCrate}" }\n`; + }); const ws = join(dir, 'ws'); mkdirSync(ws); @@ -186,7 +191,7 @@ describe.skipIf(!cargoAvailable())('Cargo.lock sync (real cargo)', () => { join(ws, 'crate-a'), 'crate-a', '0.1.0', - `\n[dependencies]\ncrate-b = { path = "../crate-b" }\nold-b = { package = "crate-b", git = "file://${gitCrate}" }\n`, + `\n[dependencies]\ncrate-b = { path = "../crate-b" }\n${gitDeps.join('')}`, ); writeCrate(join(ws, 'crate-b'), 'crate-b', '0.1.0'); cargo(ws, warmCargoHome, 'generate-lockfile'); From a0c2197d709bbc6847b68ae07e534bd1e42ccb9a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 18:33:36 +0000 Subject: [PATCH 04/18] style: add braces to every single-statement body Same sweep as #679, run on this branch so merging main resolves cleanly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- packages/config/src/load.ts | 36 +++- packages/config/src/merge.ts | 20 ++- .../config/test/unit/generate-schema.spec.ts | 8 +- packages/core/src/cargo.ts | 8 +- packages/core/src/changelogRefs.ts | 32 +++- packages/core/src/dependencyGraph.ts | 56 +++++-- packages/core/src/logger.ts | 16 +- packages/core/src/marker.ts | 8 +- packages/core/src/prerequisites.ts | 4 +- packages/core/test/unit/cargo.spec.ts | 8 +- packages/forge/src/errors.ts | 4 +- packages/forge/src/fake.ts | 16 +- packages/forge/src/github.ts | 48 ++++-- packages/forge/src/types.ts | 4 +- packages/forge/test/unit/github.spec.ts | 4 +- packages/git/src/errors.ts | 12 +- packages/git/src/fake.ts | 12 +- packages/git/src/git.ts | 75 ++++++--- packages/git/test/unit/git.spec.ts | 4 +- packages/notes/src/command.ts | 16 +- packages/notes/src/core/pipeline.ts | 28 +++- packages/notes/src/errors/index.ts | 4 +- .../notes/src/input/conventional-changelog.ts | 8 +- packages/notes/src/input/manual.ts | 8 +- packages/notes/src/llm/anthropic.ts | 4 +- packages/notes/src/llm/base.ts | 4 +- packages/notes/src/llm/context/prFetcher.ts | 12 +- packages/notes/src/llm/correctiveRetry.ts | 4 +- packages/notes/src/llm/examples/fetcher.ts | 20 ++- packages/notes/src/llm/examples/parser.ts | 24 ++- packages/notes/src/llm/messages.ts | 4 +- packages/notes/src/llm/ollama.ts | 4 +- packages/notes/src/llm/openai-compatible.ts | 4 +- packages/notes/src/llm/openai.ts | 4 +- packages/notes/src/llm/retryable.ts | 28 +++- packages/notes/src/llm/scopes.ts | 28 +++- packages/notes/src/llm/tasks/categorize.ts | 8 +- .../src/llm/tasks/enhance-and-categorize.ts | 19 ++- packages/notes/src/llm/tasks/shared.ts | 36 +++- packages/notes/src/monorepo/aggregator.ts | 4 +- packages/notes/src/output/markdown.ts | 52 ++++-- packages/notes/src/templates/loader.ts | 12 +- packages/notes/src/utils/concurrency.ts | 4 +- packages/notes/src/utils/json.ts | 8 +- packages/notes/src/utils/retry.ts | 4 +- packages/notes/test/eval/assertions.ts | 12 +- packages/notes/test/unit/llm.spec.ts | 20 ++- packages/notes/test/unit/llm/cache.spec.ts | 4 +- packages/notes/test/unit/llm/tasks.spec.ts | 40 +++-- packages/notes/test/unit/retry.spec.ts | 8 +- packages/notes/test/unit/templates.spec.ts | 4 +- .../notes/test/unit/utils/concurrency.spec.ts | 4 +- packages/publish/src/command.ts | 8 +- packages/publish/src/registry/cargo.ts | 16 +- packages/publish/src/registry/dispatcher.ts | 8 +- packages/publish/src/registry/npm.ts | 22 ++- packages/publish/src/registry/pub.ts | 4 +- packages/publish/src/stages/git-push.ts | 24 ++- packages/publish/src/stages/github-release.ts | 36 +++- packages/publish/src/types.ts | 4 +- packages/publish/src/utils/exec.ts | 4 +- packages/publish/src/utils/npm-env.ts | 4 +- packages/publish/src/utils/package-manager.ts | 12 +- packages/publish/src/utils/pub.ts | 8 +- packages/publish/src/utils/publish-retry.ts | 8 +- .../test/integration/publish-e2e.spec.ts | 16 +- .../test/unit/registry/dispatcher.spec.ts | 11 +- .../unit/stages/npm-publish-order.spec.ts | 4 +- .../release/src/backfill/github-release.ts | 16 +- packages/release/src/backfill/reconstruct.ts | 12 +- packages/release/src/backfill/repo-url.ts | 14 +- .../release/src/commands/backfill-command.ts | 12 +- packages/release/src/commands/changed.ts | 4 +- packages/release/src/commands/emitResult.ts | 8 +- packages/release/src/commands/gate-command.ts | 12 +- .../release/src/commands/labels-command.ts | 4 +- packages/release/src/draft/draft.ts | 4 +- packages/release/src/duration.ts | 24 ++- .../src/failure-report/failure-report.ts | 12 +- packages/release/src/failure-report/post.ts | 16 +- packages/release/src/gate/gate.ts | 16 +- packages/release/src/git.ts | 8 +- packages/release/src/github.ts | 8 +- packages/release/src/label-definitions.ts | 4 +- packages/release/src/label-utils.ts | 28 +++- packages/release/src/preview/detect.ts | 12 +- packages/release/src/preview/format.ts | 63 +++++-- packages/release/src/preview/merge.ts | 12 +- packages/release/src/preview/preview.ts | 42 +++-- packages/release/src/preview/refresh.ts | 16 +- packages/release/src/release.ts | 40 +++-- .../release/src/standing-pr/authorization.ts | 28 +++- .../src/standing-pr/changelog-region.ts | 67 ++++++-- .../release/src/standing-pr/notes-region.ts | 12 +- .../src/standing-pr/selection-region.ts | 135 +++++++++++---- .../release/src/standing-pr/standing-pr.ts | 158 +++++++++++++----- .../release/src/standing-pr/summary-region.ts | 36 +++- .../release/test/unit/authorization.spec.ts | 7 +- .../test/unit/commands/emitResult.spec.ts | 4 +- .../test/unit/failure-report-post.spec.ts | 7 +- packages/release/test/unit/gate.spec.ts | 12 +- packages/release/test/unit/refresh.spec.ts | 4 +- .../release/test/unit/standing-pr.spec.ts | 22 ++- packages/version/src/core/baselineResolver.ts | 12 +- packages/version/src/core/groupResolution.ts | 12 +- packages/version/src/core/groupStrategy.ts | 68 ++++++-- .../version/src/core/prerequisiteScope.ts | 10 +- .../version/src/core/versionCalculator.ts | 12 +- packages/version/src/core/versionEngine.ts | 64 +++++-- .../version/src/core/versionStrategies.ts | 16 +- packages/version/src/core/workspaceGraph.ts | 4 +- packages/version/src/git/tagsAndBranches.ts | 8 +- .../version/src/package/packageProcessor.ts | 4 +- packages/version/src/utils/formatting.ts | 8 +- packages/version/src/utils/jsonOutput.ts | 76 ++++++--- packages/version/src/utils/logging.ts | 4 +- packages/version/src/utils/versionAction.ts | 4 +- packages/version/src/workspace.ts | 4 +- .../test/unit/core/groupStrategy.spec.ts | 80 ++++++--- .../test/unit/core/versionCalculator.spec.ts | 120 +++++++++---- .../test/unit/core/versionStrategies.spec.ts | 16 +- .../unit/package/packageProcessor.spec.ts | 20 ++- .../test/unit/utils/versionUtils.spec.ts | 116 +++++++++---- packages/version/test/utils/cli.ts | 4 +- scripts/examples-smoke/build-fixture.ts | 21 ++- .../examples-smoke/generate-smoke-workflow.ts | 20 ++- scripts/generate-config-docs.ts | 56 +++++-- scripts/generate-schema.ts | 28 +++- scripts/run-action.mjs | 48 ++++-- scripts/test-harness/index.mjs | 20 ++- scripts/test-harness/run-action-local.mjs | 8 +- test/integration/examples-smoke.spec.ts | 4 +- 132 files changed, 2066 insertions(+), 687 deletions(-) diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 34b2ab45..237b4de3 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -45,13 +45,23 @@ function isRecord(value: unknown): value is Record { * keys — which would otherwise turn off a user's file output without a word. */ function assertNoRemovedReleaseNotesFields(config: unknown): void { - if (!isRecord(config) || !isRecord(config.notes) || !isRecord(config.notes.releaseNotes)) return; + if (!isRecord(config) || !isRecord(config.notes) || !isRecord(config.notes.releaseNotes)) { + return; + } const rn = config.notes.releaseNotes; const removed: string[] = []; - if ('mode' in rn) removed.push('mode'); - if ('directory' in rn) removed.push('directory'); - if (typeof rn.file === 'string') removed.push('file (string)'); - if (removed.length === 0) return; + if ('mode' in rn) { + removed.push('mode'); + } + if ('directory' in rn) { + removed.push('directory'); + } + if (typeof rn.file === 'string') { + removed.push('file (string)'); + } + if (removed.length === 0) { + return; + } throw new ConfigError( `notes.releaseNotes no longer supports ${removed.join(', ')}. Release notes go to the GitHub ` + 'release body by default; for in-repo per-version files set notes.releaseNotes.file to ' + @@ -66,12 +76,16 @@ function assertNoRemovedReleaseNotesFields(config: unknown): void { * generated JSON schema, where editors/CI reject the same unknown keys). */ function assertNoRemovedVersionFields(config: unknown): void { - if (!isRecord(config) || !isRecord(config.version)) return; + if (!isRecord(config) || !isRecord(config.version)) { + return; + } const v = config.version; const removed = ['versionStrategy', 'branchPatterns', 'defaultReleaseType', 'updateInternalDependencies'].filter( (k) => k in v, ); - if (removed.length === 0) return; + if (removed.length === 0) { + return; + } throw new ConfigError( `version no longer supports ${removed.join(', ')}. Branch-pattern versioning was never functional — ` + 'use Conventional Commits or --bump for version bumps. For coupled packages use version.groups; to ' + @@ -86,7 +100,9 @@ function assertNoRemovedVersionFields(config: unknown): void { * silently stripping these (which also diverges from the JSON schema's unknown-key rejection). */ function assertNoRemovedTopLevelFields(config: unknown): void { - if (!isRecord(config)) return; + if (!isRecord(config)) { + return; + } if (isRecord(config.ci)) { const removed = ['autoRelease', 'skipPatterns', 'minChanges'].filter((k) => k in (config.ci as object)); if (removed.length > 0) { @@ -154,7 +170,9 @@ export function loadVersionConfig(options?: LoadOptions): VersionConfig | undefi export function loadPublishConfig(options?: LoadOptions): PublishConfig | undefined { const config = loadConfig(options); - if (!config.publish) return undefined; + if (!config.publish) { + return undefined; + } const mergedGit = mergeGitConfig(config.git, config.publish.git); diff --git a/packages/config/src/merge.ts b/packages/config/src/merge.ts index 96e6b49b..6e663e9e 100644 --- a/packages/config/src/merge.ts +++ b/packages/config/src/merge.ts @@ -1,7 +1,9 @@ import type { GitConfig, PublishGitConfig } from './schema.js'; export function mergeGitConfig(topLevel?: GitConfig, packageLevel?: PublishGitConfig): GitConfig | undefined { - if (!topLevel && !packageLevel) return undefined; + if (!topLevel && !packageLevel) { + return undefined; + } const base: GitConfig = topLevel ?? { remote: 'origin', @@ -9,7 +11,9 @@ export function mergeGitConfig(topLevel?: GitConfig, packageLevel?: PublishGitCo pushMethod: 'auto', }; - if (!packageLevel) return base; + if (!packageLevel) { + return base; + } return { remote: packageLevel.remote ?? base.remote, @@ -27,9 +31,15 @@ export function deepMerge>( target: T | undefined, source: Partial | undefined, ): T | undefined { - if (!target && !source) return undefined; - if (!target) return source as T; - if (!source) return target; + if (!target && !source) { + return undefined; + } + if (!target) { + return source as T; + } + if (!source) { + return target; + } const result = { ...target }; diff --git a/packages/config/test/unit/generate-schema.spec.ts b/packages/config/test/unit/generate-schema.spec.ts index 3907c4c8..22061c68 100644 --- a/packages/config/test/unit/generate-schema.spec.ts +++ b/packages/config/test/unit/generate-schema.spec.ts @@ -37,7 +37,9 @@ describe('generate-schema', () => { let objectsChecked = 0; while (stack.length > 0) { const node = stack.pop(); - if (!node || typeof node !== 'object') continue; + if (!node || typeof node !== 'object') { + continue; + } const obj = node as Record; if (obj.type === 'object') { objectsChecked++; @@ -52,7 +54,9 @@ describe('generate-schema', () => { violations.push('object node has additionalProperties: true (silent leniency)'); } } - for (const value of Object.values(obj)) stack.push(value); + for (const value of Object.values(obj)) { + stack.push(value); + } } expect(violations).toEqual([]); expect(objectsChecked).toBeGreaterThan(10); diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index 65f12979..e5aa1087 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -29,7 +29,9 @@ export function findCargoLockfile(startDir: string): string | undefined { while (true) { const lockPath = path.join(dir, 'Cargo.lock'); const hasLock = fs.existsSync(lockPath); - if (hasLock && !nearestLock) nearestLock = lockPath; + if (hasLock && !nearestLock) { + nearestLock = lockPath; + } const cargoToml = path.join(dir, 'Cargo.toml'); if (fs.existsSync(cargoToml) && isWorkspaceManifest(cargoToml)) { @@ -38,7 +40,9 @@ export function findCargoLockfile(startDir: string): string | undefined { } const parent = path.dirname(dir); - if (parent === dir) break; // reached the filesystem root + if (parent === dir) { + break; // reached the filesystem root + } dir = parent; } return nearestLock; diff --git a/packages/core/src/changelogRefs.ts b/packages/core/src/changelogRefs.ts index 0a8c150a..ff257f47 100644 --- a/packages/core/src/changelogRefs.ts +++ b/packages/core/src/changelogRefs.ts @@ -17,16 +17,22 @@ export type ChangelogRefsMode = 'strip' | 'escape' | 'link'; */ export function parseGitHubOwnerRepo(repoUrl: string): { owner: string; repo: string } | null { const scpMatch = repoUrl.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/); - if (scpMatch) return { owner: scpMatch[1]!, repo: scpMatch[2]! }; + if (scpMatch) { + return { owner: scpMatch[1]!, repo: scpMatch[2]! }; + } try { const url = new URL(repoUrl); - if (url.hostname !== 'github.com') return null; + if (url.hostname !== 'github.com') { + return null; + } const parts = url.pathname .replace(/^\//, '') .replace(/\.git$/, '') .split('/'); - if (parts.length >= 2 && parts[0] && parts[1]) return { owner: parts[0], repo: parts[1] }; + if (parts.length >= 2 && parts[0] && parts[1]) { + return { owner: parts[0], repo: parts[1] }; + } } catch { // not a parseable URL } @@ -60,14 +66,18 @@ export function renderIssueRefs( repoUrl: string | null, prNumber?: string, ): string { - if (mode === 'strip') return ''; + if (mode === 'strip') { + return ''; + } const ids = issueIds ?? []; // prNumber is normally already in issueIds; fold it in defensively so no path can silently drop it. const allIds = prNumber && !ids.some((id) => Number(id.replace(/^#/, '')) === Number(prNumber.replace(/^#/, ''))) ? [prNumber, ...ids] : ids; - if (allIds.length === 0) return ''; + if (allIds.length === 0) { + return ''; + } const ownerRepo = mode === 'link' && repoUrl ? parseGitHubOwnerRepo(repoUrl) : null; @@ -76,7 +86,9 @@ export function renderIssueRefs( const pr = prNumber.replace(/^#/, ''); const prPart = `PR [#${pr}](https://github.com/${owner}/${repo}/pull/${pr})`; const closes = allIds.map((raw) => raw.replace(/^#/, '')).filter((num) => Number(num) !== Number(pr)); - if (closes.length === 0) return `(${prPart})`; + if (closes.length === 0) { + return `(${prPart})`; + } const closesPart = closes.map((num) => `[#${num}](https://github.com/${owner}/${repo}/issues/${num})`).join(', '); return `(${prPart} · closes ${closesPart})`; } @@ -144,10 +156,14 @@ export function neutralizeDescriptionRefs( CODE_SPAN_OR_DESC_REF, (match, lead: string | undefined, parenNum?: string, bareNum?: string) => { const num = parenNum ?? bareNum; - if (num === undefined) return match; // inline-code span — leave untouched + if (num === undefined) { + return match; // inline-code span — leave untouched + } const ws = lead ?? ''; // A ref already shown in the appended label is redundant here; `strip` drops every ref. - if (appended.has(Number(num)) || mode === 'strip') return ''; + if (appended.has(Number(num)) || mode === 'strip') { + return ''; + } const inner = ownerRepo ? `[#${num}](https://github.com/${ownerRepo.owner}/${ownerRepo.repo}/issues/${num})` : `\\#${num}`; diff --git a/packages/core/src/dependencyGraph.ts b/packages/core/src/dependencyGraph.ts index 7da923e7..473d1add 100644 --- a/packages/core/src/dependencyGraph.ts +++ b/packages/core/src/dependencyGraph.ts @@ -45,13 +45,19 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende const deps = new Map>(); const dependents = new Map>(); for (const p of packages) { - if (!deps.has(p.name)) deps.set(p.name, new Set()); - if (!dependents.has(p.name)) dependents.set(p.name, new Set()); + if (!deps.has(p.name)) { + deps.set(p.name, new Set()); + } + if (!dependents.has(p.name)) { + dependents.set(p.name, new Set()); + } } for (const p of packages) { for (const d of p.deps) { // Ignore self-edges and dependencies outside the workspace (the ∩-workspace filter). - if (d === p.name || !known.has(d)) continue; + if (d === p.name || !known.has(d)) { + continue; + } deps.get(p.name)?.add(d); dependents.get(d)?.add(p.name); } @@ -65,9 +71,13 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende const stack = [...(deps.get(name) ?? [])]; while (stack.length > 0) { const cur = stack.pop(); - if (cur === undefined || cur === name || out.has(cur)) continue; + if (cur === undefined || cur === name || out.has(cur)) { + continue; + } out.add(cur); - for (const d of deps.get(cur) ?? []) stack.push(d); + for (const d of deps.get(cur) ?? []) { + stack.push(d); + } } return out; }; @@ -78,7 +88,11 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende const indegree = new Map(); for (const n of subset) { let degree = 0; - for (const dep of deps.get(n) ?? []) if (inSubset.has(dep)) degree++; + for (const dep of deps.get(n) ?? []) { + if (inSubset.has(dep)) { + degree++; + } + } indegree.set(n, degree); } // Seed with nodes that have no in-subset dependencies, preserving input order for stability. @@ -87,18 +101,28 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende const emitted = new Set(); while (ready.length > 0) { const n = ready.shift(); - if (n === undefined || emitted.has(n)) continue; + if (n === undefined || emitted.has(n)) { + continue; + } result.push(n); emitted.add(n); for (const dependent of dependents.get(n) ?? []) { - if (!inSubset.has(dependent) || emitted.has(dependent)) continue; + if (!inSubset.has(dependent) || emitted.has(dependent)) { + continue; + } const next = (indegree.get(dependent) ?? 1) - 1; indegree.set(dependent, next); - if (next === 0) ready.push(dependent); + if (next === 0) { + ready.push(dependent); + } } } // Cycle members never reach indegree 0 — append them in input order rather than dropping them. - for (const n of subset) if (!emitted.has(n)) result.push(n); + for (const n of subset) { + if (!emitted.has(n)) { + result.push(n); + } + } return result; }; @@ -107,17 +131,23 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende if (cycle === undefined) { // A full toposort that can't emit every node has a cycle. const indegree = new Map(); - for (const p of packages) indegree.set(p.name, deps.get(p.name)?.size ?? 0); + for (const p of packages) { + indegree.set(p.name, deps.get(p.name)?.size ?? 0); + } const ready = packages.filter((p) => indegree.get(p.name) === 0).map((p) => p.name); let emittedCount = 0; while (ready.length > 0) { const n = ready.shift(); - if (n === undefined) break; + if (n === undefined) { + break; + } emittedCount++; for (const dependent of dependents.get(n) ?? []) { const next = (indegree.get(dependent) ?? 1) - 1; indegree.set(dependent, next); - if (next === 0) ready.push(dependent); + if (next === 0) { + ready.push(dependent); + } } } cycle = emittedCount !== known.size; diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts index 5169100f..d194f3ed 100644 --- a/packages/core/src/logger.ts +++ b/packages/core/src/logger.ts @@ -64,14 +64,20 @@ export function getLogLevel(): LogLevel { function shouldLog(level: LogLevel): boolean { // JSON mode no longer suppresses stderr logging — JSON output goes to stdout, // logs go to stderr, so they don't interfere with each other. - if (quietMode && level !== 'error') return false; + if (quietMode && level !== 'error') { + return false; + } // Debug messages are shown when DEBUG env var is set or log level allows it - if (level === 'debug' && (process.env.DEBUG === 'true' || process.env.DEBUG === '1')) return true; + if (level === 'debug' && (process.env.DEBUG === 'true' || process.env.DEBUG === '1')) { + return true; + } return LOG_LEVELS[level] <= LOG_LEVELS[currentLevel]; } export function log(message: string, level: LogLevel = 'info'): void { - if (!shouldLog(level)) return; + if (!shouldLog(level)) { + return; + } const formatted = COLORS[level](`${PREFIXES[level]} ${message}`); @@ -91,7 +97,9 @@ export function info(message: string): void { } export function success(message: string): void { - if (!shouldLog('info')) return; + if (!shouldLog('info')) { + return; + } console.error(chalk.green(`[SUCCESS] ${message}`)); } diff --git a/packages/core/src/marker.ts b/packages/core/src/marker.ts index af91d6cf..bc9d61af 100644 --- a/packages/core/src/marker.ts +++ b/packages/core/src/marker.ts @@ -14,10 +14,14 @@ /** Linear extract of the trimmed text between `open` and `close`. */ function sliceBetween(body: string, open: string, close: string, restIfNoClose: boolean): string | undefined { const start = body.indexOf(open); - if (start === -1) return undefined; + if (start === -1) { + return undefined; + } const from = start + open.length; const end = body.indexOf(close, from); - if (end === -1) return restIfNoClose ? body.slice(from).trim() : undefined; + if (end === -1) { + return restIfNoClose ? body.slice(from).trim() : undefined; + } return body.slice(from, end).trim(); } diff --git a/packages/core/src/prerequisites.ts b/packages/core/src/prerequisites.ts index 1bbfcbaf..e0272b88 100644 --- a/packages/core/src/prerequisites.ts +++ b/packages/core/src/prerequisites.ts @@ -53,7 +53,9 @@ export function resolvePrerequisites( } } } - for (const name of prereqSet) targetSet.add(name); + for (const name of prereqSet) { + targetSet.add(name); + } // Order by the FULL release set's dependency order, then filter to the derived subset — so a // prerequisite that depends on an explicit target sorts after it. Ordering the prerequisite slice diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index d5b20439..1feada3e 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -19,7 +19,9 @@ describe('findCargoLockfile', () => { } afterEach(() => { - for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true }); + for (const dir of tmpDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } tmpDirs.length = 0; }); @@ -308,7 +310,9 @@ describe('planCargoLockUpdates', () => { } afterEach(() => { - for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true }); + for (const dir of tmpDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } tmpDirs.length = 0; }); diff --git a/packages/forge/src/errors.ts b/packages/forge/src/errors.ts index 0b72c0de..dfcee186 100644 --- a/packages/forge/src/errors.ts +++ b/packages/forge/src/errors.ts @@ -7,7 +7,9 @@ export function forgeErrorStatus(error: unknown): number | undefined { if (error && typeof error === 'object' && 'status' in error) { const { status } = error as { status?: unknown }; - if (typeof status === 'number') return status; + if (typeof status === 'number') { + return status; + } } return undefined; } diff --git a/packages/forge/src/fake.ts b/packages/forge/src/fake.ts index 77ca9eb8..6d78d6ba 100644 --- a/packages/forge/src/fake.ts +++ b/packages/forge/src/fake.ts @@ -193,8 +193,12 @@ export class FakeForge implements Forge { this.updatedIssues.push({ issueNumber, changes }); const existing = this.issues[issueNumber]; if (existing) { - if (changes.body !== undefined) existing.body = changes.body; - if (changes.title !== undefined) existing.title = changes.title; + if (changes.body !== undefined) { + existing.body = changes.body; + } + if (changes.title !== undefined) { + existing.title = changes.title; + } } // Closing drops it from open-issue discovery. if (changes.state === 'closed') { @@ -226,7 +230,9 @@ export class FakeForge implements Forge { async updateComment(commentId: number, body: string): Promise { this.updatedComments.push({ commentId, body }); const comment = this.comments.find((c) => c.id === commentId); - if (comment) comment.body = body; + if (comment) { + comment.body = body; + } } async upsertMarkerComment(prNumber: number, marker: string, body: string): Promise { @@ -246,7 +252,9 @@ export class FakeForge implements Forge { async createLabel(label: NewLabel): Promise { this.createdLabels.push(label); - if (this.labelNames.includes(label.name)) return 'exists'; + if (this.labelNames.includes(label.name)) { + return 'exists'; + } this.labelNames.push(label.name); return 'created'; } diff --git a/packages/forge/src/github.ts b/packages/forge/src/github.ts index b95ee3fd..2ddf1bd5 100644 --- a/packages/forge/src/github.ts +++ b/packages/forge/src/github.ts @@ -42,8 +42,12 @@ function labelNames(labels: LabelLike[] | undefined): string[] { /** Whether a createLabel error is GitHub's idempotent "label already exists" 422. */ function isAlreadyExistsError(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false; - if ((error as { status?: number }).status !== 422) return false; + if (typeof error !== 'object' || error === null) { + return false; + } + if ((error as { status?: number }).status !== 422) { + return false; + } // A 422 for "already exists" carries an errors[].code === 'already_exists'. Other 422s (name too // long, disallowed characters, …) are real validation failures and must surface to the caller. const errors = (error as { response?: { data?: { errors?: Array<{ code?: string }> } } }).response?.data?.errors; @@ -111,7 +115,9 @@ export class GitHubForge implements Forge { for (const pr of data) { prs.push({ number: pr.number, headRef: pr.head.ref, draft: pr.draft ?? false, baseSha: pr.base.sha }); } - if (data.length < 100) break; + if (data.length < 100) { + break; + } } return prs; } @@ -166,7 +172,9 @@ export class GitHubForge implements Forge { }); for await (const response of iterator) { for (const issue of response.data) { - if (!issue.pull_request) return { number: issue.number, url: issue.html_url }; + if (!issue.pull_request) { + return { number: issue.number, url: issue.html_url }; + } } } return null; @@ -178,14 +186,18 @@ export class GitHubForge implements Forge { // Prefer `role_name` — it distinguishes maintain/triage, which the coarse `permission` // ('admin'|'write'|'read'|'none') collapses. Fall back to `permission` for older API shapes. const role = data.role_name as RepoPermission | undefined; - if (role && REPO_PERMISSIONS.includes(role)) return role; + if (role && REPO_PERMISSIONS.includes(role)) { + return role; + } return (data.permission as RepoPermission) ?? 'none'; } catch (error) { // 404 = not a collaborator / unknown user → genuinely no permission. Any other error (403 from a // mis-scoped token, 429 rate limit, 5xx, network) means we CAN'T determine permission — surface // it rather than silently reporting 'none', which would fail the gate closed for every real // actor (even admins) with no diagnostic. - if (forgeErrorStatus(error) === 404) return 'none'; + if (forgeErrorStatus(error) === 404) { + return 'none'; + } throw error; } } @@ -199,7 +211,9 @@ export class GitHubForge implements Forge { // 404 = not a member. Any other error (403 = the token lacks org-read scope, network, …) can't // be answered — surface it (the caller's gate wrapper warns and fails closed) rather than // silently reporting "not a member", which is indistinguishable from a real non-member. - if (forgeErrorStatus(error) === 404) return false; + if (forgeErrorStatus(error) === 404) { + return false; + } throw error; } } @@ -216,13 +230,17 @@ export class GitHubForge implements Forge { let humanMatch: ForgeComment | null = null; for await (const response of iterator) { for (const comment of response.data) { - if (!comment.body?.startsWith(marker)) continue; + if (!comment.body?.startsWith(marker)) { + continue; + } const found: ForgeComment = { id: comment.id, body: comment.body, user: comment.user ? { login: comment.user.login, type: comment.user.type } : undefined, }; - if (isBotComment(found)) return found; + if (isBotComment(found)) { + return found; + } humanMatch ??= found; } } @@ -256,7 +274,9 @@ export class GitHubForge implements Forge { per_page: 100, }); for await (const response of iterator) { - for (const label of response.data) names.push(label.name); + for (const label of response.data) { + names.push(label.name); + } } return names; } @@ -266,7 +286,9 @@ export class GitHubForge implements Forge { await this.octokit.rest.issues.createLabel({ ...this.base, ...label }); return 'created'; } catch (error) { - if (isAlreadyExistsError(error)) return 'exists'; + if (isAlreadyExistsError(error)) { + return 'exists'; + } throw error; } } @@ -286,7 +308,9 @@ export class GitHubForge implements Forge { for (const r of data) { releases.push({ tagName: r.tag_name, draft: r.draft, prerelease: r.prerelease, body: r.body ?? '' }); } - if (data.length < 100) break; + if (data.length < 100) { + break; + } } return releases; } diff --git a/packages/forge/src/types.ts b/packages/forge/src/types.ts index 7c342f3d..b2f35a27 100644 --- a/packages/forge/src/types.ts +++ b/packages/forge/src/types.ts @@ -99,7 +99,9 @@ export interface ForgeComment { */ export function isBotComment(comment: Pick): boolean { const user = comment.user; - if (!user) return true; + if (!user) { + return true; + } return user.type === 'Bot' || (user.login !== undefined && user.login.endsWith('[bot]')); } diff --git a/packages/forge/test/unit/github.spec.ts b/packages/forge/test/unit/github.spec.ts index 1ebed68c..d7054626 100644 --- a/packages/forge/test/unit/github.spec.ts +++ b/packages/forge/test/unit/github.spec.ts @@ -72,7 +72,9 @@ function makeOctokit() { function pages(...batches: T[][]) { return { async *[Symbol.asyncIterator]() { - for (const data of batches) yield { data }; + for (const data of batches) { + yield { data }; + } }, }; } diff --git a/packages/git/src/errors.ts b/packages/git/src/errors.ts index 50d682e3..58d2ac2f 100644 --- a/packages/git/src/errors.ts +++ b/packages/git/src/errors.ts @@ -46,7 +46,9 @@ export class GitError extends Error { export function gitExitCode(error: unknown): number | undefined { if (error && typeof error === 'object' && 'code' in error) { const { code } = error as { code?: unknown }; - if (typeof code === 'number') return code; + if (typeof code === 'number') { + return code; + } } return undefined; } @@ -57,7 +59,9 @@ export function gitExitCode(error: unknown): number | undefined { * that otherwise swallows a non-zero exit — because it is a configuration failure, not a "no" answer. */ export function isCommandNotFound(error: unknown): boolean { - if (!error || typeof error !== 'object') return false; + if (!error || typeof error !== 'object') { + return false; + } const { code } = error as { code?: unknown }; return code === 'ENOENT'; } @@ -68,6 +72,8 @@ export function isCommandNotFound(error: unknown): boolean { * push from an ordinary non-zero exit and report it as a timeout. */ export function isExecTimeout(error: unknown): boolean { - if (!error || typeof error !== 'object') return false; + if (!error || typeof error !== 'object') { + return false; + } return (error as { killed?: unknown }).killed === true; } diff --git a/packages/git/src/fake.ts b/packages/git/src/fake.ts index 5acdd867..2bb6c75c 100644 --- a/packages/git/src/fake.ts +++ b/packages/git/src/fake.ts @@ -79,8 +79,12 @@ export interface FakeGitSeed { } function ancestryHas(ancestors: FakeGitSeed['ancestors'], ancestor: string, ref: string): boolean { - if (!ancestors) return false; - if (typeof ancestors === 'function') return ancestors(ancestor, ref); + if (!ancestors) { + return false; + } + if (typeof ancestors === 'function') { + return ancestors(ancestor, ref); + } return (ancestors[ref] ?? []).includes(ancestor); } @@ -219,7 +223,9 @@ export class FakeGit implements Git { async tag(name: string, opts: GitTagOptions = {}): Promise { this.tagged.push({ name, message: opts.message }); - if (!this.tagsList.includes(name)) this.tagsList.push(name); + if (!this.tagsList.includes(name)) { + this.tagsList.push(name); + } } async fetch(remote: string, _opts: GitFetchOptions = {}): Promise { diff --git a/packages/git/src/git.ts b/packages/git/src/git.ts index 41e60cc2..39981d46 100644 --- a/packages/git/src/git.ts +++ b/packages/git/src/git.ts @@ -164,7 +164,9 @@ export class GitCli implements Git { async listTags(opts: GitListTagsOptions = {}): Promise { const args = ['tag']; - if (opts.sort) args.push(`--sort=${opts.sort}`); + if (opts.sort) { + args.push(`--sort=${opts.sort}`); + } const { stdout } = await this.exec(args, { cwd: this.cwd(opts.cwd) }); return splitLines(stdout); } @@ -204,18 +206,30 @@ export class GitCli implements Git { async countCommits(range: string, opts: GitCountCommitsOptions = {}): Promise { assertNotOption(range, 'range'); const args = ['rev-list', '--count', range]; - if (opts.path) args.push('--', opts.path); + if (opts.path) { + args.push('--', opts.path); + } const { stdout } = await this.exec(args, { cwd: this.cwd(opts.cwd) }); return Number.parseInt(stdout.trim(), 10) || 0; } async log(opts: GitLogOptions): Promise { - if (opts.range !== undefined) assertNotOption(opts.range, 'range'); + if (opts.range !== undefined) { + assertNotOption(opts.range, 'range'); + } const args = ['log']; - if (opts.format !== undefined) args.push(`--format=${opts.format}`); - if (opts.extraArgs) args.push(...opts.extraArgs); - if (opts.range !== undefined) args.push(opts.range); - if (opts.paths && opts.paths.length > 0) args.push('--', ...opts.paths); + if (opts.format !== undefined) { + args.push(`--format=${opts.format}`); + } + if (opts.extraArgs) { + args.push(...opts.extraArgs); + } + if (opts.range !== undefined) { + args.push(opts.range); + } + if (opts.paths && opts.paths.length > 0) { + args.push('--', ...opts.paths); + } const { stdout } = await this.exec(args, { cwd: this.cwd(opts.cwd) }); return stdout; } @@ -240,10 +254,16 @@ export class GitCli implements Git { async forEachRef(opts: GitForEachRefOptions): Promise { // `format`/`sort` are flag values by design; `pattern` is a positional ref, so guard it like // every other user-controlled positional (a leading-`-` pattern would be read as an option). - if (opts.pattern !== undefined) assertNotOption(opts.pattern, 'pattern'); + if (opts.pattern !== undefined) { + assertNotOption(opts.pattern, 'pattern'); + } const args = ['for-each-ref', `--format=${opts.format}`]; - if (opts.sort) args.push(`--sort=${opts.sort}`); - if (opts.pattern) args.push(opts.pattern); + if (opts.sort) { + args.push(`--sort=${opts.sort}`); + } + if (opts.pattern) { + args.push(opts.pattern); + } const { stdout } = await this.exec(args, { cwd: this.cwd(opts.cwd) }); return splitLines(stdout); } @@ -272,8 +292,12 @@ export class GitCli implements Git { async commit(message: string, opts: GitCommitOptions = {}): Promise { const args = ['commit', '-m', message]; - if (opts.skipHooks) args.push('--no-verify'); - if (opts.paths && opts.paths.length > 0) args.push('--', ...opts.paths); + if (opts.skipHooks) { + args.push('--no-verify'); + } + if (opts.paths && opts.paths.length > 0) { + args.push('--', ...opts.paths); + } await this.exec(args, { cwd: this.cwd(opts.cwd) }); } @@ -302,22 +326,35 @@ export class GitCli implements Git { async status(opts: GitStatusOptions = {}): Promise { const args = ['status']; - if (opts.porcelain) args.push('--porcelain'); - if (opts.paths && opts.paths.length > 0) args.push('--', ...opts.paths); + if (opts.porcelain) { + args.push('--porcelain'); + } + if (opts.paths && opts.paths.length > 0) { + args.push('--', ...opts.paths); + } const { stdout } = await this.exec(args, { cwd: this.cwd(opts.cwd) }); return stdout; } async push(opts: GitPushOptions): Promise { assertNotOption(opts.remote, 'remote'); - if (opts.ref !== undefined) assertNotOption(opts.ref, 'ref'); + if (opts.ref !== undefined) { + assertNotOption(opts.ref, 'ref'); + } const args = ['push']; - if (opts.forceWithLease) args.push('--force-with-lease'); - else if (opts.force) args.push('--force'); - if (opts.tags) args.push('--tags'); + if (opts.forceWithLease) { + args.push('--force-with-lease'); + } else if (opts.force) { + args.push('--force'); + } + if (opts.tags) { + args.push('--tags'); + } // `--end-of-options`: remote/ref can never be parsed as git options (`--receive-pack=…` → RCE). args.push('--end-of-options', opts.remote); - if (opts.ref !== undefined) args.push(opts.ref); + if (opts.ref !== undefined) { + args.push(opts.ref); + } // A push can hang on a stalled network forever; a hard timeout preserves the safety the publish // package previously got from its async exec wrapper. execFile kills the child on expiry. await this.exec(args, { cwd: this.cwd(opts.cwd), timeout: opts.timeoutMs ?? DEFAULT_PUSH_TIMEOUT_MS }); diff --git a/packages/git/test/unit/git.spec.ts b/packages/git/test/unit/git.spec.ts index 5690e5cf..4d66bce2 100644 --- a/packages/git/test/unit/git.spec.ts +++ b/packages/git/test/unit/git.spec.ts @@ -23,7 +23,9 @@ function makeRunner(responses: { stdout?: string; stderr?: string; reject?: unkn const calls: Call[] = []; const run: GitRunner = vi.fn(async (args, options) => { calls.push({ args, options }); - if (responses.reject !== undefined) throw responses.reject; + if (responses.reject !== undefined) { + throw responses.reject; + } return { stdout: responses.stdout ?? '', stderr: responses.stderr ?? '' }; }); return { run, calls }; diff --git a/packages/notes/src/command.ts b/packages/notes/src/command.ts index 78085119..b49143b5 100644 --- a/packages/notes/src/command.ts +++ b/packages/notes/src/command.ts @@ -37,7 +37,9 @@ export function createNotesCommand(): Command { .option('-q, --quiet', 'Suppress non-error output') .action(async (options) => { setVerbosity(options.verbose); - if (options.quiet) setQuietMode(true); + if (options.quiet) { + setQuietMode(true); + } try { const loadedConfig = loadConfig({ cwd: process.cwd(), configPath: options.config }); @@ -113,9 +115,15 @@ export function createNotesCommand(): Command { model: existingLlm?.model, ...(existingLlm ?? {}), }; - if (options.llmProvider) llm.provider = options.llmProvider; - if (options.llmModel) llm.model = options.llmModel; - if (options.llmBaseUrl) llm.baseURL = options.llmBaseUrl; + if (options.llmProvider) { + llm.provider = options.llmProvider; + } + if (options.llmModel) { + llm.model = options.llmModel; + } + if (options.llmBaseUrl) { + llm.baseURL = options.llmBaseUrl; + } if (options.llmTasks) { const taskNames = (options.llmTasks as string).split(',').map((t: string) => t.trim()); llm.tasks = { diff --git a/packages/notes/src/core/pipeline.ts b/packages/notes/src/core/pipeline.ts index 3c164456..e50d11a4 100644 --- a/packages/notes/src/core/pipeline.ts +++ b/packages/notes/src/core/pipeline.ts @@ -49,9 +49,13 @@ function findCompoundTagSepPos(tag: string): number { // Digit after dash — confirm semver-like by requiring ≥2 dots in the remainder let dots = 0; for (let i = afterDash; i < tag.length; i++) { - if (tag[i] === '.') dots++; + if (tag[i] === '.') { + dots++; + } + } + if (dots >= 2) { + return pos; } - if (dots >= 2) return pos; } pos = tag.lastIndexOf('-', pos - 1); } @@ -61,7 +65,9 @@ function findCompoundTagSepPos(tag: string): number { /** True when position `i` in `tag` begins a semver version: a digit, or a `v` immediately followed by a digit. */ function isVersionStartAt(tag: string, i: number): boolean { const c = tag.charCodeAt(i); - if (c >= 48 && c <= 57) return true; + if (c >= 48 && c <= 57) { + return true; + } if (tag[i] === 'v') { const n = tag.charCodeAt(i + 1); return n >= 48 && n <= 57; @@ -132,7 +138,9 @@ export function buildOrderedCategories( ): EnhancedCategory[] { const order = configCategories?.map((c) => c.name) ?? []; const mapped = rawCategories.map((c) => ({ name: c.category, entries: c.entries })); - if (order.length === 0) return mapped; + if (order.length === 0) { + return mapped; + } return mapped.sort((a, b) => { const ai = order.indexOf(a.name); const bi = order.indexOf(b.name); @@ -508,7 +516,9 @@ export async function runPipeline( } else { writeMarkdown(fileName, contexts, config, dryRun, fmtOpts); } - if (!dryRun) files.push(fileName); + if (!dryRun) { + files.push(fileName); + } } if (mode === 'packages' || mode === 'both') { @@ -547,7 +557,9 @@ export async function runPipeline( return renderTemplate(templatePath, docCtx, releaseNotesConfig.templates.engine as TemplateEngine | undefined) .content; } - if (ctx.enhanced?.releaseNotes) return ctx.enhanced.releaseNotes; + if (ctx.enhanced?.releaseNotes) { + return ctx.enhanced.releaseNotes; + } return formatVersion(ctx, fmtOpts); }; @@ -626,7 +638,9 @@ async function writeMonorepoFiles( const cwd = process.cwd(); const detected = detectMonorepo(cwd); - if (!detected.isMonorepo) return []; + if (!detected.isMonorepo) { + return []; + } const monoFiles = writeMonorepoChangelogs( contexts, diff --git a/packages/notes/src/errors/index.ts b/packages/notes/src/errors/index.ts index 6bee391c..1da789f0 100644 --- a/packages/notes/src/errors/index.ts +++ b/packages/notes/src/errors/index.ts @@ -39,7 +39,9 @@ export class LLMError extends NotesError { constructor(message: string, options?: { retryable?: boolean; cause?: unknown }) { super(message); // Chain the original provider/SDK error so its stack survives (the message only carries its text). - if (options?.cause !== undefined) this.cause = options.cause; + if (options?.cause !== undefined) { + this.cause = options.cause; + } this.retryable = options?.retryable; } } diff --git a/packages/notes/src/input/conventional-changelog.ts b/packages/notes/src/input/conventional-changelog.ts index 9a3fe4f4..e3a4d282 100644 --- a/packages/notes/src/input/conventional-changelog.ts +++ b/packages/notes/src/input/conventional-changelog.ts @@ -65,7 +65,9 @@ function extractIssueIds(text: string): { description: string; issueIds: string[ function parseEntry(line: string, sectionType: ChangelogEntry['type']): ChangelogEntry | null { const bulletMatch = line.match(BULLET); - if (!bulletMatch) return null; + if (!bulletMatch) { + return null; + } let rawText = bulletMatch[1] ?? ''; let scope: string | undefined; @@ -126,7 +128,9 @@ function parseVersionBlocks(content: string): VersionBlock[] { continue; } - if (!currentBlock) continue; + if (!currentBlock) { + continue; + } const sectionMatch = line.match(SECTION_HEADER); if (sectionMatch) { diff --git a/packages/notes/src/input/manual.ts b/packages/notes/src/input/manual.ts index 4c1bfec5..9dd48a13 100644 --- a/packages/notes/src/input/manual.ts +++ b/packages/notes/src/input/manual.ts @@ -20,7 +20,9 @@ export function parseManualInput(json: string): ChangelogInput { if (Array.isArray(input.packages)) { for (const pkg of input.packages) { - if (typeof pkg !== 'object' || pkg === null) continue; + if (typeof pkg !== 'object' || pkg === null) { + continue; + } const p = pkg as Record; @@ -97,7 +99,9 @@ function normalizeType(type: unknown): import('../core/types.js').ChangelogType if (typeof type === 'string') { const mapped = typeMap[type.toLowerCase()]; - if (mapped) return mapped; + if (mapped) { + return mapped; + } } return 'changed'; diff --git a/packages/notes/src/llm/anthropic.ts b/packages/notes/src/llm/anthropic.ts index 17cbd15f..7a1371d2 100644 --- a/packages/notes/src/llm/anthropic.ts +++ b/packages/notes/src/llm/anthropic.ts @@ -101,7 +101,9 @@ export class AnthropicProvider extends BaseLLMProvider { return { content: textBlock.text }; } catch (error) { - if (error instanceof LLMError) throw error; + if (error instanceof LLMError) { + throw error; + } if (signal.aborted) { throw new LLMError(`Anthropic request timed out after ${this.getTimeout(options)}ms`, { retryable: true }); } diff --git a/packages/notes/src/llm/base.ts b/packages/notes/src/llm/base.ts index 4fbecbb8..b260b5ea 100644 --- a/packages/notes/src/llm/base.ts +++ b/packages/notes/src/llm/base.ts @@ -20,7 +20,9 @@ export abstract class BaseLLMProvider implements LLMProvider { // - above the timer ceiling (2^31-1 ms ≈ 24.8 days) → clamp, not throw/overflow. A huge value // means "effectively no timeout", so clamping preserves intent rather than dropping to 60s. const MAX_TIMEOUT_MS = 2_147_483_647; - if (!Number.isFinite(timeout) || timeout < 1) return LLM_DEFAULTS.timeout; + if (!Number.isFinite(timeout) || timeout < 1) { + return LLM_DEFAULTS.timeout; + } return Math.min(Math.trunc(timeout), MAX_TIMEOUT_MS); } diff --git a/packages/notes/src/llm/context/prFetcher.ts b/packages/notes/src/llm/context/prFetcher.ts index 13805fa5..ba1f777e 100644 --- a/packages/notes/src/llm/context/prFetcher.ts +++ b/packages/notes/src/llm/context/prFetcher.ts @@ -29,10 +29,14 @@ function stripDetailsTags(input: string): string { const tagEnd = lower.indexOf('>', i); i = tagEnd === -1 ? input.length : tagEnd + 1; } else if (lower.startsWith('', i)) { - if (depth > 0) depth--; + if (depth > 0) { + depth--; + } i += 10; } else { - if (depth === 0) result += input[i]; + if (depth === 0) { + result += input[i]; + } i++; } } @@ -47,7 +51,9 @@ function sanitiseBody(raw: string): string { } function truncateBody(body: string): string { - if (body.length <= BODY_CAP) return body; + if (body.length <= BODY_CAP) { + return body; + } // Prefer truncating at a heading boundary const headingPos = body.lastIndexOf('\n#', BODY_CAP); diff --git a/packages/notes/src/llm/correctiveRetry.ts b/packages/notes/src/llm/correctiveRetry.ts index be1611b7..1ef65feb 100644 --- a/packages/notes/src/llm/correctiveRetry.ts +++ b/packages/notes/src/llm/correctiveRetry.ts @@ -27,7 +27,9 @@ export async function withCorrectiveRetry( const result = await call(messages, attempt === 0); const validation = validate(result); - if (validation.valid) return validation.value; + if (validation.valid) { + return validation.value; + } lastError = validation.error; diff --git a/packages/notes/src/llm/examples/fetcher.ts b/packages/notes/src/llm/examples/fetcher.ts index 4acc0b03..e5b3cc8f 100644 --- a/packages/notes/src/llm/examples/fetcher.ts +++ b/packages/notes/src/llm/examples/fetcher.ts @@ -42,7 +42,9 @@ function readCache(key: string, latestTag: string, count: number): Example[] | n const entry: CacheEntry = JSON.parse(raw); // Invalidate if the tag changed or the cached count is smaller than requested // (the user may have increased `examples` config since the last run). - if (entry.latestTag !== latestTag || entry.count < count) return null; + if (entry.latestTag !== latestTag || entry.count < count) { + return null; + } return entry.examples; } catch { return null; @@ -70,7 +72,9 @@ function matchesBareVersion(tagName: string): boolean { export async function fetchExamples(options: FetchExamplesOptions): Promise { const { owner, repo, packageName, count, githubToken, isMonorepo } = options; - if (count === 0) return []; + if (count === 0) { + return []; + } const token = githubToken ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; if (!token) { @@ -104,13 +108,19 @@ export async function fetchExamples(options: FetchExamplesOptions): Promise 0) writeCache(key, latestTag, count, examples); + if (examples.length > 0) { + writeCache(key, latestTag, count, examples); + } debug(`Fetched ${examples.length} examples for ${packageName}`); return examples; } catch (error) { diff --git a/packages/notes/src/llm/examples/parser.ts b/packages/notes/src/llm/examples/parser.ts index e9d55313..28e5f232 100644 --- a/packages/notes/src/llm/examples/parser.ts +++ b/packages/notes/src/llm/examples/parser.ts @@ -11,7 +11,9 @@ export function parseReleaseBodyToExample(markdown: string, version: string): Ex for (const raw of lines) { const line = raw.trim(); - if (!line) continue; + if (!line) { + continue; + } // ## / ### / #### Heading → new category (h1 skipped as document title) const headingMatch = /^#{2,4} (.+)/.exec(line); @@ -19,7 +21,9 @@ export function parseReleaseBodyToExample(markdown: string, version: string): Ex currentCategory = headingMatch[1]!.trim(); continue; } - if (line.startsWith('#')) continue; + if (line.startsWith('#')) { + continue; + } // Bullet entry if (BULLET_RE.test(line)) { @@ -29,18 +33,26 @@ export function parseReleaseBodyToExample(markdown: string, version: string): Ex description: leadInMatch ? text.slice(leadInMatch[0].length) : text, category: currentCategory, }; - if (leadInMatch) entry.leadIn = leadInMatch[1]; - if (/\*\*BREAKING\*\*/i.test(text)) entry.breaking = true; + if (leadInMatch) { + entry.leadIn = leadInMatch[1]; + } + if (/\*\*BREAKING\*\*/i.test(text)) { + entry.breaking = true; + } entries.push(entry); } } - if (entries.length === 0) return null; + if (entries.length === 0) { + return null; + } return { version, entries }; } export function renderExamplesBlock(examples: Example[]): string { - if (examples.length === 0) return ''; + if (examples.length === 0) { + return ''; + } // These examples are built from past release bodies and re-fed as few-shot input, and release-notes // output becomes a future example — a self-poisoning loop. Escape every interpolated field so an diff --git a/packages/notes/src/llm/messages.ts b/packages/notes/src/llm/messages.ts index 5c9a57c5..4d77ed00 100644 --- a/packages/notes/src/llm/messages.ts +++ b/packages/notes/src/llm/messages.ts @@ -11,7 +11,9 @@ export interface CompleteResult { // Opt-in via RELEASEKIT_DEBUG=1. Output may contain fetched PR bodies and // example release notes from external repos — don't enable in shared CI logs. export function debugLogMessages(providerName: string, messages: LLMMessage[]): void { - if (process.env.RELEASEKIT_DEBUG !== '1') return; + if (process.env.RELEASEKIT_DEBUG !== '1') { + return; + } console.error(`[RELEASEKIT_DEBUG] ${providerName} messages (${messages.length}):`); for (const msg of messages) { const preview = msg.content.length > 300 ? `${msg.content.slice(0, 300)}…` : msg.content; diff --git a/packages/notes/src/llm/ollama.ts b/packages/notes/src/llm/ollama.ts index 06a76258..809be773 100644 --- a/packages/notes/src/llm/ollama.ts +++ b/packages/notes/src/llm/ollama.ts @@ -129,7 +129,9 @@ export class OllamaProvider extends BaseLLMProvider { return { content }; } catch (error) { - if (error instanceof LLMError) throw error; + if (error instanceof LLMError) { + throw error; + } if (signal.aborted) { throw new LLMError(`Ollama request timed out after ${this.getTimeout(options)}ms`, { retryable: true }); } diff --git a/packages/notes/src/llm/openai-compatible.ts b/packages/notes/src/llm/openai-compatible.ts index faae2965..b3a18ea4 100644 --- a/packages/notes/src/llm/openai-compatible.ts +++ b/packages/notes/src/llm/openai-compatible.ts @@ -63,7 +63,9 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { return { content }; } catch (error) { - if (error instanceof LLMError) throw error; + if (error instanceof LLMError) { + throw error; + } if (signal.aborted) { throw new LLMError(`${this.name} request timed out after ${this.getTimeout(options)}ms`, { retryable: true }); } diff --git a/packages/notes/src/llm/openai.ts b/packages/notes/src/llm/openai.ts index aab8b556..264bcbe6 100644 --- a/packages/notes/src/llm/openai.ts +++ b/packages/notes/src/llm/openai.ts @@ -91,7 +91,9 @@ export class OpenAIProvider extends BaseLLMProvider { return { content }; } catch (error) { - if (error instanceof LLMError) throw error; + if (error instanceof LLMError) { + throw error; + } if (signal.aborted) { throw new LLMError(`OpenAI request timed out after ${this.getTimeout(options)}ms`, { retryable: true }); } diff --git a/packages/notes/src/llm/retryable.ts b/packages/notes/src/llm/retryable.ts index 9d2f555d..e983bbfd 100644 --- a/packages/notes/src/llm/retryable.ts +++ b/packages/notes/src/llm/retryable.ts @@ -19,9 +19,15 @@ const NETWORK_ERROR_CODES = new Set([ * auth, permission, validation, or not-found error that retrying can't fix — fail fast. */ export function isRetryableStatus(status: number): boolean { - if (status === 408 || status === 409 || status === 425 || status === 429) return true; - if (status >= 500) return true; - if (status >= 400) return false; + if (status === 408 || status === 409 || status === 425 || status === 429) { + return true; + } + if (status >= 500) { + return true; + } + if (status >= 400) { + return false; + } // Non-error status (shouldn't reach here for a thrown error) — retry rather than silently drop. return true; } @@ -33,10 +39,16 @@ export function isRetryableStatus(status: number): boolean { * fail-fast path is an explicit non-retryable 4xx status. */ export function isRetryableProviderError(error: unknown): boolean { - if (!error || typeof error !== 'object') return true; + if (!error || typeof error !== 'object') { + return true; + } const e = error as { status?: unknown; code?: unknown; name?: unknown }; - if (typeof e.status === 'number') return isRetryableStatus(e.status); - if (typeof e.code === 'string' && NETWORK_ERROR_CODES.has(e.code)) return true; + if (typeof e.status === 'number') { + return isRetryableStatus(e.status); + } + if (typeof e.code === 'string' && NETWORK_ERROR_CODES.has(e.code)) { + return true; + } // Everything else — abort/timeout error names (AbortError, TimeoutError, APIConnectionError, // APIConnectionTimeoutError) and unknown shapes alike — defaults to retryable; the only fail-fast // path is the explicit non-retryable 4xx status handled above. @@ -48,6 +60,8 @@ export function isRetryableProviderError(error: unknown): boolean { * any other error (unclassified) is retried by default. */ export function isRetryableLLMError(error: unknown): boolean { - if (error instanceof LLMError) return error.retryable ?? true; + if (error instanceof LLMError) { + return error.retryable ?? true; + } return true; } diff --git a/packages/notes/src/llm/scopes.ts b/packages/notes/src/llm/scopes.ts index ceab39ac..4d3e0772 100644 --- a/packages/notes/src/llm/scopes.ts +++ b/packages/notes/src/llm/scopes.ts @@ -54,9 +54,15 @@ export function resolveAllowedScopes( categories?: LLMCategory[], packageNames?: string[], ): string[] | null { - if (!scopeConfig || scopeConfig.mode === 'unrestricted') return null; - if (scopeConfig.mode === 'none') return []; - if (scopeConfig.mode === 'packages') return expandPackageScopes(packageNames ?? []); + if (!scopeConfig || scopeConfig.mode === 'unrestricted') { + return null; + } + if (scopeConfig.mode === 'none') { + return []; + } + if (scopeConfig.mode === 'packages') { + return expandPackageScopes(packageNames ?? []); + } if (scopeConfig.mode === 'restricted') { const explicit = scopeConfig.rules?.allowed ?? []; @@ -65,7 +71,9 @@ export function resolveAllowedScopes( if (categories) { const fromCategories = getAllowedScopesFromCategories(categories); for (const scopes of fromCategories.values()) { - for (const s of scopes) all.add(s); + for (const s of scopes) { + all.add(s); + } } } @@ -84,8 +92,12 @@ export function validateScope( allowedScopes: string[] | null, caseSensitive = false, ): string | undefined { - if (!scope || allowedScopes === null) return scope; - if (allowedScopes.length === 0) return undefined; + if (!scope || allowedScopes === null) { + return scope; + } + if (allowedScopes.length === 0) { + return undefined; + } const normalise = (s: string) => (caseSensitive ? s : s.toLowerCase()); const isAllowed = allowedScopes.some((a) => normalise(a) === normalise(scope)); @@ -107,7 +119,9 @@ export function validateEntryScopes( packageNames?: string[], ): ScopeValidationResult { const allowedScopes = resolveAllowedScopes(scopeConfig, categories, packageNames); - if (allowedScopes === null) return { valid: true, entries, errors: [] }; + if (allowedScopes === null) { + return { valid: true, entries, errors: [] }; + } const caseSensitive = scopeConfig?.rules?.caseSensitive ?? false; const action = scopeConfig?.rules?.invalidScopeAction ?? 'remove'; diff --git a/packages/notes/src/llm/tasks/categorize.ts b/packages/notes/src/llm/tasks/categorize.ts index ab522319..00776b26 100644 --- a/packages/notes/src/llm/tasks/categorize.ts +++ b/packages/notes/src/llm/tasks/categorize.ts @@ -55,7 +55,9 @@ export function createCategorizeValidator( return (result) => { const parsed = parseLLMResult(result); - if (!parsed.ok) return { valid: false, error: parsed.error }; + if (!parsed.ok) { + return { valid: false, error: parsed.error }; + } const zodResult = CategorizeOutputSchema.safeParse(parsed.data); if (!zodResult.success) { @@ -74,7 +76,9 @@ export function createCategorizeValidator( zodResult.data.entries, context.categories?.map((c) => c.name), ); - if (categoryError) return { valid: false, error: categoryError }; + if (categoryError) { + return { valid: false, error: categoryError }; + } // Apply scopes from LLM response const withScopes: ChangelogEntry[] = cleanEntries.map((entry, i) => { diff --git a/packages/notes/src/llm/tasks/enhance-and-categorize.ts b/packages/notes/src/llm/tasks/enhance-and-categorize.ts index 34abbcba..5e8b0374 100644 --- a/packages/notes/src/llm/tasks/enhance-and-categorize.ts +++ b/packages/notes/src/llm/tasks/enhance-and-categorize.ts @@ -74,7 +74,9 @@ export function createEnhanceAndCategorizeValidator( return (result) => { // Parse JSON (structured output or text fallback) const parsed = parseLLMResult(result); - if (!parsed.ok) return { valid: false, error: parsed.error }; + if (!parsed.ok) { + return { valid: false, error: parsed.error }; + } // Validate structure const zodResult = EnhanceAndCategorizeOutputSchema.safeParse(parsed.data); @@ -99,12 +101,16 @@ export function createEnhanceAndCategorizeValidator( zodResult.data.entries, context.categories?.map((c) => c.name), ); - if (categoryError) return { valid: false, error: categoryError }; + if (categoryError) { + return { valid: false, error: categoryError }; + } // Build enhanced entries const enhancedEntries: ChangelogEntry[] = entries.map((original, i) => { const llmEntry = zodResult.data.entries[i]; - if (!llmEntry) return original; + if (!llmEntry) { + return original; + } return { ...original, description: llmEntry.description, @@ -143,8 +149,11 @@ export function createEnhanceAndCategorizeValidator( function mergeCategories(target: CategorizedEntries[], incoming: CategorizedEntries[]): void { for (const cat of incoming) { const existing = target.find((c) => c.category === cat.category); - if (existing) existing.entries.push(...cat.entries); - else target.push({ category: cat.category, entries: [...cat.entries] }); + if (existing) { + existing.entries.push(...cat.entries); + } else { + target.push({ category: cat.category, entries: [...cat.entries] }); + } } } diff --git a/packages/notes/src/llm/tasks/shared.ts b/packages/notes/src/llm/tasks/shared.ts index 3957fecc..94a0b17c 100644 --- a/packages/notes/src/llm/tasks/shared.ts +++ b/packages/notes/src/llm/tasks/shared.ts @@ -15,7 +15,9 @@ export function escBody(s: string): string { } export function renderPRBlocks(entry: ChangelogEntry): string { - if (!entry.context?.prs.length) return ''; + if (!entry.context?.prs.length) { + return ''; + } return entry.context.prs .map( (pr) => `${pr.body ? `\n${escBody(pr.body)}\n` : ''}`, @@ -39,8 +41,12 @@ export const INSTRUCTION_HIERARCHY = */ export function renderEntry(entry: ChangelogEntry, index: number): string { const attrs = [`index="${index}"`, `type="${escAttr(entry.type)}"`]; - if (entry.scope) attrs.push(`scope="${escAttr(entry.scope)}"`); - if (entry.breaking) attrs.push('breaking="true"'); + if (entry.scope) { + attrs.push(`scope="${escAttr(entry.scope)}"`); + } + if (entry.breaking) { + attrs.push('breaking="true"'); + } const prBlocks = renderPRBlocks(entry); const body = `${escBody(entry.description)}${prBlocks ? `\n${prBlocks}` : ''}`; return `\n${body}\n`; @@ -63,8 +69,12 @@ export function groupByCategory( for (let i = 0; i < llmEntries.length; i++) { const category = llmEntries[i]?.category ?? 'Unknown'; const entry = entries[i]; - if (!entry) continue; - if (!categoryMap.has(category)) categoryMap.set(category, []); + if (!entry) { + continue; + } + if (!categoryMap.has(category)) { + categoryMap.set(category, []); + } categoryMap.get(category)!.push(entry); } const result: CategorizedEntries[] = []; @@ -80,7 +90,9 @@ export function groupByCategory( * it returns the task-specific free-form `fallback` instruction. */ export function buildCategorySection(categories: LLMCategory[] | undefined, fallback: string): string { - if (!categories || categories.length === 0) return fallback; + if (!categories || categories.length === 0) { + return fallback; + } const lines = categories.map((c) => { const scopeInfo = c.scopes?.length ? ` Allowed scopes: ${c.scopes.join(', ')}.` : ''; return `- "${c.name}": ${c.description}${scopeInfo}`; @@ -94,7 +106,9 @@ export function buildCategorySection(categories: LLMCategory[] | undefined, fall * The scope-source resolution stays in each task — only the final rendering is shared. */ export function renderScopeInstruction(pairs: Array<{ name: string; scopes: string[] }>, entrySuffix: string): string { - if (pairs.length === 0) return ''; + if (pairs.length === 0) { + return ''; + } const parts = pairs.map((p) => `For "${p.name}"${entrySuffix}, use a scope from: ${p.scopes.join(', ')}.`); return `\n${parts.join('\n')}\nOnly use scopes from these predefined lists. Set scope to null if no scope applies.`; } @@ -136,9 +150,13 @@ export function checkCategoryNames( llmEntries: Array<{ category: string }>, categoryNames: string[] | undefined, ): string | null { - if (!categoryNames?.length) return null; + if (!categoryNames?.length) { + return null; + } const invalid = llmEntries.filter((e) => !categoryNames.includes(e.category)).map((e) => e.category); - if (invalid.length === 0) return null; + if (invalid.length === 0) { + return null; + } const unique = [...new Set(invalid)]; return `Unknown categories: ${unique.join(', ')}. Valid categories: ${categoryNames.join(', ')}`; } diff --git a/packages/notes/src/monorepo/aggregator.ts b/packages/notes/src/monorepo/aggregator.ts index 14824d2d..6c1884ef 100644 --- a/packages/notes/src/monorepo/aggregator.ts +++ b/packages/notes/src/monorepo/aggregator.ts @@ -125,7 +125,9 @@ function buildPackageDirMap(rootPath: string, packagesPath: string): Map(); for (const entry of entries) { - if (entry.scope) scopeCounts.set(entry.scope, (scopeCounts.get(entry.scope) ?? 0) + 1); + if (entry.scope) { + scopeCounts.set(entry.scope, (scopeCounts.get(entry.scope) ?? 0) + 1); + } } const groupedScopes = new Set(); for (const [scope, count] of scopeCounts) { - if (count > 1) groupedScopes.add(scope); + if (count > 1) { + groupedScopes.add(scope); + } } if (groupedScopes.size === 0) { @@ -157,7 +163,9 @@ function formatCategorySection(name: string, entries: ChangelogEntry[], refOpts: function rerouteBreakingEntries(categories: EnhancedCategory[]): EnhancedCategory[] { const hasBreaking = categories.some((c) => c.entries.some((e) => e.breaking)); - if (!hasBreaking) return categories; + if (!hasBreaking) { + return categories; + } const breakingEntries: ChangelogEntry[] = []; const stripped = categories.map((c) => ({ @@ -180,7 +188,9 @@ function rerouteBreakingEntries(categories: EnhancedCategory[]): EnhancedCategor } function applyOrder(categories: EnhancedCategory[], order: string[]): EnhancedCategory[] { - if (order.length === 0) return categories; + if (order.length === 0) { + return categories; + } return [...categories].sort((a, b) => { const ai = order.indexOf(a.name); const bi = order.indexOf(b.name); @@ -199,18 +209,26 @@ function parseMdLink(text: string): { label: string; url: string } | null { let search = 0; while (search < text.length) { const bracketOpen = text.indexOf('[', search); - if (bracketOpen === -1) return null; + if (bracketOpen === -1) { + return null; + } const bracketClose = text.indexOf(']', bracketOpen + 1); - if (bracketClose === -1) return null; + if (bracketClose === -1) { + return null; + } if (text[bracketClose + 1] !== '(') { search = bracketClose + 1; continue; } const parenClose = text.indexOf(')', bracketClose + 2); - if (parenClose === -1) return null; + if (parenClose === -1) { + return null; + } const label = text.slice(bracketOpen + 1, bracketClose); const url = text.slice(bracketClose + 2, parenClose); - if (/^https?:\/\//.test(url)) return { label, url }; + if (/^https?:\/\//.test(url)) { + return { label, url }; + } search = parenClose + 1; } return null; @@ -223,7 +241,9 @@ function extractLinksFromPRBodies(entries: ChangelogEntry[], marker: string): Li for (const pr of entry.context?.prs ?? []) { for (const line of pr.body.split('\n')) { const trimmed = line.trim(); - if (!trimmed.startsWith(marker)) continue; + if (!trimmed.startsWith(marker)) { + continue; + } const rest = trimmed.slice(marker.length).trim(); const mdLink = parseMdLink(rest); if (mdLink) { @@ -245,7 +265,9 @@ function extractLinksFromPRBodies(entries: ChangelogEntry[], marker: string): Li } function resolveLinks(entries: ChangelogEntry[], linksConfig: LinksConfig | undefined): LinkItem[] { - if (!linksConfig) return []; + if (!linksConfig) { + return []; + } const items = linksConfig.items ?? []; const discovered = linksConfig.fromPRBodyMarker ? extractLinksFromPRBodies(entries, linksConfig.fromPRBodyMarker) @@ -308,7 +330,9 @@ export function formatVersion(context: TemplateContext, options?: FormatVersionO categories = applyOrder(categories, options?.categoryOrder ?? []); for (const category of categories) { - if (category.entries.length === 0) continue; + if (category.entries.length === 0) { + continue; + } lines.push(...formatCategorySection(category.name, category.entries, refOpts)); } @@ -325,7 +349,9 @@ export function formatVersion(context: TemplateContext, options?: FormatVersionO const grouped = groupEntriesByType(context.entries); for (const [type, entries] of grouped) { - if (entries.length === 0) continue; + if (entries.length === 0) { + continue; + } lines.push(`### ${TYPE_LABELS[type]}`); for (const entry of entries) { diff --git a/packages/notes/src/templates/loader.ts b/packages/notes/src/templates/loader.ts index 5eb72d94..e347f570 100644 --- a/packages/notes/src/templates/loader.ts +++ b/packages/notes/src/templates/loader.ts @@ -154,9 +154,15 @@ export function renderComposable( } function detectEngineFromFiles(_dir: string, files: string[]): TemplateEngine | null { - if (files.some((f) => f.endsWith('.liquid'))) return 'liquid'; - if (files.some((f) => f.endsWith('.hbs') || f.endsWith('.handlebars'))) return 'handlebars'; - if (files.some((f) => f.endsWith('.ejs'))) return 'ejs'; + if (files.some((f) => f.endsWith('.liquid'))) { + return 'liquid'; + } + if (files.some((f) => f.endsWith('.hbs') || f.endsWith('.handlebars'))) { + return 'handlebars'; + } + if (files.some((f) => f.endsWith('.ejs'))) { + return 'ejs'; + } return null; } diff --git a/packages/notes/src/utils/concurrency.ts b/packages/notes/src/utils/concurrency.ts index d6e476c6..315277d0 100644 --- a/packages/notes/src/utils/concurrency.ts +++ b/packages/notes/src/utils/concurrency.ts @@ -18,7 +18,9 @@ export async function mapWithConcurrency( // fresh work after the first failure (in-flight calls still settle; Promise.all surfaces the error). while (!failed) { const i = next++; - if (i >= items.length) return; + if (i >= items.length) { + return; + } try { results[i] = await fn(items[i] as T, i); } catch (err) { diff --git a/packages/notes/src/utils/json.ts b/packages/notes/src/utils/json.ts index e97ce113..0b460a95 100644 --- a/packages/notes/src/utils/json.ts +++ b/packages/notes/src/utils/json.ts @@ -17,10 +17,14 @@ export function extractJsonFromResponse(response: string): string { // which in practice means: first delimiter to last matching delimiter, ignoring any // preamble or postamble text the model may have added. const objectMatch = stripped.match(/\{[\s\S]*\}/); - if (objectMatch) return objectMatch[0]; + if (objectMatch) { + return objectMatch[0]; + } const arrayMatch = stripped.match(/\[[\s\S]*\]/); - if (arrayMatch) return arrayMatch[0]; + if (arrayMatch) { + return arrayMatch[0]; + } // No JSON delimiters found — return stripped response so JSON.parse produces a clear error return stripped; diff --git a/packages/notes/src/utils/retry.ts b/packages/notes/src/utils/retry.ts index 69377052..af0c9a6f 100644 --- a/packages/notes/src/utils/retry.ts +++ b/packages/notes/src/utils/retry.ts @@ -28,7 +28,9 @@ export async function withRetry(fn: () => Promise, options: RetryOptions = return await fn(); } catch (error) { lastError = error; - if (options.shouldRetry && !options.shouldRetry(error)) break; + if (options.shouldRetry && !options.shouldRetry(error)) { + break; + } if (attempt < maxAttempts - 1) { const base = Math.min(initialDelay * backoffFactor ** attempt, maxDelay); const jitter = base * 0.2 * (Math.random() * 2 - 1); diff --git a/packages/notes/test/eval/assertions.ts b/packages/notes/test/eval/assertions.ts index d942220d..dd9a17d0 100644 --- a/packages/notes/test/eval/assertions.ts +++ b/packages/notes/test/eval/assertions.ts @@ -18,8 +18,12 @@ export function findDuplicateDependencyChurn(text: string): string[] { /** Guard against a truncated (empty/near-empty) or runaway generation. */ export function checkLengthBounds(text: string, min: number, max: number): string[] { const n = text.trim().length; - if (n < min) return [`output too short: ${n} < ${min} chars`]; - if (n > max) return [`output too long: ${n} > ${max} chars`]; + if (n < min) { + return [`output too short: ${n} < ${min} chars`]; + } + if (n > max) { + return [`output too long: ${n} > ${max} chars`]; + } return []; } @@ -44,7 +48,9 @@ export function listItemLines(text: string): string[] { */ export function checkPastTenseLeaning(text: string, minRatio = 0.6): string[] { const items = listItemLines(text); - if (items.length === 0) return []; + if (items.length === 0) { + return []; + } const firstWords = items.map((line) => line.replace(/^\s*(?:[-*]|\d+\.)\s+/, '').split(/\s+/)[0] ?? ''); const pastCount = firstWords.filter(looksPastTense).length; const ratio = pastCount / items.length; diff --git a/packages/notes/test/unit/llm.spec.ts b/packages/notes/test/unit/llm.spec.ts index 0ab730c3..fe0beda8 100644 --- a/packages/notes/test/unit/llm.spec.ts +++ b/packages/notes/test/unit/llm.spec.ts @@ -43,7 +43,9 @@ function makeFailingProvider(after = 0): LLMProvider { capabilities: { systemRole: true, structuredOutputs: false, toolUse: false }, async complete(): Promise { calls++; - if (calls > after) throw new Error('provider error'); + if (calls > after) { + throw new Error('provider error'); + } return { content: 'ok' }; }, }; @@ -65,7 +67,9 @@ describe('enhanceEntry()', () => { it('should return the provider response as the new description', async () => { const provider = makeMockProvider('Add real-time streaming to the API'); const entry = sampleEntries[0]; - if (!entry) throw new Error('No sample entry'); + if (!entry) { + throw new Error('No sample entry'); + } const result = await enhanceEntry(provider, entry, llmContext); expect(result).toBe('Add real-time streaming to the API'); }); @@ -73,7 +77,9 @@ describe('enhanceEntry()', () => { it('should trim whitespace from the response', async () => { const provider = makeMockProvider(' trimmed '); const entry = sampleEntries[0]; - if (!entry) throw new Error('No sample entry'); + if (!entry) { + throw new Error('No sample entry'); + } const result = await enhanceEntry(provider, entry, llmContext); expect(result).toBe('trimmed'); }); @@ -81,7 +87,9 @@ describe('enhanceEntry()', () => { it('should call the provider exactly once', async () => { const provider = makeMockProvider('response'); const entry = sampleEntries[0]; - if (!entry) throw new Error('No sample entry'); + if (!entry) { + throw new Error('No sample entry'); + } await enhanceEntry(provider, entry, llmContext); expect(provider.callCount).toBe(1); }); @@ -136,7 +144,9 @@ describe('enhanceEntries()', () => { capabilities: { systemRole: true, structuredOutputs: false, toolUse: false }, async complete(): Promise { calls++; - if (calls === 2) throw new Error('second entry fails'); + if (calls === 2) { + throw new Error('second entry fails'); + } return { content: 'ok' }; }, }; diff --git a/packages/notes/test/unit/llm/cache.spec.ts b/packages/notes/test/unit/llm/cache.spec.ts index 90d8a9f7..0642e395 100644 --- a/packages/notes/test/unit/llm/cache.spec.ts +++ b/packages/notes/test/unit/llm/cache.spec.ts @@ -29,7 +29,9 @@ describe('withContentHashCache', () => { }; afterEach(() => { - for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); + for (const d of dirs) { + fs.rmSync(d, { recursive: true, force: true }); + } dirs.length = 0; }); diff --git a/packages/notes/test/unit/llm/tasks.spec.ts b/packages/notes/test/unit/llm/tasks.spec.ts index 76075288..9de793e2 100644 --- a/packages/notes/test/unit/llm/tasks.spec.ts +++ b/packages/notes/test/unit/llm/tasks.spec.ts @@ -45,7 +45,9 @@ describe('createCategorizeValidator()', () => { ); expect(result.valid).toBe(true); - if (!result.valid) return; + if (!result.valid) { + return; + } expect(result.value).toHaveLength(2); expect(result.value.find((c) => c.category === 'New Features')?.entries).toHaveLength(1); expect(result.value.find((c) => c.category === 'Bug Fixes')?.entries).toHaveLength(2); @@ -56,7 +58,9 @@ describe('createCategorizeValidator()', () => { const result = validate({ content: 'not json', structured: undefined }); expect(result.valid).toBe(false); - if (result.valid) return; + if (result.valid) { + return; + } expect(result.error).toContain('Invalid JSON'); }); @@ -65,7 +69,9 @@ describe('createCategorizeValidator()', () => { const result = validate(structured({ entries: [{ category: 'New', scope: null }] })); expect(result.valid).toBe(false); - if (result.valid) return; + if (result.valid) { + return; + } expect(result.error).toBe('Expected 3 entries, got 1'); }); @@ -88,7 +94,9 @@ describe('createCategorizeValidator()', () => { ); expect(result.valid).toBe(false); - if (result.valid) return; + if (result.valid) { + return; + } expect(result.error).toBe('Unknown categories: Mystery. Valid categories: New Features, Bug Fixes'); }); @@ -113,7 +121,9 @@ describe('createCategorizeValidator()', () => { ); expect(result.valid).toBe(true); - if (!result.valid) return; + if (!result.valid) { + return; + } const dev = result.value.find((c) => c.category === 'Developer'); expect(dev?.entries[0]?.scope).toBe('Dependencies'); expect(dev?.entries[1]?.scope).toBeUndefined(); @@ -148,7 +158,9 @@ describe('createEnhanceAndCategorizeValidator()', () => { ); expect(result.valid).toBe(true); - if (!result.valid) return; + if (!result.valid) { + return; + } expect(result.value.enhancedEntries).toHaveLength(3); expect(result.value.enhancedEntries[0]?.description).toBe('Added real-time streaming to the API'); expect(result.value.enhancedEntries[2]?.scope).toBe('Code Quality'); @@ -160,7 +172,9 @@ describe('createEnhanceAndCategorizeValidator()', () => { const result = validate({ content: 'not json', structured: undefined }); expect(result.valid).toBe(false); - if (result.valid) return; + if (result.valid) { + return; + } expect(result.error).toContain('Invalid JSON'); }); @@ -169,7 +183,9 @@ describe('createEnhanceAndCategorizeValidator()', () => { const result = validate(structured({ entries: [fullEntry('Only first', 'New')] })); expect(result.valid).toBe(false); - if (result.valid) return; + if (result.valid) { + return; + } expect(result.error).toBe('Expected 3 entries, got 1 (entries missing — cannot proceed)'); }); @@ -188,7 +204,9 @@ describe('createEnhanceAndCategorizeValidator()', () => { ); expect(result.valid).toBe(true); - if (!result.valid) return; + if (!result.valid) { + return; + } expect(result.value.enhancedEntries).toHaveLength(3); expect(result.value.enhancedEntries[2]?.description).toBe('Enhanced C'); }); @@ -209,7 +227,9 @@ describe('createEnhanceAndCategorizeValidator()', () => { ); expect(result.valid).toBe(false); - if (result.valid) return; + if (result.valid) { + return; + } expect(result.error).toBe('Unknown categories: Mystery. Valid categories: New, Fixed, Changed'); }); }); diff --git a/packages/notes/test/unit/retry.spec.ts b/packages/notes/test/unit/retry.spec.ts index de1df3f3..c31e008c 100644 --- a/packages/notes/test/unit/retry.spec.ts +++ b/packages/notes/test/unit/retry.spec.ts @@ -24,7 +24,9 @@ describe('withRetry()', () => { const result = await withRetry( async () => { calls++; - if (calls < 2) throw new Error('temporary error'); + if (calls < 2) { + throw new Error('temporary error'); + } return 'ok'; }, { maxAttempts: 3, initialDelay: 0 }, @@ -106,7 +108,9 @@ describe('withRetry()', () => { const result = await withRetry( async () => { calls++; - if (calls < 3) throw new Error('not yet'); + if (calls < 3) { + throw new Error('not yet'); + } return 'done'; }, { maxAttempts: 3, initialDelay: 0 }, diff --git a/packages/notes/test/unit/templates.spec.ts b/packages/notes/test/unit/templates.spec.ts index 034e2609..f23903ef 100644 --- a/packages/notes/test/unit/templates.spec.ts +++ b/packages/notes/test/unit/templates.spec.ts @@ -69,7 +69,9 @@ describe('Handlebars Engine', () => { registerHandlebarsHelpers(); const template = '{{capitalize type}}'; const entry = sampleContext.entries[0]; - if (!entry) throw new Error('No entry found'); + if (!entry) { + throw new Error('No entry found'); + } const result = renderHandlebars(template, { ...sampleContext, ...entry } as unknown as TemplateContext); expect(result).toBe('Added'); }); diff --git a/packages/notes/test/unit/utils/concurrency.spec.ts b/packages/notes/test/unit/utils/concurrency.spec.ts index 623c2e14..8fef7694 100644 --- a/packages/notes/test/unit/utils/concurrency.spec.ts +++ b/packages/notes/test/unit/utils/concurrency.spec.ts @@ -51,7 +51,9 @@ describe('mapWithConcurrency()', () => { await expect( mapWithConcurrency([0, 1, 2], 1, async (n) => { seen.push(n); - if (n === 1) throw new Error('boom'); + if (n === 1) { + throw new Error('boom'); + } return n; }), ).rejects.toThrow('boom'); diff --git a/packages/publish/src/command.ts b/packages/publish/src/command.ts index 3b3e1f2c..1d2dbca8 100644 --- a/packages/publish/src/command.ts +++ b/packages/publish/src/command.ts @@ -21,8 +21,12 @@ export function createPublishCommand(): Command { .option('--json', 'Output results as JSON', false) .option('--verbose', 'Verbose logging', false) .action(async (options) => { - if (options.verbose) setLogLevel('debug'); - if (options.json) setJsonMode(true); + if (options.verbose) { + setLogLevel('debug'); + } + if (options.json) { + setJsonMode(true); + } try { const config = loadConfig({ configPath: options.config }); diff --git a/packages/publish/src/registry/cargo.ts b/packages/publish/src/registry/cargo.ts index ecd39a1b..6cefe8a2 100644 --- a/packages/publish/src/registry/cargo.ts +++ b/packages/publish/src/registry/cargo.ts @@ -28,8 +28,12 @@ async function isCratePublished(name: string, version: string): Promise signal: AbortSignal.timeout(CRATES_IO_API_TIMEOUT_MS), headers: { 'User-Agent': CRATES_IO_USER_AGENT }, }); - if (response.status === 200) return true; - if (response.status === 404) return false; + if (response.status === 200) { + return true; + } + if (response.status === 404) { + return false; + } debug(`crates.io published-check returned ${response.status} for ${name}@${version}, will attempt publish`); return false; } catch (error) { @@ -94,8 +98,12 @@ export const cargoRegistry: Registry = { async publish(target: CargoTarget, ctx: PipelineContext, session: CargoSession): Promise { const publishArgs = ['publish', '--manifest-path', target.manifestPath]; - if (ctx.config.cargo.noVerify) publishArgs.push('--no-verify'); - if (session.isDirty) publishArgs.push('--allow-dirty'); + if (ctx.config.cargo.noVerify) { + publishArgs.push('--no-verify'); + } + if (session.isDirty) { + publishArgs.push('--allow-dirty'); + } await execCommand('cargo', publishArgs, { cwd: ctx.cwd, diff --git a/packages/publish/src/registry/dispatcher.ts b/packages/publish/src/registry/dispatcher.ts index 700e4bff..5f63cdbb 100644 --- a/packages/publish/src/registry/dispatcher.ts +++ b/packages/publish/src/registry/dispatcher.ts @@ -57,7 +57,9 @@ export async function runPublishStage( result.skipped = true; result.success = true; result.reason = skip.reason; - if (skip.alreadyPublished) result.alreadyPublished = true; + if (skip.alreadyPublished) { + result.alreadyPublished = true; + } results.push(result); continue; } @@ -87,7 +89,9 @@ export async function runPublishStage( }); result.success = true; - if (!dryRun) success(`Published ${target.packageName}@${target.version} to ${registry.displayName}`); + if (!dryRun) { + success(`Published ${target.packageName}@${target.version} to ${registry.displayName}`); + } results.push(result); } catch (error) { // A surfaced already-published conflict (a publish that landed but whose response was lost, diff --git a/packages/publish/src/registry/npm.ts b/packages/publish/src/registry/npm.ts index b91e5d7f..f623a83a 100644 --- a/packages/publish/src/registry/npm.ts +++ b/packages/publish/src/registry/npm.ts @@ -46,7 +46,9 @@ export function orderNpmUpdates u.filePath.endsWith('package.json')); - if (npm.length <= 1) return updates; + if (npm.length <= 1) { + return updates; + } const other = updates.filter((u) => !u.filePath.endsWith('package.json')); const byName = new Map(npm.map((u) => [u.packageName, u])); @@ -54,8 +56,14 @@ export function orderNpmUpdates 0) { const remaining = new Set(npm.map((u) => u.packageName)); orderedNames = explicitOrder.filter((n) => remaining.has(n)); - for (const n of orderedNames) remaining.delete(n); - for (const u of npm) if (remaining.has(u.packageName)) orderedNames.push(u.packageName); + for (const n of orderedNames) { + remaining.delete(n); + } + for (const u of npm) { + if (remaining.has(u.packageName)) { + orderedNames.push(u.packageName); + } + } } else { const graphPackages: GraphPackage[] = npm.map((u) => { const pkgJsonPath = path.resolve(cwd, u.filePath); @@ -190,8 +198,12 @@ export const npmRegistry: Registry = { }); debug('Publish command completed successfully'); - if (publishResult.stdout) debug(`Publish stdout: ${publishResult.stdout}`); - if (publishResult.stderr) debug(`Publish stderr: ${publishResult.stderr}`); + if (publishResult.stdout) { + debug(`Publish stdout: ${publishResult.stdout}`); + } + if (publishResult.stderr) { + debug(`Publish stderr: ${publishResult.stderr}`); + } }, dispose(session: NpmSession): void { diff --git a/packages/publish/src/registry/pub.ts b/packages/publish/src/registry/pub.ts index 2511cde9..0f711a21 100644 --- a/packages/publish/src/registry/pub.ts +++ b/packages/publish/src/registry/pub.ts @@ -71,7 +71,9 @@ export const pubRegistry: Registry = { async isPublished(target: PubTarget): Promise { // The pub.dev REST API only makes sense for pub.dev targets; skip it for custom registries. - if (!targetsPubDev(target.publishTo)) return false; + if (!targetsPubDev(target.publishTo)) { + return false; + } return isPubPackagePublished(target.packageName, target.version); }, diff --git a/packages/publish/src/stages/git-push.ts b/packages/publish/src/stages/git-push.ts index 4f56c094..c782b3e5 100644 --- a/packages/publish/src/stages/git-push.ts +++ b/packages/publish/src/stages/git-push.ts @@ -10,8 +10,12 @@ function toGithubAuthedUrl(remoteUrl: string, token: string): string | undefined // Avoid logging token by never returning it via logs. try { const url = new URL(remoteUrl); - if (url.protocol !== 'https:') return undefined; - if (url.host !== 'github.com') return undefined; + if (url.protocol !== 'https:') { + return undefined; + } + if (url.host !== 'github.com') { + return undefined; + } // Use the recommended username for GitHub token auth in HTTPS URLs. url.username = 'x-access-token'; @@ -35,7 +39,9 @@ export interface PushSetup { export async function preparePushSetup(ctx: PipelineContext): Promise { const { config, cwd, output } = ctx; - if (!config.git.push) return null; + if (!config.git.push) { + return null; + } const { remote } = config.git; const git = createGitCli(); @@ -55,7 +61,9 @@ export async function preparePushSetup(ctx: PipelineContext): Promise 0) { const resolved = resolveTagPackage(tag, packageNames); - if (resolved) return applyTemplate(resolved.packageName, resolved.version); + if (resolved) { + return applyTemplate(resolved.packageName, resolved.version); + } } // Fallback: try splitting on last @ for unsanitized "pkg@version" tags const atIndex = tag.lastIndexOf('@'); - if (atIndex === -1) return tag; + if (atIndex === -1) { + return tag; + } return applyTemplate(tag.slice(0, atIndex), tag.slice(atIndex + 1)); } @@ -142,11 +150,15 @@ function findNotesForTag(tag: string, notes: Record): string | u const resolved = resolveTagPackage(tag, Object.keys(notes)); if (resolved) { const body = notes[resolved.packageName]; - if (body?.trim()) return body; + if (body?.trim()) { + return body; + } } // Single-package fallback for version-only tags (e.g., "v1.0.0") const entries = Object.values(notes).filter((b) => b.trim()); - if (entries.length === 1 && isVersionOnlyTag(tag)) return entries[0]; + if (entries.length === 1 && isVersionOnlyTag(tag)) { + return entries[0]; + } return undefined; } @@ -154,7 +166,9 @@ function findNotesForTag(tag: string, notes: Record): string | u * Find the changelog for a given tag and format its entries as markdown. */ function formatChangelogForTag(tag: string, changelogs: VersionPackageChangelog[]): string | undefined { - if (changelogs.length === 0) return undefined; + if (changelogs.length === 0) { + return undefined; + } const resolved = resolveTagPackage( tag, @@ -168,7 +182,9 @@ function formatChangelogForTag(tag: string, changelogs: VersionPackageChangelog[ target = changelogs[0]; } - if (!target || target.entries.length === 0) return undefined; + if (!target || target.entries.length === 0) { + return undefined; + } const lines: string[] = []; for (const entry of target.entries) { @@ -202,7 +218,9 @@ export async function runGithubReleaseStage(ctx: PipelineContext): Promise } const firstTag = tags[0]; - if (!firstTag) return; + if (!firstTag) { + return; + } const tagsToRelease = config.githubRelease.perPackage ? tags : [firstTag]; const skipPackages = config.githubRelease.skipPackages ?? []; diff --git a/packages/publish/src/types.ts b/packages/publish/src/types.ts index 6e7f0ceb..add8ef5c 100644 --- a/packages/publish/src/types.ts +++ b/packages/publish/src/types.ts @@ -218,7 +218,9 @@ export function getDefaultConfig(): PublishConfig { export function toPublishConfig(config: BasePublishConfig | undefined): PublishConfig { const defaults = getDefaultConfig(); - if (!config) return defaults; + if (!config) { + return defaults; + } return { npm: { diff --git a/packages/publish/src/utils/exec.ts b/packages/publish/src/utils/exec.ts index e6ce28d7..18a40aca 100644 --- a/packages/publish/src/utils/exec.ts +++ b/packages/publish/src/utils/exec.ts @@ -93,7 +93,9 @@ export async function execCommand(file: string, args: string[], options: ExecOpt * Combine an exec error's message, stdout, and stderr into a single string for pattern matching. */ export function getExecErrorOutput(error: unknown): string { - if (!error || typeof error !== 'object') return String(error); + if (!error || typeof error !== 'object') { + return String(error); + } const e = error as { message?: string; stdout?: string; stderr?: string }; return [e.message, e.stdout, e.stderr].filter(Boolean).join('\n'); } diff --git a/packages/publish/src/utils/npm-env.ts b/packages/publish/src/utils/npm-env.ts index d63189a1..52cc032a 100644 --- a/packages/publish/src/utils/npm-env.ts +++ b/packages/publish/src/utils/npm-env.ts @@ -44,7 +44,9 @@ export function createNpmSubprocessIsolation(options: { // Default: no isolation. Still allow targeted env unsets. const baseEnv: Record = {}; - if (!authMethod) return { env: baseEnv, cleanup: () => {} }; + if (!authMethod) { + return { env: baseEnv, cleanup: () => {} }; + } const token = process.env.NPM_TOKEN ?? process.env.NODE_AUTH_TOKEN; const registryHost = (() => { diff --git a/packages/publish/src/utils/package-manager.ts b/packages/publish/src/utils/package-manager.ts index eac533bb..120741dc 100644 --- a/packages/publish/src/utils/package-manager.ts +++ b/packages/publish/src/utils/package-manager.ts @@ -4,8 +4,12 @@ import * as path from 'node:path'; export type PackageManager = 'pnpm' | 'npm' | 'yarn'; export function detectPackageManager(cwd: string): PackageManager { - if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'; - if (fs.existsSync(path.join(cwd, 'yarn.lock'))) return 'yarn'; + if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) { + return 'pnpm'; + } + if (fs.existsSync(path.join(cwd, 'yarn.lock'))) { + return 'yarn'; + } return 'npm'; } @@ -21,7 +25,9 @@ export function buildPublishCommand( if (pm === 'pnpm') { file = 'pnpm'; args = ['publish', '--access', options.access, '--tag', options.tag]; - if (options.noGitChecks) args.push('--no-git-checks'); + if (options.noGitChecks) { + args.push('--no-git-checks'); + } } else { file = 'npm'; args = ['publish', '--access', options.access, '--tag', options.tag]; diff --git a/packages/publish/src/utils/pub.ts b/packages/publish/src/utils/pub.ts index 14199734..a0e2f7a1 100644 --- a/packages/publish/src/utils/pub.ts +++ b/packages/publish/src/utils/pub.ts @@ -18,8 +18,12 @@ export async function isPubPackagePublished(name: string, version: string): Prom signal: AbortSignal.timeout(PUB_DEV_API_TIMEOUT_MS), headers: { 'User-Agent': PUB_DEV_USER_AGENT }, }); - if (response.status === 200) return true; - if (response.status === 404) return false; + if (response.status === 200) { + return true; + } + if (response.status === 404) { + return false; + } debug(`pub.dev published-check returned ${response.status} for ${name}@${version}, will attempt publish`); return false; } catch (error) { diff --git a/packages/publish/src/utils/publish-retry.ts b/packages/publish/src/utils/publish-retry.ts index 69d49446..f6d01728 100644 --- a/packages/publish/src/utils/publish-retry.ts +++ b/packages/publish/src/utils/publish-retry.ts @@ -44,11 +44,15 @@ export function classifyPublishError(error: unknown): PublishErrorClass { const output = getExecErrorOutput(error); for (const pattern of PERMANENT_PATTERNS) { - if (pattern.test(output)) return 'permanent'; + if (pattern.test(output)) { + return 'permanent'; + } } for (const pattern of TRANSIENT_PATTERNS) { - if (pattern.test(output)) return 'transient'; + if (pattern.test(output)) { + return 'transient'; + } } return 'permanent'; diff --git a/packages/publish/test/integration/publish-e2e.spec.ts b/packages/publish/test/integration/publish-e2e.spec.ts index c829f690..a2e8847d 100644 --- a/packages/publish/test/integration/publish-e2e.spec.ts +++ b/packages/publish/test/integration/publish-e2e.spec.ts @@ -240,8 +240,12 @@ beforeAll(() => { }); afterAll(() => { - if (originalPath !== undefined) process.env.PATH = originalPath; - for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); + if (originalPath !== undefined) { + process.env.PATH = originalPath; + } + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } }); beforeEach(() => { @@ -255,7 +259,9 @@ afterEach(async () => { } process.chdir(ORIGINAL_CWD); for (const key of Object.keys(process.env)) { - if (!(key in savedEnv)) delete process.env[key]; + if (!(key in savedEnv)) { + delete process.env[key]; + } } Object.assign(process.env, savedEnv); }); @@ -363,7 +369,9 @@ describe.skipIf(!E2E_ENABLED)('Publish pipeline e2e (real npm registry)', () => // registry + token) wins, and pin globalconfig / cache / HOME to throwaway paths so no ambient // `~/.npmrc` leaks in either. The full-env restore in afterEach undoes all of this. for (const key of Object.keys(process.env)) { - if (/^npm_config_/i.test(key)) delete process.env[key]; + if (/^npm_config_/i.test(key)) { + delete process.env[key]; + } } const home = mkTemp('npm-home'); const emptyGlobalNpmrc = path.join(home, 'global-npmrc'); diff --git a/packages/publish/test/unit/registry/dispatcher.spec.ts b/packages/publish/test/unit/registry/dispatcher.spec.ts index c7104e23..b69c094e 100644 --- a/packages/publish/test/unit/registry/dispatcher.spec.ts +++ b/packages/publish/test/unit/registry/dispatcher.spec.ts @@ -111,8 +111,11 @@ describe('runPublishStage dispatcher', () => { await expect(runPublishStage(cargoRegistry, ctx)).resolves.toBeUndefined(); expect(ctx.output.cargo).toHaveLength(0); } finally { - if (savedToken === undefined) delete process.env.CARGO_REGISTRY_TOKEN; - else process.env.CARGO_REGISTRY_TOKEN = savedToken; + if (savedToken === undefined) { + delete process.env.CARGO_REGISTRY_TOKEN; + } else { + process.env.CARGO_REGISTRY_TOKEN = savedToken; + } } }); @@ -183,7 +186,9 @@ describe('runPublishStage dispatcher', () => { let calls = 0; const publish = vi.fn(async () => { calls += 1; - if (calls === 1) throw execError('503 Service Unavailable'); + if (calls === 1) { + throw execError('503 Service Unavailable'); + } }); const ctx = createContext(); diff --git a/packages/publish/test/unit/stages/npm-publish-order.spec.ts b/packages/publish/test/unit/stages/npm-publish-order.spec.ts index be3e66eb..a09df118 100644 --- a/packages/publish/test/unit/stages/npm-publish-order.spec.ts +++ b/packages/publish/test/unit/stages/npm-publish-order.spec.ts @@ -26,7 +26,9 @@ describe('orderNpmUpdates', () => { beforeEach(() => { vi.mocked(fs.readFileSync).mockImplementation((p: fs.PathOrFileDescriptor) => { const manifest = manifests[String(p)]; - if (!manifest) throw new Error(`no manifest for ${String(p)}`); + if (!manifest) { + throw new Error(`no manifest for ${String(p)}`); + } return JSON.stringify(manifest); }); }); diff --git a/packages/release/src/backfill/github-release.ts b/packages/release/src/backfill/github-release.ts index a7612086..1e8894e0 100644 --- a/packages/release/src/backfill/github-release.ts +++ b/packages/release/src/backfill/github-release.ts @@ -26,8 +26,12 @@ export function decideReleaseUpdate( onlyMissing: boolean, force: boolean = false, ): UpdateDecision { - if (existingBody === null) return { action: 'skip', reason: 'no-release' }; - if (onlyMissing && existingBody.includes(NOTES_MARKER)) return { action: 'skip', reason: 'already-backfilled' }; + if (existingBody === null) { + return { action: 'skip', reason: 'no-release' }; + } + if (onlyMissing && existingBody.includes(NOTES_MARKER)) { + return { action: 'skip', reason: 'already-backfilled' }; + } if (!force && !onlyMissing && existingBody.trim() && !existingBody.includes(NOTES_MARKER)) { return { action: 'skip', reason: 'hand-edited' }; } @@ -57,7 +61,9 @@ export function getReleaseBody(tag: string): string | null { throw new Error('GitHub CLI (`gh`) not found — install it and run `gh auth login` to update release bodies.'); } const stderr = (typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString()) || e.message || ''; - if (/release not found/i.test(stderr)) return null; + if (/release not found/i.test(stderr)) { + return null; + } throw new Error(`\`gh release view ${tag}\` failed: ${stderr.trim() || 'unknown error'}`); } } @@ -84,7 +90,9 @@ export function getReleaseInfo(tag: string): { isDraft: boolean; body: string | throw new Error('GitHub CLI (`gh`) not found — install it and run `gh auth login` to update release bodies.'); } const stderr = (typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString()) || e.message || ''; - if (/release not found/i.test(stderr)) return null; + if (/release not found/i.test(stderr)) { + return null; + } throw new Error(`\`gh release view ${tag}\` failed: ${stderr.trim() || 'unknown error'}`); } } diff --git a/packages/release/src/backfill/reconstruct.ts b/packages/release/src/backfill/reconstruct.ts index abee1236..31b5c7fb 100644 --- a/packages/release/src/backfill/reconstruct.ts +++ b/packages/release/src/backfill/reconstruct.ts @@ -75,10 +75,16 @@ export async function reconstructChangelogs(opts: ReconstructOptions): Promise tasks[k] !== false).length; } diff --git a/packages/release/src/commands/changed.ts b/packages/release/src/commands/changed.ts index 2f5518fe..3e27ab66 100644 --- a/packages/release/src/commands/changed.ts +++ b/packages/release/src/commands/changed.ts @@ -15,6 +15,8 @@ import type { ReleaseOutput } from '../types.js'; */ export function publishDidChange(result: ReleaseOutput | null | undefined): boolean { const output = result?.publishOutput; - if (!output || output.dryRun) return false; + if (!output || output.dryRun) { + return false; + } return [...output.npm, ...output.cargo, ...output.pub].some((r) => r.success && !r.skipped && !r.alreadyPublished); } diff --git a/packages/release/src/commands/emitResult.ts b/packages/release/src/commands/emitResult.ts index 8a213bc0..5df7b680 100644 --- a/packages/release/src/commands/emitResult.ts +++ b/packages/release/src/commands/emitResult.ts @@ -32,13 +32,17 @@ export function emitResult( data: unknown, opts: { json?: boolean; output?: string; changed?: boolean; warnings?: EnvelopeWarning[] }, ): void { - if (!opts.json && !opts.output) return; + if (!opts.json && !opts.output) { + return; + } writeEnvelope(successEnvelope(data ?? null, { changed: opts.changed, warnings: opts.warnings }), opts); } /** Emit a structured error envelope for a thrown value on the JSON channel. */ export function emitError(error: unknown, opts: { json?: boolean; output?: string }): void { - if (!opts.json && !opts.output) return; + if (!opts.json && !opts.output) { + return; + } writeEnvelope(errorEnvelope([toEnvelopeError(error)]), opts); } diff --git a/packages/release/src/commands/gate-command.ts b/packages/release/src/commands/gate-command.ts index 28035a05..11799241 100644 --- a/packages/release/src/commands/gate-command.ts +++ b/packages/release/src/commands/gate-command.ts @@ -14,9 +14,15 @@ export function createGateCommand(): Command { .option('-q, --quiet', 'Suppress non-error output', false) .option('--project-dir ', 'Project directory', process.cwd()) .action(async (opts) => { - if (opts.json) setJsonMode(true); - if (opts.verbose) setLogLevel('debug'); - if (opts.quiet) setQuietMode(true); + if (opts.json) { + setJsonMode(true); + } + if (opts.verbose) { + setLogLevel('debug'); + } + if (opts.quiet) { + setQuietMode(true); + } try { const result = await runGate({ diff --git a/packages/release/src/commands/labels-command.ts b/packages/release/src/commands/labels-command.ts index 5c512125..0648f9f7 100644 --- a/packages/release/src/commands/labels-command.ts +++ b/packages/release/src/commands/labels-command.ts @@ -45,7 +45,9 @@ async function detectRepoFromGit(projectDir?: string): Promise 0) parts.push(`${days}d`); - if (hours > 0) parts.push(`${hours}h`); - if (minutes > 0) parts.push(`${minutes}m`); - if (parts.length === 0) parts.push(`${Math.ceil(ms / 1000)}s`); + if (days > 0) { + parts.push(`${days}d`); + } + if (hours > 0) { + parts.push(`${hours}h`); + } + if (minutes > 0) { + parts.push(`${minutes}m`); + } + if (parts.length === 0) { + parts.push(`${Math.ceil(ms / 1000)}s`); + } return parts.join(' '); } diff --git a/packages/release/src/failure-report/failure-report.ts b/packages/release/src/failure-report/failure-report.ts index e4bcdbd7..da0a3bba 100644 --- a/packages/release/src/failure-report/failure-report.ts +++ b/packages/release/src/failure-report/failure-report.ts @@ -137,7 +137,9 @@ export function buildLedger(versionOutput: VersionOutput, publishOutput: Publish return updates.map((u) => { const entry = byPackage.get(u.packageName); - if (entry) return entry; + if (entry) { + return entry; + } return { packageName: u.packageName, version: u.newVersion, @@ -301,7 +303,9 @@ export function renderResolvedReport(versionOutput: VersionOutput): string { * treated as `unresolved` (the conservative default — surface the warning rather than hide it). */ export function parseFailureReportStatus(body: string): FailureReportStatus | null { - if (!body.startsWith(FAILURE_MARKER)) return null; + if (!body.startsWith(FAILURE_MARKER)) { + return null; + } return STATUS_FIELD.decode(body) ?? 'unresolved'; } @@ -310,7 +314,9 @@ export function parseFailureReportStatus(body: string): FailureReportStatus | nu * is not a failure report or the data comment is missing/malformed. */ export function parseFailureReportData(body: string): FailureReportData | null { - if (!body.startsWith(FAILURE_MARKER)) return null; + if (!body.startsWith(FAILURE_MARKER)) { + return null; + } return DATA_FIELD.decode(body); } diff --git a/packages/release/src/failure-report/post.ts b/packages/release/src/failure-report/post.ts index 7ca4a966..757d05bc 100644 --- a/packages/release/src/failure-report/post.ts +++ b/packages/release/src/failure-report/post.ts @@ -70,7 +70,9 @@ export async function resolveFailureReportIfPresent( ): Promise { try { const existing = await findPreviewComment(forge, prNumber, FAILURE_MARKER); - if (existing === null) return; + if (existing === null) { + return; + } await postOrUpdateComment(forge, prNumber, renderResolvedReport(versionOutput), FAILURE_MARKER); info(`Marked publish-failure report on PR #${prNumber} as resolved`); } catch (err) { @@ -116,8 +118,12 @@ export async function detectUnresolvedFailure(forge: Forge, prNumber: number): P return null; } - if (!body) return null; - if (parseFailureReportStatus(body) !== 'unresolved') return null; + if (!body) { + return null; + } + if (parseFailureReportStatus(body) !== 'unresolved') { + return null; + } return parseReportLedgerSummary(body, prNumber); } @@ -129,7 +135,9 @@ export async function detectUnresolvedFailure(forge: Forge, prNumber: number): P */ function parseReportLedgerSummary(body: string, prNumber: number): UnresolvedFailure | null { const data = parseFailureReportData(body); - if (!data) return null; + if (!data) { + return null; + } return { previousLabel: data.label, published: data.published, diff --git a/packages/release/src/gate/gate.ts b/packages/release/src/gate/gate.ts index 7614396b..a810feaf 100644 --- a/packages/release/src/gate/gate.ts +++ b/packages/release/src/gate/gate.ts @@ -225,9 +225,15 @@ async function computeGateResult(input: ComputeGateInput): Promise { * - In `commit` mode, don't notify on `release:skip` — that's deliberate, not user error. */ function shouldNotifyPR(e: PREvaluation, trigger: 'commit' | 'label'): boolean { - if (e.shouldRelease) return false; - if (e.blocked) return true; - if (!e.hasReleaseIntent) return false; + if (e.shouldRelease) { + return false; + } + if (e.blocked) { + return true; + } + if (!e.hasReleaseIntent) { + return false; + } return trigger === 'label'; } @@ -239,7 +245,9 @@ async function notifyInsufficientLabels( ): Promise { await Promise.all( evaluations.map(async (e) => { - if (!shouldNotifyPR(e, trigger)) return; + if (!shouldNotifyPR(e, trigger)) { + return; + } try { const body = buildNotifyBody(e, labelConfig); await postOrUpdateComment(forge, e.prNumber, body, NOTIFY_MARKER); diff --git a/packages/release/src/git.ts b/packages/release/src/git.ts index 7f2bd2ee..84a4830c 100644 --- a/packages/release/src/git.ts +++ b/packages/release/src/git.ts @@ -17,10 +17,14 @@ export interface GitHubContext { export function getGitHubContext(): GitHubContext | null { const repo = process.env.GITHUB_REPOSITORY; - if (!repo) return null; + if (!repo) { + return null; + } const [owner, repoName] = repo.split('/'); - if (!owner || !repoName) return null; + if (!owner || !repoName) { + return null; + } return { owner, diff --git a/packages/release/src/github.ts b/packages/release/src/github.ts index 2781cdcf..f94ab259 100644 --- a/packages/release/src/github.ts +++ b/packages/release/src/github.ts @@ -9,7 +9,9 @@ export const MARKER = ''; * token before reaching the forge-backed paths; the assertion is a safety net for that invariant. */ export function forgeFor(context: { token: string | null | undefined; owner: string; repo: string }): Forge { - if (!context.token) throw new Error('A GitHub token is required to access the forge.'); + if (!context.token) { + throw new Error('A GitHub token is required to access the forge.'); + } return createGitHubForge({ token: context.token, owner: context.owner, repo: context.repo }); } @@ -53,7 +55,9 @@ export async function findMergedPRsSinceLastRelease(forge: Forge, projectDir: st const prGroups = await Promise.all(mergeShas.map((sha) => findMergedPRsForCommit(forge, sha))); const seen = new Set(); for (const prs of prGroups) { - for (const n of prs) seen.add(n); + for (const n of prs) { + seen.add(n); + } } return [...seen]; } diff --git a/packages/release/src/label-definitions.ts b/packages/release/src/label-definitions.ts index 17e7a94d..bf71a10a 100644 --- a/packages/release/src/label-definitions.ts +++ b/packages/release/src/label-definitions.ts @@ -107,7 +107,9 @@ export function deriveLabelDefinitions( // first (more specific) definition so each label is created exactly once. const seen = new Set(); return definitions.filter((def) => { - if (!def.name || seen.has(def.name)) return false; + if (!def.name || seen.has(def.name)) { + return false; + } seen.add(def.name); return true; }); diff --git a/packages/release/src/label-utils.ts b/packages/release/src/label-utils.ts index bd98f44e..fdb13ed8 100644 --- a/packages/release/src/label-utils.ts +++ b/packages/release/src/label-utils.ts @@ -107,17 +107,31 @@ export function detectLabelConflicts(prLabels: string[], labels: LabelConfig = D * `release:graduate` wins over everything and returns undefined (graduation is bump-less). */ export function composeBumpFromLabels(prLabels: string[], labels: LabelConfig = DEFAULT_LABELS): string | undefined { - if (prLabels.includes(labels.graduate)) return undefined; + if (prLabels.includes(labels.graduate)) { + return undefined; + } if (prLabels.includes(labels.prerelease)) { - if (prLabels.includes(labels.major)) return 'premajor'; - if (prLabels.includes(labels.minor)) return 'preminor'; - if (prLabels.includes(labels.patch)) return 'prepatch'; + if (prLabels.includes(labels.major)) { + return 'premajor'; + } + if (prLabels.includes(labels.minor)) { + return 'preminor'; + } + if (prLabels.includes(labels.patch)) { + return 'prepatch'; + } return 'prerelease'; } - if (prLabels.includes(labels.major)) return 'major'; - if (prLabels.includes(labels.minor)) return 'minor'; - if (prLabels.includes(labels.patch)) return 'patch'; + if (prLabels.includes(labels.major)) { + return 'major'; + } + if (prLabels.includes(labels.minor)) { + return 'minor'; + } + if (prLabels.includes(labels.patch)) { + return 'patch'; + } return undefined; } diff --git a/packages/release/src/preview/detect.ts b/packages/release/src/preview/detect.ts index 291e1ed9..042a699c 100644 --- a/packages/release/src/preview/detect.ts +++ b/packages/release/src/preview/detect.ts @@ -17,12 +17,16 @@ export function detectPrerelease(packagePaths: string[], projectDir: string): Pr : [path.join(projectDir, 'package.json')]; for (const pkgPath of paths) { - if (!fs.existsSync(pkgPath)) continue; + if (!fs.existsSync(pkgPath)) { + continue; + } try { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); const result = parsePrerelease(pkg.version); - if (result.isPrerelease) return result; + if (result.isPrerelease) { + return result; + } } catch { // Skip unreadable package.json files } @@ -36,7 +40,9 @@ export function detectPrerelease(packagePaths: string[], projectDir: string): Pr * E.g. "0.3.0-next.4" → { isPrerelease: true, identifier: "next" } */ export function parsePrerelease(version: string | undefined): PrereleaseDetection { - if (!version) return { isPrerelease: false }; + if (!version) { + return { isPrerelease: false }; + } const match = version.match(/-([a-zA-Z0-9][a-zA-Z0-9-]*)(?:\.\d+)*(?:\+[^\s]+)?$/); if (match) { diff --git a/packages/release/src/preview/format.ts b/packages/release/src/preview/format.ts index 6fff23a5..6f3660ab 100644 --- a/packages/release/src/preview/format.ts +++ b/packages/release/src/preview/format.ts @@ -107,7 +107,9 @@ function getIntroMessage(strategy: ReleaseStrategy, standingPrNumber?: number): } function getLabelBanner(labelContext?: LabelContext): string[] { - if (!labelContext) return []; + if (!labelContext) { + return []; + } const lines: string[] = []; @@ -129,7 +131,9 @@ function getLabelBanner(labelContext?: LabelContext): string[] { ) { descriptor.push(labelContext.bumpLabel); const bumpLabelName = labelContext.labels?.[labelContext.bumpLabel]; - if (bumpLabelName) sources.push(`\`${bumpLabelName}\``); + if (bumpLabelName) { + sources.push(`\`${bumpLabelName}\``); + } } if (labelContext.prerelease) { descriptor.push('prerelease'); @@ -159,10 +163,18 @@ function getLabelBanner(labelContext?: LabelContext): string[] { // Show what was seen, point at the override surface (the standing PR) and the bypass label. if (labelContext.advisoryInStandingPr) { const seen: string[] = []; - if (labelContext.bumpLabel) seen.push(`\`bump:${labelContext.bumpLabel}\``); - if (labelContext.scopeLabels?.length) seen.push(...labelContext.scopeLabels.map((s) => `\`${s}\``)); - if (labelContext.stable) seen.push(`\`${labelContext.labels?.graduate ?? 'release:graduate'}\``); - if (labelContext.prerelease) seen.push(`\`${labelContext.labels?.prerelease ?? 'channel:prerelease'}\``); + if (labelContext.bumpLabel) { + seen.push(`\`bump:${labelContext.bumpLabel}\``); + } + if (labelContext.scopeLabels?.length) { + seen.push(...labelContext.scopeLabels.map((s) => `\`${s}\``)); + } + if (labelContext.stable) { + seen.push(`\`${labelContext.labels?.graduate ?? 'release:graduate'}\``); + } + if (labelContext.prerelease) { + seen.push(`\`${labelContext.labels?.prerelease ?? 'channel:prerelease'}\``); + } const seenStr = seen.length ? ` (saw: ${seen.join(', ')})` : ''; const immediateLabel = labelContext.labels?.immediate ?? 'release:immediate'; lines.push( @@ -373,14 +385,23 @@ export function formatPreviewComment(result: ReleaseOutput | null, options?: For const coversAll = syncListed.length === publishableUpdates(versionOutput).length; const heading = coversAll ? 'All packages' : 'Also bumped'; bumped.push(`**${heading} → ${syncListed[0]?.newVersion}** (sync versioning)`, ''); - for (const u of syncListed) bumped.push(`- \`${u.packageName}\``); + for (const u of syncListed) { + bumped.push(`- \`${u.packageName}\``); + } } } else { - if (hasPackageChangelogs || sharedEntries) bumped.push('**Also bumped** (sync versioning)', ''); - else bumped.push('**Bumped** (sync versioning — no individual changes)', ''); - for (const u of syncBumpedOnly) bumped.push(`- \`${u.packageName}\` → ${u.newVersion}`); + if (hasPackageChangelogs || sharedEntries) { + bumped.push('**Also bumped** (sync versioning)', ''); + } else { + bumped.push('**Bumped** (sync versioning — no individual changes)', ''); + } + for (const u of syncBumpedOnly) { + bumped.push(`- \`${u.packageName}\` → ${u.newVersion}`); + } + } + if (bumped.length > 0) { + lines.push(...blockquote(bumped), ''); } - if (bumped.length > 0) lines.push(...blockquote(bumped), ''); } } @@ -423,7 +444,9 @@ function renderQueuedTable(snapshot: StandingPRSnapshot): string[] { const changelogs = snapshot.manifest.versionOutput.changelogs.filter( (cl) => cl.entries.length > 0 || cl.version !== cl.previousVersion, ); - if (changelogs.length === 0) return []; + if (changelogs.length === 0) { + return []; + } const lines: string[] = [ '', '### Currently queued for release', @@ -482,8 +505,12 @@ function renderMergeTable(rows: MergedRow[]): string[] { const standing = row.standing ?? '—'; const current = row.current ?? '—'; let afterCell = row.afterMerge; - if (row.status === 'escalated' && row.standing) afterCell += ` ⚠ escalated from ${row.standing}`; - if (row.status === 'new-from-pr') afterCell += ' (new)'; + if (row.status === 'escalated' && row.standing) { + afterCell += ` ⚠ escalated from ${row.standing}`; + } + if (row.status === 'new-from-pr') { + afterCell += ' (new)'; + } lines.push(`| \`${row.packageName}\` | ${standing} | ${current} | ${afterCell} |`); } lines.push(''); @@ -494,7 +521,9 @@ function renderEntries(entries: VersionChangelogEntry[], refs: ChangelogRefsMode const lines: string[] = []; const grouped = new Map(); for (const entry of entries) { - if (!grouped.has(entry.type)) grouped.set(entry.type, []); + if (!grouped.has(entry.type)) { + grouped.set(entry.type, []); + } grouped.get(entry.type)?.push(entry); } const renderedTypes = new Set(); @@ -563,6 +592,8 @@ function capitalize(str: string): string { * Markdown blockquote. Trailing blanks are dropped so the quote ends cleanly before the next block. */ function blockquote(lines: string[]): string[] { const trimmed = [...lines]; - while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') trimmed.pop(); + while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') { + trimmed.pop(); + } return trimmed.map((l) => (l.length > 0 ? `> ${l}` : '>')); } diff --git a/packages/release/src/preview/merge.ts b/packages/release/src/preview/merge.ts index 393f1043..ba0b1aba 100644 --- a/packages/release/src/preview/merge.ts +++ b/packages/release/src/preview/merge.ts @@ -29,7 +29,9 @@ export function mergeForPreview( const byName = new Map(); for (const cl of standingChangelogs) { - if (!cl.entries.length && cl.version === cl.previousVersion) continue; + if (!cl.entries.length && cl.version === cl.previousVersion) { + continue; + } byName.set(cl.packageName, { packageName: cl.packageName, baseline: cl.previousVersion, @@ -40,7 +42,9 @@ export function mergeForPreview( } for (const cl of currentChangelogs) { - if (!cl.entries.length && cl.version === cl.previousVersion) continue; + if (!cl.entries.length && cl.version === cl.previousVersion) { + continue; + } const existing = byName.get(cl.packageName); if (!existing) { byName.set(cl.packageName, { @@ -54,7 +58,9 @@ export function mergeForPreview( } // Only record a current version when it is parseable — an invalid string would surface // as a garbled value in the "This PR" column of the rendered table. - if (semver.valid(cl.version)) existing.current = cl.version; + if (semver.valid(cl.version)) { + existing.current = cl.version; + } // Guard semver comparison against malformed version strings (older manifests, unexpected // calculator output) — semver.gt throws on invalid input, which would otherwise abort the // entire preview. Treat unparseable versions as 'unchanged' to keep the preview non-fatal. diff --git a/packages/release/src/preview/preview.ts b/packages/release/src/preview/preview.ts index 4d85657e..03a08125 100644 --- a/packages/release/src/preview/preview.ts +++ b/packages/release/src/preview/preview.ts @@ -52,7 +52,9 @@ interface PullRequestEvent { /** Read and parse the GitHub Actions event payload once, or undefined on any error. */ function readEventPayload(): PullRequestEvent | undefined { const eventPath = process.env.GITHUB_EVENT_PATH; - if (!eventPath || !fs.existsSync(eventPath)) return undefined; + if (!eventPath || !fs.existsSync(eventPath)) { + return undefined; + } try { return JSON.parse(fs.readFileSync(eventPath, 'utf-8')) as PullRequestEvent; } catch { @@ -374,11 +376,19 @@ async function applyLabelOverrides( // do not propagate to `result`. Version analysis still runs (commit-driven) so the // preview can show this PR's contribution to the standing PR via the merge table. if (advisoryInStandingPr) { - if (prLabels.includes(labels.major)) labelContext.bumpLabel = 'major'; - else if (prLabels.includes(labels.minor)) labelContext.bumpLabel = 'minor'; - else if (prLabels.includes(labels.patch)) labelContext.bumpLabel = 'patch'; - if (prLabels.includes(labels.graduate)) labelContext.stable = true; - if (prLabels.includes(labels.prerelease)) labelContext.prerelease = true; + if (prLabels.includes(labels.major)) { + labelContext.bumpLabel = 'major'; + } else if (prLabels.includes(labels.minor)) { + labelContext.bumpLabel = 'minor'; + } else if (prLabels.includes(labels.patch)) { + labelContext.bumpLabel = 'patch'; + } + if (prLabels.includes(labels.graduate)) { + labelContext.stable = true; + } + if (prLabels.includes(labels.prerelease)) { + labelContext.prerelease = true; + } return { options: result, labelContext }; } @@ -507,10 +517,20 @@ async function applyLabelOverrides( * `preminor`, `prepatch`, `premajor`, or `prerelease` — only the first three carry a magnitude. */ function magnitudeFromBump(bump: string | undefined): string | undefined { - if (!bump) return undefined; - if (bump === 'major' || bump === 'minor' || bump === 'patch') return bump; - if (bump === 'premajor') return 'major'; - if (bump === 'preminor') return 'minor'; - if (bump === 'prepatch') return 'patch'; + if (!bump) { + return undefined; + } + if (bump === 'major' || bump === 'minor' || bump === 'patch') { + return bump; + } + if (bump === 'premajor') { + return 'major'; + } + if (bump === 'preminor') { + return 'minor'; + } + if (bump === 'prepatch') { + return 'patch'; + } return undefined; } diff --git a/packages/release/src/preview/refresh.ts b/packages/release/src/preview/refresh.ts index b187dc77..68707408 100644 --- a/packages/release/src/preview/refresh.ts +++ b/packages/release/src/preview/refresh.ts @@ -80,15 +80,23 @@ export async function refreshFeederPreviews(options: RefreshAfterReleaseOptions) const eligible: OpenPullRequest[] = []; for (const pr of open) { - if (pr.draft) continue; // drafts don't carry previews - if (pr.headRef === branch) continue; // the standing PR's manifest is authoritative, never previewed + if (pr.draft) { + continue; // drafts don't carry previews + } + if (pr.headRef === branch) { + continue; // the standing PR's manifest is authoritative, never previewed + } // Only refresh PRs that already have a preview — never manufacture one on a PR that opted out. - if (!(await forge.findComment(pr.number, MARKER))) continue; + if (!(await forge.findComment(pr.number, MARKER))) { + continue; + } eligible.push(pr); // Open PRs come back most-recently-updated first, so the first MAX_FEEDER_PR_REFRESH are exactly // the set we refresh. Stop probing once one past the cap is in hand — bounds the per-PR // findComment calls instead of fetching comments for every open PR on a busy repo. - if (eligible.length > MAX_FEEDER_PR_REFRESH) break; + if (eligible.length > MAX_FEEDER_PR_REFRESH) { + break; + } } let toRefresh = eligible; diff --git a/packages/release/src/release.ts b/packages/release/src/release.ts index 491a003f..da15899c 100644 --- a/packages/release/src/release.ts +++ b/packages/release/src/release.ts @@ -25,7 +25,9 @@ async function resolveReleaseReportTarget(): Promise<{ mode: 'direct' | 'manual'; } | null> { const githubContext = getGitHubContext(); - if (!githubContext?.token) return null; + if (!githubContext?.token) { + return null; + } const forge = forgeFor({ token: githubContext.token, owner: githubContext.owner, repo: githubContext.repo }); const { sha } = githubContext; @@ -68,8 +70,12 @@ async function reportReleaseFailure(versionOutput: VersionOutput, err: PipelineE export function resolveScopeToTarget(scopeName: string, scopeLabels: Record): string { const prefixed = `scope:${scopeName}`; - if (scopeLabels[prefixed]) return scopeLabels[prefixed]; - if (scopeLabels[scopeName]) return scopeLabels[scopeName]; + if (scopeLabels[prefixed]) { + return scopeLabels[prefixed]; + } + if (scopeLabels[scopeName]) { + return scopeLabels[scopeName]; + } const available = Object.keys(scopeLabels).join(', '); throw new Error(`Scope "${scopeName}" not found in ci.scopeLabels. Available: ${available}`); } @@ -182,22 +188,36 @@ async function applyScopeLabelsFromPR( function applyStepOverrides(options: ReleaseOptions, releaseConfig: ReleaseConfig | undefined): void { // Steps array: a step absent from the list is skipped unless CLI already set the flag. if (releaseConfig?.steps) { - if (!releaseConfig.steps.includes('notes') && !options.skipNotes) options.skipNotes = true; - if (!releaseConfig.steps.includes('publish') && !options.skipPublish) options.skipPublish = true; + if (!releaseConfig.steps.includes('notes') && !options.skipNotes) { + options.skipNotes = true; + } + if (!releaseConfig.steps.includes('publish') && !options.skipPublish) { + options.skipPublish = true; + } } // ci overrides: can suppress a step even when it appears in 'steps'. - if (releaseConfig?.ci?.notes === false && !options.skipNotes) options.skipNotes = true; - if (releaseConfig?.ci?.githubRelease === false && !options.skipGithubRelease) options.skipGithubRelease = true; + if (releaseConfig?.ci?.notes === false && !options.skipNotes) { + options.skipNotes = true; + } + if (releaseConfig?.ci?.githubRelease === false && !options.skipGithubRelease) { + options.skipGithubRelease = true; + } } export async function runRelease(inputOptions: ReleaseOptions): Promise { // Work on a copy so config-driven overrides never mutate the caller's object const options = { ...inputOptions }; - if (options.verbose) setLogLevel('debug'); - if (options.quiet) setQuietMode(true); - if (options.json) setJsonMode(true); + if (options.verbose) { + setLogLevel('debug'); + } + if (options.quiet) { + setQuietMode(true); + } + if (options.json) { + setJsonMode(true); + } // Load release config for automation behavior let releaseKitConfig: ReturnType; diff --git a/packages/release/src/standing-pr/authorization.ts b/packages/release/src/standing-pr/authorization.ts index e7ae987b..3473da91 100644 --- a/packages/release/src/standing-pr/authorization.ts +++ b/packages/release/src/standing-pr/authorization.ts @@ -37,7 +37,9 @@ export interface EventActor { /** Read the triggering actor (and the merger, for publish) from the GitHub Actions event payload. */ export function getEventActor(): EventActor { const eventPath = process.env.GITHUB_EVENT_PATH; - if (!eventPath) return {}; + if (!eventPath) { + return {}; + } try { const event = JSON.parse(fs.readFileSync(eventPath, 'utf-8')) as { action?: string; @@ -63,7 +65,9 @@ function isBot(login: string | undefined, type: string | undefined): boolean { /** An `allowedActors` entry naming a GitHub team, e.g. `@acme/releasers` → `{ org, team }`. */ function parseTeamRef(entry: string): { org: string; team: string } | undefined { - if (!entry.startsWith('@') || !entry.includes('/')) return undefined; + if (!entry.startsWith('@') || !entry.includes('/')) { + return undefined; + } const [org, team] = entry.slice(1).split('/'); return org && team ? { org, team } : undefined; } @@ -83,25 +87,35 @@ export async function isAuthorizedActor( type: string | undefined, authz: StandingPrAuthorization, ): Promise { - if (isBot(login, type)) return true; - if (!login) return false; + if (isBot(login, type)) { + return true; + } + if (!login) { + return false; + } const entries = authz.allowedActors ?? []; // 1. Username allow-list. GitHub usernames are case-insensitive, so compare case-folded — an // `allowedActors: ['Alice']` entry must still match an event delivering `login: 'alice'`. const lc = login.toLowerCase(); - if (entries.some((e) => !parseTeamRef(e) && e.toLowerCase() === lc)) return true; + if (entries.some((e) => !parseTeamRef(e) && e.toLowerCase() === lc)) { + return true; + } // 2. Repository permission threshold. const permission = await forge.getActorPermission(login); - if (PERMISSION_RANK[permission] >= PERMISSION_RANK[authz.requiredPermission]) return true; + if (PERMISSION_RANK[permission] >= PERMISSION_RANK[authz.requiredPermission]) { + return true; + } // 3. Team membership — last, since it costs an API call per team and a 403 (no org-read scope) // propagates to the caller's gate wrapper. An `@org/team` member is authorized regardless of // repo permission. for (const entry of entries) { const ref = parseTeamRef(entry); - if (ref && (await forge.isTeamMember(ref.org, ref.team, login))) return true; + if (ref && (await forge.isTeamMember(ref.org, ref.team, login))) { + return true; + } } return false; } diff --git a/packages/release/src/standing-pr/changelog-region.ts b/packages/release/src/standing-pr/changelog-region.ts index c6bae0ea..b29045f8 100644 --- a/packages/release/src/standing-pr/changelog-region.ts +++ b/packages/release/src/standing-pr/changelog-region.ts @@ -86,7 +86,9 @@ function dedupe(attributed: AttributedEntry[]): DedupedEntry[] { const byKey = new Map(); const order: DedupedEntry[] = []; for (const { entry, pkg } of attributed) { - if (entry.synthetic) continue; + if (entry.synthetic) { + continue; + } const key = dedupeKey(entry); let agg = byKey.get(key); if (!agg) { @@ -94,7 +96,9 @@ function dedupe(attributed: AttributedEntry[]): DedupedEntry[] { byKey.set(key, agg); order.push(agg); } - if (pkg) agg.pkgs.add(pkg); + if (pkg) { + agg.pkgs.add(pkg); + } } return order; } @@ -138,7 +142,11 @@ const DEMOTED_HEADING = 'Dependencies & version bumps'; * removed, so the caller's de-duplicated change count is unchanged. */ function renderGrouped(deduped: DedupedEntry[], refOpts: RefRenderOptions, demoteScopes: readonly string[]): string[] { const distinct = new Set(); - for (const d of deduped) for (const p of d.pkgs) distinct.add(p); + for (const d of deduped) { + for (const p of d.pkgs) { + distinct.add(p); + } + } // Attribution only earns its place when the list spans more than one package. const attribution = distinct.size > 1; @@ -146,8 +154,11 @@ function renderGrouped(deduped: DedupedEntry[], refOpts: RefRenderOptions, demot const promoted: DedupedEntry[] = []; const demoted: DedupedEntry[] = []; for (const d of deduped) { - if (demote && d.entry.scope !== undefined && demoteScopes.includes(d.entry.scope)) demoted.push(d); - else promoted.push(d); + if (demote && d.entry.scope !== undefined && demoteScopes.includes(d.entry.scope)) { + demoted.push(d); + } else { + promoted.push(d); + } } const byLabel = new Map(); @@ -164,12 +175,16 @@ function renderGrouped(deduped: DedupedEntry[], refOpts: RefRenderOptions, demot const lines: string[] = []; const emit = (label: string): void => { const list = byLabel.get(label); - if (!list?.length) return; + if (!list?.length) { + return; + } // `#### `, not bold `**…**`: inside the blockquote GitHub gives a heading paragraph-level // top margin, so consecutive sections stay visually separated — bold text collapses to near-zero // spacing there. Matches the preview surface, which already uses `#### ` headings. lines.push(`#### ${label}`, ''); - for (const d of list) lines.push(entryLine(d, attribution, refOpts)); + for (const d of list) { + lines.push(entryLine(d, attribution, refOpts)); + } lines.push(''); }; const rendered = new Set(); @@ -177,15 +192,23 @@ function renderGrouped(deduped: DedupedEntry[], refOpts: RefRenderOptions, demot emit(label); rendered.add(label); } - for (const label of byLabel.keys()) if (!rendered.has(label)) emit(label); + for (const label of byLabel.keys()) { + if (!rendered.has(label)) { + emit(label); + } + } // The demoted subsection trails every type bucket, so low-signal dependency churn never crowds the // user-facing changes at the top. First-seen order is preserved (deduped iteration order). if (demoted.length > 0) { lines.push(`#### ${DEMOTED_HEADING}`, ''); - for (const d of demoted) lines.push(entryLine(d, attribution, refOpts)); + for (const d of demoted) { + lines.push(entryLine(d, attribution, refOpts)); + } lines.push(''); } - if (lines[lines.length - 1] === '') lines.pop(); + if (lines[lines.length - 1] === '') { + lines.pop(); + } return lines; } @@ -238,11 +261,17 @@ export function makeRowChangelogRenderer( const attributed: AttributedEntry[] = []; for (const name of packageNames) { const cl = byPkg.get(name); - if (!cl) continue; - for (const entry of cl.entries) attributed.push({ entry, pkg: name }); + if (!cl) { + continue; + } + for (const entry of cl.entries) { + attributed.push({ entry, pkg: name }); + } } const deduped = dedupe(attributed); - if (deduped.length === 0) return ''; + if (deduped.length === 0) { + return ''; + } const summary = heldBack ? `Changelog (${pluralEntries(deduped.length)}) — held back, won’t publish` : `Changelog (${pluralEntries(deduped.length)})`; @@ -257,10 +286,14 @@ function collectAttributed(versionOutput: VersionOutput, sharedOnly: boolean): A const attributed: AttributedEntry[] = []; if (!sharedOnly) { for (const cl of versionOutput.changelogs) { - for (const entry of cl.entries) attributed.push({ entry, pkg: cl.packageName }); + for (const entry of cl.entries) { + attributed.push({ entry, pkg: cl.packageName }); + } } } - for (const entry of versionOutput.sharedEntries ?? []) attributed.push({ entry }); + for (const entry of versionOutput.sharedEntries ?? []) { + attributed.push({ entry }); + } return attributed; } @@ -286,7 +319,9 @@ export function renderCombinedFooter( opts: { sharedOnly?: boolean; refs?: ChangelogRefsMode; demoteScopes?: readonly string[] } = {}, ): string { const deduped = dedupe(collectAttributed(versionOutput, !!opts.sharedOnly)); - if (deduped.length === 0) return ''; + if (deduped.length === 0) { + return ''; + } const n = deduped.length; const summary = opts.sharedOnly ? `Show project-wide changes (${n} ${n === 1 ? 'change' : 'changes'})` diff --git a/packages/release/src/standing-pr/notes-region.ts b/packages/release/src/standing-pr/notes-region.ts index 9883a69c..faa0af32 100644 --- a/packages/release/src/standing-pr/notes-region.ts +++ b/packages/release/src/standing-pr/notes-region.ts @@ -24,7 +24,9 @@ const NOTES_TRUNCATION_MARKER = '\n\n…(truncated)'; /** Trim one package's notes to {@link MAX_NOTES_CHARS_PER_PACKAGE}, on a line boundary where possible. */ export function truncatePackageNotes(notes: string): string { - if (notes.length <= MAX_NOTES_CHARS_PER_PACKAGE) return notes; + if (notes.length <= MAX_NOTES_CHARS_PER_PACKAGE) { + return notes; + } const budget = MAX_NOTES_CHARS_PER_PACKAGE - NOTES_TRUNCATION_MARKER.length; const slice = notes.slice(0, budget); const lastNewline = slice.lastIndexOf('\n'); @@ -35,7 +37,9 @@ export function truncatePackageNotes(notes: string): string { /** Render the editable region for the given per-package notes, with keyed markers per package. */ export function renderNotesRegion(notesByPackage: Record): string { const packages = Object.keys(notesByPackage).sort(); - if (packages.length === 0) return ''; + if (packages.length === 0) { + return ''; + } const lines: string[] = [REGION_HEADING, '', REGION_HINT, '']; for (const pkg of packages) { @@ -49,7 +53,9 @@ export function extractNotesRegions(body: string, packageNames: string[]): Recor const result: Record = {}; for (const pkg of packageNames) { const region = extractNotesRegion(body, pkg); - if (region !== undefined && region.length > 0) result[pkg] = region; + if (region !== undefined && region.length > 0) { + result[pkg] = region; + } } return result; } diff --git a/packages/release/src/standing-pr/selection-region.ts b/packages/release/src/standing-pr/selection-region.ts index 2a789f07..a11feee5 100644 --- a/packages/release/src/standing-pr/selection-region.ts +++ b/packages/release/src/standing-pr/selection-region.ts @@ -53,7 +53,9 @@ function channelOf(update: Update): ReleaseChannel { /** A release unit's channel is its primary's; an unchanged primary (no `primaryUpdate`) inherits it * from its members, which are single-channel by construction for `linked`/`fixed` groups. */ function unitChannel(unit: ReleaseUnit): ReleaseChannel { - if (unit.primaryUpdate) return channelOf(unit.primaryUpdate); + if (unit.primaryUpdate) { + return channelOf(unit.primaryUpdate); + } const child = unit.children[0]; return child ? channelOf(child) : 'stable'; } @@ -64,15 +66,20 @@ function byChannel(items: readonly T[], key: (item: T) => ReleaseChannel): Ma for (const item of items) { const k = key(item); const list = map.get(k); - if (list) list.push(item); - else map.set(k, [item]); + if (list) { + list.push(item); + } else { + map.set(k, [item]); + } } return map; } /** Push a section heading, ensuring a blank line separates it from the rows above. */ function pushSectionHeading(lines: string[], channel: ReleaseChannel): void { - if (lines[lines.length - 1] !== '') lines.push(''); + if (lines[lines.length - 1] !== '') { + lines.push(''); + } lines.push(SECTION_HEADING[channel], ''); } @@ -80,7 +87,9 @@ function pushSectionHeading(lines: string[], channel: ReleaseChannel): void { * on (npm publishes a prerelease under its preid, e.g. `1.0.0-next.1` → `next`), so a mixed PR shows * per-package maturity at a glance. */ function versionDisplay(update: Update): string { - if (channelOf(update) !== 'prerelease') return update.newVersion; + if (channelOf(update) !== 'prerelease') { + return update.newVersion; + } // SEAM: the per-package "graduate to stable" affordance — gated on a `graduate:` // label — attaches to prerelease rows here once that mechanism merges. This renders the channel // grouping + dist-tag only; it deliberately renders no affordance yet. @@ -149,9 +158,13 @@ export function computeHierarchy(updates: Update[], cfg: PrimaryConfig): Selecti // A changed primary carries its group on its update; an unchanged one is resolved from config. const groupOfPrimary = (p: string): string | undefined => { const own = updateByName.get(p)?.group; - if (own) return own; + if (own) { + return own; + } for (const [name, gc] of Object.entries(groups)) { - if (shouldMatchPackageTargets(p, gc.packages ?? [])) return name; + if (shouldMatchPackageTargets(p, gc.packages ?? [])) { + return name; + } } return undefined; }; @@ -161,12 +174,24 @@ export function computeHierarchy(updates: Update[], cfg: PrimaryConfig): Selecti const seen = new Set(); const result: Update[] = []; const add = (u: Update) => { - if (u.packageName === p || declaredPrimaryNames.has(u.packageName) || seen.has(u.packageName)) return; + if (u.packageName === p || declaredPrimaryNames.has(u.packageName) || seen.has(u.packageName)) { + return; + } seen.add(u.packageName); result.push(u); }; - if (group) for (const u of updates) if (u.group === group) add(u); - for (const u of updates) if (u.role === 'prerequisite' && u.prerequisiteOf?.includes(p)) add(u); + if (group) { + for (const u of updates) { + if (u.group === group) { + add(u); + } + } + } + for (const u of updates) { + if (u.role === 'prerequisite' && u.prerequisiteOf?.includes(p)) { + add(u); + } + } return result.sort((a, b) => a.packageName.localeCompare(b.packageName)); }; @@ -176,7 +201,9 @@ export function computeHierarchy(updates: Update[], cfg: PrimaryConfig): Selecti for (const primaryName of [...declaredPrimaryNames].sort((a, b) => a.localeCompare(b))) { const children = childrenOf(primaryName); const primaryUpdate = updateByName.get(primaryName); - if (!primaryUpdate && children.length === 0) continue; // declared but nothing in its unit changed + if (!primaryUpdate && children.length === 0) { + continue; // declared but nothing in its unit changed + } const group = groupOfPrimary(primaryName); const groupSync = group ? groups[group]?.sync : undefined; units.push({ primaryName, primaryUpdate, children, groupSync }); @@ -208,12 +235,24 @@ export function cascadeDeselection(hierarchy: SelectionHierarchy, deselected: Re // one). Escalate it to holding its whole unit: a package a maintainer held back must never silently // ship, and holding the unit keeps the render coherent (the primary shows unticked). for (const [child, owners] of hierarchy.childOwners) { - if (deselected.has(child)) for (const owner of owners) deselectedPrimaries.add(owner); + if (deselected.has(child)) { + for (const owner of owners) { + deselectedPrimaries.add(owner); + } + } + } + for (const p of deselectedPrimaries) { + effective.add(p); + } + for (const orphan of hierarchy.orphans) { + if (deselected.has(orphan.packageName)) { + effective.add(orphan.packageName); + } } - for (const p of deselectedPrimaries) effective.add(p); - for (const orphan of hierarchy.orphans) if (deselected.has(orphan.packageName)) effective.add(orphan.packageName); for (const [child, owners] of hierarchy.childOwners) { - if (owners.every((o) => deselectedPrimaries.has(o))) effective.add(child); + if (owners.every((o) => deselectedPrimaries.has(o))) { + effective.add(child); + } } return effective; } @@ -295,9 +334,13 @@ function attachChangelog( heldBack: boolean, indent: string, ): void { - if (!rowChangelog) return; + if (!rowChangelog) { + return; + } const block = rowChangelog(packageNames, heldBack, indent); - if (block) lines.push(block); + if (block) { + lines.push(block); + } } /** @@ -333,7 +376,9 @@ function attachChannelToggle( indent: string, channel: ChannelToggleConfig | undefined, ): void { - if (!channel || !selected) return; + if (!channel || !selected) { + return; + } const name = update.packageName; if (channelOf(update) === 'stable') { const preid = channel.prereleaseIdentifier ?? 'next'; @@ -401,7 +446,9 @@ function renderFlat( const channels = CHANNEL_ORDER.filter((c) => (updatesByChannel.get(c)?.length ?? 0) > 0); const showHeadings = channels.length > 1; for (const channel of channels) { - if (showHeadings) pushSectionHeading(lines, channel); + if (showHeadings) { + pushSectionHeading(lines, channel); + } renderFlatSection(lines, updatesByChannel.get(channel) ?? [], selected, rowChangelog, channelToggle); } } @@ -419,13 +466,17 @@ function renderUnit( const heldBack = !selected(unit.primaryName); // The primary's channel toggle shifts the whole unit (the wiring expands the scope to its group); // an unchanged primary anchoring the unit has no version to shift, so it gets none. - if (unit.primaryUpdate) attachChannelToggle(lines, unit.primaryUpdate, !heldBack, ' ', channel); + if (unit.primaryUpdate) { + attachChannelToggle(lines, unit.primaryUpdate, !heldBack, ' ', channel); + } if (streamlined) { if (unit.children.length > 0) { // Children inside a collapsed pane as plain bullets — a blank line after lets GitHub // render the nested markdown list. lines.push(`
ships ${unit.children.length} ${unitWord(unit)}`, ''); - for (const child of unit.children) lines.push(childBullet(child, unit.groupSync)); + for (const child of unit.children) { + lines.push(childBullet(child, unit.groupSync)); + } lines.push('
'); } // The streamlined unit ships primary + its members together (coupled or bundled), so its changelog @@ -479,12 +530,16 @@ function renderHierarchical( ); const showHeadings = channels.length > 1; for (const channel of channels) { - if (showHeadings) pushSectionHeading(lines, channel); - for (const unit of unitsByChannel.get(channel) ?? []) + if (showHeadings) { + pushSectionHeading(lines, channel); + } + for (const unit of unitsByChannel.get(channel) ?? []) { renderUnit(lines, unit, selected, streamlined, rowChangelog, channelToggle); + } // Orphans stay flat top-level checkboxes (fail-safe), grouped under their own channel. - for (const orphan of orphansByChannel.get(channel) ?? []) + for (const orphan of orphansByChannel.get(channel) ?? []) { renderOrphan(lines, orphan, selected, rowChangelog, channelToggle); + } } } @@ -532,19 +587,27 @@ export function renderSelectionRegion( */ export function extractSelection(body: string): { deselected: string[] } | undefined { const region = extractSelectionRegion(body); - if (region === undefined) return undefined; + if (region === undefined) { + return undefined; + } const deselected: string[] = []; for (const line of region.split('\n')) { const markerStart = line.indexOf('', nameStart); - if (nameEnd === -1) continue; + if (nameEnd === -1) { + continue; + } const name = line.slice(nameStart, nameEnd); // A row is selected unless its checkbox glyph is explicitly empty. `includes('[ ]')` is a fixed // substring check on the row text before the marker — no prose parsing, no backtracking regex. - if (line.slice(0, markerStart).includes('[ ]')) deselected.push(name); + if (line.slice(0, markerStart).includes('[ ]')) { + deselected.push(name); + } } return { deselected }; } @@ -556,10 +619,14 @@ function markerHits(region: string, marker: string): Array<{ name: string; ticke const hits: Array<{ name: string; ticked: boolean; unticked: boolean }> = []; for (const line of region.split('\n')) { const markerStart = line.indexOf(open); - if (markerStart === -1) continue; + if (markerStart === -1) { + continue; + } const nameStart = markerStart + open.length; const nameEnd = line.indexOf(' -->', nameStart); - if (nameEnd === -1) continue; + if (nameEnd === -1) { + continue; + } const before = line.slice(0, markerStart); hits.push({ name: line.slice(nameStart, nameEnd), @@ -578,7 +645,9 @@ function markerHits(region: string, marker: string): Array<{ name: string; ticke */ export function extractChannelSelection(body: string): { prereleased: string[]; graduated: string[] } { const region = extractSelectionRegion(body); - if (region === undefined) return { prereleased: [], graduated: [] }; + if (region === undefined) { + return { prereleased: [], graduated: [] }; + } const heldBack = new Set( markerHits(region, 'rk-sel') .filter((h) => h.unticked) @@ -634,7 +703,9 @@ export function selectionWarnings( ): SelectionWarning[] { const warnings: SelectionWarning[] = []; for (const update of updates) { - if (!deselected.has(update.packageName)) continue; + if (!deselected.has(update.packageName)) { + continue; + } if (update.group && groups[update.group]?.sync === 'independent') { // Only a *partial* hold-back splits the group. When a primary toggle cascades the whole // independent group out together, every member is deselected — coherent, so don't warn. diff --git a/packages/release/src/standing-pr/standing-pr.ts b/packages/release/src/standing-pr/standing-pr.ts index ef851697..89bc166e 100644 --- a/packages/release/src/standing-pr/standing-pr.ts +++ b/packages/release/src/standing-pr/standing-pr.ts @@ -196,9 +196,13 @@ async function getHeadSha(cwd: string): Promise { * idempotent and the workflow guards out the bot's own edits, so an `edited` re-trigger is safe. */ function isStandingPrEventRun(): boolean { - if (process.env.GITHUB_EVENT_NAME !== 'pull_request') return false; + if (process.env.GITHUB_EVENT_NAME !== 'pull_request') { + return false; + } const eventPath = process.env.GITHUB_EVENT_PATH; - if (!eventPath) return false; + if (!eventPath) { + return false; + } try { const event = JSON.parse(fs.readFileSync(eventPath, 'utf-8')) as { action?: string }; return event.action === 'labeled' || event.action === 'unlabeled' || event.action === 'edited'; @@ -245,7 +249,9 @@ async function commitAndForcePush(branch: string, cwd: string): Promise { * state can't pollute the notes commit. */ async function commitNotesFiles(files: string[], versionOutput: VersionOutput, cwd: string): Promise { - if (files.length === 0) return; + if (files.length === 0) { + return; + } const git = createGitCli(); @@ -307,7 +313,9 @@ async function commitNotesFiles(files: string[], versionOutput: VersionOutput, c * publish pipeline's `--tags` push will publish whatever tags we managed to create. */ export async function createReleaseTags(tags: string[], cwd: string): Promise { - if (tags.length === 0) return; + if (tags.length === 0) { + return; + } const git = createGitCli(); @@ -371,7 +379,9 @@ async function deleteReleaseBranch(releaseBranch: string, cwd: string): Promise< export const STANDING_PR_BODY_CAP = 64000; function truncateAtLineBoundary(text: string, maxChars: number): string { - if (text.length <= maxChars) return text; + if (text.length <= maxChars) { + return text; + } const slice = text.slice(0, maxChars); const lastNewline = slice.lastIndexOf('\n'); return lastNewline > 0 ? slice.slice(0, lastNewline) : slice; @@ -423,7 +433,9 @@ function renderPrBody(versionOutput: VersionOutput, options: RenderPrBodyOptions // One-line release headline under the heading — a neutral "what will publish" scan before // the (conditional) supersede alert and the package list. Absent for sync (its own display leads). - if (summaryLine) lines.push(summaryLine, ''); + if (summaryLine) { + lines.push(summaryLine, ''); + } // While a prior release remains partially published, lead with the supersede warning so the // maintainer sees the retry-vs-supersede choice before the package list. @@ -434,7 +446,9 @@ function renderPrBody(versionOutput: VersionOutput, options: RenderPrBodyOptions // The scannable current → next table, opt-in via ci.standingPr.summaryTable, sits above the // selection region (and after any supersede warning) — it complements the checkbox list, which // GitHub renders interactively only as a list, never in a table cell. - if (summaryTable) lines.push(summaryTable, ''); + if (summaryTable) { + lines.push(summaryTable, ''); + } // The root lockstep bump (sync mode) is never published — keep it out of the package list. const updates = publishableUpdates(versionOutput); @@ -463,11 +477,17 @@ function renderPrBody(versionOutput: VersionOutput, options: RenderPrBodyOptions } } - if (footerSection) lines.push('', footerSection, ''); - if (extraNotice) lines.push('', extraNotice, ''); + if (footerSection) { + lines.push('', footerSection, ''); + } + if (extraNotice) { + lines.push('', extraNotice, ''); + } // The editable release-notes region (opt-in via the preview-notes label) sits below the changelog // and above the merge instructions, so a reviewer reads/edits it in the natural place. - if (notesSection) lines.push('', notesSection, ''); + if (notesSection) { + lines.push('', notesSection, ''); + } lines.push('---', '> Merge this PR to publish. The release will be triggered automatically.'); lines.push('', ATTRIBUTION_FOOTER); return lines.join('\n'); @@ -480,19 +500,25 @@ function renderPrBody(versionOutput: VersionOutput, options: RenderPrBodyOptions // Level 0 — the full body: per-row changelogs (non-sync) plus the combined deduped footer. const fullSelection = renderSelectionBlock?.(true); const full = build(fullSelection, footer, undefined, notesSection); - if (full.length <= STANDING_PR_BODY_CAP) return full; + if (full.length <= STANDING_PR_BODY_CAP) { + return full; + } if (renderSelectionBlock) { // Non-sync: always keep the rows (the PR must stay usable); shed changelog volume in stages. // Level 1 — keep the per-row changelogs, drop the redundant deduped footer. if (footer) { const body = build(fullSelection, '', undefined, notesSection); - if (body.length <= STANDING_PR_BODY_CAP) return body; + if (body.length <= STANDING_PR_BODY_CAP) { + return body; + } } // Level 2 — drop the per-row changelogs too (bare rows) and point reviewers at CHANGELOG.md. const bareSelection = renderSelectionBlock(false); const body = build(bareSelection, '', TRUNCATION_NOTICE, notesSection); - if (body.length <= STANDING_PR_BODY_CAP) return body; + if (body.length <= STANDING_PR_BODY_CAP) { + return body; + } // Level 3 — even the notice overflows (thousands of rows): bare rows only. return build(bareSelection, '', undefined, notesSection); } @@ -502,7 +528,9 @@ function renderPrBody(versionOutput: VersionOutput, options: RenderPrBodyOptions // 8 = 4 (\n\n around the footer) + 2 (\n\n before the notice) + 2 safety margin. const room = STANDING_PR_BODY_CAP - build(undefined, '', undefined, notesSection).length - TRUNCATION_NOTICE.length - 8; - if (room <= 0) return build(undefined, '', undefined, notesSection); + if (room <= 0) { + return build(undefined, '', undefined, notesSection); + } return build( undefined, `${truncateAtLineBoundary(footer, room)}\n\n${TRUNCATION_NOTICE}`, @@ -514,12 +542,16 @@ function renderPrBody(versionOutput: VersionOutput, options: RenderPrBodyOptions }; const primary = shed(notesRegion); - if (primary.length <= STANDING_PR_BODY_CAP || !notesRegion) return primary; + if (primary.length <= STANDING_PR_BODY_CAP || !notesRegion) { + return primary; + } // The trimmed changelog still overflows with the notes present — the notes are (part of) the excess. // Collapse them to a short notice, then drop them outright, so an inflated notes region can never // wedge the PR at GitHub's 422. const collapsed = shed(NOTES_OMITTED_NOTICE); - if (collapsed.length <= STANDING_PR_BODY_CAP) return collapsed; + if (collapsed.length <= STANDING_PR_BODY_CAP) { + return collapsed; + } return shed(undefined); } @@ -610,13 +642,19 @@ export async function fetchStandingPRSnapshot( ): Promise { const branch = ciConfig?.standingPr?.branch ?? 'release/next'; const pr = await forge.findStandingPR(branch); - if (!pr) return null; + if (!pr) { + return null; + } const comment = await findManifestComment(forge, pr.number); - if (!comment) return null; + if (!comment) { + return null; + } // findManifestComment falls back to a human-authored comment when no bot one exists; refuse to // surface a forged manifest in the preview, matching the publish path's author check. - if (!isBotComment(comment)) return null; + if (!isBotComment(comment)) { + return null; + } let manifest: StandingPRManifest; try { @@ -737,14 +775,18 @@ function graduatedPackageNames(versionOutput: VersionOutput): string[] { ); const names = new Set(); for (const u of updates) { - if (u.action === 'graduated' || (u.group && graduatedGroups.has(u.group))) names.add(u.packageName); + if (u.action === 'graduated' || (u.group && graduatedGroups.has(u.group))) { + names.add(u.packageName); + } } return [...names].sort(); } function resolveStandingPrLabelOverrides(prLabels: string[], ciConfig: CIConfig | undefined): StandingPrOverrides { // Return a fresh object rather than a shared constant — callers may mutate `conflicts`. - if (!prLabels || prLabels.length === 0) return { conflicts: [] }; + if (!prLabels || prLabels.length === 0) { + return { conflicts: [] }; + } const labels = ciConfig?.labels ?? DEFAULT_LABELS; const scopeLabels = ciConfig?.scopeLabels ?? {}; const conflicts: string[] = []; @@ -758,9 +800,13 @@ function resolveStandingPrLabelOverrides(prLabels: string[], ciConfig: CIConfig ].filter(Boolean) as string[]; if (bumpsPresent.length > 1) { conflicts.push(`Conflicting bump labels on standing PR (${bumpsPresent.join(', ')}) — remove all but one`); - } else if (prLabels.includes(labels.major)) bump = 'major'; - else if (prLabels.includes(labels.minor)) bump = 'minor'; - else if (prLabels.includes(labels.patch)) bump = 'patch'; + } else if (prLabels.includes(labels.major)) { + bump = 'major'; + } else if (prLabels.includes(labels.minor)) { + bump = 'minor'; + } else if (prLabels.includes(labels.patch)) { + bump = 'patch'; + } // Graduate / prerelease release-type const hasGraduate = prLabels.includes(labels.graduate); @@ -770,8 +816,12 @@ function resolveStandingPrLabelOverrides(prLabels: string[], ciConfig: CIConfig if (hasGraduate && hasPrerelease) { conflicts.push(`Conflicting release-type labels on standing PR (${labels.graduate} and ${labels.prerelease})`); } else { - if (hasGraduate) stable = true; - if (hasPrerelease) prerelease = true; + if (hasGraduate) { + stable = true; + } + if (hasPrerelease) { + prerelease = true; + } } // Per-package graduation: each `graduate:` label graduates that one prerelease to @@ -985,7 +1035,9 @@ export async function runStandingPRUpdate(options: StandingPROptions): Promise u.packageName === name); const groupSync = update?.group ? groups[update.group]?.sync : undefined; if (groupSync === 'fixed' || groupSync === 'linked') { @@ -1242,7 +1310,13 @@ export async function runStandingPRUpdate(options: StandingPROptions): Promise u.packageName === name)?.group; - if (grp) for (const u of dryUpdates) if (u.group === grp) out.add(u.packageName); + if (grp) { + for (const u of dryUpdates) { + if (u.group === grp) { + out.add(u.packageName); + } + } + } } return [...out]; }; @@ -1416,7 +1490,9 @@ export async function runStandingPRUpdate(options: StandingPROptions): Promise 0) notesRegion = renderNotesRegion(merged); + if (Object.keys(merged).length > 0) { + notesRegion = renderNotesRegion(merged); + } } // The interactive selection region — the package list for non-sync releases. Rendered from the @@ -1428,7 +1504,9 @@ export async function runStandingPRUpdate(options: StandingPROptions): Promise string) | undefined; if (versionOutputDry.strategy !== 'sync') { const warnings = selectionWarnings(dryUpdates, effectiveDeselected, groups); - for (const w of warnings) warn(`Selection: ${w.reason}`); + for (const w of warnings) { + warn(`Selection: ${w.reason}`); + } const warningSuffix = warnings.length > 0 ? `\n\n${warnings.map((w) => `> ⚠️ ${w.reason}`).join('\n')}` : ''; // Per-row changelogs are sourced from the DRY changelogs (the full changed set) so a held-back // row still shows its greyed changelog — the write output omits held-back packages entirely. @@ -1471,7 +1549,9 @@ export async function runStandingPRUpdate(options: StandingPROptions): Promise { const prev = baselineOf(update); - if (!prev || !semver.valid(update.newVersion)) return null; + if (!prev || !semver.valid(update.newVersion)) { + return null; + } return semver.diff(prev, update.newVersion); } @@ -54,9 +58,15 @@ function isMajorBump(update: Update): boolean { * the resolved action/channel, else a dash. */ function bumpLabel(update: Update): string { const diff = bumpDiff(update); - if (diff) return diff; - if (update.action === 'first-release') return 'first release'; - if (channelOf(update) === 'prerelease') return 'prerelease'; + if (diff) { + return diff; + } + if (update.action === 'first-release') { + return 'first release'; + } + if (channelOf(update) === 'prerelease') { + return 'prerelease'; + } return '—'; } @@ -83,11 +93,17 @@ export function renderReleaseSummaryLine(versionOutput: VersionOutput, input: Re const major = updates.filter(isMajorBump).length; const buckets: string[] = []; - if (stable > 0) buckets.push(plural(stable, 'stable', 'stable')); - if (prerelease > 0) buckets.push(plural(prerelease, 'prerelease', 'prerelease')); + if (stable > 0) { + buckets.push(plural(stable, 'stable', 'stable')); + } + if (prerelease > 0) { + buckets.push(plural(prerelease, 'prerelease', 'prerelease')); + } const segments: string[] = []; - if (buckets.length > 1) segments.push(...buckets); + if (buckets.length > 1) { + segments.push(...buckets); + } segments.push(plural(changes, 'change')); const majorClause = major > 0 ? `⚠️ ${plural(major, 'major bump')}.` : 'No major bumps.'; @@ -102,7 +118,9 @@ export function renderReleaseSummaryLine(versionOutput: VersionOutput, input: Re */ export function renderVersionSummaryTable(versionOutput: VersionOutput): string { const updates = publishableUpdates(versionOutput); - if (updates.length === 0) return ''; + if (updates.length === 0) { + return ''; + } const lines: string[] = [ `
Version summary (${plural(updates.length, 'package')})`, diff --git a/packages/release/test/unit/authorization.spec.ts b/packages/release/test/unit/authorization.spec.ts index 248e648e..a74a7fc5 100644 --- a/packages/release/test/unit/authorization.spec.ts +++ b/packages/release/test/unit/authorization.spec.ts @@ -68,8 +68,11 @@ describe('getEventActor', () => { tmpFile = `${process.env.TMPDIR ?? '/tmp'}/rk-event-${process.pid}.json`; }); afterEach(() => { - if (ORIG === undefined) delete process.env.GITHUB_EVENT_PATH; - else process.env.GITHUB_EVENT_PATH = ORIG; + if (ORIG === undefined) { + delete process.env.GITHUB_EVENT_PATH; + } else { + process.env.GITHUB_EVENT_PATH = ORIG; + } try { fs.unlinkSync(tmpFile); } catch {} diff --git a/packages/release/test/unit/commands/emitResult.spec.ts b/packages/release/test/unit/commands/emitResult.spec.ts index 67a6c641..c0a6d869 100644 --- a/packages/release/test/unit/commands/emitResult.spec.ts +++ b/packages/release/test/unit/commands/emitResult.spec.ts @@ -9,7 +9,9 @@ describe('emitResult', () => { const dirs: string[] = []; afterEach(() => { - for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }); + } dirs.length = 0; vi.restoreAllMocks(); }); diff --git a/packages/release/test/unit/failure-report-post.spec.ts b/packages/release/test/unit/failure-report-post.spec.ts index b75b7214..7cbcf71d 100644 --- a/packages/release/test/unit/failure-report-post.spec.ts +++ b/packages/release/test/unit/failure-report-post.spec.ts @@ -172,8 +172,11 @@ describe('detectUnresolvedFailure env hygiene', () => { savedSummary = process.env.GITHUB_STEP_SUMMARY; }); afterEach(() => { - if (savedSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY; - else process.env.GITHUB_STEP_SUMMARY = savedSummary; + if (savedSummary === undefined) { + delete process.env.GITHUB_STEP_SUMMARY; + } else { + process.env.GITHUB_STEP_SUMMARY = savedSummary; + } }); it('should act as a placeholder to anchor the env reset hooks', () => { expect(true).toBe(true); diff --git a/packages/release/test/unit/gate.spec.ts b/packages/release/test/unit/gate.spec.ts index e9cfb9e3..90da54d5 100644 --- a/packages/release/test/unit/gate.spec.ts +++ b/packages/release/test/unit/gate.spec.ts @@ -34,8 +34,12 @@ vi.mock('../../src/github.js', () => ({ vi.mock('../../src/release.js', () => ({ resolveScopeToTarget: (scopeName: string, scopeLabels: Record) => { const prefixed = `scope:${scopeName}`; - if (scopeLabels[prefixed]) return scopeLabels[prefixed]; - if (scopeLabels[scopeName]) return scopeLabels[scopeName]; + if (scopeLabels[prefixed]) { + return scopeLabels[prefixed]; + } + if (scopeLabels[scopeName]) { + return scopeLabels[scopeName]; + } throw new Error(`Scope "${scopeName}" not found`); }, runRelease: vi.fn(), @@ -47,7 +51,9 @@ vi.mock('../../src/git.js', () => ({ const repo = process.env.GITHUB_REPOSITORY; const sha = process.env.GITHUB_SHA; const token = process.env.GITHUB_TOKEN; - if (!repo) return null; + if (!repo) { + return null; + } const [owner, repoName] = repo.split('/'); return { owner, repo: repoName ?? '', sha: sha ?? null, token: token ?? null }; }, diff --git a/packages/release/test/unit/refresh.spec.ts b/packages/release/test/unit/refresh.spec.ts index ec7711c5..9a0eacae 100644 --- a/packages/release/test/unit/refresh.spec.ts +++ b/packages/release/test/unit/refresh.spec.ts @@ -136,7 +136,9 @@ describe('runRefreshAfterRelease', () => { it('should continue refreshing remaining PRs when one runPreview throws', async () => { mockForgeFor.mockReturnValue(fakeForge([pr(10), pr(11), pr(12)])); mockRunPreview.mockImplementation(async (opts: { pr: string }) => { - if (opts.pr === '11') throw new Error('boom'); + if (opts.pr === '11') { + throw new Error('boom'); + } }); await runRefreshAfterRelease({ projectDir: '/p' }); diff --git a/packages/release/test/unit/standing-pr.spec.ts b/packages/release/test/unit/standing-pr.spec.ts index 7b827bbf..8e7f0285 100644 --- a/packages/release/test/unit/standing-pr.spec.ts +++ b/packages/release/test/unit/standing-pr.spec.ts @@ -42,7 +42,9 @@ class ControllableGit extends FakeGit { } if (opts.format === '%H') { // createReleaseTags resolves an existing tag's commit — default to HEAD ("tag at HEAD"). - if (this.tagResolveThrows) throw new Error('cannot resolve tag'); + if (this.tagResolveThrows) { + throw new Error('cannot resolve tag'); + } return `${this.tagCommitSha ?? (await this.headSha(opts.cwd))}\n`; } return super.log(opts); @@ -1096,10 +1098,16 @@ describe('runStandingPRUpdate', () => { // Did NOT noop on the first guard — the label-triggered run proceeded to update the PR. expect(result.action).not.toBe('noop'); } finally { - if (prevName === undefined) delete process.env.GITHUB_EVENT_NAME; - else process.env.GITHUB_EVENT_NAME = prevName; - if (prevPath === undefined) delete process.env.GITHUB_EVENT_PATH; - else process.env.GITHUB_EVENT_PATH = prevPath; + if (prevName === undefined) { + delete process.env.GITHUB_EVENT_NAME; + } else { + process.env.GITHUB_EVENT_NAME = prevName; + } + if (prevPath === undefined) { + delete process.env.GITHUB_EVENT_PATH; + } else { + process.env.GITHUB_EVENT_PATH = prevPath; + } } }); @@ -2360,7 +2368,9 @@ describe('runStandingPRUpdate', () => { const bodies = forge.updatedPullRequests .map((u) => u.changes.body) .filter((b): b is string => typeof b === 'string'); - for (const b of bodies) expect(b.length).toBeLessThanOrEqual(STANDING_PR_BODY_CAP); + for (const b of bodies) { + expect(b.length).toBeLessThanOrEqual(STANDING_PR_BODY_CAP); + } }); }); }); diff --git a/packages/version/src/core/baselineResolver.ts b/packages/version/src/core/baselineResolver.ts index 76ea2e96..04572912 100644 --- a/packages/version/src/core/baselineResolver.ts +++ b/packages/version/src/core/baselineResolver.ts @@ -211,9 +211,15 @@ export class BaselineResolver { * the nearest reachable tag rather than flooding with full history. */ async sharedFloor(perPackageRange: string): Promise { - if (this.opts.baseRef) return perPackageRange; - if (this.opts.sharedChangelogFloor === 'sinceLastRelease') return this.nearestReachableRange(); - if (perPackageRange !== 'HEAD') return perPackageRange; + if (this.opts.baseRef) { + return perPackageRange; + } + if (this.opts.sharedChangelogFloor === 'sinceLastRelease') { + return this.nearestReachableRange(); + } + if (perPackageRange !== 'HEAD') { + return perPackageRange; + } return this.nearestReachableRange(); } diff --git a/packages/version/src/core/groupResolution.ts b/packages/version/src/core/groupResolution.ts index 37dc7fa9..6cd4e342 100644 --- a/packages/version/src/core/groupResolution.ts +++ b/packages/version/src/core/groupResolution.ts @@ -107,7 +107,9 @@ export function resolveGroups(config: Config, packages: Package[]): GroupResolut const name = pkg.packageJson.name; // The implicit sync group is "every package" by definition — don't route it through glob // matching, where a bare `*` would fail to cross the `/` in scoped names. - if (!matchesAll && !shouldMatchPackageTargets(name, def.packages)) continue; + if (!matchesAll && !shouldMatchPackageTargets(name, def.packages)) { + continue; + } const existing = memberOf.get(name); if (existing) { @@ -182,13 +184,17 @@ export function expandTargetsForAtomicGroups( resolution: GroupResolution, targets: string[], ): { targets: string[]; expandedGroups: string[] } { - if (targets.length === 0) return { targets, expandedGroups: [] }; + if (targets.length === 0) { + return { targets, expandedGroups: [] }; + } const expanded = new Set(targets); const expandedGroups: string[] = []; for (const group of resolution.groups) { - if (group.sync === 'linked') continue; + if (group.sync === 'linked') { + continue; + } const someTargeted = group.members.some((m) => shouldMatchPackageTargets(m.packageJson.name, targets)); const allTargeted = group.members.every((m) => shouldMatchPackageTargets(m.packageJson.name, targets)); diff --git a/packages/version/src/core/groupStrategy.ts b/packages/version/src/core/groupStrategy.ts index 5200685a..8d69cd33 100644 --- a/packages/version/src/core/groupStrategy.ts +++ b/packages/version/src/core/groupStrategy.ts @@ -84,7 +84,9 @@ interface MemberPlan { } function cleanBaseline(version: string | undefined): string { - if (!version) return '0.0.0'; + if (!version) { + return '0.0.0'; + } // semver.coerce() strips the prerelease (1.0.0-next.0 -> 1.0.0), which would graduate a linked or // fixed group off its prerelease line on every increment. Parse prerelease-preserving first: // clean() for a bare semver, then coerce({ includePrerelease }) only to pull it from a tag prefix. @@ -115,7 +117,9 @@ async function planMember( }); usedPackageSpecificTag = !!latestTag; } - if (!latestTag) latestTag = globalTag; + if (!latestTag) { + latestTag = globalTag; + } const ownNext = await calculateVersion(config, { latestTag, @@ -138,7 +142,9 @@ async function planMember( /** Derive the bump magnitude a member earned by comparing its computed next version to baseline. */ function memberBumpRank(plan: MemberPlan): number { - if (!plan.changed) return -1; + if (!plan.changed) { + return -1; + } const diff = semver.diff(plan.baseline, plan.ownNext); if (!diff) { // Same version family (e.g. prerelease increment) — treat as a patch-level change so the @@ -165,9 +171,13 @@ interface GroupComputation { * `release:graduate` path → every group graduates) or some member falls inside that scope. */ function isGroupGraduating(group: ResolvedGroup, config: Config): boolean { - if (!config.stableOnly) return false; + if (!config.stableOnly) { + return false; + } const scope = config.graduateScope; - if (!scope || scope.length === 0) return true; + if (!scope || scope.length === 0) { + return true; + } return group.members.some((m) => shouldMatchPackageTargets(m.packageJson.name, scope)); } @@ -181,9 +191,13 @@ function isGroupGraduating(group: ResolvedGroup, config: Config): boolean { * happens to land on a prerelease line yet. */ function isGroupPrereleasing(group: ResolvedGroup, config: Config): boolean { - if (!config.isPrerelease) return false; + if (!config.isPrerelease) { + return false; + } const scope = config.prereleaseScope; - if (!scope || scope.length === 0) return true; + if (!scope || scope.length === 0) { + return true; + } return group.members.some((m) => shouldMatchPackageTargets(m.packageJson.name, scope)); } @@ -298,12 +312,20 @@ function shouldProcess(pkg: Package, config: Config): boolean { function readRepoUrl(pkgDir: string): string | null { try { const pkgJsonPath = path.join(pkgDir, 'package.json'); - if (!fs.existsSync(pkgJsonPath)) return null; + if (!fs.existsSync(pkgJsonPath)) { + return null; + } const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); let url: string | undefined = typeof pkgJson.repository === 'string' ? pkgJson.repository : pkgJson.repository?.url; - if (!url) return null; - if (url.startsWith('git+')) url = url.slice(4); - if (url.endsWith('.git')) url = url.slice(0, -4); + if (!url) { + return null; + } + if (url.startsWith('git+')) { + url = url.slice(4); + } + if (url.endsWith('.git')) { + url = url.slice(0, -4); + } return url; } catch { return null; @@ -344,7 +366,9 @@ async function extractEntries( entries = await extractChangelogEntriesFromCommits(input.pkgDir, revisionRange); } catch (error) { // A strictReachable violation must abort the run, not degrade to a minimal entry. - if (error instanceof StrictReachableError) throw error; + if (error instanceof StrictReachableError) { + throw error; + } log(`Error extracting changelog entries: ${error instanceof Error ? error.message : String(error)}`, 'warning'); } if (entries.length === 0) { @@ -356,7 +380,9 @@ async function extractEntries( // swallows git failures and returns [] — so leave it unflagged and let the bump render a // visible block instead of vanishing as a no-change carry. const fallback: ChangelogEntry = { type: 'changed', description: `Update version to ${input.nextVersion}` }; - if (input.isCarry) fallback.synthetic = true; + if (input.isCarry) { + fallback.synthetic = true; + } entries = [fallback]; } return { entries, revisionRange, previousVersion }; @@ -380,7 +406,9 @@ async function releaseGroup( for (const plan of releasing) { const pkg = plan.pkg; const name = pkg.packageJson.name; - if (!shouldProcess(pkg, config)) continue; + if (!shouldProcess(pkg, config)) { + continue; + } // Independent members release on their own commit-driven line; fixed/linked members adopt the // shared group version. @@ -512,7 +540,9 @@ export function createGroupStrategy(config: Config): (packages: PackagesWithRoot for (const group of resolution.groups) { const members = group.members.filter(targetFilter).filter((p) => shouldProcess(p, config)); - if (members.length === 0) continue; + if (members.length === 0) { + continue; + } const plans = await Promise.all(members.map((pkg) => planMember(config, pkg, formattedPrefix, globalTag))); const computation = computeGroup(group, plans, config); @@ -564,9 +594,13 @@ export function createGroupStrategy(config: Config): (packages: PackagesWithRoot // Ungrouped packages version independently — same per-member machinery, no shared version. for (const pkg of resolution.ungrouped) { - if (!targetFilter(pkg) || !shouldProcess(pkg, config)) continue; + if (!targetFilter(pkg) || !shouldProcess(pkg, config)) { + continue; + } const plan = await planMember(config, pkg, formattedPrefix, globalTag); - if (!plan.changed) continue; + if (!plan.changed) { + continue; + } const name = pkg.packageJson.name; const packageJsonPath = path.join(pkg.dir, 'package.json'); diff --git a/packages/version/src/core/prerequisiteScope.ts b/packages/version/src/core/prerequisiteScope.ts index 0bab7370..ec58d854 100644 --- a/packages/version/src/core/prerequisiteScope.ts +++ b/packages/version/src/core/prerequisiteScope.ts @@ -41,14 +41,20 @@ export function resolvePrerequisiteTargets( const overrideSet = new Set(explicitNames); for (const name of explicitNames) { const group = resolution.groupOf(name); - if (group) for (const member of group.members) overrideSet.add(member.packageJson.name); + if (group) { + for (const member of group.members) { + overrideSet.add(member.packageJson.name); + } + } } // Detect which transitive dependencies of the expanded targets actually changed — only those // become prerequisites (an unchanged dependency needs no release). const candidateDeps = new Set(); for (const name of overrideSet) { - for (const dep of graph.transitiveDependencies(name)) candidateDeps.add(dep); + for (const dep of graph.transitiveDependencies(name)) { + candidateDeps.add(dep); + } } const changed = [...candidateDeps].filter((name) => isChanged(name)); diff --git a/packages/version/src/core/versionCalculator.ts b/packages/version/src/core/versionCalculator.ts index e73ff118..d1f70147 100644 --- a/packages/version/src/core/versionCalculator.ts +++ b/packages/version/src/core/versionCalculator.ts @@ -116,7 +116,9 @@ export function applyOverrideScope( * no-tag axis, so they warn like the default.) */ function guardFirstReleaseBump(config: Config, name: string | undefined, currentVer: string, type: ReleaseType): void { - if (config.allowFirstBump) return; + if (config.allowFirstBump) { + return; + } const pkg = name || 'package'; const bumped = bumpVersion(currentVer, type); const message = @@ -126,7 +128,9 @@ function guardFirstReleaseBump(config: Config, name: string | undefined, current if ((config.mismatchStrategy ?? 'warn') === 'error') { throw new Error(`First-release version overshoot: ${message}`); } - if (config.mismatchStrategy === 'ignore') return; + if (config.mismatchStrategy === 'ignore') { + return; + } log(message, 'warning'); } @@ -189,7 +193,9 @@ async function calculateVersionInner(config: Config, options: VersionOptions): P } // Fallback to hardcoded patterns for backward compatibility - if (!packageName) return escapeRegExp(prefix); + if (!packageName) { + return escapeRegExp(prefix); + } const sanitized = sanitizePackageName(packageName); const escapedRaw = escapeRegExp(`${packageName}@${prefix}`); const escapedDash = escapeRegExp(`${sanitized}-${prefix}`); diff --git a/packages/version/src/core/versionEngine.ts b/packages/version/src/core/versionEngine.ts index cfbb43bc..0ab20185 100644 --- a/packages/version/src/core/versionEngine.ts +++ b/packages/version/src/core/versionEngine.ts @@ -50,15 +50,23 @@ export class VersionEngine { const effective: Config = { ...config }; if (runOptions) { - if (runOptions.dryRun) effective.dryRun = true; - if (runOptions.sync) effective.sync = true; - if (runOptions.bump) effective.type = runOptions.bump; + if (runOptions.dryRun) { + effective.dryRun = true; + } + if (runOptions.sync) { + effective.sync = true; + } + if (runOptions.bump) { + effective.type = runOptions.bump; + } if (runOptions.prerelease) { effective.prereleaseIdentifier = typeof runOptions.prerelease === 'string' ? runOptions.prerelease : effective.prereleaseIdentifier || 'next'; effective.isPrerelease = true; } - if (runOptions.stable) effective.stableOnly = true; + if (runOptions.stable) { + effective.stableOnly = true; + } // Per-package graduation: graduate only the named packages, leaving other prereleases on // their line. Sets stableOnly + graduateScope; applyOverrideScope clears stableOnly for any // package outside the scope. The global `stable` flag wins — when it's set we graduate ALL @@ -78,12 +86,24 @@ export class VersionEngine { effective.prereleaseIdentifier = effective.prereleaseIdentifier || 'next'; effective.prereleaseScope = runOptions.prereleaseScope; } - if (runOptions.allowFirstBump) effective.allowFirstBump = true; - if (runOptions.targets?.length) this.runtimeTargets = runOptions.targets; - if (runOptions.baseRef) effective.baseRef = runOptions.baseRef; - if (runOptions.overrideScope) effective.overrideScope = runOptions.overrideScope; - if (runOptions.includePrerequisites) this.includePrerequisites = true; - if (runOptions.exclude?.length) this.excludePackages = runOptions.exclude; + if (runOptions.allowFirstBump) { + effective.allowFirstBump = true; + } + if (runOptions.targets?.length) { + this.runtimeTargets = runOptions.targets; + } + if (runOptions.baseRef) { + effective.baseRef = runOptions.baseRef; + } + if (runOptions.overrideScope) { + effective.overrideScope = runOptions.overrideScope; + } + if (runOptions.includePrerequisites) { + this.includePrerequisites = true; + } + if (runOptions.exclude?.length) { + this.excludePackages = runOptions.exclude; + } } // Default values for required properties @@ -388,7 +408,9 @@ export class VersionEngine { tagTemplate: this.config.tagTemplate, packageSpecificTags: this.config.packageSpecificTags, }); - if (!tag) tag = await getLatestTag(formattedPrefix); + if (!tag) { + tag = await getLatestTag(formattedPrefix); + } } catch { // No tag resolvable — getCommitsLength falls back to git describe / full history. } @@ -411,7 +433,9 @@ export class VersionEngine { try { const pkg = parseCargoToml(cargoPath).package; const publish = pkg?.publish; - if (pkg?.name && publish !== false && !(Array.isArray(publish) && publish.length === 0)) return true; + if (pkg?.name && publish !== false && !(Array.isArray(publish) && publish.length === 0)) { + return true; + } } catch { // Unparseable Cargo.toml — fall through; don't claim publishable on a parse failure. } @@ -419,7 +443,9 @@ export class VersionEngine { const pubspecPath = path.join(packageDir, 'pubspec.yaml'); if (fs.existsSync(pubspecPath)) { try { - if (parsePubspec(pubspecPath).publish_to !== 'none') return true; + if (parsePubspec(pubspecPath).publish_to !== 'none') { + return true; + } } catch { // Unparseable pubspec — fall through. } @@ -487,9 +513,15 @@ export class VersionEngine { const namedExactly = new Set(this.config.packages ?? []); const beforeCount = mergedPackages.packages.length; mergedPackages.packages = mergedPackages.packages.filter((pkg) => { - if (!isPrivatePackageJson(pkg.packageJson, path.join(pkg.dir, 'package.json'))) return true; - if (namedExactly.has(pkg.packageJson.name)) return true; - if (this.isNativelyPublishable(pkg.dir)) return true; + if (!isPrivatePackageJson(pkg.packageJson, path.join(pkg.dir, 'package.json'))) { + return true; + } + if (namedExactly.has(pkg.packageJson.name)) { + return true; + } + if (this.isNativelyPublishable(pkg.dir)) { + return true; + } log(`Skipping private npm package ${pkg.packageJson.name}: package.json "private": true`, 'debug'); return false; }); diff --git a/packages/version/src/core/versionStrategies.ts b/packages/version/src/core/versionStrategies.ts index 5fbb9eb3..0bf6526a 100644 --- a/packages/version/src/core/versionStrategies.ts +++ b/packages/version/src/core/versionStrategies.ts @@ -339,7 +339,9 @@ export function createSyncStrategy(config: Config): StrategyFunction { } } catch (error) { // A strictReachable violation must abort the run, not degrade to a minimal entry. - if (error instanceof StrictReachableError) throw error; + if (error instanceof StrictReachableError) { + throw error; + } log(`Error extracting changelog entries: ${error instanceof Error ? error.message : String(error)}`, 'warning'); changelogEntries = [{ type: 'changed', description: `Update version to ${nextVersion}` }]; } @@ -457,7 +459,9 @@ export function createSyncStrategy(config: Config): StrategyFunction { const baselineTag = config.baselineTagTemplate ? formatTag(nextVersion, formattedPrefix, mainPkgName, config.baselineTagTemplate, false) : undefined; - if (baselineTag) addBaselineTag(baselineTag); + if (baselineTag) { + addBaselineTag(baselineTag); + } const allTags = baselineTag ? [...nextTags, baselineTag] : nextTags; // Link per-package tags back to their update records so the publish pipeline // can push each tag immediately after that package publishes. @@ -465,7 +469,9 @@ export function createSyncStrategy(config: Config): StrategyFunction { for (let i = 0; i < workspaceNames.length; i++) { const pkgName = workspaceNames[i]; const pkgTag = nextTags[i]; - if (pkgName && pkgTag) setPackageUpdateTag(pkgName, pkgTag); + if (pkgName && pkgTag) { + setPackageUpdateTag(pkgName, pkgTag); + } } } // Resolved version action. Every package moves in lockstep to the same nextVersion @@ -609,7 +615,9 @@ export function createSingleStrategy(config: Config): StrategyFunction { } } catch (error) { // A strictReachable violation must abort the run, not degrade to a minimal entry. - if (error instanceof StrictReachableError) throw error; + if (error instanceof StrictReachableError) { + throw error; + } log(`Error extracting changelog entries: ${error instanceof Error ? error.message : String(error)}`, 'warning'); // Fall back to minimal entry changelogEntries = [{ type: 'changed', description: `Update version to ${nextVersion}` }]; diff --git a/packages/version/src/core/workspaceGraph.ts b/packages/version/src/core/workspaceGraph.ts index debe2058..407f9291 100644 --- a/packages/version/src/core/workspaceGraph.ts +++ b/packages/version/src/core/workspaceGraph.ts @@ -26,7 +26,9 @@ function cargoDeps(cargoTomlPath: string, pkgDir: string, nameByDir: Map { - if (!ref || ref.trim() === '') return Promise.resolve(false); + if (!ref || ref.trim() === '') { + return Promise.resolve(false); + } // The seam's refExists is the same `rev-parse --verify --quiet ^{commit}` soft lookup. return git.refExists(ref, cwd); } @@ -304,7 +306,9 @@ export async function getLatestStableTag(versionPrefix?: string): Promise { diff --git a/packages/version/src/package/packageProcessor.ts b/packages/version/src/package/packageProcessor.ts index 8d99121e..f327ea5d 100644 --- a/packages/version/src/package/packageProcessor.ts +++ b/packages/version/src/package/packageProcessor.ts @@ -297,7 +297,9 @@ export class PackageProcessor { } } catch (error) { // A strictReachable violation must abort the run, not degrade to a minimal entry. - if (error instanceof StrictReachableError) throw error; + if (error instanceof StrictReachableError) { + throw error; + } log(`Error extracting changelog entries: ${error instanceof Error ? error.message : String(error)}`, 'warning'); // Fall back to minimal entry changelogEntries = [ diff --git a/packages/version/src/utils/formatting.ts b/packages/version/src/utils/formatting.ts index 1d6fc6b6..675e7431 100644 --- a/packages/version/src/utils/formatting.ts +++ b/packages/version/src/utils/formatting.ts @@ -170,7 +170,9 @@ export function deriveBaselineTagPrefix( formattedPrefix: string, packageName?: string, ): string | undefined { - if (!template) return undefined; + if (!template) { + return undefined; + } return template .split('${' + 'version}')[0] .replace(/\$\{prefix\}/g, formattedPrefix) @@ -187,6 +189,8 @@ export function deriveBaselineTagPrefix( * alone — only the display surfaces are cleaned up. */ export function displayTag(tag: string, baselineTagPrefix: string | undefined, formattedPrefix: string): string { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; } diff --git a/packages/version/src/utils/jsonOutput.ts b/packages/version/src/utils/jsonOutput.ts index 3676222b..3e0def0c 100644 --- a/packages/version/src/utils/jsonOutput.ts +++ b/packages/version/src/utils/jsonOutput.ts @@ -55,7 +55,9 @@ export function enableJsonOutput(dryRun = false): void { * Called during dryRun passes in place of writing directly to disk. */ export function recordPendingWrite(path: string, content: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } _pendingWrites.push({ path, content }); } @@ -91,7 +93,9 @@ export function isJsonOutputMode(): boolean { * (preview, standing PR) can render sync releases as a single versioned unit. */ export function setVersioningStrategy(strategy: 'sync' | 'single' | 'async' | 'group'): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } _jsonData.strategy = strategy; } @@ -109,7 +113,9 @@ export function setVersioningStrategy(strategy: 'sync' | 'single' | 'async' | 'g * for the same directory exists. */ export function addPackageUpdate(packageName: string, newVersion: string, filePath: string, isRoot?: boolean): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } // Channel is derived per-package from the resolved version so every consumer (preview, // standing PR) reads a single authoritative value rather than re-deriving it. @@ -143,9 +149,13 @@ export function addPackageUpdate(packageName: string, newVersion: string, filePa * Called after the tag name is computed, to link it back to the update record. */ export function setPackageUpdateTag(packageName: string, tag: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } const update = _jsonData.updates.find((u) => u.packageName === packageName); - if (update) update.tag = tag; + if (update) { + update.tag = tag; + } } /** @@ -154,9 +164,13 @@ export function setPackageUpdateTag(packageName: string, tag: string): void { * package.json update has been recorded. */ export function setPackageUpdateGroup(packageName: string, group: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } const update = _jsonData.updates.find((u) => u.packageName === packageName); - if (update) update.group = group; + if (update) { + update.group = group; + } } /** @@ -166,7 +180,9 @@ export function setPackageUpdateGroup(packageName: string, group: string): void * isn't found (mirrors the other setPackageUpdate* helpers). */ export function setPackageUpdateAction(packageName: string, action: VersionAction, reason: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } const update = _jsonData.updates.find((u) => u.packageName === packageName); if (update) { update.action = action; @@ -182,9 +198,13 @@ export function setPackageUpdateAction(packageName: string, action: VersionActio * setPackageUpdate* helpers). */ export function setPackageUpdatePreviousVersion(packageName: string, previousVersion: string | null): void { - if (!_jsonOutputMode || previousVersion === null) return; + if (!_jsonOutputMode || previousVersion === null) { + return; + } const update = _jsonData.updates.find((u) => u.packageName === packageName); - if (update) update.previousVersion = previousVersion; + if (update) { + update.previousVersion = previousVersion; + } } /** @@ -194,7 +214,9 @@ export function setPackageUpdatePreviousVersion(packageName: string, previousVer * (otherwise-private) update list. */ export function setAllPackageUpdateActions(action: VersionAction, reason: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } for (const update of _jsonData.updates) { update.action = action; update.actionReason = reason; @@ -207,7 +229,9 @@ export function setAllPackageUpdateActions(action: VersionAction, reason: string * the field absent everywhere. Owns the iteration internally, mirroring {@link setAllPackageUpdateActions}. */ export function setAllPackageUpdatePreviousVersions(previousVersion: string | null): void { - if (!_jsonOutputMode || previousVersion === null) return; + if (!_jsonOutputMode || previousVersion === null) { + return; + } for (const update of _jsonData.updates) { update.previousVersion = previousVersion; } @@ -220,7 +244,9 @@ export function setAllPackageUpdatePreviousVersions(previousVersion: string | nu * the engine after the strategy has produced the updates. */ export function tagPrerequisiteRoles(overrideScope: string[], prerequisiteOf: Record): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } const targets = new Set(overrideScope); for (const update of _jsonData.updates) { if (targets.has(update.packageName)) { @@ -236,7 +262,9 @@ export function tagPrerequisiteRoles(overrideScope: string[], prerequisiteOf: Re * Add changelog data for a package to the JSON output */ export function addChangelogData(data: VersionPackageChangelog): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } _jsonData.changelogs.push(data); } @@ -246,7 +274,9 @@ export function addChangelogData(data: VersionPackageChangelog): void { * that don't belong to any specific package. */ export function setSharedEntries(entries: VersionChangelogEntry[]): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } _jsonData.sharedEntries = entries.length > 0 ? entries : undefined; } @@ -254,7 +284,9 @@ export function setSharedEntries(entries: VersionChangelogEntry[]): void { * Add a tag to the JSON output */ export function addTag(tag: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } _jsonData.tags.push(tag); } @@ -264,9 +296,13 @@ export function addTag(tag: string): void { * consumer tags but excluded from GitHub Release creation. */ export function addBaselineTag(tag: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } - if (!_jsonData.baselineTags) _jsonData.baselineTags = []; + if (!_jsonData.baselineTags) { + _jsonData.baselineTags = []; + } _jsonData.baselineTags.push(tag); } @@ -274,7 +310,9 @@ export function addBaselineTag(tag: string): void { * Set the commit message in the JSON output */ export function setCommitMessage(message: string): void { - if (!_jsonOutputMode) return; + if (!_jsonOutputMode) { + return; + } _jsonData.commitMessage = message; } diff --git a/packages/version/src/utils/logging.ts b/packages/version/src/utils/logging.ts index 82031f6c..110791ab 100644 --- a/packages/version/src/utils/logging.ts +++ b/packages/version/src/utils/logging.ts @@ -10,7 +10,9 @@ import { isJsonOutputMode } from './jsonOutput.js'; * Print a figlet banner */ export function printFiglet(text: string): void { - if (isJsonOutputMode()) return; + if (isJsonOutputMode()) { + return; + } console.log( chalk.yellow( diff --git a/packages/version/src/utils/versionAction.ts b/packages/version/src/utils/versionAction.ts index 9fa6385a..e4a4eb56 100644 --- a/packages/version/src/utils/versionAction.ts +++ b/packages/version/src/utils/versionAction.ts @@ -40,7 +40,9 @@ function versionFromTag(tag: string): string | null { // Bounded quantifiers (not `\d+`) guard against polynomial backtracking on uncontrolled tag input // (CodeQL js/polynomial-redos) — real semver components never approach these lengths. const match = tag.match(/\d{1,16}\.\d{1,16}\.\d{1,16}(?:-[0-9A-Za-z.-]{1,256})?/); - if (!match) return null; + if (!match) { + return null; + } return semver.valid(match[0]); } diff --git a/packages/version/src/workspace.ts b/packages/version/src/workspace.ts index 4567255d..47a278ca 100644 --- a/packages/version/src/workspace.ts +++ b/packages/version/src/workspace.ts @@ -32,7 +32,9 @@ export async function getWorkspacePackageVersions( for (const p of packages) { const name = p.packageJson.name; const version = p.packageJson.version; - if (name && version) versions[name] = version; + if (name && version) { + versions[name] = version; + } } return versions; } diff --git a/packages/version/test/unit/core/groupStrategy.spec.ts b/packages/version/test/unit/core/groupStrategy.spec.ts index 5d0b0b52..5ebd5555 100644 --- a/packages/version/test/unit/core/groupStrategy.spec.ts +++ b/packages/version/test/unit/core/groupStrategy.spec.ts @@ -87,7 +87,9 @@ describe('createGroupStrategy', () => { // Only native-utils has a releasable change (patch). vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-utils') return '2.3.1'; + if (opts.name === '@wdio/native-utils') { + return '2.3.1'; + } return ''; }); @@ -161,8 +163,12 @@ describe('createGroupStrategy', () => { // core gets a minor, utils gets a patch — group should bump minor. vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.4.0'; - if (opts.name === '@wdio/native-utils') return '2.3.1'; + if (opts.name === '@wdio/native-core') { + return '2.4.0'; + } + if (opts.name === '@wdio/native-utils') { + return '2.3.1'; + } return ''; }); @@ -206,7 +212,9 @@ describe('createGroupStrategy', () => { // Only utils changed. vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-utils') return '2.3.1'; + if (opts.name === '@wdio/native-utils') { + return '2.3.1'; + } return ''; }); @@ -237,8 +245,12 @@ describe('createGroupStrategy', () => { // core minor, utils patch, spy unchanged → both changed members align to 2.4.0. vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.4.0'; - if (opts.name === '@wdio/native-utils') return '2.3.1'; + if (opts.name === '@wdio/native-core') { + return '2.4.0'; + } + if (opts.name === '@wdio/native-utils') { + return '2.3.1'; + } return ''; }); @@ -269,7 +281,9 @@ describe('createGroupStrategy', () => { // Only the service earned a change — a premajor prerelease. vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/flutter-service') return '1.0.0-next.0'; + if (opts.name === '@wdio/flutter-service') { + return '1.0.0-next.0'; + } return ''; }); @@ -297,7 +311,9 @@ describe('createGroupStrategy', () => { const contract = mkPackage('wdio_flutter', '0.0.1'); vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/flutter-service') return '1.0.0-next.0'; + if (opts.name === '@wdio/flutter-service') { + return '1.0.0-next.0'; + } return ''; }); @@ -528,7 +544,9 @@ describe('createGroupStrategy', () => { vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { // core earns a patch from its own 2.3.0 baseline. - if (opts.name === '@wdio/native-core') return '2.3.1'; + if (opts.name === '@wdio/native-core') { + return '2.3.1'; + } return ''; }); @@ -558,9 +576,13 @@ describe('createGroupStrategy', () => { const newcomer = mkPackage('@wdio/native-fresh', '1.0.0'); vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.3.1'; + if (opts.name === '@wdio/native-core') { + return '2.3.1'; + } // newcomer also has a change, but its own bump is irrelevant under fixed. - if (opts.name === '@wdio/native-fresh') return '1.0.1'; + if (opts.name === '@wdio/native-fresh') { + return '1.0.1'; + } return ''; }); @@ -586,8 +608,12 @@ describe('createGroupStrategy', () => { const utils = mkPackage('@wdio/native-utils', '2.3.0'); vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.3.1'; - if (opts.name === '@wdio/native-utils') return '2.3.1'; + if (opts.name === '@wdio/native-core') { + return '2.3.1'; + } + if (opts.name === '@wdio/native-utils') { + return '2.3.1'; + } return ''; }); @@ -616,7 +642,9 @@ describe('createGroupStrategy', () => { const b = mkPackage('@app/b', '1.0.0'); vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@app/a') return '1.1.0'; + if (opts.name === '@app/a') { + return '1.1.0'; + } return ''; }); @@ -645,7 +673,9 @@ describe('createGroupStrategy', () => { // Only native-core is in graduateScope → its calculator returns the stable base; native-utils // is out of scope so (per applyOverrideScope, simulated here) it advances its prerelease line. vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '1.0.0'; + if (opts.name === '@wdio/native-core') { + return '1.0.0'; + } return '1.0.0-next.6'; }); @@ -678,7 +708,9 @@ describe('createGroupStrategy', () => { // Both members changed; native-core is the graduate target. Linked releases only changed members, // all at the shared graduated version. vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.0.0'; + if (opts.name === '@wdio/native-core') { + return '2.0.0'; + } return '2.0.0-next.3'; }); @@ -869,8 +901,12 @@ describe('createGroupStrategy', () => { const standalone = mkPackage('@app/standalone', '5.0.0'); vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.3.1'; - if (opts.name === '@app/standalone') return '5.1.0'; + if (opts.name === '@wdio/native-core') { + return '2.3.1'; + } + if (opts.name === '@app/standalone') { + return '5.1.0'; + } return ''; }); @@ -913,8 +949,12 @@ describe('createGroupStrategy', () => { // core earns a minor, utils a patch — independent means each keeps its own commit-driven bump // (a fixed/linked group would write the shared 2.4.0 to both). vi.mocked(calculator.calculateVersion).mockImplementation(async (_cfg, opts) => { - if (opts.name === '@wdio/native-core') return '2.4.0'; - if (opts.name === '@wdio/native-utils') return '2.3.1'; + if (opts.name === '@wdio/native-core') { + return '2.4.0'; + } + if (opts.name === '@wdio/native-utils') { + return '2.3.1'; + } return ''; }); diff --git a/packages/version/test/unit/core/versionCalculator.spec.ts b/packages/version/test/unit/core/versionCalculator.spec.ts index 94f2c67c..9ab03dc1 100644 --- a/packages/version/test/unit/core/versionCalculator.spec.ts +++ b/packages/version/test/unit/core/versionCalculator.spec.ts @@ -100,43 +100,85 @@ describe('Version Calculator', () => { // Mock normalizePrereleaseIdentifier vi.spyOn(versionUtils, 'normalizePrereleaseIdentifier').mockImplementation((val) => { - if (val === true) return 'next'; + if (val === true) { + return 'next'; + } return val as string; }); // Mock bumpVersion vi.spyOn(versionUtils, 'bumpVersion').mockImplementation((version, releaseType, identifier) => { // Special cases for test expectations - if (version === '1.0.0' && releaseType === 'minor' && !identifier) return '1.1.0'; - if (version === '1.0.0-next.0' && releaseType === 'major' && !identifier) return '2.0.0'; - if (version === '1.0.0-beta.1' && releaseType === 'minor' && !identifier) return '1.1.0'; - if (version === '1.0.0-alpha.2' && releaseType === 'patch' && !identifier) return '1.0.1'; - if (version === '1.0.0' && releaseType === 'prerelease' && identifier === 'alpha') return '1.0.0-alpha.1'; - if (version === '1.3.0' && releaseType === 'major' && identifier === 'next') return '2.0.0-next.0'; - if (version === '1.3.0' && releaseType === 'minor' && identifier === 'next') return '1.4.0-next.0'; - if (version === '1.3.1' && releaseType === 'patch' && identifier === 'next') return '1.3.2-next.0'; - if (version === '1.0.0-beta.1' && releaseType === 'major' && !identifier) return '2.0.0'; - if (version === '1.0.0-test' && releaseType === 'minor' && !identifier) return '1.1.0'; - if (version === '1.0.0' && releaseType === 'patch' && !identifier) return '1.0.1'; + if (version === '1.0.0' && releaseType === 'minor' && !identifier) { + return '1.1.0'; + } + if (version === '1.0.0-next.0' && releaseType === 'major' && !identifier) { + return '2.0.0'; + } + if (version === '1.0.0-beta.1' && releaseType === 'minor' && !identifier) { + return '1.1.0'; + } + if (version === '1.0.0-alpha.2' && releaseType === 'patch' && !identifier) { + return '1.0.1'; + } + if (version === '1.0.0' && releaseType === 'prerelease' && identifier === 'alpha') { + return '1.0.0-alpha.1'; + } + if (version === '1.3.0' && releaseType === 'major' && identifier === 'next') { + return '2.0.0-next.0'; + } + if (version === '1.3.0' && releaseType === 'minor' && identifier === 'next') { + return '1.4.0-next.0'; + } + if (version === '1.3.1' && releaseType === 'patch' && identifier === 'next') { + return '1.3.2-next.0'; + } + if (version === '1.0.0-beta.1' && releaseType === 'major' && !identifier) { + return '2.0.0'; + } + if (version === '1.0.0-test' && releaseType === 'minor' && !identifier) { + return '1.1.0'; + } + if (version === '1.0.0' && releaseType === 'patch' && !identifier) { + return '1.0.1'; + } // For branch pattern tests - if (version === '1.0.0' && releaseType === 'minor' && identifier === undefined) return '1.1.0'; - if (version === '1.0.0' && releaseType === 'patch' && identifier === undefined) return '1.0.1'; + if (version === '1.0.0' && releaseType === 'minor' && identifier === undefined) { + return '1.1.0'; + } + if (version === '1.0.0' && releaseType === 'patch' && identifier === undefined) { + return '1.0.1'; + } // For Package.json fallback tests - if (version === '0.0.0' && releaseType === 'minor') return '0.1.0'; - if (version === '2.0.0' && releaseType === 'minor' && !identifier) return '2.1.0'; + if (version === '0.0.0' && releaseType === 'minor') { + return '0.1.0'; + } + if (version === '2.0.0' && releaseType === 'minor' && !identifier) { + return '2.1.0'; + } // For conventional commits - if (version === '1.0.0' && releaseType === 'patch' && identifier === undefined) return '1.0.1'; - if (version === '0.0.0' && releaseType === 'patch') return '0.0.1'; + if (version === '1.0.0' && releaseType === 'patch' && identifier === undefined) { + return '1.0.1'; + } + if (version === '0.0.0' && releaseType === 'patch') { + return '0.0.1'; + } // Handle specific prerelease cases - if (version === '1.3.0' && releaseType === 'premajor' && identifier === 'next') return '2.0.0-next.0'; + if (version === '1.3.0' && releaseType === 'premajor' && identifier === 'next') { + return '2.0.0-next.0'; + } // Handle smart fallback cases where git tag version is used directly - if (version === '1.1.0' && releaseType === 'patch' && !identifier) return '1.1.1'; - if (version === '1.2.0' && releaseType === 'patch' && !identifier) return '1.2.1'; + if (version === '1.1.0' && releaseType === 'patch' && !identifier) { + return '1.1.1'; + } + if (version === '1.2.0' && releaseType === 'patch' && !identifier) { + return '1.2.1'; + } // Fallback - for unexpected cases, increment patch const semverResult = semver.inc(version, 'patch'); @@ -145,7 +187,9 @@ describe('Version Calculator', () => { // Create a mock for semver.inc vi.spyOn(semver, 'inc').mockImplementation((version, releaseType, identifier) => { - if (!version) return null; + if (!version) { + return null; + } // Convert version to string safely const versionStr = typeof version === 'string' ? version : (version as { version?: string })?.version || '0.0.0'; @@ -153,8 +197,12 @@ describe('Version Calculator', () => { const parts = versionStr.split('.'); // Handle different release types - if (releaseType === 'major') return `${Number(parts[0]) + 1}.0.0`; - if (releaseType === 'minor') return `${parts[0]}.${Number(parts[1]) + 1}.0`; + if (releaseType === 'major') { + return `${Number(parts[0]) + 1}.0.0`; + } + if (releaseType === 'minor') { + return `${parts[0]}.${Number(parts[1]) + 1}.0`; + } if (releaseType === 'patch') { // Handle prerelease versions if (versionStr.includes('-')) { @@ -641,7 +689,9 @@ describe('Version Calculator', () => { // Mock semver.clean to behave like the real library: only pure semver strings // (optionally v-prefixed) are accepted; compound tags like "scope-pkg-v1.2.3" return null. vi.spyOn(semver, 'clean').mockImplementation((version) => { - if (typeof version !== 'string') return null; + if (typeof version !== 'string') { + return null; + } const match = version.match(/^v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)$/); return match ? match[1] : null; }); @@ -682,7 +732,9 @@ describe('Version Calculator', () => { // Mock semver.clean to behave like the real library vi.spyOn(semver, 'clean').mockImplementation((version) => { - if (typeof version !== 'string') return null; + if (typeof version !== 'string') { + return null; + } const match = version.match(/^v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)$/); return match ? match[1] : null; }); @@ -729,7 +781,9 @@ describe('Version Calculator', () => { // Mock semver.clean to behave like the real library vi.spyOn(semver, 'clean').mockImplementation((version) => { - if (typeof version !== 'string') return null; + if (typeof version !== 'string') { + return null; + } const match = version.match(/^v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)$/); return match ? match[1] : null; }); @@ -777,7 +831,9 @@ describe('Version Calculator', () => { // instead of 0.22.0. vi.resetAllMocks(); vi.spyOn(semver, 'clean').mockImplementation((version) => { - if (typeof version !== 'string') return null; + if (typeof version !== 'string') { + return null; + } const match = version.match(/^v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)$/); return match ? match[1] : null; }); @@ -1144,7 +1200,9 @@ describe('Version Calculator', () => { // Implement semver.inc with a more complex behaviour for different scenarios vi.spyOn(semver, 'inc').mockImplementation((version, releaseType, identifier) => { - if (!version) return '1.0.0'; + if (!version) { + return '1.0.0'; + } // For test: should use package.json version when no latestTag exists with explicit bump if (version === '1.0.0-beta.1' && releaseType === 'patch') { @@ -1643,7 +1701,9 @@ describe('Version Calculator', () => { // Mock existing prerelease version vi.spyOn(semver, 'prerelease').mockReturnValue(['next', 0]); vi.spyOn(semver, 'clean').mockImplementation((version) => { - if (version === 'v1.0.0-next.0') return '1.0.0-next.0'; + if (version === 'v1.0.0-next.0') { + return '1.0.0-next.0'; + } return version?.replace(/^[^\d]*/, '') || null; }); vi.spyOn(versionUtils, 'bumpVersion').mockReturnValue('1.0.0'); // Clean to stable diff --git a/packages/version/test/unit/core/versionStrategies.spec.ts b/packages/version/test/unit/core/versionStrategies.spec.ts index 150b2b75..6e0b04d0 100644 --- a/packages/version/test/unit/core/versionStrategies.spec.ts +++ b/packages/version/test/unit/core/versionStrategies.spec.ts @@ -36,14 +36,18 @@ vi.mock('../../../src/utils/formatting.js', () => ({ return template.replace(/\$\{version\}/g, version).replace(/\$\{packageName\}/g, packageName || ''); }), deriveBaselineTagPrefix: vi.fn().mockImplementation((template, formattedPrefix, packageName) => { - if (!template) return undefined; + if (!template) { + return undefined; + } return template .split('${' + 'version}')[0] .replace(/\$\{prefix\}/g, formattedPrefix) .replace(/\$\{packageName\}/g, packageName ?? ''); }), displayTag: vi.fn().mockImplementation((tag, baselineTagPrefix, formattedPrefix) => { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; }), })); @@ -104,7 +108,9 @@ describe('Version Strategies', () => { vi.mocked(formatting.formatCommitMessage, { partial: true }).mockReturnValue('chore: release package-a v1.1.0'); vi.mocked(formatting.deriveBaselineTagPrefix, { partial: true }).mockImplementation( (template, formattedPrefix, packageName) => { - if (!template) return undefined; + if (!template) { + return undefined; + } return template .split('${' + 'version}')[0] .replace(/\$\{prefix\}/g, formattedPrefix) @@ -113,7 +119,9 @@ describe('Version Strategies', () => { ); vi.mocked(formatting.displayTag, { partial: true }).mockImplementation( (tag, baselineTagPrefix, formattedPrefix) => { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; }, ); diff --git a/packages/version/test/unit/package/packageProcessor.spec.ts b/packages/version/test/unit/package/packageProcessor.spec.ts index 7951ea3c..b6ac8d6b 100644 --- a/packages/version/test/unit/package/packageProcessor.spec.ts +++ b/packages/version/test/unit/package/packageProcessor.spec.ts @@ -36,7 +36,9 @@ vi.mock('../../../src/utils/formatting.js', () => ({ escapeRegExp: vi.fn().mockImplementation((str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), deriveBaselineTagPrefix: vi.fn().mockReturnValue(undefined), displayTag: vi.fn().mockImplementation((tag, baselineTagPrefix, formattedPrefix) => { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; }), })); @@ -380,7 +382,9 @@ describe('Package Processor', () => { vi.spyOn(gitTags, 'getLatestTagForPackage').mockResolvedValue('release/v1.0.0'); vi.spyOn(formatting, 'deriveBaselineTagPrefix').mockReturnValue('release/v'); vi.spyOn(formatting, 'displayTag').mockImplementation((tag, baselineTagPrefix, formattedPrefix) => { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; }); @@ -404,7 +408,9 @@ describe('Package Processor', () => { vi.spyOn(gitTags, 'getLatestTagForPackage').mockResolvedValue('release/v1.0.0'); vi.spyOn(formatting, 'deriveBaselineTagPrefix').mockReturnValue('release/v'); vi.spyOn(formatting, 'displayTag').mockImplementation((tag, baselineTagPrefix, formattedPrefix) => { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; }); @@ -427,7 +433,9 @@ describe('Package Processor', () => { vi.spyOn(gitTags, 'getLatestTagForPackage').mockResolvedValue('release/v1.0.0'); vi.spyOn(formatting, 'deriveBaselineTagPrefix').mockReturnValue('release/v'); vi.spyOn(formatting, 'displayTag').mockImplementation((tag, baselineTagPrefix, formattedPrefix) => { - if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) return tag; + if (!baselineTagPrefix || !tag.startsWith(baselineTagPrefix)) { + return tag; + } return `${formattedPrefix}${tag.slice(baselineTagPrefix.length)}`; }); // Baseline resolves as a ref but can't be verified from HEAD (shallow clone / unpushed tag). @@ -700,7 +708,9 @@ describe('Package Processor', () => { const pkgAEntry = { type: 'added', description: 'New feature in pkg-a' }; vi.spyOn(commitParser, 'extractChangelogEntriesFromCommits').mockImplementation((_dir) => { - if (_dir.includes('package-a')) return [pkgAEntry]; + if (_dir.includes('package-a')) { + return [pkgAEntry]; + } return []; }); vi.spyOn(commitParser, 'extractRepoLevelChangelogEntries').mockResolvedValue([repoLevelEntry]); diff --git a/packages/version/test/unit/utils/versionUtils.spec.ts b/packages/version/test/unit/utils/versionUtils.spec.ts index b0ea4d8a..960c1d0e 100644 --- a/packages/version/test/unit/utils/versionUtils.spec.ts +++ b/packages/version/test/unit/utils/versionUtils.spec.ts @@ -171,16 +171,36 @@ describe('Version Utils', () => { beforeEach(() => { // Setup semver mocks vi.mocked(semver.prerelease, { partial: true }).mockImplementation((version) => { - if (version === '1.0.0-beta.1') return ['beta', 1]; - if (version === '1.0.0-beta.2') return ['beta', 2]; - if (version === '1.0.0-alpha.3') return ['alpha', 3]; - if (version === '1.0.0-next.0') return ['next', 0]; - if (version === '2.0.0-alpha.3') return ['alpha', 3]; - if (version === '2.0.0-next.0') return ['next', 0]; - if (version === '3.0.0-rc.1') return ['rc', 1]; - if (version === '2.1.0-next.4') return ['next', 4]; - if (version === '3.5.0-beta.12') return ['beta', 12]; - if (version === '4.0.1-rc.2') return ['rc', 2]; + if (version === '1.0.0-beta.1') { + return ['beta', 1]; + } + if (version === '1.0.0-beta.2') { + return ['beta', 2]; + } + if (version === '1.0.0-alpha.3') { + return ['alpha', 3]; + } + if (version === '1.0.0-next.0') { + return ['next', 0]; + } + if (version === '2.0.0-alpha.3') { + return ['alpha', 3]; + } + if (version === '2.0.0-next.0') { + return ['next', 0]; + } + if (version === '3.0.0-rc.1') { + return ['rc', 1]; + } + if (version === '2.1.0-next.4') { + return ['next', 4]; + } + if (version === '3.5.0-beta.12') { + return ['beta', 12]; + } + if (version === '4.0.1-rc.2') { + return ['rc', 2]; + } return null; }); @@ -270,15 +290,33 @@ describe('Version Utils', () => { vi.mocked(semver.inc, { partial: true }).mockImplementation((version, releaseType, identifier) => { // Stable base versions (after stripping prerelease) - if (version === '1.0.0' && releaseType === 'major') return '2.0.0'; - if (version === '1.0.0' && releaseType === 'minor') return '1.1.0'; - if (version === '1.0.0' && releaseType === 'patch') return '1.0.1'; - if (version === '2.0.0' && releaseType === 'major') return '3.0.0'; - if (version === '3.0.0' && releaseType === 'major') return '4.0.0'; - if (version === '2.1.0' && releaseType === 'minor') return '2.2.0'; - if (version === '3.5.0' && releaseType === 'minor') return '3.6.0'; - if (version === '4.0.1' && releaseType === 'patch') return '4.0.2'; - if (version === '4.0.1' && releaseType === 'minor') return '4.1.0'; + if (version === '1.0.0' && releaseType === 'major') { + return '2.0.0'; + } + if (version === '1.0.0' && releaseType === 'minor') { + return '1.1.0'; + } + if (version === '1.0.0' && releaseType === 'patch') { + return '1.0.1'; + } + if (version === '2.0.0' && releaseType === 'major') { + return '3.0.0'; + } + if (version === '3.0.0' && releaseType === 'major') { + return '4.0.0'; + } + if (version === '2.1.0' && releaseType === 'minor') { + return '2.2.0'; + } + if (version === '3.5.0' && releaseType === 'minor') { + return '3.6.0'; + } + if (version === '4.0.1' && releaseType === 'patch') { + return '4.0.2'; + } + if (version === '4.0.1' && releaseType === 'minor') { + return '4.1.0'; + } if (version === '1.0.0' && releaseType === 'premajor' && identifier === 'alpha') { return '2.0.0-alpha.0'; @@ -298,24 +336,44 @@ describe('Version Utils', () => { // Cases for prerelease increment on existing prerelease versions // Using 'prerelease' release type increments the prerelease counter only if (releaseType === 'prerelease' && identifier === 'next') { - if (version === '1.0.0-next.0') return '1.0.0-next.1'; - if (version === '1.0.0-next.4') return '1.0.0-next.5'; - if (version === '2.0.0-next.0') return '2.0.0-next.1'; + if (version === '1.0.0-next.0') { + return '1.0.0-next.1'; + } + if (version === '1.0.0-next.4') { + return '1.0.0-next.5'; + } + if (version === '2.0.0-next.0') { + return '2.0.0-next.1'; + } } if (releaseType === 'prerelease' && identifier === 'beta') { - if (version === '1.0.0-beta.1') return '1.0.0-beta.2'; - if (version === '1.0.0-beta.2') return '1.0.0-beta.3'; + if (version === '1.0.0-beta.1') { + return '1.0.0-beta.2'; + } + if (version === '1.0.0-beta.2') { + return '1.0.0-beta.3'; + } } if (releaseType === 'prerelease' && identifier === 'alpha') { - if (version === '1.0.0-alpha.3') return '1.0.0-alpha.4'; - if (version === '2.0.0-alpha.3') return '2.0.0-alpha.4'; + if (version === '1.0.0-alpha.3') { + return '1.0.0-alpha.4'; + } + if (version === '2.0.0-alpha.3') { + return '2.0.0-alpha.4'; + } } // Handle prepatch/preminor/premajor calls that fall through (for stable versions with identifiers) if (identifier === 'next' || identifier === 'alpha' || identifier === 'beta') { - if (version === '2.0.0' && releaseType === 'premajor') return '3.0.0-next.0'; - if (version === '1.0.0' && releaseType === 'preminor') return '1.1.0-next.0'; - if (version === '1.0.0' && releaseType === 'prepatch') return '1.0.1-next.0'; + if (version === '2.0.0' && releaseType === 'premajor') { + return '3.0.0-next.0'; + } + if (version === '1.0.0' && releaseType === 'preminor') { + return '1.1.0-next.0'; + } + if (version === '1.0.0' && releaseType === 'prepatch') { + return '1.0.1-next.0'; + } } return `${version}.incremented`; diff --git a/packages/version/test/utils/cli.ts b/packages/version/test/utils/cli.ts index 2e1a3d8d..36a3b175 100644 --- a/packages/version/test/utils/cli.ts +++ b/packages/version/test/utils/cli.ts @@ -10,6 +10,8 @@ import { join } from 'node:path'; export function executeCliCommand(command: string, cwd: string, dryRun = true) { const cliPath = join(process.cwd(), 'dist/cli.js'); const args = command.split(' '); - if (dryRun) args.push('--dry-run'); + if (dryRun) { + args.push('--dry-run'); + } return spawnSync('node', [cliPath, ...args], { cwd, encoding: 'utf-8' }); } diff --git a/scripts/examples-smoke/build-fixture.ts b/scripts/examples-smoke/build-fixture.ts index 8dabae2d..5c6df4d4 100644 --- a/scripts/examples-smoke/build-fixture.ts +++ b/scripts/examples-smoke/build-fixture.ts @@ -38,10 +38,15 @@ function parseArgs(argv: string[]): Args { const args: Partial = { seedLockfile: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === '--scenario') args.scenario = argv[++i]; - else if (a === '--out') args.out = argv[++i]; - else if (a === '--tarball-dir') args.tarballDir = argv[++i]; - else if (a === '--seed-lockfile') args.seedLockfile = true; + if (a === '--scenario') { + args.scenario = argv[++i]; + } else if (a === '--out') { + args.out = argv[++i]; + } else if (a === '--tarball-dir') { + args.tarballDir = argv[++i]; + } else if (a === '--seed-lockfile') { + args.seedLockfile = true; + } } if (!args.scenario || !args.out || !args.tarballDir) { throw new Error('Usage: build-fixture --scenario --out --tarball-dir [--seed-lockfile]'); @@ -51,7 +56,9 @@ function parseArgs(argv: string[]): Args { function findTarball(dir: string, prefix: string): string { const file = readdirSync(dir).find((f) => f.startsWith(prefix) && f.endsWith('.tgz')); - if (!file) throw new Error(`Could not find ${prefix}*.tgz in ${dir}`); + if (!file) { + throw new Error(`Could not find ${prefix}*.tgz in ${dir}`); + } return file; } @@ -158,7 +165,9 @@ function buildNpmCargoMonorepo(out: string, tarballNames: Record function main(): void { const { scenario: scenarioId, out, tarballDir, seedLockfile } = parseArgs(process.argv.slice(2)); const scenario = SCENARIOS.find((s) => s.id === scenarioId); - if (!scenario) throw new Error(`Unknown scenario: ${scenarioId}`); + if (!scenario) { + throw new Error(`Unknown scenario: ${scenarioId}`); + } const outDir = resolve(out); rmSync(outDir, { recursive: true, force: true }); diff --git a/scripts/examples-smoke/generate-smoke-workflow.ts b/scripts/examples-smoke/generate-smoke-workflow.ts index c2a43943..3bf9b1fa 100644 --- a/scripts/examples-smoke/generate-smoke-workflow.ts +++ b/scripts/examples-smoke/generate-smoke-workflow.ts @@ -76,8 +76,12 @@ function extractSetupSteps(scenario: Scenario): Step[] { } const setup: Step[] = []; for (const step of job.steps) { - if (isReleasekitRun(step)) break; // setup is everything before the release call - if (isCheckout(step) || isOutOfScopeApiStep(step)) continue; + if (isReleasekitRun(step)) { + break; // setup is everything before the release call + } + if (isCheckout(step) || isOutOfScopeApiStep(step)) { + continue; + } // Strip only envs that would fail or do harm in a dry run: references to // `secrets.*`. Keep diagnostic toggles (LOG_LEVEL, RUST_BACKTRACE, ...) and // plain literals — the smoke job's value is the missing-tool signal, not @@ -86,7 +90,9 @@ function extractSetupSteps(scenario: Scenario): Step[] { if (cleaned.env && typeof cleaned.env === 'object') { const filtered: Record = {}; for (const [k, v] of Object.entries(cleaned.env as Record)) { - if (!isSecretReferencingEnvValue(v)) filtered[k] = v; + if (!isSecretReferencingEnvValue(v)) { + filtered[k] = v; + } } if (Object.keys(filtered).length > 0) { cleaned.env = filtered; @@ -114,7 +120,9 @@ function runtimeProbe(scenario: Scenario): Step | undefined { publish?: { cargo?: { enabled?: boolean } }; }; const cargoEnabled = cfg.version?.cargo?.enabled === true || cfg.publish?.cargo?.enabled === true; - if (!cargoEnabled) return undefined; + if (!cargoEnabled) { + return undefined; + } return { name: 'Probe: cargo is on PATH (config enables cargo)', // The example's rust-toolchain step must have put cargo here; if it was @@ -149,7 +157,9 @@ function smokeJob(scenario: Scenario): Record { } const probe = runtimeProbe(scenario); - if (probe) steps.push(probe); + if (probe) { + steps.push(probe); + } steps.push({ name: `Dry-run release (${scenario.proves})`, diff --git a/scripts/generate-config-docs.ts b/scripts/generate-config-docs.ts index ad462d7e..a2266b6f 100644 --- a/scripts/generate-config-docs.ts +++ b/scripts/generate-config-docs.ts @@ -39,20 +39,32 @@ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')) as Schema; // --------------------------------------------------------------------------- function ensurePeriod(str: string | undefined): string { - if (!str) return ''; + if (!str) { + return ''; + } const s = str.trim(); return s.endsWith('.') ? s : `${s}.`; } function fmtDefault(val: unknown): string { - if (val === undefined || val === null) return '—'; - if (typeof val === 'boolean') return `\`${val}\``; - if (typeof val === 'string') return `\`"${val}"\``; + if (val === undefined || val === null) { + return '—'; + } + if (typeof val === 'boolean') { + return `\`${val}\``; + } + if (typeof val === 'string') { + return `\`"${val}"\``; + } if (Array.isArray(val)) { - if (val.length === 0) return '`[]`'; + if (val.length === 0) { + return '`[]`'; + } return `\`${JSON.stringify(val)}\``; } - if (typeof val === 'number') return `\`${val}\``; + if (typeof val === 'number') { + return `\`${val}\``; + } return `\`${JSON.stringify(val)}\``; } @@ -63,9 +75,15 @@ function fmtType(prop: SchemaProperty): string { if (prop.oneOf) { return prop.oneOf .map((s) => { - if (s.type === 'boolean' && s.enum) return (s.enum as unknown[]).map((v) => `\`${v}\``).join(' | '); - if (s.type === 'boolean') return 'boolean'; - if (s.type === 'string' && s.enum) return s.enum.map((v) => `\`"${v}"\``).join(' | '); + if (s.type === 'boolean' && s.enum) { + return (s.enum as unknown[]).map((v) => `\`${v}\``).join(' | '); + } + if (s.type === 'boolean') { + return 'boolean'; + } + if (s.type === 'string' && s.enum) { + return s.enum.map((v) => `\`"${v}"\``).join(' | '); + } return s.type ?? 'any'; }) .join(' | '); @@ -74,9 +92,15 @@ function fmtType(prop: SchemaProperty): string { const itemType = prop.items?.type ?? 'any'; return `\`${itemType}[]\``; } - if (prop.type === 'object') return 'object'; - if (prop.type === 'integer') return 'integer'; - if (prop.type === 'number') return 'number'; + if (prop.type === 'object') { + return 'object'; + } + if (prop.type === 'integer') { + return 'integer'; + } + if (prop.type === 'number') { + return 'number'; + } return prop.type ?? '—'; } @@ -85,7 +109,9 @@ function escapePipes(str: string): string { } function propsTable(properties: Record): string { - if (!properties || Object.keys(properties).length === 0) return ''; + if (!properties || Object.keys(properties).length === 0) { + return ''; + } const rows = Object.entries(properties).map(([key, prop]) => { const type = escapePipes(fmtType(prop)); const def = fmtDefault(prop.default); @@ -312,7 +338,9 @@ function renderPublish(prop: SchemaProperty): void { // --------------------------------------------------------------------------- function renderOneOfBooleanObject(key: string, schemaProp: SchemaProperty): void { const objSchema = schemaProp.oneOf?.find((s) => s.type === 'object'); - if (!objSchema) return; + if (!objSchema) { + return; + } emit('Set to `false` to disable.', ''); diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts index e064f3e3..a24df33f 100644 --- a/scripts/generate-schema.ts +++ b/scripts/generate-schema.ts @@ -163,9 +163,15 @@ function orderedKeywordKeys(keys: string[]): string[] { return [...keys].sort((a, b) => { const ia = KEY_ORDER.indexOf(a); const ib = KEY_ORDER.indexOf(b); - if (ia === -1 && ib === -1) return a.localeCompare(b); - if (ia === -1) return 1; - if (ib === -1) return -1; + if (ia === -1 && ib === -1) { + return a.localeCompare(b); + } + if (ia === -1) { + return 1; + } + if (ib === -1) { + return -1; + } return ia - ib; }); } @@ -249,8 +255,12 @@ function printUnifiedDiff(before: string, after: string, label: string): void { ['-u', `--label=${label} (committed)`, `--label=${label} (generated)`, beforePath, afterPath], { encoding: 'utf8' }, ); - if (result.stdout) process.stderr.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); + if (result.stdout) { + process.stderr.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } if (result.status === 127 || result.error) { // `diff` not on PATH — fall back to a line-by-line diff. Good enough // for diagnostic output on a (typically small) schema file. @@ -267,8 +277,12 @@ function printLineDiffFallback(before: string, after: string, label: string): vo const afterLines = after.split('\n'); for (let i = 0; i < Math.max(beforeLines.length, afterLines.length); i++) { if (beforeLines[i] !== afterLines[i]) { - if (beforeLines[i] !== undefined) process.stderr.write(`- ${beforeLines[i]}\n`); - if (afterLines[i] !== undefined) process.stderr.write(`+ ${afterLines[i]}\n`); + if (beforeLines[i] !== undefined) { + process.stderr.write(`- ${beforeLines[i]}\n`); + } + if (afterLines[i] !== undefined) { + process.stderr.write(`+ ${afterLines[i]}\n`); + } } } } diff --git a/scripts/run-action.mjs b/scripts/run-action.mjs index 227a295a..5d343648 100644 --- a/scripts/run-action.mjs +++ b/scripts/run-action.mjs @@ -7,7 +7,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; function normalizeBoolean(value, defaultValue = false) { - if (value === undefined || value === '') return defaultValue; + if (value === undefined || value === '') { + return defaultValue; + } return value === 'true'; } @@ -161,7 +163,9 @@ export function writeGateOutputs(stdout, verbose = false) { export function parseReleaseOutput(stdout, verbose = false) { const trimmed = stdout.trim(); - if (!trimmed) return undefined; + if (!trimmed) { + return undefined; + } let parsed; try { parsed = JSON.parse(trimmed); @@ -210,7 +214,9 @@ export function resolveReleaseTags({ parsedTags, gitTags, allowRecovery }) { function setOutput(name, value) { const outputPath = process.env.GITHUB_OUTPUT; - if (!outputPath) return; + if (!outputPath) { + return; + } const text = value === undefined ? '' : String(value); const delimiter = `releasekit_${randomBytes(8).toString('hex')}`; @@ -520,7 +526,9 @@ export function writeStandingPROutputs(stdout, verbose = false) { export function writeSummary(markdown) { const summaryPath = process.env.GITHUB_STEP_SUMMARY; - if (!summaryPath) return; + if (!summaryPath) { + return; + } try { fs.appendFileSync(summaryPath, markdown); } catch (err) { @@ -551,11 +559,21 @@ export function buildReleaseSummary(input, parsed, success) { } const settings = []; - if (input.bump) settings.push(`| Bump | \`${input.bump}\` |`); - if (input.target) settings.push(`| Target | \`${input.target}\` |`); - if (input.scope) settings.push(`| Scope | \`${input.scope}\` |`); - if (input.prerelease) settings.push(`| Prerelease | \`${input.prerelease}\` |`); - if (normalizeBoolean(input.stable)) settings.push(`| Stable | Yes |`); + if (input.bump) { + settings.push(`| Bump | \`${input.bump}\` |`); + } + if (input.target) { + settings.push(`| Target | \`${input.target}\` |`); + } + if (input.scope) { + settings.push(`| Scope | \`${input.scope}\` |`); + } + if (input.prerelease) { + settings.push(`| Prerelease | \`${input.prerelease}\` |`); + } + if (normalizeBoolean(input.stable)) { + settings.push(`| Stable | Yes |`); + } if (settings.length > 0) { lines.push('| Setting | Value |'); @@ -616,9 +634,15 @@ export function buildGateSummary(_input, parsed, success) { lines.push(`| Check | Result |`); lines.push(`|-------|--------|`); lines.push(`| Should release | ${icon} ${shouldRelease ? 'Yes' : 'No'} |`); - if (parsed?.bump) lines.push(`| Bump | \`${parsed.bump}\` |`); - if (parsed?.scope) lines.push(`| Scope | \`${parsed.scope}\` |`); - if (parsed?.target) lines.push(`| Target | \`${parsed.target}\` |`); + if (parsed?.bump) { + lines.push(`| Bump | \`${parsed.bump}\` |`); + } + if (parsed?.scope) { + lines.push(`| Scope | \`${parsed.scope}\` |`); + } + if (parsed?.target) { + lines.push(`| Target | \`${parsed.target}\` |`); + } lines.push(''); if (parsed?.blocked) { diff --git a/scripts/test-harness/index.mjs b/scripts/test-harness/index.mjs index 1a560273..21c18fee 100644 --- a/scripts/test-harness/index.mjs +++ b/scripts/test-harness/index.mjs @@ -140,13 +140,19 @@ try { encoding: 'utf-8', env: process.env, }); - if (result.stdout) console.log(result.stdout); - if (result.stderr) console.log(result.stderr); + if (result.stdout) { + console.log(result.stdout); + } + if (result.stderr) { + console.log(result.stderr); + } return result; }; const assert = (cond, msg) => { - if (!cond) throw new Error(`Backfill assertion failed: ${msg}`); + if (!cond) { + throw new Error(`Backfill assertion failed: ${msg}`); + } }; // 1. --all --apply: reconstruct every workspace package from the global tag series, write files. @@ -166,8 +172,12 @@ try { assert(body.includes(exp.date), `${exp.file} should be dated ${exp.date}`); assert(!body.includes(today), `${exp.file} should not be stamped with today's date (${today})`); // Entries are scoped to the package's own directory. - for (const s of exp.has) assert(body.includes(s), `${exp.file} should mention "${s}"`); - for (const s of exp.hasNot) assert(!body.includes(s), `${exp.file} should NOT mention "${s}" (path scoping)`); + for (const s of exp.has) { + assert(body.includes(s), `${exp.file} should mention "${s}"`); + } + for (const s of exp.hasNot) { + assert(!body.includes(s), `${exp.file} should NOT mention "${s}" (path scoping)`); + } console.log(`✓ ${exp.file}: dated ${exp.date}, scoped correctly`); } diff --git a/scripts/test-harness/run-action-local.mjs b/scripts/test-harness/run-action-local.mjs index 9b4db312..4a01dfc5 100644 --- a/scripts/test-harness/run-action-local.mjs +++ b/scripts/test-harness/run-action-local.mjs @@ -4,7 +4,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; function normalizeBoolean(value, defaultValue = false) { - if (value === undefined || value === '') return defaultValue; + if (value === undefined || value === '') { + return defaultValue; + } return value === 'true'; } @@ -78,7 +80,9 @@ function buildPreviewArgs(input) { export function parseReleaseOutput(stdout) { const trimmed = stdout.trim(); - if (!trimmed) return undefined; + if (!trimmed) { + return undefined; + } try { return JSON.parse(trimmed); } catch { diff --git a/test/integration/examples-smoke.spec.ts b/test/integration/examples-smoke.spec.ts index da3e5c58..46006068 100644 --- a/test/integration/examples-smoke.spec.ts +++ b/test/integration/examples-smoke.spec.ts @@ -67,7 +67,9 @@ describe('examples smoke-test generator', () => { it('should not add a cargo probe to npm-only scenarios', () => { for (const scenario of SCENARIOS) { - if (scenario.fixture === 'npm-cargo-monorepo') continue; + if (scenario.fixture === 'npm-cargo-monorepo') { + continue; + } const hasCargoProbe = stepsOf(`smoke-${scenario.id}`).some((s) => s.run === 'cargo --version'); expect(hasCargoProbe).toBe(false); } From ca79dde78c342ee8277e2b9cbbba40fd2803b321 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:30:56 +0000 Subject: [PATCH 05/18] fix(version): sync a lock entry only for the crate it records, never an ignored lock Crates in different workspaces can share a name (every `tauri init` app is `app`), and every bump was applied to every lock with a path entry of that name, so one app's lock took another app's version. A crate's own lock still takes the crate's bump whatever version it records. In any other lock, the entry follows a bump only when something in that lock depends on it (an entry nothing depends on is that lock's own root or member, a different crate) and it is at the crate's old or new version; bumps record the manifest's pre-bump version for this. Same-named bumps to different versions that fit one entry fail the run. A crate's own lock that git ignores is no longer synced or staged: the direct-commit release's `git add` refused it and failed the release. `Git` gains `isIgnored` (`check-ignore -q`); the planner now does its own discovery, so `findCommittedCargoLockfiles` is gone. Also: version's syncCargoLockfiles returns void, the unused `git` parameters and FakeGit's trackedFiles seed are dropped, the duplicate comment in VersionEngine.run is removed, and tests pin numeric pre-release ordering and the several-path-entries refusal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- packages/core/src/cargo.ts | 78 +++-- packages/core/src/index.ts | 3 +- packages/core/test/unit/cargo.spec.ts | 272 +++++++++++++----- packages/git/src/fake.ts | 12 +- packages/git/src/git.ts | 4 + packages/git/src/types.ts | 2 + packages/git/test/unit/fake.spec.ts | 7 - packages/git/test/unit/git.spec.ts | 9 + packages/publish/src/stages/prepare.ts | 8 +- packages/publish/src/utils/cargo.ts | 30 +- .../publish/test/unit/stages/prepare.spec.ts | 50 +++- packages/version/src/cargo/cargoHandler.ts | 4 +- packages/version/src/cargo/cargoLock.ts | 20 +- packages/version/src/core/versionEngine.ts | 2 - .../test/unit/cargo/cargoHandler.spec.ts | 2 + .../version/test/unit/cargo/cargoLock.spec.ts | 39 +++ test/integration/cargo-lock-sync.spec.ts | 36 +++ 17 files changed, 424 insertions(+), 154 deletions(-) diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index e5aa1087..a25ba99c 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -248,22 +248,11 @@ export function setCargoLockVersion(lockContent: string, crateName: string, vers return updated.join('\n'); } -/** The part of `@releasekit/git`'s `Git` seam that lists tracked files (core doesn't depend on it). */ -export interface TrackedFileLister { +/** The part of `@releasekit/git`'s `Git` seam the Cargo.lock sync uses (core doesn't depend on it). */ +export interface CargoLockGit { isRepository(cwd?: string): Promise; lsFiles(opts?: { paths?: string[]; cwd?: string }): Promise; -} - -/** - * Every Cargo.lock tracked by git anywhere in the repository containing `cwd`, as absolute paths — - * empty outside a git repository. Untracked and ignored locks are left out. - */ -export async function findCommittedCargoLockfiles(git: TrackedFileLister, cwd: string): Promise { - if (!(await git.isRepository(cwd))) { - return []; - } - const tracked = await git.lsFiles({ paths: [':(top,glob)**/Cargo.lock'], cwd }); - return tracked.map((lockPath) => path.resolve(cwd, lockPath)); + isIgnored(file: string, cwd?: string): Promise; } /** A crate whose new version the Cargo.lock files that record it must follow. */ @@ -271,6 +260,8 @@ export interface CargoLockBump { cargoTomlPath: string; name: string; version: string; + /** The version the manifest had before the bump; absent when it inherits `version.workspace = true`. */ + fromVersion?: string; } /** A Cargo.lock that lists at least one bumped crate as a path package, with the content it must have. */ @@ -283,19 +274,36 @@ export interface CargoLockUpdate { /** * Work out the content of every Cargo.lock that records a bumped crate, by editing the lock text with * {@link setCargoLockVersion} — nothing runs cargo, so no Rust toolchain, registry index or network is - * needed. The candidates are `committedLocks` (every tracked lock, which covers apps that use a crate - * through a path dependency) plus each crate's own governing lock ({@link findCargoLockfile}), which is - * synced even when it isn't tracked. A lock gets every bump it lists, so a lock shared by several bumped - * crates is rewritten once; locks that list none are left out. + * needed. The candidates are every lock git tracks (apps that use a crate through a path dependency + * have their own) plus each crate's own governing lock ({@link findCargoLockfile}), which is synced + * even when untracked but not when git ignores it, since it couldn't be staged. + * + * Crates in different workspaces can share a name (every `tauri init` app is `app`), so a lock's path + * entry follows a bump only when it is that crate: always in the crate's own lock; in any other lock, + * only when something in that lock depends on the entry (one nothing depends on is that lock's own + * root or workspace member) and it is at the crate's old or new version. A lock gets every bump it + * records, so a lock shared by several bumped crates is rewritten once; locks that record none are + * left out. * * Nothing is written: the caller writes the changed content. Throws, naming the lock, when a lock can't * be read or edited unambiguously, so no lock is written unless every lock can be. */ -export function planCargoLockUpdates(bumps: CargoLockBump[], committedLocks: string[]): CargoLockUpdate[] { - const lockPaths = new Set(committedLocks.map((lockPath) => path.resolve(lockPath))); +export async function planCargoLockUpdates( + git: CargoLockGit, + cwd: string, + bumps: CargoLockBump[], +): Promise { + const lockPaths = new Set(); + if (await git.isRepository(cwd)) { + for (const lockPath of await git.lsFiles({ paths: [':(top,glob)**/Cargo.lock'], cwd })) { + lockPaths.add(path.resolve(cwd, lockPath)); + } + } + const ownLocks = new Map(); for (const bump of bumps) { const ownLock = findCargoLockfile(path.dirname(bump.cargoTomlPath)); - if (ownLock) { + if (ownLock && !(await git.isIgnored(ownLock, cwd))) { + ownLocks.set(bump, ownLock); lockPaths.add(ownLock); } } @@ -307,15 +315,37 @@ export function planCargoLockUpdates(bumps: CargoLockBump[], committedLocks: str continue; } const original = fs.readFileSync(lockPath, 'utf8'); + const lines = original.split('\n'); + const pathEntries = readCargoLockEntries(lines).filter((entry) => !entry.hasSource); + const references = new Set(lines.map((line) => line.trim().replace(/,$/, ''))); + const isPathDependency = (bump: CargoLockBump): boolean => + pathEntries.some( + (entry) => + entry.name === bump.name && + (bump.fromVersion === undefined || [bump.fromVersion, bump.version].includes(entry.version)) && + (references.has(`"${entry.name}"`) || references.has(`"${entry.name} ${entry.version}"`)), + ); + let content = original; let listed = false; - for (const bump of bumps) { + for (const name of new Set(bumps.map((bump) => bump.name))) { + const sameName = bumps.filter((bump) => bump.name === name); + const own = sameName.filter((bump) => ownLocks.get(bump) === lockPath); + const matching = own.length > 0 ? own : sameName.filter(isPathDependency); + if (matching.length === 0) { + continue; + } + if (new Set(matching.map((bump) => bump.version)).size > 1) { + const manifests = matching.map((bump) => bump.cargoTomlPath).join(', '); + throw new Error(`can't set ${name} in ${lockPath}: it could be the crate at any of ${manifests}`); + } + const [{ version }] = matching; let next: string | undefined; try { - next = setCargoLockVersion(content, bump.name, bump.version); + next = setCargoLockVersion(content, name, version); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - throw new Error(`can't set ${bump.name} to ${bump.version} in ${lockPath}: ${detail}`); + throw new Error(`can't set ${name} to ${version} in ${lockPath}: ${detail}`); } if (next !== undefined) { listed = true; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5ed52ce7..2d33c309 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,10 +1,9 @@ export { type CargoLockBump, + type CargoLockGit, type CargoLockUpdate, findCargoLockfile, - findCommittedCargoLockfiles, planCargoLockUpdates, - type TrackedFileLister, } from './cargo.js'; export { type ChangelogRefsMode, diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index 1feada3e..9bec8842 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -2,12 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - findCargoLockfile, - findCommittedCargoLockfiles, - planCargoLockUpdates, - setCargoLockVersion, -} from '../../src/cargo.js'; +import { type CargoLockBump, findCargoLockfile, planCargoLockUpdates, setCargoLockVersion } from '../../src/cargo.js'; describe('findCargoLockfile', () => { const tmpDirs: string[] = []; @@ -201,6 +196,44 @@ describe('setCargoLockVersion', () => { expect(setCargoLockVersion(before, 'dupe', '3.0.0')).toBe(after); }); + it('should order pre-release identifiers numerically when the new version passes other entries', () => { + // Byte-for-byte what `cargo update --workspace` writes after bumping the path crate `dupe` from + // 1.0.0-beta.2 to 1.0.0-beta.11: beta.9 < beta.10 < beta.11 for entries, as strings for references. + const entry = (version: string, ...rest: string[]) => [ + '', + '[[package]]', + 'name = "dupe"', + `version = "${version}"`, + ...rest, + ]; + const app = (...versions: string[]) => [ + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + 'dependencies = [', + ...versions.map((version) => ` "dupe 1.0.0-beta.${version}",`), + ']', + ]; + const before = lock( + 'version = 4', + '', + ...app('10', '2', '9'), + ...entry('1.0.0-beta.2'), + ...entry('1.0.0-beta.9', `source = "${GIT}"`), + ...entry('1.0.0-beta.10', `source = "${GIT}"`), + ); + const after = lock( + 'version = 4', + '', + ...app('10', '11', '9'), + ...entry('1.0.0-beta.9', `source = "${GIT}"`), + ...entry('1.0.0-beta.10', `source = "${GIT}"`), + ...entry('1.0.0-beta.11'), + ); + + expect(setCargoLockVersion(before, 'dupe', '1.0.0-beta.11')).toBe(after); + }); + it('should rewrite path references in a v1 lock without touching registry references or checksums', () => { // v1 locks always spell out the version, and add the source for registry packages. const before = lock( @@ -274,39 +307,79 @@ describe('setCargoLockVersion', () => { expect(() => setCargoLockVersion(before, 'core', '2.0.0')).toThrow(/from another source/); }); + + it('should throw when the lock has several path entries of the name', () => { + const before = lock( + 'version = 4', + '', + '[[package]]', + 'name = "core"', + 'version = "1.4.0"', + '', + '[[package]]', + 'name = "core"', + 'version = "3.0.0"', + ); + + expect(() => setCargoLockVersion(before, 'core', '2.0.0')).toThrow('it has 2 path entries named core'); + }); }); describe('planCargoLockUpdates', () => { const tmpDirs: string[] = []; + /** The git seam as the planner sees it: `tracked` locks, and the files git ignores. */ + function fakeGit(tracked: string[], ignored: string[] = []) { + return { + isRepository: vi.fn(async () => true), + lsFiles: vi.fn(async () => tracked), + isIgnored: vi.fn(async (file: string) => ignored.includes(file)), + }; + } + + function write(file: string, content: string): string { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + return file; + } + + /** A lock whose root package `root` depends on `dependency` by path. */ + const lockWithDependency = (root: string, dependency: string, version: string): string => + lock( + 'version = 4', + '', + '[[package]]', + `name = "${dependency}"`, + `version = "${version}"`, + '', + '[[package]]', + `name = "${root}"`, + 'version = "0.1.0"', + 'dependencies = [', + ` "${dependency}",`, + ']', + ); + /** A workspace whose root lock lists `core`, plus a separate app whose own lock uses `core` by path. */ function repoWithLocks(): { root: string; manifest: string; ownLock: string; appLock: string } { const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-plan-'))); tmpDirs.push(root); - fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["crates/*"]\n'); - const member = path.join(root, 'crates', 'core'); - fs.mkdirSync(member, { recursive: true }); - const manifest = path.join(member, 'Cargo.toml'); - fs.writeFileSync(manifest, '[package]\nname = "core"\nversion = "1.5.0"\n'); - const ownLock = path.join(root, 'Cargo.lock'); - fs.writeFileSync(ownLock, lock('version = 4', '', '[[package]]', 'name = "core"', 'version = "1.4.0"')); - fs.mkdirSync(path.join(root, 'app')); - const appLock = path.join(root, 'app', 'Cargo.lock'); - fs.writeFileSync( - appLock, - lock( - 'version = 4', - '', - '[[package]]', - 'name = "app"', - 'version = "0.1.0"', - '', - '[[package]]', - 'name = "core"', - 'version = "1.4.0"', - ), + write(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["crates/*"]\n'); + const manifest = write( + path.join(root, 'crates', 'core', 'Cargo.toml'), + '[package]\nname = "core"\nversion = "1.5.0"\n', + ); + const ownLock = write( + path.join(root, 'Cargo.lock'), + lock('version = 4', '', '[[package]]', 'name = "core"', 'version = "1.4.0"'), ); - return { root, manifest, ownLock, appLock }; + write(path.join(root, 'app', 'Cargo.toml'), '[package]\nname = "app"\nversion = "0.1.0"\n\n[workspace]\n'); + return { + root, + manifest, + ownLock, + appLock: write(path.join(root, 'app', 'Cargo.lock'), lockWithDependency('app', 'core', '1.4.0')), + }; } afterEach(() => { @@ -316,85 +389,130 @@ describe('planCargoLockUpdates', () => { tmpDirs.length = 0; }); - it('should plan the crate’s own lock and every committed lock that lists it, writing nothing', () => { + it('should plan the crate’s own lock and every tracked lock that uses it, writing nothing', async () => { const { root, manifest, ownLock, appLock } = repoWithLocks(); - const unrelated = path.join(root, 'other.lock'); - fs.writeFileSync(unrelated, lock('version = 4', '', '[[package]]', 'name = "other"', 'version = "1.0.0"')); + write(path.join(root, 'other', 'Cargo.lock'), lockWithDependency('other', 'serde', '1.0.0')); + const git = fakeGit(['../../app/Cargo.lock', '../../other/Cargo.lock']); + const bump = { cargoTomlPath: manifest, name: 'core', version: '1.5.0', fromVersion: '1.4.0' }; - const updates = planCargoLockUpdates( - [{ cargoTomlPath: manifest, name: 'core', version: '1.5.0' }], - [appLock, unrelated], - ); + const updates = await planCargoLockUpdates(git, path.dirname(manifest), [bump]); expect(updates).toEqual([ { lockPath: appLock, content: fs.readFileSync(appLock, 'utf8').replace('1.4.0', '1.5.0'), changed: true }, { lockPath: ownLock, content: fs.readFileSync(ownLock, 'utf8').replace('1.4.0', '1.5.0'), changed: true }, ]); + expect(git.lsFiles).toHaveBeenCalledWith({ paths: [':(top,glob)**/Cargo.lock'], cwd: path.dirname(manifest) }); expect(fs.readFileSync(ownLock, 'utf8')).toContain('version = "1.4.0"'); }); - it('should apply every bump a shared lock lists in one update', () => { + it('should plan only the crate’s own lock outside a git repository', async () => { + const { manifest, ownLock } = repoWithLocks(); + const git = { ...fakeGit([]), isRepository: vi.fn(async () => false) }; + + const updates = await planCargoLockUpdates(git, path.dirname(manifest), [ + { cargoTomlPath: manifest, name: 'core', version: '1.5.0', fromVersion: '1.4.0' }, + ]); + + expect(updates.map((update) => update.lockPath)).toEqual([ownLock]); + expect(git.lsFiles).not.toHaveBeenCalled(); + }); + + it('should not plan an own lock git ignores', async () => { + const { root, manifest, ownLock } = repoWithLocks(); + + const updates = await planCargoLockUpdates(fakeGit([], [ownLock]), root, [ + { cargoTomlPath: manifest, name: 'core', version: '1.5.0', fromVersion: '1.4.0' }, + ]); + + expect(updates).toEqual([]); + }); + + it('should apply every bump a shared lock lists in one update', async () => { const { root, manifest, appLock } = repoWithLocks(); - const updates = planCargoLockUpdates( - [ - { cargoTomlPath: path.join(root, 'app', 'Cargo.toml'), name: 'app', version: '0.2.0' }, - { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }, - ], - [appLock], - ); + const updates = await planCargoLockUpdates(fakeGit([appLock]), root, [ + { cargoTomlPath: path.join(root, 'app', 'Cargo.toml'), name: 'app', version: '0.2.0', fromVersion: '0.1.0' }, + { cargoTomlPath: manifest, name: 'core', version: '1.5.0', fromVersion: '1.4.0' }, + ]); expect(updates.find((update) => update.lockPath === appLock)?.content).toBe( - lock( - 'version = 4', - '', - '[[package]]', - 'name = "app"', - 'version = "0.2.0"', - '', - '[[package]]', - 'name = "core"', - 'version = "1.5.0"', - ), + fs.readFileSync(appLock, 'utf8').replace('"0.1.0"', '"0.2.0"').replace('"1.4.0"', '"1.5.0"'), ); }); - it('should report a lock already at the new version as unchanged, and skip a tracked lock missing from disk', () => { + it('should report a lock already at the new version as unchanged, and skip a tracked lock missing from disk', async () => { const { root, manifest, ownLock } = repoWithLocks(); fs.writeFileSync(ownLock, lock('version = 4', '', '[[package]]', 'name = "core"', 'version = "1.5.0"')); - const updates = planCargoLockUpdates( - [{ cargoTomlPath: manifest, name: 'core', version: '1.5.0' }], - [path.join(root, 'deleted', 'Cargo.lock')], - ); + const updates = await planCargoLockUpdates(fakeGit([path.join(root, 'deleted', 'Cargo.lock')]), root, [ + { cargoTomlPath: manifest, name: 'core', version: '1.5.0', fromVersion: '1.5.0' }, + ]); expect(updates).toEqual([{ lockPath: ownLock, content: fs.readFileSync(ownLock, 'utf8'), changed: false }]); }); - it('should name the lock when an edit cannot be made unambiguously', () => { - const { manifest, appLock } = repoWithLocks(); - fs.appendFileSync(appLock, lock('', '[[package]]', 'name = "core"', 'version = "1.5.0"', `source = "${REGISTRY}"`)); + it('should update another lock’s entry of the crate’s name only when it is a path dependency at the old or new version', async () => { + // Every `tauri init` app is a crate named `app`, so other locks can hold a different `app`. + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-names-'))); + tmpDirs.push(root); + const manifest = write(path.join(root, 'one', 'Cargo.toml'), '[package]\nname = "app"\nversion = "0.1.0"\n'); + const ownLock = write(path.join(root, 'one', 'Cargo.lock'), lockWithDependency('app', 'dep', '1.0.0')); + const otherApp = write(path.join(root, 'two', 'Cargo.lock'), lockWithDependency('app', 'dep', '1.0.0')); + const atOld = write(path.join(root, 'at-old', 'Cargo.lock'), lockWithDependency('harness', 'app', '0.1.0')); + const atNew = write(path.join(root, 'at-new', 'Cargo.lock'), lockWithDependency('harness', 'app', '0.2.0')); + const atOther = write(path.join(root, 'at-other', 'Cargo.lock'), lockWithDependency('harness', 'app', '0.3.0')); + + const updates = await planCargoLockUpdates(fakeGit([ownLock, otherApp, atOld, atNew, atOther]), root, [ + { cargoTomlPath: manifest, name: 'app', version: '0.2.0', fromVersion: '0.1.0' }, + ]); - expect(() => - planCargoLockUpdates([{ cargoTomlPath: manifest, name: 'core', version: '1.5.0' }], [appLock]), - ).toThrow(`can't set core to 1.5.0 in ${appLock}: it also has core 1.5.0 from another source`); + expect(updates).toEqual([ + { + lockPath: ownLock, + content: lockWithDependency('app', 'dep', '1.0.0').replace('"0.1.0"', '"0.2.0"'), + changed: true, + }, + { lockPath: atOld, content: lockWithDependency('harness', 'app', '0.2.0'), changed: true }, + { lockPath: atNew, content: lockWithDependency('harness', 'app', '0.2.0'), changed: false }, + ]); }); -}); -describe('findCommittedCargoLockfiles', () => { - it('should list tracked locks across the whole repository as absolute paths', async () => { - const lsFiles = vi.fn(async () => ['../Cargo.lock', 'app/Cargo.lock']); + /** A lock that uses `app` 0.1.0 by path, and two same-named crates bumped from 0.1.0. */ + function sameNameBumps(otherVersion: string): { root: string; harness: string; bumps: CargoLockBump[] } { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-names-'))); + tmpDirs.push(root); + const harness = write(path.join(root, 'harness', 'Cargo.lock'), lockWithDependency('harness', 'app', '0.1.0')); + const bumps = [ + { cargoTomlPath: path.join(root, 'one', 'Cargo.toml'), name: 'app', version: '0.2.0', fromVersion: '0.1.0' }, + { cargoTomlPath: path.join(root, 'two', 'Cargo.toml'), name: 'app', version: otherVersion, fromVersion: '0.1.0' }, + ]; + return { root, harness, bumps }; + } + + it('should refuse a path dependency that same-named crates bump to different versions', async () => { + const { root, harness, bumps } = sameNameBumps('0.1.1'); + + await expect(planCargoLockUpdates(fakeGit([harness]), root, bumps)).rejects.toThrow( + `can't set app in ${harness}: it could be the crate at any of ${bumps[0].cargoTomlPath}, ${bumps[1].cargoTomlPath}`, + ); + }); + + it('should sync a path dependency that same-named crates bump to the same version', async () => { + const { root, harness, bumps } = sameNameBumps('0.2.0'); - const locks = await findCommittedCargoLockfiles({ isRepository: async () => true, lsFiles }, '/repo/crates'); + const [update] = await planCargoLockUpdates(fakeGit([harness]), root, bumps); - expect(locks).toEqual(['/repo/Cargo.lock', '/repo/crates/app/Cargo.lock']); - expect(lsFiles).toHaveBeenCalledWith({ paths: [':(top,glob)**/Cargo.lock'], cwd: '/repo/crates' }); + expect(update.content).toBe(lockWithDependency('harness', 'app', '0.2.0')); }); - it('should return no locks outside a git repository', async () => { - const lsFiles = vi.fn(async () => []); + it('should name the lock when an edit cannot be made unambiguously', async () => { + const { root, manifest, appLock } = repoWithLocks(); + fs.appendFileSync(appLock, lock('', '[[package]]', 'name = "core"', 'version = "1.5.0"', `source = "${REGISTRY}"`)); - expect(await findCommittedCargoLockfiles({ isRepository: async () => false, lsFiles }, '/tmp/x')).toEqual([]); - expect(lsFiles).not.toHaveBeenCalled(); + await expect( + planCargoLockUpdates(fakeGit([appLock]), root, [ + { cargoTomlPath: manifest, name: 'core', version: '1.5.0', fromVersion: '1.4.0' }, + ]), + ).rejects.toThrow(`can't set core to 1.5.0 in ${appLock}: it also has core 1.5.0 from another source`); }); }); diff --git a/packages/git/src/fake.ts b/packages/git/src/fake.ts index 2bb6c75c..8996bf41 100644 --- a/packages/git/src/fake.ts +++ b/packages/git/src/fake.ts @@ -74,8 +74,6 @@ export interface FakeGitSeed { commitCounts?: Record; /** Working-tree status text returned by `status`. Defaults to empty (clean). */ status?: string; - /** Paths returned by `lsFiles` (the seam doesn't match pathspecs, so callers seed final paths). */ - trackedFiles?: string[]; } function ancestryHas(ancestors: FakeGitSeed['ancestors'], ancestor: string, ref: string): boolean { @@ -109,7 +107,6 @@ export class FakeGit implements Git { private readonly refLines: string[]; private readonly commitCounts: Record; private statusText: string; - private readonly trackedFiles: string[]; // Recorded mutations, for assertions. readonly added: string[][] = []; @@ -139,7 +136,6 @@ export class FakeGit implements Git { this.refLines = [...(seed.refLines ?? [])]; this.commitCounts = seed.commitCounts ?? {}; this.statusText = seed.status ?? ''; - this.trackedFiles = [...(seed.trackedFiles ?? [])]; } // — Queries — @@ -195,8 +191,14 @@ export class FakeGit implements Git { return [...(this.diffNames[commitSha] ?? [])]; } + /** The fake tracks no files. */ async lsFiles(_opts: GitLsFilesOptions = {}): Promise { - return [...this.trackedFiles]; + return []; + } + + /** The fake ignores no files. */ + async isIgnored(_file: string, _cwd?: string): Promise { + return false; } async forEachRef(_opts: GitForEachRefOptions): Promise { diff --git a/packages/git/src/git.ts b/packages/git/src/git.ts index 39981d46..03d287a4 100644 --- a/packages/git/src/git.ts +++ b/packages/git/src/git.ts @@ -251,6 +251,10 @@ export class GitCli implements Git { return stdout.split('\0').filter((file) => file.length > 0); } + async isIgnored(file: string, cwd?: string): Promise { + return this.execSoft(['check-ignore', '-q', '--', file], { cwd: this.cwd(cwd), ignoreOutput: true }); + } + async forEachRef(opts: GitForEachRefOptions): Promise { // `format`/`sort` are flag values by design; `pattern` is a positional ref, so guard it like // every other user-controlled positional (a leading-`-` pattern would be read as an option). diff --git a/packages/git/src/types.ts b/packages/git/src/types.ts index 864332c5..208951e2 100644 --- a/packages/git/src/types.ts +++ b/packages/git/src/types.ts @@ -140,6 +140,8 @@ export interface Git { diffTreeNames(commitSha: string, cwd?: string): Promise; /** Tracked file paths matching `paths` (`ls-files -z`), relative to `cwd` as git prints them. */ lsFiles(opts?: GitLsFilesOptions): Promise; + /** Whether git ignores `file` (`check-ignore -q`); false for a tracked file, and on failure (e.g. outside a repository). */ + isIgnored(file: string, cwd?: string): Promise; /** One formatted line per matching ref (`for-each-ref`), empties dropped. */ forEachRef(opts: GitForEachRefOptions): Promise; /** Whether `branch` exists on `remote` (`ls-remote --exit-code --heads`). */ diff --git a/packages/git/test/unit/fake.spec.ts b/packages/git/test/unit/fake.spec.ts index 22a4f343..027dd04b 100644 --- a/packages/git/test/unit/fake.spec.ts +++ b/packages/git/test/unit/fake.spec.ts @@ -77,13 +77,6 @@ describe('FakeGit queries', () => { expect(await git.diffTreeNames('sha2')).toEqual([]); }); - it('should return seeded tracked files', async () => { - expect(await createFakeGit({ trackedFiles: ['Cargo.lock'] }).lsFiles({ paths: ['*.lock'] })).toEqual([ - 'Cargo.lock', - ]); - expect(await createFakeGit().lsFiles()).toEqual([]); - }); - it('should return seeded for-each-ref lines', async () => { const git = createFakeGit({ refLines: ['v1 sha1', 'v2 sha2'] }); expect(await git.forEachRef({ format: '%(refname)' })).toEqual(['v1 sha1', 'v2 sha2']); diff --git a/packages/git/test/unit/git.spec.ts b/packages/git/test/unit/git.spec.ts index 4d66bce2..c44da4dd 100644 --- a/packages/git/test/unit/git.spec.ts +++ b/packages/git/test/unit/git.spec.ts @@ -175,6 +175,15 @@ describe('GitCli queries', () => { expect(calls[0].options.cwd).toBe('/r/crates'); }); + it('should answer isIgnored via check-ignore -q exit code, passing the file after --', async () => { + const ignored = makeRunner(); + expect(await new GitCli(ignored.run).isIgnored('/r/Cargo.lock', '/r')).toBe(true); + expect(ignored.calls[0].args).toEqual(['check-ignore', '-q', '--', '/r/Cargo.lock']); + expect(ignored.calls[0].options).toEqual({ cwd: '/r', ignoreOutput: true }); + const notIgnored = makeRunner({ reject: exitError(1) }); + expect(await new GitCli(notIgnored.run).isIgnored('Cargo.lock')).toBe(false); + }); + it('should run for-each-ref with format, and append sort/pattern when given', async () => { const { run, calls } = makeRunner({ stdout: 'v1 sha1\nv2 sha2\n' }); expect( diff --git a/packages/publish/src/stages/prepare.ts b/packages/publish/src/stages/prepare.ts index 31aaf360..6fd7a888 100644 --- a/packages/publish/src/stages/prepare.ts +++ b/packages/publish/src/stages/prepare.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { debug, info, isPathWithinRoot } from '@releasekit/core'; +import { type CargoLockBump, debug, info, isPathWithinRoot } from '@releasekit/core'; import { createPublishError, PublishErrorCode } from '../errors/index.js'; import type { PipelineContext } from '../types.js'; import { syncCargoLockfiles, updateCargoVersion } from '../utils/cargo.js'; @@ -62,7 +62,7 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { // `git add -A` — commits every lock that records a bumped crate, fixture apps' locks included, and // is correct even run in isolation. The standing-PR flow stages the locks via its own `git add -A`, // with git-commit skipped, so this re-sync is a harmless no-op there. - const bumps: Array<{ cargoTomlPath: string; version: string }> = []; + const bumps: Array> = []; for (const update of input.updates) { const pkgDir = path.dirname(path.resolve(cwd, update.filePath)); const cargoPath = path.join(pkgDir, 'Cargo.toml'); @@ -76,9 +76,9 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { continue; } - updateCargoVersion(cargoPath, update.newVersion); + const fromVersion = updateCargoVersion(cargoPath, update.newVersion); debug(`Updated ${cargoPath} to version ${update.newVersion}`); - bumps.push({ cargoTomlPath: cargoPath, version: update.newVersion }); + bumps.push({ cargoTomlPath: cargoPath, version: update.newVersion, fromVersion }); } const lockfiles = await syncCargoLockfiles(cwd, bumps); diff --git a/packages/publish/src/utils/cargo.ts b/packages/publish/src/utils/cargo.ts index 0360087e..08b7912a 100644 --- a/packages/publish/src/utils/cargo.ts +++ b/packages/publish/src/utils/cargo.ts @@ -1,8 +1,8 @@ import * as fs from 'node:fs'; import type { CargoManifest } from '@releasekit/config'; import { parseCargoToml } from '@releasekit/config'; -import { findCommittedCargoLockfiles, planCargoLockUpdates, warn } from '@releasekit/core'; -import { createGitCli, type Git } from '@releasekit/git'; +import { type CargoLockBump, planCargoLockUpdates, warn } from '@releasekit/core'; +import { createGitCli } from '@releasekit/git'; import * as TOML from 'smol-toml'; import { createPublishError, PublishErrorCode } from '../errors/index.js'; @@ -20,20 +20,16 @@ export { parseCargoToml }; * and a lock fix-up must not strand a release that is already on the base branch; an unsyncable lock * fails the version step instead, before anything is committed. */ -export async function syncCargoLockfiles( - cwd: string, - bumps: Array<{ cargoTomlPath: string; version: string }>, - git: Git = createGitCli(), -): Promise { +export async function syncCargoLockfiles(cwd: string, bumps: Array>): Promise { if (bumps.length === 0) { return []; } try { - const lockBumps = bumps.flatMap(({ cargoTomlPath, version }) => { - const name = parseCargoToml(cargoTomlPath).package?.name; - return name ? [{ cargoTomlPath, name, version }] : []; + const lockBumps = bumps.flatMap((bump) => { + const name = parseCargoToml(bump.cargoTomlPath).package?.name; + return name ? [{ ...bump, name }] : []; }); - const updates = planCargoLockUpdates(lockBumps, await findCommittedCargoLockfiles(git, cwd)); + const updates = await planCargoLockUpdates(createGitCli(), cwd, lockBumps); for (const { lockPath, content, changed } of updates) { if (changed) { fs.writeFileSync(lockPath, content); @@ -50,13 +46,17 @@ export async function syncCargoLockfiles( export const CRATES_IO_USER_AGENT = 'releasekit/publish (https://github.com/goosewobbler/releasekit)'; export const CRATES_IO_API_TIMEOUT_MS = 30_000; -export function updateCargoVersion(cargoPath: string, newVersion: string): void { +/** Set a crate's version, returning the version it replaced (undefined when inherited from the workspace). */ +export function updateCargoVersion(cargoPath: string, newVersion: string): string | undefined { try { const cargo = parseCargoToml(cargoPath); - if (cargo.package) { - cargo.package.version = newVersion; - fs.writeFileSync(cargoPath, TOML.stringify(cargo as Record)); + if (!cargo.package) { + return undefined; } + const previous = cargo.package.version; + cargo.package.version = newVersion; + fs.writeFileSync(cargoPath, TOML.stringify(cargo as Record)); + return typeof previous === 'string' ? previous : undefined; } catch (error) { throw createPublishError( PublishErrorCode.CARGO_TOML_ERROR, diff --git a/packages/publish/test/unit/stages/prepare.spec.ts b/packages/publish/test/unit/stages/prepare.spec.ts index 0b96a0fa..c8788528 100644 --- a/packages/publish/test/unit/stages/prepare.spec.ts +++ b/packages/publish/test/unit/stages/prepare.spec.ts @@ -10,6 +10,10 @@ import type { PipelineContext } from '../../../src/types.js'; /** A committed lock listing the path crate `foo` at `version`. */ const fooLock = (version: string): string => `version = 4\n\n[[package]]\nname = "foo"\nversion = "${version}"\n`; +/** A fixture app's committed lock: the app uses `foo` (at `version`) by path. */ +const appLockUsingFoo = (version: string): string => + `${fooLock(version)}\n[[package]]\nname = "app"\nversion = "0.1.0"\ndependencies = [\n "foo",\n]\n`; + function createContext(overrides?: Partial): PipelineContext { return { input: { @@ -179,7 +183,7 @@ describe('prepare stage', () => { fs.writeFileSync(ownLock, fooLock('1.0.0')); const appLock = path.join(dir, 'fixtures', 'app', 'Cargo.lock'); fs.mkdirSync(path.dirname(appLock), { recursive: true }); - fs.writeFileSync(appLock, `${fooLock('1.0.0')}\n[[package]]\nname = "app"\nversion = "0.1.0"\n`); + fs.writeFileSync(appLock, appLockUsingFoo('1.0.0')); execFileSync('git', ['init', '-q'], { cwd: dir }); execFileSync('git', ['add', '-A'], { cwd: dir }); @@ -199,9 +203,7 @@ describe('prepare stage', () => { await runPrepareStage(ctx); expect(fs.readFileSync(ownLock, 'utf-8')).toBe(fooLock('1.1.0')); - expect(fs.readFileSync(appLock, 'utf-8')).toBe( - `${fooLock('1.1.0')}\n[[package]]\nname = "app"\nversion = "0.1.0"\n`, - ); + expect(fs.readFileSync(appLock, 'utf-8')).toBe(appLockUsingFoo('1.1.0')); expect(ctx.additionalFiles).toEqual(expect.arrayContaining([ownLock, appLock])); }); @@ -234,13 +236,18 @@ describe('prepare stage', () => { expect(ctx.additionalFiles ?? []).not.toContain(lockPath); }); - it('should still stage a Cargo.lock the version step already synced', async () => { + it('should still stage the Cargo.lock files the version step already synced', async () => { const dir = createTmpDir(); const crateDir = path.join(dir, 'crates', 'foo'); fs.mkdirSync(crateDir, { recursive: true }); fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.1.0"\n'); const lockPath = path.join(crateDir, 'Cargo.lock'); fs.writeFileSync(lockPath, fooLock('1.1.0')); + const appLock = path.join(dir, 'fixtures', 'app', 'Cargo.lock'); + fs.mkdirSync(path.dirname(appLock), { recursive: true }); + fs.writeFileSync(appLock, appLockUsingFoo('1.1.0')); + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); const config = getDefaultConfig(); config.cargo.enabled = true; @@ -257,7 +264,38 @@ describe('prepare stage', () => { await runPrepareStage(ctx); - expect(ctx.additionalFiles).toContain(lockPath); + expect(ctx.additionalFiles).toEqual(expect.arrayContaining([lockPath, appLock])); + }); + + it('should neither sync nor stage a crate’s own Cargo.lock that git ignores', async () => { + // `git add` refuses an ignored path, which would fail the direct-commit release. + const dir = createTmpDir(); + const crateDir = path.join(dir, 'crates', 'foo'); + fs.mkdirSync(crateDir, { recursive: true }); + fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); + const lockPath = path.join(crateDir, 'Cargo.lock'); + fs.writeFileSync(lockPath, fooLock('1.0.0')); + fs.writeFileSync(path.join(dir, '.gitignore'), 'Cargo.lock\n'); + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + + const config = getDefaultConfig(); + config.cargo.enabled = true; + const ctx = createContext({ + cwd: dir, + config, + input: { + dryRun: false, + updates: [{ packageName: 'foo', newVersion: '1.1.0', filePath: 'crates/foo/Cargo.toml' }], + changelogs: [], + tags: [], + }, + }); + + await runPrepareStage(ctx); + + expect(fs.readFileSync(lockPath, 'utf-8')).toBe(fooLock('1.0.0')); + expect(ctx.additionalFiles ?? []).not.toContain(lockPath); }); it('should not stage a Cargo.lock in dry-run mode', async () => { diff --git a/packages/version/src/cargo/cargoHandler.ts b/packages/version/src/cargo/cargoHandler.ts index 39d1668a..4e9c42da 100644 --- a/packages/version/src/cargo/cargoHandler.ts +++ b/packages/version/src/cargo/cargoHandler.ts @@ -73,6 +73,8 @@ export function updateCargoVersion(cargoPath: string, version: string, dryRun = throw new Error(`No package name found in ${cargoPath}`); } + // Not a string when the crate inherits `version.workspace = true`. + const fromVersion = typeof cargo.package?.version === 'string' ? cargo.package.version : undefined; // Update the version in the manifest if (!cargo.package) { cargo.package = { name: packageName, version }; @@ -91,7 +93,7 @@ export function updateCargoVersion(cargoPath: string, version: string, dryRun = // Track update for JSON output addPackageUpdate(packageName, version, cargoPath); - recordCargoBump({ cargoTomlPath: cargoPath, name: packageName, version }); + recordCargoBump({ cargoTomlPath: cargoPath, name: packageName, version, fromVersion }); log(`${dryRun ? '[DRY RUN] Would update' : 'Updated'} Cargo.toml at ${cargoPath} to version ${version}`, 'success'); } catch (error) { diff --git a/packages/version/src/cargo/cargoLock.ts b/packages/version/src/cargo/cargoLock.ts index 8bdc7ff6..394068b1 100644 --- a/packages/version/src/cargo/cargoLock.ts +++ b/packages/version/src/cargo/cargoLock.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; -import { type CargoLockBump, findCommittedCargoLockfiles, planCargoLockUpdates } from '@releasekit/core'; -import { createGitCli, type Git } from '@releasekit/git'; +import { type CargoLockBump, planCargoLockUpdates } from '@releasekit/core'; +import { createGitCli } from '@releasekit/git'; import { CargoLockSyncError } from '../errors/cargoLockSyncError.js'; import { recordPendingWrite } from '../utils/jsonOutput.js'; import { log } from '../utils/logging.js'; @@ -21,24 +21,23 @@ export function clearCargoBumps(): void { * Sync every Cargo.lock that records a crate bumped since the last sync, so no committed lock is left * at the old version (which breaks `cargo build --locked` and leaks a lock diff into later builds). The * candidates are every Cargo.lock tracked by git — apps that use a crate through a path dependency - * have their own — plus each crate's own lock; see core's `planCargoLockUpdates`. We never create a lock. + * have their own — plus each crate's own lock unless git ignores it; see core's `planCargoLockUpdates`. + * We never create a lock. * * Under dry-run the edits are still worked out, so an unsyncable lock fails the dry run too, and are * recorded as pending writes (flushed with the manifests by `release`) instead of written. * - * Returns every lock that lists a bumped crate, changed or not, so a caller can stage them. Throws a - * {@link CargoLockSyncError} when a lock can't be read, edited or written; every edit is worked out - * before the first write, so a lock that can't be edited leaves all of them untouched. + * Throws a {@link CargoLockSyncError} when a lock can't be read, edited or written; every edit is worked + * out before the first write, so a lock that can't be edited leaves all of them untouched. */ -export async function syncCargoLockfiles(cwd: string, dryRun: boolean, git: Git = createGitCli()): Promise { +export async function syncCargoLockfiles(cwd: string, dryRun: boolean): Promise { const bumps = recordedBumps.splice(0); if (bumps.length === 0) { - return []; + return; } try { - const updates = planCargoLockUpdates(bumps, await findCommittedCargoLockfiles(git, cwd)); - for (const { lockPath, content, changed } of updates) { + for (const { lockPath, content, changed } of await planCargoLockUpdates(createGitCli(), cwd, bumps)) { if (!changed) { continue; } @@ -50,7 +49,6 @@ export async function syncCargoLockfiles(cwd: string, dryRun: boolean, git: Git log(`Synced Cargo.lock at ${lockPath}`, 'success'); } } - return updates.map(({ lockPath }) => lockPath); } catch (error) { throw new CargoLockSyncError(error instanceof Error ? error.message : String(error)); } diff --git a/packages/version/src/core/versionEngine.ts b/packages/version/src/core/versionEngine.ts index 0ab20185..e7ce6dd7 100644 --- a/packages/version/src/core/versionEngine.ts +++ b/packages/version/src/core/versionEngine.ts @@ -639,8 +639,6 @@ export class VersionEngine { clearCargoBumps(); // Execute the strategy function await this.currentStrategy(packages, targets); - // Every strategy records its Cargo.toml bumps (updateCargoVersion); syncing the locks that - // record those crates here, in one pass, covers all strategies and fails the run on an unsyncable lock. await syncCargoLockfiles(packages.root ?? cwd(), this.config.dryRun ?? false); // Once the strategy has produced the updates, tag each one's role: explicit (group-expanded) // targets keep the override; derived prerequisites carry their own bump and record the diff --git a/packages/version/test/unit/cargo/cargoHandler.spec.ts b/packages/version/test/unit/cargo/cargoHandler.spec.ts index b9b1932d..c759d37d 100644 --- a/packages/version/test/unit/cargo/cargoHandler.spec.ts +++ b/packages/version/test/unit/cargo/cargoHandler.spec.ts @@ -156,6 +156,7 @@ describe('Cargo Handler', () => { cargoTomlPath: mockCargoPath, name: 'test-package', version: '2.0.0', + fromVersion: '1.0.0', }); }); @@ -171,6 +172,7 @@ describe('Cargo Handler', () => { cargoTomlPath: mockCargoPath, name: 'test-package', version: '2.0.0', + fromVersion: '1.0.0', }); }); diff --git a/packages/version/test/unit/cargo/cargoLock.spec.ts b/packages/version/test/unit/cargo/cargoLock.spec.ts index 1ae8b9e9..7c45c8ec 100644 --- a/packages/version/test/unit/cargo/cargoLock.spec.ts +++ b/packages/version/test/unit/cargo/cargoLock.spec.ts @@ -246,6 +246,33 @@ describe('Cargo.lock sync on Cargo.toml bumps', () => { } }); + it.each([ + ['both apps', { one: '0.2.0', two: '0.1.1' }], + ['one app', { one: '0.2.0' }], + ])( + 'should sync each Tauri app’s lock to its own crate when the crates share a name (%s bumped)', + async (_label, next: Record) => { + // Two JS packages whose src-tauri crates have the `tauri init` name and version, `app` 0.1.0. + const tauriLock = (version: string): string => + `version = 4\n\n[[package]]\nname = "app"\nversion = "${version}"\n`; + const apps = ['one', 'two'].map((name) => { + const dir = path.join(root, 'apps', name); + writeCrate(path.join(dir, 'src-tauri'), 'app', '0.1.0'); + fs.writeFileSync(path.join(dir, 'src-tauri', 'Cargo.lock'), tauriLock('0.1.0')); + fs.writeFileSync(path.join(dir, 'package.json'), `{ "name": "${name}", "version": "0.1.0" }\n`); + return { dir, relativeDir: `apps/${name}`, packageJson: { name, version: '0.1.0' } } as unknown as Package; + }); + git(root, 'add', '-A'); + bump(next); + + await runVersion('async', { cargo: { enabled: true, paths: ['src-tauri'] } }, workspace(root, apps)); + + for (const name of ['one', 'two']) { + expect(read(path.join(root, 'apps', name, 'src-tauri', 'Cargo.lock'))).toBe(tauriLock(next[name] ?? '0.1.0')); + } + }, + ); + it('should sync the locks without a Rust toolchain or a crates.io index', async () => { // A fresh CI runner: git on PATH but no cargo, and an empty CARGO_HOME. Nothing may shell out to cargo. const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'rk-no-cargo-')); @@ -317,6 +344,18 @@ describe('Cargo.lock sync on Cargo.toml bumps', () => { expect(read(fixtureLock)).toBe(appLock({ alpha: '1.0.0', beta: '1.0.0' })); }); + it('should not sync a crate’s own lock that git ignores', async () => { + // A library that ignores its lock, left on disk by an earlier `cargo test`: it can't be staged. + git(root, 'rm', '-q', '--cached', 'crates/Cargo.lock'); + fs.writeFileSync(path.join(root, '.gitignore'), 'Cargo.lock\n'); + bump({ alpha: '1.1.0' }); + + await runVersion('async', {}, workspace(root, both())); + + expect(read(ownLock)).toBe(workspaceLock({ alpha: '1.0.0', beta: '1.0.0' })); + expect(read(fixtureLock)).toBe(appLock({ alpha: '1.1.0', beta: '1.0.0' })); + }); + it('should not create a lock when none is committed', async () => { fs.rmSync(ownLock); fs.rmSync(fixtureLock); diff --git a/test/integration/cargo-lock-sync.spec.ts b/test/integration/cargo-lock-sync.spec.ts index 6f8a20e0..204222c4 100644 --- a/test/integration/cargo-lock-sync.spec.ts +++ b/test/integration/cargo-lock-sync.spec.ts @@ -170,6 +170,42 @@ describe.skipIf(!cargoAvailable())('Cargo.lock sync (real cargo)', () => { }, ); + it('should leave a same-named crate’s lock alone when another Tauri app is bumped', () => { + // Two Tauri apps whose src-tauri crates both have the `tauri init` name and version, `app` 0.1.0. + const repo = join(dir, 'repo'); + const manifest = (app: string): string => join(repo, 'apps', app, 'src-tauri', 'Cargo.toml'); + const lockOf = (app: string): string => join(repo, 'apps', app, 'src-tauri', 'Cargo.lock'); + for (const app of ['one', 'two']) { + writeCrate(join(repo, 'apps', app, 'src-tauri'), 'app', '0.1.0'); + writeFileSync(join(repo, 'apps', app, 'package.json'), `{ "name": "${app}", "version": "0.1.0" }\n`); + cargo(repo, emptyCargoHome, 'generate-lockfile', '--offline', '--manifest-path', manifest(app)); + } + writeFileSync(join(repo, 'pnpm-workspace.yaml'), 'packages:\n - "apps/*"\n'); + commitWithChange(repo, ['one', 'two'], join(repo, 'apps', 'one', 'src-tauri')); + const untouched = readFileSync(lockOf('two'), 'utf-8'); + + releasekitVersion(repo, { cargo: { enabled: true, paths: ['src-tauri'] } }); + + expect(crateVersion(manifest('one'))).toBe('0.2.0'); + expect(pathEntryVersions(readFileSync(lockOf('one'), 'utf-8'), 'app')).toEqual(['0.2.0']); + expect(readFileSync(lockOf('two'), 'utf-8')).toBe(untouched); + for (const app of ['one', 'two']) { + const lock = readFileSync(lockOf(app), 'utf-8'); + cargo( + repo, + emptyCargoHome, + 'metadata', + '--locked', + '--offline', + '--format-version', + '1', + '--manifest-path', + manifest(app), + ); + expect(readFileSync(lockOf(app), 'utf-8')).toBe(lock); + } + }); + it('should write the same lock cargo writes, also when the bump passes other versions of the crate', () => { // Git crates named like a workspace member put several versions of `crate-b` in the lock, so cargo // spells out versions in references to it ("crate-b 0.1.0"). Bumping crate-b from 0.1.0 to 0.2.0 From 9df34f79b6f304cad0738ec836387d37defc0662 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:31:03 +0000 Subject: [PATCH 06/18] docs: document CARGO_LOCK_SYNC_ERROR and how lock entries are matched docs/cli.md lists the code (category `cargo-lock-sync`, exit code 8). docs/rust.md states which lock entries follow a bump and covers every reason the sync refuses a lock, not only the other-source one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/cli.md | 1 + docs/rust.md | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 50278aa4..2c7f934b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -52,6 +52,7 @@ The orchestration commands — `release`, `gate`, and `standing-pr` — emit the | `GITHUB_ERROR` | `github` | 6 | | `GIT_ERROR` | `git` | 7 | | `VERSION_ERROR` | `version` | 8 | +| `CARGO_LOCK_SYNC_ERROR` | `cargo-lock-sync` | 8 | | `PUBLISH_ERROR` | `publish` | 9 | `schemaVersion` bumps only on a breaking change to the envelope shape and is stable across minor releases, so agents and CI can pin against it. diff --git a/docs/rust.md b/docs/rust.md index 49b0f486..b7f66594 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -9,7 +9,7 @@ See [configuration.md](./configuration.md) for the full config reference. ## What's supported - `Cargo.toml` version bumping driven by conventional commits -- `Cargo.lock` sync on every bump: each committed lock that lists a bumped crate as a path package (the crate's own lock, and those of apps that use it by path) is updated in the same release commit, without running cargo +- `Cargo.lock` sync on every bump: each committed lock that lists a bumped crate as a path package (the crate's own lock, and those of apps that use it by path) is updated in the same release commit, without running cargo. The crate's own lock is synced even when untracked, but never when git ignores it. In any other lock, an entry with the crate's name only counts as the crate when something in that lock depends on it at the crate's old or new version, so same-named crates in different workspaces (every `tauri init` app is `app`) leave each other's locks alone - Per-crate git tags (via `version.packageSpecificTags`) - Publishing to crates.io with `cargo publish` - Workspace dependency ordering via topological sort (Kahn's algorithm) so path-dependent crates are published in the correct sequence @@ -208,9 +208,14 @@ jobs: ## Edge cases and troubleshooting -**`Failed to sync Cargo.lock: can't set to in `** +**`Failed to sync Cargo.lock: …`** -releasekit edits each lock's text the way cargo would, and fails the run rather than leave a lock behind. The edit is refused when that lock also lists the crate from a registry or git source at the old or the new version, because cargo would then have to change how it tells the two apart. Sync that lock with cargo yourself (`cargo update` with the new version in `Cargo.toml`) and commit it, or drop the second source of the crate. +releasekit edits each lock's text the way cargo would, and fails the version step (`CARGO_LOCK_SYNC_ERROR`, exit code 8) rather than leave a lock behind. The message names the lock and the reason: + +- `it also has from another source`: the lock also lists the crate from a registry or git source at the old or the new version, so cargo would have to change how it tells the two apart. Sync that lock with cargo yourself (`cargo update` with the new version in `Cargo.toml`) and commit it, or drop the second source of the crate. +- `it has path entries named `: the lock holds several local crates of that name, so releasekit can't tell which one is the bumped crate. Sync that lock with cargo yourself. +- `it could be the crate at any of `: several bumped crates share the name and are moving to different versions, and the lock uses one of them by path at a version that fits more than one. Sync that lock with cargo yourself, or rename one of the crates. +- A file-system error such as `EACCES`: the lock couldn't be read or written. **`error: failed to verify package tarball`** From 79c35ebe1d3f7cefb24787990a4020570aeb516c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:34:36 +0000 Subject: [PATCH 07/18] docs: keep the exit-code table to families; shorten the lock-sync bullet Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/cli.md | 1 - docs/rust.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 2c7f934b..50278aa4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -52,7 +52,6 @@ The orchestration commands — `release`, `gate`, and `standing-pr` — emit the | `GITHUB_ERROR` | `github` | 6 | | `GIT_ERROR` | `git` | 7 | | `VERSION_ERROR` | `version` | 8 | -| `CARGO_LOCK_SYNC_ERROR` | `cargo-lock-sync` | 8 | | `PUBLISH_ERROR` | `publish` | 9 | `schemaVersion` bumps only on a breaking change to the envelope shape and is stable across minor releases, so agents and CI can pin against it. diff --git a/docs/rust.md b/docs/rust.md index b7f66594..ddba4598 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -9,7 +9,7 @@ See [configuration.md](./configuration.md) for the full config reference. ## What's supported - `Cargo.toml` version bumping driven by conventional commits -- `Cargo.lock` sync on every bump: each committed lock that lists a bumped crate as a path package (the crate's own lock, and those of apps that use it by path) is updated in the same release commit, without running cargo. The crate's own lock is synced even when untracked, but never when git ignores it. In any other lock, an entry with the crate's name only counts as the crate when something in that lock depends on it at the crate's old or new version, so same-named crates in different workspaces (every `tauri init` app is `app`) leave each other's locks alone +- `Cargo.lock` sync on every bump: each committed lock that lists a bumped crate as a path package (the crate's own lock, and those of apps that use it by path) is updated in the same release commit, without running cargo. Gitignored locks are left alone, and so are same-named crates in other workspaces (every `tauri init` app is `app`) - Per-crate git tags (via `version.packageSpecificTags`) - Publishing to crates.io with `cargo publish` - Workspace dependency ordering via topological sort (Kahn's algorithm) so path-dependent crates are published in the correct sequence From dfec95e036b2c56ce513b9d5185f1ddd8fdd8488 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:01:33 +0000 Subject: [PATCH 08/18] fix(version): treat a lock beside a crate's manifest as its own findCargoLockfile doesn't read an ancestor workspace's `exclude`, so a crate excluded from it (a Tauri app's src-tauri, say) got the ancestor's lock as its own, and its real lock was matched as another workspace's: its root entry, which nothing depends on, was skipped and left stale. A lock beside the manifest can only be the crate's own (or an unused stray), so it now counts as one. When the manifest records no version (`version.workspace = true`, or none), the old version now comes from the crate's own lock, so other locks are still matched on it; only without an own lock does any version match. Tests pin both, the any-version fallback, publish reporting the replaced version, and the versioned-reference match in core. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- packages/core/src/cargo.ts | 53 ++++++++++----- packages/core/test/unit/cargo.spec.ts | 67 +++++++++++++++++-- .../publish/test/unit/stages/prepare.spec.ts | 38 +++++++++++ .../version/test/unit/cargo/cargoLock.spec.ts | 20 ++++++ 4 files changed, 156 insertions(+), 22 deletions(-) diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index a25ba99c..0d7d1489 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -260,7 +260,7 @@ export interface CargoLockBump { cargoTomlPath: string; name: string; version: string; - /** The version the manifest had before the bump; absent when it inherits `version.workspace = true`. */ + /** The version the manifest had before the bump; absent when it records none (`version.workspace = true`). */ fromVersion?: string; } @@ -275,15 +275,16 @@ export interface CargoLockUpdate { * Work out the content of every Cargo.lock that records a bumped crate, by editing the lock text with * {@link setCargoLockVersion} — nothing runs cargo, so no Rust toolchain, registry index or network is * needed. The candidates are every lock git tracks (apps that use a crate through a path dependency - * have their own) plus each crate's own governing lock ({@link findCargoLockfile}), which is synced - * even when untracked but not when git ignores it, since it couldn't be staged. + * have their own) plus each crate's own locks — its governing lock ({@link findCargoLockfile}) and a + * lock beside its manifest — which are synced even when untracked but not when git ignores them, since + * they couldn't be staged. * - * Crates in different workspaces can share a name (every `tauri init` app is `app`), so a lock's path - * entry follows a bump only when it is that crate: always in the crate's own lock; in any other lock, - * only when something in that lock depends on the entry (one nothing depends on is that lock's own - * root or workspace member) and it is at the crate's old or new version. A lock gets every bump it - * records, so a lock shared by several bumped crates is rewritten once; locks that record none are - * left out. + * Crates in different workspaces can share a name (every `tauri init` app is `app`). A crate's own lock + * always takes its bump; in any other lock, an entry of that name follows the bump only when something + * in that lock depends on it (one nothing depends on is that lock's own root or a member) at the + * crate's old or new version. A different crate of the same name used by path at one of those + * versions can't be told apart. A lock gets every bump it records, so a lock shared by several bumped + * crates is rewritten once; locks that record none are left out. * * Nothing is written: the caller writes the changed content. Throws, naming the lock, when a lock can't * be read or edited unambiguously, so no lock is written unless every lock can be. @@ -299,12 +300,26 @@ export async function planCargoLockUpdates( lockPaths.add(path.resolve(cwd, lockPath)); } } - const ownLocks = new Map(); + const owners = new Map(); + const fromVersions = new Map(); for (const bump of bumps) { - const ownLock = findCargoLockfile(path.dirname(bump.cargoTomlPath)); - if (ownLock && !(await git.isIgnored(ownLock, cwd))) { - ownLocks.set(bump, ownLock); - lockPaths.add(ownLock); + fromVersions.set(bump, bump.fromVersion); + const dir = path.dirname(bump.cargoTomlPath); + // findCargoLockfile can't see that an ancestor workspace excludes the crate; a lock beside its + // manifest can only be the crate's own (or an unused stray). + for (const ownLock of new Set([findCargoLockfile(dir), path.join(dir, 'Cargo.lock')])) { + if (ownLock && fs.existsSync(ownLock) && !(await git.isIgnored(ownLock, cwd))) { + owners.set(ownLock, [...(owners.get(ownLock) ?? []), bump]); + lockPaths.add(ownLock); + // A manifest that inherits its version, or has none, doesn't say what the crate was at. + fromVersions.set( + bump, + fromVersions.get(bump) ?? + readCargoLockEntries(fs.readFileSync(ownLock, 'utf8').split('\n')).find( + (entry) => entry.name === bump.name && !entry.hasSource, + )?.version, + ); + } } } @@ -318,19 +333,21 @@ export async function planCargoLockUpdates( const lines = original.split('\n'); const pathEntries = readCargoLockEntries(lines).filter((entry) => !entry.hasSource); const references = new Set(lines.map((line) => line.trim().replace(/,$/, ''))); - const isPathDependency = (bump: CargoLockBump): boolean => - pathEntries.some( + const isPathDependency = (bump: CargoLockBump): boolean => { + const fromVersion = fromVersions.get(bump); + return pathEntries.some( (entry) => entry.name === bump.name && - (bump.fromVersion === undefined || [bump.fromVersion, bump.version].includes(entry.version)) && + (fromVersion === undefined || [fromVersion, bump.version].includes(entry.version)) && (references.has(`"${entry.name}"`) || references.has(`"${entry.name} ${entry.version}"`)), ); + }; let content = original; let listed = false; for (const name of new Set(bumps.map((bump) => bump.name))) { const sameName = bumps.filter((bump) => bump.name === name); - const own = sameName.filter((bump) => ownLocks.get(bump) === lockPath); + const own = sameName.filter((bump) => owners.get(lockPath)?.includes(bump)); const matching = own.length > 0 ? own : sameName.filter(isPathDependency); if (matching.length === 0) { continue; diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index 9bec8842..638e8f05 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -461,10 +461,35 @@ describe('planCargoLockUpdates', () => { const atOld = write(path.join(root, 'at-old', 'Cargo.lock'), lockWithDependency('harness', 'app', '0.1.0')); const atNew = write(path.join(root, 'at-new', 'Cargo.lock'), lockWithDependency('harness', 'app', '0.2.0')); const atOther = write(path.join(root, 'at-other', 'Cargo.lock'), lockWithDependency('harness', 'app', '0.3.0')); - - const updates = await planCargoLockUpdates(fakeGit([ownLock, otherApp, atOld, atNew, atOther]), root, [ - { cargoTomlPath: manifest, name: 'app', version: '0.2.0', fromVersion: '0.1.0' }, - ]); + // With a registry `app` beside it, cargo spells out the path entry's version in references. + const versioned = (version: string): string => + lock( + 'version = 4', + '', + '[[package]]', + 'name = "app"', + `version = "${version}"`, + '', + '[[package]]', + 'name = "app"', + 'version = "0.9.0"', + `source = "${REGISTRY}"`, + '', + '[[package]]', + 'name = "harness"', + 'version = "0.1.0"', + 'dependencies = [', + ` "app ${version}",`, + ' "app 0.9.0",', + ']', + ); + const atOldVersioned = write(path.join(root, 'at-old-versioned', 'Cargo.lock'), versioned('0.1.0')); + + const updates = await planCargoLockUpdates( + fakeGit([ownLock, otherApp, atOld, atNew, atOther, atOldVersioned]), + root, + [{ cargoTomlPath: manifest, name: 'app', version: '0.2.0', fromVersion: '0.1.0' }], + ); expect(updates).toEqual([ { @@ -474,9 +499,43 @@ describe('planCargoLockUpdates', () => { }, { lockPath: atOld, content: lockWithDependency('harness', 'app', '0.2.0'), changed: true }, { lockPath: atNew, content: lockWithDependency('harness', 'app', '0.2.0'), changed: false }, + { lockPath: atOldVersioned, content: versioned('0.2.0'), changed: true }, ]); }); + /** A crate whose manifest doesn't record its version, and locks that use an `app` 0.1.0 and 0.5.0 by path. */ + function versionlessBump(ownLockVersion?: string): { root: string; locks: string[]; bump: CargoLockBump } { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-from-'))); + tmpDirs.push(root); + const manifest = write(path.join(root, 'app', 'Cargo.toml'), '[package]\nname = "app"\nversion.workspace = true\n'); + if (ownLockVersion) { + write( + path.join(root, 'app', 'Cargo.lock'), + lock('version = 4', '', '[[package]]', 'name = "app"', `version = "${ownLockVersion}"`), + ); + } + const locks = ['0.1.0', '0.5.0'].map((version) => + write(path.join(root, `at-${version}`, 'Cargo.lock'), lockWithDependency('harness', 'app', version)), + ); + return { root, locks, bump: { cargoTomlPath: manifest, name: 'app', version: '0.2.0' } }; + } + + it('should take the old version from the crate’s own lock when its manifest doesn’t record one', async () => { + const { root, locks, bump } = versionlessBump('0.1.0'); + + const updates = await planCargoLockUpdates(fakeGit(locks), root, [bump]); + + expect(updates.map((update) => update.lockPath)).toEqual([locks[0], path.join(root, 'app', 'Cargo.lock')]); + }); + + it('should update a path dependency at any version when nothing records the crate’s old version', async () => { + const { root, locks, bump } = versionlessBump(); + + const updates = await planCargoLockUpdates(fakeGit(locks), root, [bump]); + + expect(updates.map((update) => update.lockPath)).toEqual(locks); + }); + /** A lock that uses `app` 0.1.0 by path, and two same-named crates bumped from 0.1.0. */ function sameNameBumps(otherVersion: string): { root: string; harness: string; bumps: CargoLockBump[] } { const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-names-'))); diff --git a/packages/publish/test/unit/stages/prepare.spec.ts b/packages/publish/test/unit/stages/prepare.spec.ts index c8788528..47136261 100644 --- a/packages/publish/test/unit/stages/prepare.spec.ts +++ b/packages/publish/test/unit/stages/prepare.spec.ts @@ -207,6 +207,44 @@ describe('prepare stage', () => { expect(ctx.additionalFiles).toEqual(expect.arrayContaining([ownLock, appLock])); }); + it('should leave a lock alone that uses a different crate of the same name at another version', async () => { + // No own lock records foo's old version, so only the manifest's says which `foo` the bump is. + const dir = createTmpDir(); + const crateDir = path.join(dir, 'crates', 'foo'); + fs.mkdirSync(crateDir, { recursive: true }); + fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); + const appLock = path.join(dir, 'fixtures', 'app', 'Cargo.lock'); + const otherLock = path.join(dir, 'fixtures', 'other', 'Cargo.lock'); + for (const [lockPath, version] of [ + [appLock, '1.0.0'], + [otherLock, '0.9.0'], + ]) { + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, appLockUsingFoo(version)); + } + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + + const config = getDefaultConfig(); + config.cargo.enabled = true; + const ctx = createContext({ + cwd: dir, + config, + input: { + dryRun: false, + updates: [{ packageName: 'foo', newVersion: '1.1.0', filePath: 'crates/foo/Cargo.toml' }], + changelogs: [], + tags: [], + }, + }); + + await runPrepareStage(ctx); + + expect(fs.readFileSync(appLock, 'utf-8')).toBe(appLockUsingFoo('1.1.0')); + expect(fs.readFileSync(otherLock, 'utf-8')).toBe(appLockUsingFoo('0.9.0')); + expect(ctx.additionalFiles).toEqual([appLock]); + }); + it('should not fail the publish when a lock cannot be synced', async () => { const dir = createTmpDir(); const crateDir = path.join(dir, 'crates', 'foo'); diff --git a/packages/version/test/unit/cargo/cargoLock.spec.ts b/packages/version/test/unit/cargo/cargoLock.spec.ts index 7c45c8ec..57821e92 100644 --- a/packages/version/test/unit/cargo/cargoLock.spec.ts +++ b/packages/version/test/unit/cargo/cargoLock.spec.ts @@ -273,6 +273,26 @@ describe('Cargo.lock sync on Cargo.toml bumps', () => { }, ); + it('should sync the lock of a crate that an ancestor Cargo workspace excludes', async () => { + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nexclude = ["apps/desk/src-tauri"]\n'); + const appDir = path.join(root, 'apps', 'desk'); + const appLock = path.join(appDir, 'src-tauri', 'Cargo.lock'); + writeCrate(path.join(appDir, 'src-tauri'), 'desk', '0.1.0'); + fs.writeFileSync(appLock, 'version = 4\n\n[[package]]\nname = "desk"\nversion = "0.1.0"\n'); + fs.writeFileSync(path.join(appDir, 'package.json'), '{ "name": "desk", "version": "0.1.0" }\n'); + git(root, 'add', '-A'); + bump({ desk: '0.2.0' }); + const app = { dir: appDir, relativeDir: 'apps/desk', packageJson: { name: 'desk', version: '0.1.0' } }; + + await runVersion( + 'async', + { cargo: { enabled: true, paths: ['src-tauri'] } }, + workspace(root, [app as unknown as Package]), + ); + + expect(read(appLock)).toBe('version = 4\n\n[[package]]\nname = "desk"\nversion = "0.2.0"\n'); + }); + it('should sync the locks without a Rust toolchain or a crates.io index', async () => { // A fresh CI runner: git on PATH but no cargo, and an empty CARGO_HOME. Nothing may shell out to cargo. const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'rk-no-cargo-')); From e877d06caf0688c0df03bb9fcc2fe906ad4bddfc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:01:34 +0000 Subject: [PATCH 09/18] docs(rust): narrow the same-name claim, note the residual case and exit codes Only other apps' own crates of the same name are told apart; a different same-named crate that a lock depends on at the bumped crate's old or new version is not, so the unsupported list says so and how to avoid it. The lock-sync error exits 8 from `release` and `standing-pr` but 1 from `releasekit version`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/rust.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/rust.md b/docs/rust.md index ddba4598..b5794e08 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -9,7 +9,7 @@ See [configuration.md](./configuration.md) for the full config reference. ## What's supported - `Cargo.toml` version bumping driven by conventional commits -- `Cargo.lock` sync on every bump: each committed lock that lists a bumped crate as a path package (the crate's own lock, and those of apps that use it by path) is updated in the same release commit, without running cargo. Gitignored locks are left alone, and so are same-named crates in other workspaces (every `tauri init` app is `app`) +- `Cargo.lock` sync on every bump: each committed lock that lists a bumped crate as a path package (the crate's own lock, and those of apps that use it by path) is updated in the same release commit, without running cargo. Gitignored locks are left alone, and so are other apps' own crates of the same name (every `tauri init` app is `app`) - Per-crate git tags (via `version.packageSpecificTags`) - Publishing to crates.io with `cargo publish` - Workspace dependency ordering via topological sort (Kahn's algorithm) so path-dependent crates are published in the correct sequence @@ -24,6 +24,7 @@ See [configuration.md](./configuration.md) for the full config reference. - OIDC / trusted publishing for crates.io — token-based auth only via `CARGO_REGISTRY_TOKEN` - Automatic workspace root discovery — crate directories must be listed in `version.cargo.paths` or inferred from `version.packages` - Comment and formatting preservation in `Cargo.toml` — the file is rewritten on version bump using `smol-toml`'s serialiser; whitespace, comments, and key order may change +- Telling apart path crates of the same name that something depends on — bumping one also rewrites another lock's entry for a different crate of that name when a package in that lock depends on it at the bumped crate's old or new version (say, two workspaces that each have a `utils` member). Give such crates distinct names --- @@ -210,7 +211,7 @@ jobs: **`Failed to sync Cargo.lock: …`** -releasekit edits each lock's text the way cargo would, and fails the version step (`CARGO_LOCK_SYNC_ERROR`, exit code 8) rather than leave a lock behind. The message names the lock and the reason: +releasekit edits each lock's text the way cargo would, and fails the version step (`CARGO_LOCK_SYNC_ERROR`: exit code 8 from `release` and `standing-pr`, 1 from `releasekit version`) rather than leave a lock behind. The message names the lock and the reason: - `it also has from another source`: the lock also lists the crate from a registry or git source at the old or the new version, so cargo would have to change how it tells the two apart. Sync that lock with cargo yourself (`cargo update` with the new version in `Cargo.toml`) and commit it, or drop the second source of the crate. - `it has path entries named `: the lock holds several local crates of that name, so releasekit can't tell which one is the bumped crate. Sync that lock with cargo yourself. From 620e9d783cf51cfedfcc8b8024d7dd764462f447 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:02:13 +0000 Subject: [PATCH 10/18] docs(rust): shorten the same-name limitation Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/rust.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rust.md b/docs/rust.md index b5794e08..b2aeff1b 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -24,7 +24,7 @@ See [configuration.md](./configuration.md) for the full config reference. - OIDC / trusted publishing for crates.io — token-based auth only via `CARGO_REGISTRY_TOKEN` - Automatic workspace root discovery — crate directories must be listed in `version.cargo.paths` or inferred from `version.packages` - Comment and formatting preservation in `Cargo.toml` — the file is rewritten on version bump using `smol-toml`'s serialiser; whitespace, comments, and key order may change -- Telling apart path crates of the same name that something depends on — bumping one also rewrites another lock's entry for a different crate of that name when a package in that lock depends on it at the bumped crate's old or new version (say, two workspaces that each have a `utils` member). Give such crates distinct names +- Same-named path crates at the same version in different workspaces (say, two `utils` members): bumping one can also rewrite the other in a lock that depends on it. Give such crates distinct names --- From 98727586aa2b67f8669ee6e7665e6a99183e150d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:25:32 +0000 Subject: [PATCH 11/18] fix(core): find a crate's Cargo.lock by cargo's workspace-root rule findCargoLockfile took the first ancestor `[workspace]` as the crate's root without reading its `exclude`, so a crate excluded from the repo's workspace treated that workspace's lock as its own, where every bump is applied: two excluded Tauri apps named `app` failed the run as ambiguous in a root lock that has no `app`, and bumping one excluded `app` set the root workspace's own member `app` to its version. It now follows cargo: a crate whose manifest has a `[workspace]` is its own root; otherwise the nearest ancestor workspace that doesn't exclude it is (an `exclude` path prefix not overridden by a `members` one, both literal); with none, the crate is its own root. Only the root's lock is returned, which also drops the nearest-lock fallback for standalone crates (cargo never reads a non-root ancestor's lock). Checked against cargo 1.97. `exclude` is read with smol-toml, now a core dependency. The round-2 "lock beside the manifest" own-lock candidate is dropped: the new rule returns it whenever it is the crate's lock, and a member's stray lock is unused by cargo. The planner's doc now notes that any version matches when neither the manifest nor the crate's own lock records the old one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- packages/core/package.json | 3 +- packages/core/src/cargo.ts | 120 +++++++++--------- packages/core/test/unit/cargo.spec.ts | 59 +++++++-- .../publish/test/unit/stages/prepare.spec.ts | 1 + .../version/test/unit/cargo/cargoLock.spec.ts | 61 +++++++-- pnpm-lock.yaml | 3 + 6 files changed, 169 insertions(+), 78 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 6d8ffba6..3767906e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,7 +27,8 @@ "dependencies": { "@manypkg/get-packages": "catalog:", "chalk": "catalog:", - "minimatch": "catalog:" + "minimatch": "catalog:", + "smol-toml": "catalog:" }, "devDependencies": { "@biomejs/biome": "catalog:", diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index 0d7d1489..d67d9cf6 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -1,51 +1,61 @@ import fs from 'node:fs'; import path from 'node:path'; +import * as TOML from 'smol-toml'; -/** A `Cargo.toml` declares a workspace when it has a `[workspace]` (or `[workspace.*]`) table. */ -function isWorkspaceManifest(cargoTomlPath: string): boolean { +interface CargoWorkspace { + members?: unknown; + exclude?: unknown; +} + +/** The `[workspace]` table of the manifest at `cargoTomlPath`, if it exists and declares one. */ +function readWorkspace(cargoTomlPath: string): CargoWorkspace | undefined { try { - return /^\s*\[workspace[\].]/m.test(fs.readFileSync(cargoTomlPath, 'utf8')); + return (TOML.parse(fs.readFileSync(cargoTomlPath, 'utf8')) as { workspace?: CargoWorkspace }).workspace; } catch { - return false; + return undefined; } } /** - * Find the `Cargo.lock` that governs a crate at `startDir` — the one cargo resolves the crate - * into — by walking up from `startDir`: - * - * - If an ancestor `Cargo.toml` declares a `[workspace]`, that's the workspace root and its - * `Cargo.lock` is authoritative. A workspace member's own (stray) lock is ignored by cargo, so we - * must not target it — only the root lock actually moves. - * - Otherwise (a standalone crate, no workspace) the nearest committed `Cargo.lock` governs. - * - * Returns `undefined` when no committed lock governs the crate. That is deliberate: libraries - * commonly don't commit a lock, and a version bump must never *create* one as a side effect — only - * sync an existing committed lock so it doesn't drift. + * Cargo's `is_excluded`: an `exclude` entry is a path prefix of the manifest and no `members` entry is. + * Cargo matches both literally, without expanding globs. */ -export function findCargoLockfile(startDir: string): string | undefined { - let dir = path.resolve(startDir); - let nearestLock: string | undefined; - while (true) { - const lockPath = path.join(dir, 'Cargo.lock'); - const hasLock = fs.existsSync(lockPath); - if (hasLock && !nearestLock) { - nearestLock = lockPath; - } - - const cargoToml = path.join(dir, 'Cargo.toml'); - if (fs.existsSync(cargoToml) && isWorkspaceManifest(cargoToml)) { - // Workspace root: its lock is the one cargo updates (or none, if no lock is committed). - return hasLock ? lockPath : undefined; - } +function isExcluded(workspace: CargoWorkspace, rootDir: string, manifestPath: string): boolean { + const covers = (entries: unknown): boolean => + Array.isArray(entries) && + entries.some((entry) => { + const prefix = typeof entry === 'string' ? path.resolve(rootDir, entry) : undefined; + return prefix !== undefined && (manifestPath === prefix || manifestPath.startsWith(prefix + path.sep)); + }); + return covers(workspace.exclude) && !covers(workspace.members); +} - const parent = path.dirname(dir); - if (parent === dir) { - break; // reached the filesystem root +/** + * Find the `Cargo.lock` that governs the crate at `crateDir` — the one cargo resolves the crate into, + * at its workspace root. Cargo's rule: a crate whose own manifest has a `[workspace]` is its own root; + * otherwise the nearest ancestor `[workspace]` that doesn't exclude it is, and with none the crate is + * its own root. Cargo ignores every other lock: a member's stray one, or one in an ancestor directory + * that isn't the root. + * + * Returns `undefined` when the root has no lock. That is deliberate: libraries commonly don't commit a + * lock, and a version bump must never *create* one as a side effect — only sync an existing committed + * lock so it doesn't drift. + */ +export function findCargoLockfile(crateDir: string): string | undefined { + const manifestPath = path.join(path.resolve(crateDir), 'Cargo.toml'); + let root = path.dirname(manifestPath); + if (!readWorkspace(manifestPath)) { + for (let dir = root; path.dirname(dir) !== dir; ) { + dir = path.dirname(dir); + const workspace = readWorkspace(path.join(dir, 'Cargo.toml')); + if (workspace && !isExcluded(workspace, dir, manifestPath)) { + root = dir; + break; + } } - dir = parent; } - return nearestLock; + const lockPath = path.join(root, 'Cargo.lock'); + return fs.existsSync(lockPath) ? lockPath : undefined; } interface CargoLockEntry { @@ -275,16 +285,16 @@ export interface CargoLockUpdate { * Work out the content of every Cargo.lock that records a bumped crate, by editing the lock text with * {@link setCargoLockVersion} — nothing runs cargo, so no Rust toolchain, registry index or network is * needed. The candidates are every lock git tracks (apps that use a crate through a path dependency - * have their own) plus each crate's own locks — its governing lock ({@link findCargoLockfile}) and a - * lock beside its manifest — which are synced even when untracked but not when git ignores them, since - * they couldn't be staged. + * have their own) plus each crate's own lock ({@link findCargoLockfile}), which is synced even when + * untracked but not when git ignores it, since it couldn't be staged. * * Crates in different workspaces can share a name (every `tauri init` app is `app`). A crate's own lock * always takes its bump; in any other lock, an entry of that name follows the bump only when something * in that lock depends on it (one nothing depends on is that lock's own root or a member) at the - * crate's old or new version. A different crate of the same name used by path at one of those - * versions can't be told apart. A lock gets every bump it records, so a lock shared by several bumped - * crates is rewritten once; locks that record none are left out. + * crate's old or new version (any version when neither its manifest nor its own lock records the old + * one). A different crate of the same name used by path at a matching version can't be told apart. + * A lock gets every bump it records, so a lock shared by several bumped crates is rewritten once; + * locks that record none are left out. * * Nothing is written: the caller writes the changed content. Throws, naming the lock, when a lock can't * be read or edited unambiguously, so no lock is written unless every lock can be. @@ -304,22 +314,18 @@ export async function planCargoLockUpdates( const fromVersions = new Map(); for (const bump of bumps) { fromVersions.set(bump, bump.fromVersion); - const dir = path.dirname(bump.cargoTomlPath); - // findCargoLockfile can't see that an ancestor workspace excludes the crate; a lock beside its - // manifest can only be the crate's own (or an unused stray). - for (const ownLock of new Set([findCargoLockfile(dir), path.join(dir, 'Cargo.lock')])) { - if (ownLock && fs.existsSync(ownLock) && !(await git.isIgnored(ownLock, cwd))) { - owners.set(ownLock, [...(owners.get(ownLock) ?? []), bump]); - lockPaths.add(ownLock); - // A manifest that inherits its version, or has none, doesn't say what the crate was at. - fromVersions.set( - bump, - fromVersions.get(bump) ?? - readCargoLockEntries(fs.readFileSync(ownLock, 'utf8').split('\n')).find( - (entry) => entry.name === bump.name && !entry.hasSource, - )?.version, - ); - } + const ownLock = findCargoLockfile(path.dirname(bump.cargoTomlPath)); + if (ownLock && !(await git.isIgnored(ownLock, cwd))) { + owners.set(ownLock, [...(owners.get(ownLock) ?? []), bump]); + lockPaths.add(ownLock); + // A manifest that inherits its version, or has none, doesn't say what the crate was at. + fromVersions.set( + bump, + fromVersions.get(bump) ?? + readCargoLockEntries(fs.readFileSync(ownLock, 'utf8').split('\n')).find( + (entry) => entry.name === bump.name && !entry.hasSource, + )?.version, + ); } } diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index 638e8f05..4ef6d5fc 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -27,8 +27,9 @@ describe('findCargoLockfile', () => { expect(findCargoLockfile(dir)).toBe(lock); }); - it('should walk up to the nearest ancestor lock for a workspace member', () => { + it('should walk up to the workspace root’s lock for a member', () => { const root = tmp(); + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["crates/*"]\n'); const lock = path.join(root, 'Cargo.lock'); fs.writeFileSync(lock, ''); const member = path.join(root, 'crates', 'foo'); @@ -36,16 +37,56 @@ describe('findCargoLockfile', () => { expect(findCargoLockfile(member)).toBe(lock); }); - it('should return the nearest lock for a standalone (non-workspace) crate when both a member and an ancestor have one', () => { - // No [workspace] manifest anywhere → the standalone fallback (nearest lock wins). With a - // workspace root present, the root lock would win instead — see the workspace-root test below. + it('should ignore a lock in an ancestor directory that is not a workspace root', () => { + // cargo only uses its workspace root's lock; with no `[workspace]` above, the crate is its own root. const root = tmp(); + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[package]\nname = "outer"\nversion = "1.0.0"\n'); fs.writeFileSync(path.join(root, 'Cargo.lock'), ''); - const member = path.join(root, 'crates', 'foo'); - fs.mkdirSync(member, { recursive: true }); - const memberLock = path.join(member, 'Cargo.lock'); - fs.writeFileSync(memberLock, ''); - expect(findCargoLockfile(member)).toBe(memberLock); + const crate = path.join(root, 'crates', 'foo'); + fs.mkdirSync(crate, { recursive: true }); + expect(findCargoLockfile(crate)).toBeUndefined(); + fs.writeFileSync(path.join(crate, 'Cargo.lock'), ''); + expect(findCargoLockfile(crate)).toBe(path.join(crate, 'Cargo.lock')); + }); + + it.each([ + ['an ancestor workspace excludes it', '[workspace]\nmembers = ["crates/*"]\nexclude = ["apps/x"]\n', 'own'], + ['an ancestor workspace excludes a directory above it', '[workspace]\nexclude = ["./apps/"]\n', 'own'], + ['a literal member entry overrides an exclude', '[workspace]\nmembers = ["apps/x"]\nexclude = ["apps"]\n', 'root'], + ['an exclude is a glob, which cargo doesn’t expand', '[workspace]\nexclude = ["apps/*"]\n', 'root'], + ])('should follow cargo’s workspace-root rule when %s', (_label, rootManifest, expected) => { + const root = tmp(); + fs.writeFileSync(path.join(root, 'Cargo.toml'), rootManifest); + fs.writeFileSync(path.join(root, 'Cargo.lock'), ''); + const crate = path.join(root, 'apps', 'x'); + fs.mkdirSync(crate, { recursive: true }); + fs.writeFileSync(path.join(crate, 'Cargo.toml'), '[package]\nname = "x"\nversion = "0.1.0"\n'); + fs.writeFileSync(path.join(crate, 'Cargo.lock'), ''); + + expect(findCargoLockfile(crate)).toBe(path.join(expected === 'own' ? crate : root, 'Cargo.lock')); + }); + + it('should skip a workspace that excludes the crate for an outer one that has it as a member', () => { + const root = tmp(); + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["inner/app"]\n'); + fs.writeFileSync(path.join(root, 'Cargo.lock'), ''); + fs.mkdirSync(path.join(root, 'inner', 'app'), { recursive: true }); + fs.writeFileSync(path.join(root, 'inner', 'Cargo.toml'), '[workspace]\nexclude = ["app"]\n'); + fs.writeFileSync(path.join(root, 'inner', 'Cargo.lock'), ''); + + expect(findCargoLockfile(path.join(root, 'inner', 'app'))).toBe(path.join(root, 'Cargo.lock')); + }); + + it('should treat a crate whose manifest declares a workspace as its own root', () => { + const root = tmp(); + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["crates/*"]\n'); + fs.writeFileSync(path.join(root, 'Cargo.lock'), ''); + const app = path.join(root, 'fixtures', 'app'); + fs.mkdirSync(app, { recursive: true }); + fs.writeFileSync(path.join(app, 'Cargo.toml'), '[package]\nname = "app"\nversion = "0.1.0"\n\n[workspace]\n'); + fs.writeFileSync(path.join(app, 'Cargo.lock'), ''); + + expect(findCargoLockfile(app)).toBe(path.join(app, 'Cargo.lock')); }); it('should return undefined when no lock exists above the start dir', () => { diff --git a/packages/publish/test/unit/stages/prepare.spec.ts b/packages/publish/test/unit/stages/prepare.spec.ts index 47136261..c455f91f 100644 --- a/packages/publish/test/unit/stages/prepare.spec.ts +++ b/packages/publish/test/unit/stages/prepare.spec.ts @@ -341,6 +341,7 @@ describe('prepare stage', () => { const crateDir = path.join(dir, 'crates', 'foo'); fs.mkdirSync(crateDir, { recursive: true }); fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); + fs.writeFileSync(path.join(dir, 'Cargo.toml'), '[workspace]\nmembers = ["crates/foo"]\n'); fs.writeFileSync(path.join(dir, 'Cargo.lock'), fooLock('1.0.0')); const config = getDefaultConfig(); diff --git a/packages/version/test/unit/cargo/cargoLock.spec.ts b/packages/version/test/unit/cargo/cargoLock.spec.ts index 57821e92..e0fcc4ca 100644 --- a/packages/version/test/unit/cargo/cargoLock.spec.ts +++ b/packages/version/test/unit/cargo/cargoLock.spec.ts @@ -246,33 +246,72 @@ describe('Cargo.lock sync on Cargo.toml bumps', () => { } }); + const tauriLock = (version: string): string => `version = 4\n\n[[package]]\nname = "app"\nversion = "${version}"\n`; + const tauriLockOf = (name: string): string => path.join(root, 'apps', name, 'src-tauri', 'Cargo.lock'); + + /** A JS package `apps/` whose src-tauri crate has the `tauri init` name, `app`. */ + function tauriApp(name: string, version: string, withLock = true): Package { + const dir = path.join(root, 'apps', name); + writeCrate(path.join(dir, 'src-tauri'), 'app', version); + if (withLock) { + fs.writeFileSync(tauriLockOf(name), tauriLock(version)); + } + fs.writeFileSync(path.join(dir, 'package.json'), `{ "name": "${name}", "version": "${version}" }\n`); + return { dir, relativeDir: `apps/${name}`, packageJson: { name, version } } as unknown as Package; + } + it.each([ ['both apps', { one: '0.2.0', two: '0.1.1' }], ['one app', { one: '0.2.0' }], ])( 'should sync each Tauri app’s lock to its own crate when the crates share a name (%s bumped)', async (_label, next: Record) => { - // Two JS packages whose src-tauri crates have the `tauri init` name and version, `app` 0.1.0. - const tauriLock = (version: string): string => - `version = 4\n\n[[package]]\nname = "app"\nversion = "${version}"\n`; - const apps = ['one', 'two'].map((name) => { - const dir = path.join(root, 'apps', name); - writeCrate(path.join(dir, 'src-tauri'), 'app', '0.1.0'); - fs.writeFileSync(path.join(dir, 'src-tauri', 'Cargo.lock'), tauriLock('0.1.0')); - fs.writeFileSync(path.join(dir, 'package.json'), `{ "name": "${name}", "version": "0.1.0" }\n`); - return { dir, relativeDir: `apps/${name}`, packageJson: { name, version: '0.1.0' } } as unknown as Package; - }); + const apps = [tauriApp('one', '0.1.0'), tauriApp('two', '0.1.0')]; git(root, 'add', '-A'); bump(next); await runVersion('async', { cargo: { enabled: true, paths: ['src-tauri'] } }, workspace(root, apps)); for (const name of ['one', 'two']) { - expect(read(path.join(root, 'apps', name, 'src-tauri', 'Cargo.lock'))).toBe(tauriLock(next[name] ?? '0.1.0')); + expect(read(tauriLockOf(name))).toBe(tauriLock(next[name] ?? '0.1.0')); } }, ); + it('should sync excluded apps’ own locks, not the ancestor workspace’s, when the crates share a name', async () => { + fs.writeFileSync( + path.join(root, 'Cargo.toml'), + '[workspace]\nmembers = ["shared"]\nexclude = ["apps/one/src-tauri", "apps/two/src-tauri"]\n', + ); + const rootLock = 'version = 4\n\n[[package]]\nname = "shared"\nversion = "1.0.0"\n'; + fs.writeFileSync(path.join(root, 'Cargo.lock'), rootLock); + const apps = [tauriApp('one', '0.1.0'), tauriApp('two', '0.3.0')]; + git(root, 'add', '-A'); + bump({ one: '0.2.0', two: '0.4.0' }); + + await runVersion('async', { cargo: { enabled: true, paths: ['src-tauri'] } }, workspace(root, apps)); + + expect(read(tauriLockOf('one'))).toBe(tauriLock('0.2.0')); + expect(read(tauriLockOf('two'))).toBe(tauriLock('0.4.0')); + expect(read(path.join(root, 'Cargo.lock'))).toBe(rootLock); + }); + + it('should leave an ancestor workspace’s member alone when an excluded crate of the same name is bumped', async () => { + fs.writeFileSync( + path.join(root, 'Cargo.toml'), + '[workspace]\nmembers = ["apps/one/src-tauri"]\nexclude = ["apps/two/src-tauri"]\n', + ); + fs.writeFileSync(path.join(root, 'Cargo.lock'), tauriLock('0.1.0')); + const apps = [tauriApp('one', '0.1.0', false), tauriApp('two', '0.3.0')]; + git(root, 'add', '-A'); + bump({ two: '0.4.0' }); + + await runVersion('async', { cargo: { enabled: true, paths: ['src-tauri'] } }, workspace(root, apps)); + + expect(read(tauriLockOf('two'))).toBe(tauriLock('0.4.0')); + expect(read(path.join(root, 'Cargo.lock'))).toBe(tauriLock('0.1.0')); + }); + it('should sync the lock of a crate that an ancestor Cargo workspace excludes', async () => { fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nexclude = ["apps/desk/src-tauri"]\n'); const appDir = path.join(root, 'apps', 'desk'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fc2f444..78b4f4fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,9 @@ importers: minimatch: specifier: 'catalog:' version: 10.2.5 + smol-toml: + specifier: 'catalog:' + version: 1.9.1 devDependencies: '@biomejs/biome': specifier: 'catalog:' From 6dfda65b1555c2fcc74c1c2f0de6bb129888412f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:25:33 +0000 Subject: [PATCH 12/18] docs(rust): note the any-version case of the same-name limitation Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/rust.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rust.md b/docs/rust.md index b2aeff1b..4c98026e 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -24,7 +24,7 @@ See [configuration.md](./configuration.md) for the full config reference. - OIDC / trusted publishing for crates.io — token-based auth only via `CARGO_REGISTRY_TOKEN` - Automatic workspace root discovery — crate directories must be listed in `version.cargo.paths` or inferred from `version.packages` - Comment and formatting preservation in `Cargo.toml` — the file is rewritten on version bump using `smol-toml`'s serialiser; whitespace, comments, and key order may change -- Same-named path crates at the same version in different workspaces (say, two `utils` members): bumping one can also rewrite the other in a lock that depends on it. Give such crates distinct names +- Same-named path crates at the same version in different workspaces (say, two `utils` members), or at any version when the bumped crate inherits its version and has no committed lock of its own: bumping one can also rewrite the other in a lock that depends on it. Give such crates distinct names --- From 26d823ff291e2f19e26d95a041a1ab4907283de2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:40:21 +0000 Subject: [PATCH 13/18] test(core): pin whole-component exclude matching; note the unfollowed workspace pointer Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HQN3v9Z4f1mu3xK4Z7K44e --- docs/rust.md | 2 +- packages/core/src/cargo.ts | 2 +- packages/core/test/unit/cargo.spec.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/rust.md b/docs/rust.md index 4c98026e..d56bf7c7 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -24,7 +24,7 @@ See [configuration.md](./configuration.md) for the full config reference. - OIDC / trusted publishing for crates.io — token-based auth only via `CARGO_REGISTRY_TOKEN` - Automatic workspace root discovery — crate directories must be listed in `version.cargo.paths` or inferred from `version.packages` - Comment and formatting preservation in `Cargo.toml` — the file is rewritten on version bump using `smol-toml`'s serialiser; whitespace, comments, and key order may change -- Same-named path crates at the same version in different workspaces (say, two `utils` members), or at any version when the bumped crate inherits its version and has no committed lock of its own: bumping one can also rewrite the other in a lock that depends on it. Give such crates distinct names +- Same-named path crates at the same version in different workspaces (say, two `utils` members), or at any version when the bumped crate inherits its version and has no lock of its own: bumping one can also rewrite the other in a lock that depends on it. Give such crates distinct names --- diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index d67d9cf6..018b83b0 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -35,7 +35,7 @@ function isExcluded(workspace: CargoWorkspace, rootDir: string, manifestPath: st * at its workspace root. Cargo's rule: a crate whose own manifest has a `[workspace]` is its own root; * otherwise the nearest ancestor `[workspace]` that doesn't exclude it is, and with none the crate is * its own root. Cargo ignores every other lock: a member's stray one, or one in an ancestor directory - * that isn't the root. + * that isn't the root. A `package.workspace = ""` pointer isn't followed. * * Returns `undefined` when the root has no lock. That is deliberate: libraries commonly don't commit a * lock, and a version bump must never *create* one as a side effect — only sync an existing committed diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index 4ef6d5fc..fd455d52 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -54,6 +54,7 @@ describe('findCargoLockfile', () => { ['an ancestor workspace excludes a directory above it', '[workspace]\nexclude = ["./apps/"]\n', 'own'], ['a literal member entry overrides an exclude', '[workspace]\nmembers = ["apps/x"]\nexclude = ["apps"]\n', 'root'], ['an exclude is a glob, which cargo doesn’t expand', '[workspace]\nexclude = ["apps/*"]\n', 'root'], + ['an exclude only shares a prefix with its directory name', '[workspace]\nexclude = ["app"]\n', 'root'], ])('should follow cargo’s workspace-root rule when %s', (_label, rootManifest, expected) => { const root = tmp(); fs.writeFileSync(path.join(root, 'Cargo.toml'), rootManifest); From bca9c13f9143d63290b76363aedadb698ee9f298 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 00:30:01 +0100 Subject: [PATCH 14/18] refactor: cut Cargo.lock sync comments to the non-obvious Drop doc comments that restate a name or signature, issue numbers and history; shorten the rest to the cargo behaviour the code relies on. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/cargo.ts | 89 +++++-------------- packages/core/test/unit/cargo.spec.ts | 8 +- packages/git/src/fake.ts | 2 - packages/publish/src/stages/prepare.ts | 7 +- packages/publish/src/utils/cargo.ts | 14 ++- packages/version/src/cargo/cargoHandler.ts | 5 +- packages/version/src/cargo/cargoLock.ts | 16 ++-- .../version/src/errors/cargoLockSyncError.ts | 6 +- .../version/test/unit/cargo/cargoLock.spec.ts | 8 +- test/integration/cargo-lock-sync.spec.ts | 16 ++-- 10 files changed, 49 insertions(+), 122 deletions(-) diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index 018b83b0..a5bfcd2d 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -7,7 +7,6 @@ interface CargoWorkspace { exclude?: unknown; } -/** The `[workspace]` table of the manifest at `cargoTomlPath`, if it exists and declares one. */ function readWorkspace(cargoTomlPath: string): CargoWorkspace | undefined { try { return (TOML.parse(fs.readFileSync(cargoTomlPath, 'utf8')) as { workspace?: CargoWorkspace }).workspace; @@ -16,10 +15,7 @@ function readWorkspace(cargoTomlPath: string): CargoWorkspace | undefined { } } -/** - * Cargo's `is_excluded`: an `exclude` entry is a path prefix of the manifest and no `members` entry is. - * Cargo matches both literally, without expanding globs. - */ +// Mirrors cargo's `is_excluded`, which matches `exclude` and `members` as literal path prefixes (no globs). function isExcluded(workspace: CargoWorkspace, rootDir: string, manifestPath: string): boolean { const covers = (entries: unknown): boolean => Array.isArray(entries) && @@ -31,15 +27,10 @@ function isExcluded(workspace: CargoWorkspace, rootDir: string, manifestPath: st } /** - * Find the `Cargo.lock` that governs the crate at `crateDir` — the one cargo resolves the crate into, - * at its workspace root. Cargo's rule: a crate whose own manifest has a `[workspace]` is its own root; - * otherwise the nearest ancestor `[workspace]` that doesn't exclude it is, and with none the crate is - * its own root. Cargo ignores every other lock: a member's stray one, or one in an ancestor directory - * that isn't the root. A `package.workspace = ""` pointer isn't followed. - * - * Returns `undefined` when the root has no lock. That is deliberate: libraries commonly don't commit a - * lock, and a version bump must never *create* one as a side effect — only sync an existing committed - * lock so it doesn't drift. + * The Cargo.lock at the crate's workspace root, by cargo's rule: the crate itself if its manifest has a + * `[workspace]`, else the nearest ancestor `[workspace]` that doesn't exclude it, else the crate. + * `package.workspace` pointers aren't followed. Undefined when the root has no lock: a bump syncs a + * committed lock but never creates one. */ export function findCargoLockfile(crateDir: string): string | undefined { const manifestPath = path.join(path.resolve(crateDir), 'Cargo.toml'); @@ -61,17 +52,14 @@ export function findCargoLockfile(crateDir: string): string | undefined { interface CargoLockEntry { name: string; version: string; - /** Index of the entry's `[[package]]` line. */ start: number; - /** Index of the entry's last non-blank line. */ + /** Last non-blank line. */ end: number; - /** Index of the entry's `version = "…"` line. */ versionLine: number; /** Registry and git packages carry a `source`; path and workspace packages don't. */ hasSource: boolean; } -/** Read the `[[package]]` entries of a Cargo.lock split into lines, keeping each line's position. */ function readCargoLockEntries(lines: string[]): CargoLockEntry[] { const entries: CargoLockEntry[] = []; let current: (Partial & { start: number; end: number; hasSource: boolean }) | undefined; @@ -114,7 +102,6 @@ function readCargoLockEntries(lines: string[]): CargoLockEntry[] { return entries; } -/** Split a version into its numeric core, pre-release identifiers and build metadata. */ function parseVersion(version: string): { core: number[]; pre: string[]; build: string } { const plus = version.indexOf('+'); const withoutBuild = plus === -1 ? version : version.slice(0, plus); @@ -128,11 +115,7 @@ function parseVersion(version: string): { core: number[]; pre: string[]; build: const compareStrings = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); -/** - * Semver precedence, which is how cargo orders same-name `[[package]]` entries: numeric core, then a - * pre-release sorts before the release, identifier by identifier (numeric ones numerically and before - * alphanumeric ones). Build metadata only breaks ties. - */ +/** Semver precedence: the order cargo keeps same-name `[[package]]` entries in. */ function compareVersions(a: string, b: string): number { const left = parseVersion(a); const right = parseVersion(b); @@ -160,11 +143,7 @@ function compareVersions(a: string, b: string): number { return left.pre.length - right.pre.length || compareStrings(left.build, right.build); } -/** - * Put same-name `[[package]]` entries back in cargo's order (by version) after one of them changed - * version. They sit next to each other, so the entries are permuted while the blank lines between - * them stay put. A lock not laid out that way is left as it is. - */ +/** Re-sort adjacent same-name entries by version, keeping blank lines in place; non-adjacent ones are left. */ function sortSameNameEntries(lines: string[], group: Array<{ entry: CargoLockEntry; version: string }>): void { const gaps = group.slice(1).map(({ entry }, i) => lines.slice(group[i].entry.end + 1, entry.start)); if (gaps.some((gap) => gap.some((line) => line.trim() !== ''))) { @@ -177,11 +156,7 @@ function sortSameNameEntries(lines: string[], group: Array<{ entry: CargoLockEnt lines.splice(first, group[group.length - 1].entry.end + 1 - first, ...region); } -/** - * Put the run of `name` references around each `" "` dependency line back in cargo's - * order. Cargo sorts a package's dependencies by name and then by the version as a string, so - * `"foo 10.0.0"` sorts before `"foo 2.0.0"`. - */ +/** Re-sort each run of `name` references; cargo orders them by version as a string (`10.0.0` before `2.0.0`). */ function sortReferenceRuns(lines: string[], name: string, version: string): void { const referenceVersion = (line: string): string | undefined => { const reference = /^\s*"([^" ]+) ([^" ]+)[^"]*",?\s*$/.exec(line); @@ -207,19 +182,12 @@ function sortReferenceRuns(lines: string[], name: string, version: string): void } /** - * Point a path or workspace crate's own `[[package]]` entry in Cargo.lock content at `version`, making - * the edit cargo itself makes for a version change: the entry's `version` line, plus every - * `" "` dependency reference. Cargo only writes the version into a reference when - * the lock holds more than one version of that name — otherwise dependents list the bare name, which - * needs no edit. That encoding is the same in every lockfile format, and cargo never adds a source to - * a reference to a path package. Registry and git entries of the same name are never touched, but - * when the new version passes one of theirs, the entries and references are put back in cargo's order. + * Set a path crate's version in Cargo.lock content the way cargo would: its `version` line and any + * `" "` references (cargo writes the version into a reference only when the lock + * holds several versions of the name), re-sorting same-name entries if their order changes. * - * Returns the updated content (identical when the entry is already at `version`), or `undefined` when - * the lock has no path entry for `crateName`. Throws rather than guess when the lock holds the name - * more than once in a way this edit would change cargo's encoding of: several path entries, or another - * entry at the old or new version (those references carry a source that would have to be added or - * dropped). + * Undefined when the lock has no path entry for the crate. Throws when the name is ambiguous: several + * path entries, or another source's entry at the old or new version. */ export function setCargoLockVersion(lockContent: string, crateName: string, version: string): string | undefined { const lines = lockContent.split('\n'); @@ -258,23 +226,21 @@ export function setCargoLockVersion(lockContent: string, crateName: string, vers return updated.join('\n'); } -/** The part of `@releasekit/git`'s `Git` seam the Cargo.lock sync uses (core doesn't depend on it). */ +/** The slice of `@releasekit/git`'s `Git` this needs; core doesn't depend on that package. */ export interface CargoLockGit { isRepository(cwd?: string): Promise; lsFiles(opts?: { paths?: string[]; cwd?: string }): Promise; isIgnored(file: string, cwd?: string): Promise; } -/** A crate whose new version the Cargo.lock files that record it must follow. */ export interface CargoLockBump { cargoTomlPath: string; name: string; version: string; - /** The version the manifest had before the bump; absent when it records none (`version.workspace = true`). */ + /** Absent when the manifest inherits its version (`version.workspace = true`). */ fromVersion?: string; } -/** A Cargo.lock that lists at least one bumped crate as a path package, with the content it must have. */ export interface CargoLockUpdate { lockPath: string; content: string; @@ -282,22 +248,15 @@ export interface CargoLockUpdate { } /** - * Work out the content of every Cargo.lock that records a bumped crate, by editing the lock text with - * {@link setCargoLockVersion} — nothing runs cargo, so no Rust toolchain, registry index or network is - * needed. The candidates are every lock git tracks (apps that use a crate through a path dependency - * have their own) plus each crate's own lock ({@link findCargoLockfile}), which is synced even when - * untracked but not when git ignores it, since it couldn't be staged. + * Plan the content of every Cargo.lock that records a bumped crate, by text edit (no cargo, toolchain or + * network). Candidates are every tracked lock plus each crate's own lock unless git ignores it. * - * Crates in different workspaces can share a name (every `tauri init` app is `app`). A crate's own lock - * always takes its bump; in any other lock, an entry of that name follows the bump only when something - * in that lock depends on it (one nothing depends on is that lock's own root or a member) at the - * crate's old or new version (any version when neither its manifest nor its own lock records the old - * one). A different crate of the same name used by path at a matching version can't be told apart. - * A lock gets every bump it records, so a lock shared by several bumped crates is rewritten once; - * locks that record none are left out. + * Names repeat across workspaces (every `tauri init` app is `app`), so outside a crate's own lock an + * entry follows the bump only if something there depends on it at the crate's old or new version (any + * version when the old one is unknown). A different same-named path crate at a matching version can't + * be told apart. * - * Nothing is written: the caller writes the changed content. Throws, naming the lock, when a lock can't - * be read or edited unambiguously, so no lock is written unless every lock can be. + * Writes nothing; throws, naming the lock, when any lock can't be edited unambiguously. */ export async function planCargoLockUpdates( git: CargoLockGit, @@ -318,7 +277,7 @@ export async function planCargoLockUpdates( if (ownLock && !(await git.isIgnored(ownLock, cwd))) { owners.set(ownLock, [...(owners.get(ownLock) ?? []), bump]); lockPaths.add(ownLock); - // A manifest that inherits its version, or has none, doesn't say what the crate was at. + // An inherited version isn't in the manifest; take it from the crate's own lock. fromVersions.set( bump, fromVersions.get(bump) ?? diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index fd455d52..06484dbd 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -38,7 +38,6 @@ describe('findCargoLockfile', () => { }); it('should ignore a lock in an ancestor directory that is not a workspace root', () => { - // cargo only uses its workspace root's lock; with no `[workspace]` above, the crate is its own root. const root = tmp(); fs.writeFileSync(path.join(root, 'Cargo.toml'), '[package]\nname = "outer"\nversion = "1.0.0"\n'); fs.writeFileSync(path.join(root, 'Cargo.lock'), ''); @@ -197,8 +196,7 @@ describe('setCargoLockVersion', () => { }); it('should keep cargo’s order when the new version passes same-named entries from other sources', () => { - // Byte-for-byte what `cargo update --workspace` writes after bumping the path crate `dupe` from - // 1.0.0 to 3.0.0: entries are ordered by semver, references by the version as a string. + // Byte-for-byte what `cargo update --workspace` writes after bumping `dupe` from 1.0.0 to 3.0.0. const source = (repo: string) => `source = "git+https://example.com/${repo}#9a1327a6d3f8bc80a0f02fe01325c7828d587668"`; const entry = (version: string, ...rest: string[]) => [ @@ -239,8 +237,7 @@ describe('setCargoLockVersion', () => { }); it('should order pre-release identifiers numerically when the new version passes other entries', () => { - // Byte-for-byte what `cargo update --workspace` writes after bumping the path crate `dupe` from - // 1.0.0-beta.2 to 1.0.0-beta.11: beta.9 < beta.10 < beta.11 for entries, as strings for references. + // Byte-for-byte what `cargo update --workspace` writes after bumping `dupe` from 1.0.0-beta.2 to 1.0.0-beta.11. const entry = (version: string, ...rest: string[]) => [ '', '[[package]]', @@ -370,7 +367,6 @@ describe('setCargoLockVersion', () => { describe('planCargoLockUpdates', () => { const tmpDirs: string[] = []; - /** The git seam as the planner sees it: `tracked` locks, and the files git ignores. */ function fakeGit(tracked: string[], ignored: string[] = []) { return { isRepository: vi.fn(async () => true), diff --git a/packages/git/src/fake.ts b/packages/git/src/fake.ts index 8996bf41..07abc4e8 100644 --- a/packages/git/src/fake.ts +++ b/packages/git/src/fake.ts @@ -191,12 +191,10 @@ export class FakeGit implements Git { return [...(this.diffNames[commitSha] ?? [])]; } - /** The fake tracks no files. */ async lsFiles(_opts: GitLsFilesOptions = {}): Promise { return []; } - /** The fake ignores no files. */ async isIgnored(_file: string, _cwd?: string): Promise { return false; } diff --git a/packages/publish/src/stages/prepare.ts b/packages/publish/src/stages/prepare.ts index 6fd7a888..df6818fa 100644 --- a/packages/publish/src/stages/prepare.ts +++ b/packages/publish/src/stages/prepare.ts @@ -57,11 +57,8 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { // Update Cargo.toml versions for cargo packages if (config.cargo.enabled) { - // Cargo.lock files to stage alongside the manifests. We re-sync the locks here (rather than trust - // the version step) so the direct-commit flow — git-commit stages an explicit file list, not - // `git add -A` — commits every lock that records a bumped crate, fixture apps' locks included, and - // is correct even run in isolation. The standing-PR flow stages the locks via its own `git add -A`, - // with git-commit skipped, so this re-sync is a harmless no-op there. + // Re-synced here so the direct-commit flow, which stages an explicit file list, commits every lock + // that records a bump. A no-op in the standing-PR flow, which stages with `git add -A`. const bumps: Array> = []; for (const update of input.updates) { const pkgDir = path.dirname(path.resolve(cwd, update.filePath)); diff --git a/packages/publish/src/utils/cargo.ts b/packages/publish/src/utils/cargo.ts index 08b7912a..5747a190 100644 --- a/packages/publish/src/utils/cargo.ts +++ b/packages/publish/src/utils/cargo.ts @@ -10,15 +10,11 @@ export type { CargoManifest }; export { parseCargoToml }; /** - * Sync every Cargo.lock that records a crate this publish bumped — its own lock and the tracked locks - * of apps that use it through a path dependency — with the same direct lock edit as the version step - * (core's `planCargoLockUpdates`), so no Rust toolchain is needed. + * Sync every Cargo.lock that records a crate this publish bumped (core's `planCargoLockUpdates`). Returns + * every such lock, changed or not, so the caller stages them all. * - * Returns every lock that lists a bumped crate, changed or not, so the caller stages all of them: the - * version step has usually written them already, and they still belong in the release commit. A - * failure is logged, never thrown. In the standing-PR flow this runs after the release PR has merged, - * and a lock fix-up must not strand a release that is already on the base branch; an unsyncable lock - * fails the version step instead, before anything is committed. + * Logs rather than throws: in the standing-PR flow this runs after the merge, and a lock fix-up mustn't + * strand a release already on the base branch. The version step fails on an unsyncable lock instead. */ export async function syncCargoLockfiles(cwd: string, bumps: Array>): Promise { if (bumps.length === 0) { @@ -46,7 +42,7 @@ export async function syncCargoLockfiles(cwd: string, bumps: Array { const bumps = recordedBumps.splice(0); diff --git a/packages/version/src/errors/cargoLockSyncError.ts b/packages/version/src/errors/cargoLockSyncError.ts index 05d701a0..5b9c464e 100644 --- a/packages/version/src/errors/cargoLockSyncError.ts +++ b/packages/version/src/errors/cargoLockSyncError.ts @@ -1,10 +1,6 @@ import { VersionError } from './versionError.js'; -/** - * Thrown when a Cargo.lock that records a bumped crate can't be synced to the crate's new version. - * It fails the run on purpose: a lock left at the old version breaks every later `cargo --locked` run, - * and a warning is how that drift used to go unnoticed. - */ +/** Fatal on purpose: a lock left at the old version breaks every later `cargo --locked` run. */ export class CargoLockSyncError extends VersionError { constructor(details: string) { super(`Failed to sync Cargo.lock: ${details}`, 'CARGO_LOCK_SYNC_ERROR', [ diff --git a/packages/version/test/unit/cargo/cargoLock.spec.ts b/packages/version/test/unit/cargo/cargoLock.spec.ts index e0fcc4ca..0fb59c14 100644 --- a/packages/version/test/unit/cargo/cargoLock.spec.ts +++ b/packages/version/test/unit/cargo/cargoLock.spec.ts @@ -1,8 +1,7 @@ /** - * Cargo.lock sync across every path that bumps a Cargo.toml (#496, #650). Each test runs a real - * strategy through `VersionEngine.run` over a real git work tree on disk — only tag lookups, commit - * parsing and the version calculation are mocked — and checks the locks the standing-PR flow stages - * with `git add -A`: the crates' own workspace lock and a fixture app's lock that uses them by path. + * Cargo.lock sync on every path that bumps a Cargo.toml. Each test runs a real strategy through + * `VersionEngine.run` over a git work tree on disk (only tag lookups, commit parsing and the version + * calculation are mocked) and checks the crates' own lock and a fixture app's lock. */ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; @@ -137,7 +136,6 @@ function bump(next: Record): void { vi.mocked(calculator.calculateVersion).mockImplementation(async (_config, options) => next[options.name ?? ''] ?? ''); } -/** Run one strategy the way the version CLI and the release flows do: through `VersionEngine.run`. */ async function runVersion(strategy: StrategyType, config: Partial, packages: PackagesWithRoot): Promise { const engine = new VersionEngine(baseConfig(config)); engine.setStrategy(strategy); diff --git a/test/integration/cargo-lock-sync.spec.ts b/test/integration/cargo-lock-sync.spec.ts index 204222c4..d01f566d 100644 --- a/test/integration/cargo-lock-sync.spec.ts +++ b/test/integration/cargo-lock-sync.spec.ts @@ -1,11 +1,7 @@ /** - * Integration test: Cargo.lock sync (#496, #650) checked against REAL cargo. - * - * Each test runs `releasekit version` (the built CLI) with no cargo on PATH and an empty CARGO_HOME, - * as on a fresh CI runner, and then asks real cargo whether the locks it left are right: cargo must - * accept every lock that records a bumped crate under `--locked` without changing it, and the edit - * must be byte-for-byte the one `cargo update --workspace` makes. Skipped automatically where cargo - * isn't on PATH (CI installs a Rust toolchain for this job). + * Cargo.lock sync checked against real cargo: after the built `releasekit version` runs without cargo, + * cargo must accept every synced lock under `--locked` unchanged, and the edit must match + * `cargo update --workspace` byte for byte. Skipped where cargo isn't installed. */ import { execFileSync, spawnSync } from 'node:child_process'; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; @@ -207,10 +203,8 @@ describe.skipIf(!cargoAvailable())('Cargo.lock sync (real cargo)', () => { }); it('should write the same lock cargo writes, also when the bump passes other versions of the crate', () => { - // Git crates named like a workspace member put several versions of `crate-b` in the lock, so cargo - // spells out versions in references to it ("crate-b 0.1.0"). Bumping crate-b from 0.1.0 to 0.2.0 - // passes 0.1.5 in entry order (semver) and 0.10.0 in reference order (plain string), so the edit - // also has to put both back in cargo's order. + // Git crates also named `crate-b` make cargo spell out versions in its references. Bumping 0.1.0 to + // 0.2.0 passes 0.1.5 in entry order (semver) and 0.10.0 in reference order (string), so both re-sort. const gitDeps = ['0.0.9', '0.1.5', '0.10.0'].map((version) => { const gitCrate = join(dir, `git-crate-b-${version}`); writeCrate(gitCrate, 'crate-b', version); From 811b99ad1d3a0de32981db8429f113a67efbab73 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 04:05:20 +0100 Subject: [PATCH 15/18] fix(core): sync another workspace's Cargo.lock only if it reaches the crate by path Name and version can't identify a path crate: two workspaces can each have a `core` 0.1.0, and bumping one rewrote the other's lock, breaking `--locked` builds. A lock other than the crate's own now takes a bump only if its workspace reaches the crate's directory through path dependencies (target-specific, `workspace = true`, `[patch]`; dev-dependencies of its own members only), as cargo resolves them. Drops the version heuristic and `fromVersion`. Co-Authored-By: Claude Opus 5.5 --- docs/rust.md | 4 +- packages/core/src/cargo.ts | 137 +++++++----- packages/core/test/unit/cargo.spec.ts | 198 ++++++++---------- packages/publish/src/stages/prepare.ts | 4 +- packages/publish/src/utils/cargo.ts | 12 +- .../publish/test/unit/stages/prepare.spec.ts | 44 +--- packages/version/src/cargo/cargoHandler.ts | 4 +- .../test/unit/cargo/cargoHandler.spec.ts | 2 - test/integration/cargo-lock-sync.spec.ts | 43 ++++ 9 files changed, 221 insertions(+), 227 deletions(-) diff --git a/docs/rust.md b/docs/rust.md index d56bf7c7..9b7f1ced 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -9,7 +9,7 @@ See [configuration.md](./configuration.md) for the full config reference. ## What's supported - `Cargo.toml` version bumping driven by conventional commits -- `Cargo.lock` sync on every bump: each committed lock that lists a bumped crate as a path package (the crate's own lock, and those of apps that use it by path) is updated in the same release commit, without running cargo. Gitignored locks are left alone, and so are other apps' own crates of the same name (every `tauri init` app is `app`) +- `Cargo.lock` sync on every bump: the crate's own lock, and every other committed lock whose workspace reaches the crate's directory through path dependencies (`workspace = true` and `[patch]` included), is updated in the same release commit, without running cargo. Gitignored locks are left alone, and so are other crates of the same name (every `tauri init` app is `app`) - Per-crate git tags (via `version.packageSpecificTags`) - Publishing to crates.io with `cargo publish` - Workspace dependency ordering via topological sort (Kahn's algorithm) so path-dependent crates are published in the correct sequence @@ -24,7 +24,6 @@ See [configuration.md](./configuration.md) for the full config reference. - OIDC / trusted publishing for crates.io — token-based auth only via `CARGO_REGISTRY_TOKEN` - Automatic workspace root discovery — crate directories must be listed in `version.cargo.paths` or inferred from `version.packages` - Comment and formatting preservation in `Cargo.toml` — the file is rewritten on version bump using `smol-toml`'s serialiser; whitespace, comments, and key order may change -- Same-named path crates at the same version in different workspaces (say, two `utils` members), or at any version when the bumped crate inherits its version and has no lock of its own: bumping one can also rewrite the other in a lock that depends on it. Give such crates distinct names --- @@ -215,7 +214,6 @@ releasekit edits each lock's text the way cargo would, and fails the version ste - `it also has from another source`: the lock also lists the crate from a registry or git source at the old or the new version, so cargo would have to change how it tells the two apart. Sync that lock with cargo yourself (`cargo update` with the new version in `Cargo.toml`) and commit it, or drop the second source of the crate. - `it has path entries named `: the lock holds several local crates of that name, so releasekit can't tell which one is the bumped crate. Sync that lock with cargo yourself. -- `it could be the crate at any of `: several bumped crates share the name and are moving to different versions, and the lock uses one of them by path at a version that fits more than one. Sync that lock with cargo yourself, or rename one of the crates. - A file-system error such as `EACCES`: the lock couldn't be read or written. **`error: failed to verify package tarball`** diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index a5bfcd2d..36084bb1 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -7,14 +7,20 @@ interface CargoWorkspace { exclude?: unknown; } -function readWorkspace(cargoTomlPath: string): CargoWorkspace | undefined { +type Table = Record; +type ReadManifest = (cargoTomlPath: string) => Table | undefined; + +function readManifest(cargoTomlPath: string): Table | undefined { try { - return (TOML.parse(fs.readFileSync(cargoTomlPath, 'utf8')) as { workspace?: CargoWorkspace }).workspace; + return TOML.parse(fs.readFileSync(cargoTomlPath, 'utf8')); } catch { return undefined; } } +const asTable = (value: unknown): Table => + typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Table) : {}; + // Mirrors cargo's `is_excluded`, which matches `exclude` and `members` as literal path prefixes (no globs). function isExcluded(workspace: CargoWorkspace, rootDir: string, manifestPath: string): boolean { const covers = (entries: unknown): boolean => @@ -27,28 +33,70 @@ function isExcluded(workspace: CargoWorkspace, rootDir: string, manifestPath: st } /** - * The Cargo.lock at the crate's workspace root, by cargo's rule: the crate itself if its manifest has a - * `[workspace]`, else the nearest ancestor `[workspace]` that doesn't exclude it, else the crate. - * `package.workspace` pointers aren't followed. Undefined when the root has no lock: a bump syncs a - * committed lock but never creates one. + * The crate's workspace root, by cargo's rule: the crate itself if its manifest has a `[workspace]`, else + * the nearest ancestor `[workspace]` that doesn't exclude it, else the crate. `package.workspace` + * pointers aren't followed. */ -export function findCargoLockfile(crateDir: string): string | undefined { +function findCargoWorkspaceRoot(crateDir: string, read: ReadManifest = readManifest): string { const manifestPath = path.join(path.resolve(crateDir), 'Cargo.toml'); let root = path.dirname(manifestPath); - if (!readWorkspace(manifestPath)) { + if (!read(manifestPath)?.workspace) { for (let dir = root; path.dirname(dir) !== dir; ) { dir = path.dirname(dir); - const workspace = readWorkspace(path.join(dir, 'Cargo.toml')); + const workspace = read(path.join(dir, 'Cargo.toml'))?.workspace as CargoWorkspace | undefined; if (workspace && !isExcluded(workspace, dir, manifestPath)) { root = dir; break; } } } - const lockPath = path.join(root, 'Cargo.lock'); + return root; +} + +/** + * The Cargo.lock at the crate's workspace root. Undefined when the root has no lock: a bump syncs a + * committed lock but never creates one. + */ +export function findCargoLockfile(crateDir: string): string | undefined { + const lockPath = path.join(findCargoWorkspaceRoot(crateDir), 'Cargo.lock'); return fs.existsSync(lockPath) ? lockPath : undefined; } +const DEPENDENCY_KINDS = ['dependencies', 'dev-dependencies', 'build-dependencies']; + +/** + * Directories of the path crates a lock at `lockRoot` resolves: path dependencies followed transitively, + * across workspaces, from the manifests whose workspace root is `lockRoot`. + */ +function findPathCrates(lockRoot: string, manifestDirs: string[], read: ReadManifest): Set { + const found = new Set(); + const queue = manifestDirs.filter((dir) => findCargoWorkspaceRoot(dir, read) === lockRoot); + for (let dir = queue.pop(); dir !== undefined; dir = queue.pop()) { + if (found.has(dir)) { + continue; + } + found.add(dir); + const manifest = asTable(read(path.join(dir, 'Cargo.toml'))); + const root = findCargoWorkspaceRoot(dir, read); + const inherited = asTable(asTable(asTable(read(path.join(root, 'Cargo.toml'))).workspace).dependencies); + // Cargo resolves dev-dependencies for the lock's own workspace members only. + const kinds = root === lockRoot ? DEPENDENCY_KINDS : DEPENDENCY_KINDS.filter((kind) => kind !== 'dev-dependencies'); + const tables = [manifest, ...Object.values(asTable(manifest.target)).map(asTable)].flatMap((scope) => + kinds.map((kind) => asTable(scope[kind])), + ); + for (const table of [...tables, ...Object.values(asTable(manifest.patch)).map(asTable)]) { + for (const [key, value] of Object.entries(table)) { + const dependency = asTable(value); + const [base, source] = dependency.workspace === true ? [root, asTable(inherited[key])] : [dir, dependency]; + if (typeof source.path === 'string') { + queue.push(path.resolve(base, source.path)); + } + } + } + } + return found; +} + interface CargoLockEntry { name: string; version: string; @@ -237,8 +285,6 @@ export interface CargoLockBump { cargoTomlPath: string; name: string; version: string; - /** Absent when the manifest inherits its version (`version.workspace = true`). */ - fromVersion?: string; } export interface CargoLockUpdate { @@ -251,10 +297,8 @@ export interface CargoLockUpdate { * Plan the content of every Cargo.lock that records a bumped crate, by text edit (no cargo, toolchain or * network). Candidates are every tracked lock plus each crate's own lock unless git ignores it. * - * Names repeat across workspaces (every `tauri init` app is `app`), so outside a crate's own lock an - * entry follows the bump only if something there depends on it at the crate's old or new version (any - * version when the old one is unknown). A different same-named path crate at a matching version can't - * be told apart. + * A crate's own lock always takes its bump. Names repeat across workspaces (every `tauri init` app is + * `app`), so another lock takes it only if the lock's workspace reaches the crate's directory by path. * * Writes nothing; throws, naming the lock, when any lock can't be edited unambiguously. */ @@ -264,29 +308,31 @@ export async function planCargoLockUpdates( bumps: CargoLockBump[], ): Promise { const lockPaths = new Set(); + const manifestDirs: string[] = []; if (await git.isRepository(cwd)) { - for (const lockPath of await git.lsFiles({ paths: [':(top,glob)**/Cargo.lock'], cwd })) { - lockPaths.add(path.resolve(cwd, lockPath)); + for (const file of await git.lsFiles({ paths: [':(top,glob)**/Cargo.lock', ':(top,glob)**/Cargo.toml'], cwd })) { + if (path.basename(file) === 'Cargo.lock') { + lockPaths.add(path.resolve(cwd, file)); + } else { + manifestDirs.push(path.resolve(cwd, path.dirname(file))); + } } } const owners = new Map(); - const fromVersions = new Map(); for (const bump of bumps) { - fromVersions.set(bump, bump.fromVersion); const ownLock = findCargoLockfile(path.dirname(bump.cargoTomlPath)); if (ownLock && !(await git.isIgnored(ownLock, cwd))) { owners.set(ownLock, [...(owners.get(ownLock) ?? []), bump]); lockPaths.add(ownLock); - // An inherited version isn't in the manifest; take it from the crate's own lock. - fromVersions.set( - bump, - fromVersions.get(bump) ?? - readCargoLockEntries(fs.readFileSync(ownLock, 'utf8').split('\n')).find( - (entry) => entry.name === bump.name && !entry.hasSource, - )?.version, - ); } } + const manifests = new Map(); + const read: ReadManifest = (cargoTomlPath) => { + if (!manifests.has(cargoTomlPath)) { + manifests.set(cargoTomlPath, readManifest(cargoTomlPath)); + } + return manifests.get(cargoTomlPath); + }; const updates: CargoLockUpdate[] = []; for (const lockPath of lockPaths) { @@ -295,39 +341,22 @@ export async function planCargoLockUpdates( continue; } const original = fs.readFileSync(lockPath, 'utf8'); - const lines = original.split('\n'); - const pathEntries = readCargoLockEntries(lines).filter((entry) => !entry.hasSource); - const references = new Set(lines.map((line) => line.trim().replace(/,$/, ''))); - const isPathDependency = (bump: CargoLockBump): boolean => { - const fromVersion = fromVersions.get(bump); - return pathEntries.some( - (entry) => - entry.name === bump.name && - (fromVersion === undefined || [fromVersion, bump.version].includes(entry.version)) && - (references.has(`"${entry.name}"`) || references.has(`"${entry.name} ${entry.version}"`)), - ); - }; - + let pathCrates: Set | undefined; let content = original; let listed = false; - for (const name of new Set(bumps.map((bump) => bump.name))) { - const sameName = bumps.filter((bump) => bump.name === name); - const own = sameName.filter((bump) => owners.get(lockPath)?.includes(bump)); - const matching = own.length > 0 ? own : sameName.filter(isPathDependency); - if (matching.length === 0) { - continue; - } - if (new Set(matching.map((bump) => bump.version)).size > 1) { - const manifests = matching.map((bump) => bump.cargoTomlPath).join(', '); - throw new Error(`can't set ${name} in ${lockPath}: it could be the crate at any of ${manifests}`); + for (const bump of bumps) { + if (!owners.get(lockPath)?.includes(bump)) { + pathCrates ??= findPathCrates(path.dirname(lockPath), manifestDirs, read); + if (!pathCrates.has(path.resolve(path.dirname(bump.cargoTomlPath)))) { + continue; + } } - const [{ version }] = matching; let next: string | undefined; try { - next = setCargoLockVersion(content, name, version); + next = setCargoLockVersion(content, bump.name, bump.version); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - throw new Error(`can't set ${name} to ${version} in ${lockPath}: ${detail}`); + throw new Error(`can't set ${bump.name} to ${bump.version} in ${lockPath}: ${detail}`); } if (next !== undefined) { listed = true; diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index 06484dbd..d1599f18 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { type CargoLockBump, findCargoLockfile, planCargoLockUpdates, setCargoLockVersion } from '../../src/cargo.js'; +import { findCargoLockfile, planCargoLockUpdates, setCargoLockVersion } from '../../src/cargo.js'; describe('findCargoLockfile', () => { const tmpDirs: string[] = []; @@ -411,11 +411,15 @@ describe('planCargoLockUpdates', () => { path.join(root, 'Cargo.lock'), lock('version = 4', '', '[[package]]', 'name = "core"', 'version = "1.4.0"'), ); - write(path.join(root, 'app', 'Cargo.toml'), '[package]\nname = "app"\nversion = "0.1.0"\n\n[workspace]\n'); + const appManifest = write( + path.join(root, 'app', 'Cargo.toml'), + '[package]\nname = "app"\nversion = "0.1.0"\n\n[workspace]\n\n[dependencies]\ncore = { path = "../crates/core" }\n', + ); return { root, manifest, ownLock, + appManifest, appLock: write(path.join(root, 'app', 'Cargo.lock'), lockWithDependency('app', 'core', '1.4.0')), }; } @@ -430,8 +434,8 @@ describe('planCargoLockUpdates', () => { it('should plan the crate’s own lock and every tracked lock that uses it, writing nothing', async () => { const { root, manifest, ownLock, appLock } = repoWithLocks(); write(path.join(root, 'other', 'Cargo.lock'), lockWithDependency('other', 'serde', '1.0.0')); - const git = fakeGit(['../../app/Cargo.lock', '../../other/Cargo.lock']); - const bump = { cargoTomlPath: manifest, name: 'core', version: '1.5.0', fromVersion: '1.4.0' }; + const git = fakeGit(['../../app/Cargo.lock', '../../app/Cargo.toml', '../../other/Cargo.lock']); + const bump = { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }; const updates = await planCargoLockUpdates(git, path.dirname(manifest), [bump]); @@ -439,7 +443,10 @@ describe('planCargoLockUpdates', () => { { lockPath: appLock, content: fs.readFileSync(appLock, 'utf8').replace('1.4.0', '1.5.0'), changed: true }, { lockPath: ownLock, content: fs.readFileSync(ownLock, 'utf8').replace('1.4.0', '1.5.0'), changed: true }, ]); - expect(git.lsFiles).toHaveBeenCalledWith({ paths: [':(top,glob)**/Cargo.lock'], cwd: path.dirname(manifest) }); + expect(git.lsFiles).toHaveBeenCalledWith({ + paths: [':(top,glob)**/Cargo.lock', ':(top,glob)**/Cargo.toml'], + cwd: path.dirname(manifest), + }); expect(fs.readFileSync(ownLock, 'utf8')).toContain('version = "1.4.0"'); }); @@ -448,7 +455,7 @@ describe('planCargoLockUpdates', () => { const git = { ...fakeGit([]), isRepository: vi.fn(async () => false) }; const updates = await planCargoLockUpdates(git, path.dirname(manifest), [ - { cargoTomlPath: manifest, name: 'core', version: '1.5.0', fromVersion: '1.4.0' }, + { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }, ]); expect(updates.map((update) => update.lockPath)).toEqual([ownLock]); @@ -459,18 +466,18 @@ describe('planCargoLockUpdates', () => { const { root, manifest, ownLock } = repoWithLocks(); const updates = await planCargoLockUpdates(fakeGit([], [ownLock]), root, [ - { cargoTomlPath: manifest, name: 'core', version: '1.5.0', fromVersion: '1.4.0' }, + { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }, ]); expect(updates).toEqual([]); }); it('should apply every bump a shared lock lists in one update', async () => { - const { root, manifest, appLock } = repoWithLocks(); + const { root, manifest, appManifest, appLock } = repoWithLocks(); - const updates = await planCargoLockUpdates(fakeGit([appLock]), root, [ - { cargoTomlPath: path.join(root, 'app', 'Cargo.toml'), name: 'app', version: '0.2.0', fromVersion: '0.1.0' }, - { cargoTomlPath: manifest, name: 'core', version: '1.5.0', fromVersion: '1.4.0' }, + const updates = await planCargoLockUpdates(fakeGit([appLock, appManifest]), root, [ + { cargoTomlPath: appManifest, name: 'app', version: '0.2.0' }, + { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }, ]); expect(updates.find((update) => update.lockPath === appLock)?.content).toBe( @@ -483,132 +490,91 @@ describe('planCargoLockUpdates', () => { fs.writeFileSync(ownLock, lock('version = 4', '', '[[package]]', 'name = "core"', 'version = "1.5.0"')); const updates = await planCargoLockUpdates(fakeGit([path.join(root, 'deleted', 'Cargo.lock')]), root, [ - { cargoTomlPath: manifest, name: 'core', version: '1.5.0', fromVersion: '1.5.0' }, + { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }, ]); expect(updates).toEqual([{ lockPath: ownLock, content: fs.readFileSync(ownLock, 'utf8'), changed: false }]); }); - it('should update another lock’s entry of the crate’s name only when it is a path dependency at the old or new version', async () => { - // Every `tauri init` app is a crate named `app`, so other locks can hold a different `app`. - const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-names-'))); - tmpDirs.push(root); - const manifest = write(path.join(root, 'one', 'Cargo.toml'), '[package]\nname = "app"\nversion = "0.1.0"\n'); - const ownLock = write(path.join(root, 'one', 'Cargo.lock'), lockWithDependency('app', 'dep', '1.0.0')); - const otherApp = write(path.join(root, 'two', 'Cargo.lock'), lockWithDependency('app', 'dep', '1.0.0')); - const atOld = write(path.join(root, 'at-old', 'Cargo.lock'), lockWithDependency('harness', 'app', '0.1.0')); - const atNew = write(path.join(root, 'at-new', 'Cargo.lock'), lockWithDependency('harness', 'app', '0.2.0')); - const atOther = write(path.join(root, 'at-other', 'Cargo.lock'), lockWithDependency('harness', 'app', '0.3.0')); - // With a registry `app` beside it, cargo spells out the path entry's version in references. - const versioned = (version: string): string => - lock( - 'version = 4', - '', - '[[package]]', - 'name = "app"', - `version = "${version}"`, - '', - '[[package]]', - 'name = "app"', - 'version = "0.9.0"', - `source = "${REGISTRY}"`, - '', - '[[package]]', - 'name = "harness"', - 'version = "0.1.0"', - 'dependencies = [', - ` "app ${version}",`, - ' "app 0.9.0",', - ']', - ); - const atOldVersioned = write(path.join(root, 'at-old-versioned', 'Cargo.lock'), versioned('0.1.0')); - - const updates = await planCargoLockUpdates( - fakeGit([ownLock, otherApp, atOld, atNew, atOther, atOldVersioned]), - root, - [{ cargoTomlPath: manifest, name: 'app', version: '0.2.0', fromVersion: '0.1.0' }], - ); - - expect(updates).toEqual([ - { - lockPath: ownLock, - content: lockWithDependency('app', 'dep', '1.0.0').replace('"0.1.0"', '"0.2.0"'), - changed: true, - }, - { lockPath: atOld, content: lockWithDependency('harness', 'app', '0.2.0'), changed: true }, - { lockPath: atNew, content: lockWithDependency('harness', 'app', '0.2.0'), changed: false }, - { lockPath: atOldVersioned, content: versioned('0.2.0'), changed: true }, - ]); - }); - - /** A crate whose manifest doesn't record its version, and locks that use an `app` 0.1.0 and 0.5.0 by path. */ - function versionlessBump(ownLockVersion?: string): { root: string; locks: string[]; bump: CargoLockBump } { - const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-from-'))); + /** A `plugins` workspace whose `core` 1.0.0 is bumped, and an app workspace with a `core` 1.0.0 of its own. */ + function pluginsAndApp(appDependencies: string, apiDependencies: string) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-reach-'))); tmpDirs.push(root); - const manifest = write(path.join(root, 'app', 'Cargo.toml'), '[package]\nname = "app"\nversion.workspace = true\n'); - if (ownLockVersion) { + const crate = (dir: string, name: string, version: string, extra = ''): string => + write(path.join(root, dir, 'Cargo.toml'), `[package]\nname = "${name}"\nversion = "${version}"\n\n${extra}\n`); + const manifests = [ write( - path.join(root, 'app', 'Cargo.lock'), - lock('version = 4', '', '[[package]]', 'name = "app"', `version = "${ownLockVersion}"`), - ); - } - const locks = ['0.1.0', '0.5.0'].map((version) => - write(path.join(root, `at-${version}`, 'Cargo.lock'), lockWithDependency('harness', 'app', version)), - ); - return { root, locks, bump: { cargoTomlPath: manifest, name: 'app', version: '0.2.0' } }; - } - - it('should take the old version from the crate’s own lock when its manifest doesn’t record one', async () => { - const { root, locks, bump } = versionlessBump('0.1.0'); - - const updates = await planCargoLockUpdates(fakeGit(locks), root, [bump]); - - expect(updates.map((update) => update.lockPath)).toEqual([locks[0], path.join(root, 'app', 'Cargo.lock')]); - }); - - it('should update a path dependency at any version when nothing records the crate’s old version', async () => { - const { root, locks, bump } = versionlessBump(); - - const updates = await planCargoLockUpdates(fakeGit(locks), root, [bump]); - - expect(updates.map((update) => update.lockPath)).toEqual(locks); - }); - - /** A lock that uses `app` 0.1.0 by path, and two same-named crates bumped from 0.1.0. */ - function sameNameBumps(otherVersion: string): { root: string; harness: string; bumps: CargoLockBump[] } { - const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-names-'))); - tmpDirs.push(root); - const harness = write(path.join(root, 'harness', 'Cargo.lock'), lockWithDependency('harness', 'app', '0.1.0')); - const bumps = [ - { cargoTomlPath: path.join(root, 'one', 'Cargo.toml'), name: 'app', version: '0.2.0', fromVersion: '0.1.0' }, - { cargoTomlPath: path.join(root, 'two', 'Cargo.toml'), name: 'app', version: otherVersion, fromVersion: '0.1.0' }, + path.join(root, 'plugins', 'Cargo.toml'), + '[workspace]\nmembers = ["api", "core"]\n\n[workspace.dependencies]\ncore = { path = "core" }\n', + ), + crate('plugins/core', 'core', '1.0.0'), + crate('plugins/api', 'api', '0.1.0', apiDependencies), + crate('app', 'app', '0.1.0', `[workspace]\n\n${appDependencies}`), + crate('app/core', 'core', '1.0.0'), ]; - return { root, harness, bumps }; + const pluginsLock = write(path.join(root, 'plugins', 'Cargo.lock'), lockWithDependency('api', 'core', '1.0.0')); + const appLock = write(path.join(root, 'app', 'Cargo.lock'), lockWithDependency('app', 'core', '1.0.0')); + return { + root, + pluginsLock, + appLock, + git: fakeGit([...manifests, pluginsLock, appLock]), + bump: { cargoTomlPath: manifests[1], name: 'core', version: '1.1.0' }, + }; } - it('should refuse a path dependency that same-named crates bump to different versions', async () => { - const { root, harness, bumps } = sameNameBumps('0.1.1'); + it.each([ + ['depends on the bumped crate by path', '[dependencies]\ncore = { path = "../plugins/core" }', ''], + [ + 'has it as a target-specific dev-dependency', + `[target.'cfg(unix)'.dev-dependencies]\ncore = { path = "../plugins/core" }`, + '', + ], + [ + 'inherits it from its workspace', + '[workspace.dependencies]\ncore = { path = "../plugins/core" }\n\n[dependencies]\ncore = { workspace = true }', + '', + ], + ['patches it in', '[patch.crates-io]\ncore = { path = "../plugins/core" }', ''], + [ + 'depends on a crate that inherits it from another workspace', + '[dependencies]\napi = { path = "../plugins/api" }', + '[dependencies]\ncore = { workspace = true }', + ], + ])('should sync an app’s lock when the app %s', async (_label, appDependencies, apiDependencies) => { + const { root, appLock, git, bump } = pluginsAndApp(appDependencies, apiDependencies); - await expect(planCargoLockUpdates(fakeGit([harness]), root, bumps)).rejects.toThrow( - `can't set app in ${harness}: it could be the crate at any of ${bumps[0].cargoTomlPath}, ${bumps[1].cargoTomlPath}`, - ); + const updates = await planCargoLockUpdates(git, root, [bump]); + + expect(updates.find((update) => update.lockPath === appLock)).toEqual({ + lockPath: appLock, + content: lockWithDependency('app', 'core', '1.1.0'), + changed: true, + }); }); - it('should sync a path dependency that same-named crates bump to the same version', async () => { - const { root, harness, bumps } = sameNameBumps('0.2.0'); + it.each([ + ['depends on its own crate of the same name and version', '[dependencies]\ncore = { path = "core" }', ''], + [ + 'depends on a crate that has the bumped crate as a dev-dependency', + '[dependencies]\ncore = { path = "core" }\napi = { path = "../plugins/api" }', + '[dev-dependencies]\ncore = { path = "../core" }', + ], + ])('should leave an app’s lock alone when the app %s', async (_label, appDependencies, apiDependencies) => { + const { root, pluginsLock, git, bump } = pluginsAndApp(appDependencies, apiDependencies); - const [update] = await planCargoLockUpdates(fakeGit([harness]), root, bumps); + const updates = await planCargoLockUpdates(git, root, [bump]); - expect(update.content).toBe(lockWithDependency('harness', 'app', '0.2.0')); + expect(updates.map((update) => update.lockPath)).toEqual([pluginsLock]); }); it('should name the lock when an edit cannot be made unambiguously', async () => { - const { root, manifest, appLock } = repoWithLocks(); + const { root, manifest, appManifest, appLock } = repoWithLocks(); fs.appendFileSync(appLock, lock('', '[[package]]', 'name = "core"', 'version = "1.5.0"', `source = "${REGISTRY}"`)); await expect( - planCargoLockUpdates(fakeGit([appLock]), root, [ - { cargoTomlPath: manifest, name: 'core', version: '1.5.0', fromVersion: '1.4.0' }, + planCargoLockUpdates(fakeGit([appLock, appManifest]), root, [ + { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }, ]), ).rejects.toThrow(`can't set core to 1.5.0 in ${appLock}: it also has core 1.5.0 from another source`); }); diff --git a/packages/publish/src/stages/prepare.ts b/packages/publish/src/stages/prepare.ts index df6818fa..616bf2e1 100644 --- a/packages/publish/src/stages/prepare.ts +++ b/packages/publish/src/stages/prepare.ts @@ -73,9 +73,9 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { continue; } - const fromVersion = updateCargoVersion(cargoPath, update.newVersion); + updateCargoVersion(cargoPath, update.newVersion); debug(`Updated ${cargoPath} to version ${update.newVersion}`); - bumps.push({ cargoTomlPath: cargoPath, version: update.newVersion, fromVersion }); + bumps.push({ cargoTomlPath: cargoPath, version: update.newVersion }); } const lockfiles = await syncCargoLockfiles(cwd, bumps); diff --git a/packages/publish/src/utils/cargo.ts b/packages/publish/src/utils/cargo.ts index 5747a190..4c93ad50 100644 --- a/packages/publish/src/utils/cargo.ts +++ b/packages/publish/src/utils/cargo.ts @@ -42,17 +42,13 @@ export async function syncCargoLockfiles(cwd: string, bumps: Array)); } - const previous = cargo.package.version; - cargo.package.version = newVersion; - fs.writeFileSync(cargoPath, TOML.stringify(cargo as Record)); - return typeof previous === 'string' ? previous : undefined; } catch (error) { throw createPublishError( PublishErrorCode.CARGO_TOML_ERROR, diff --git a/packages/publish/test/unit/stages/prepare.spec.ts b/packages/publish/test/unit/stages/prepare.spec.ts index c455f91f..a861e545 100644 --- a/packages/publish/test/unit/stages/prepare.spec.ts +++ b/packages/publish/test/unit/stages/prepare.spec.ts @@ -10,7 +10,9 @@ import type { PipelineContext } from '../../../src/types.js'; /** A committed lock listing the path crate `foo` at `version`. */ const fooLock = (version: string): string => `version = 4\n\n[[package]]\nname = "foo"\nversion = "${version}"\n`; -/** A fixture app's committed lock: the app uses `foo` (at `version`) by path. */ +/** A fixture app's manifest and committed lock: the app uses `foo` (at `version`) by path. */ +const appManifestUsingFoo = + '[package]\nname = "app"\nversion = "0.1.0"\n\n[workspace]\n\n[dependencies]\nfoo = { path = "../../crates/foo" }\n'; const appLockUsingFoo = (version: string): string => `${fooLock(version)}\n[[package]]\nname = "app"\nversion = "0.1.0"\ndependencies = [\n "foo",\n]\n`; @@ -183,6 +185,7 @@ describe('prepare stage', () => { fs.writeFileSync(ownLock, fooLock('1.0.0')); const appLock = path.join(dir, 'fixtures', 'app', 'Cargo.lock'); fs.mkdirSync(path.dirname(appLock), { recursive: true }); + fs.writeFileSync(path.join(path.dirname(appLock), 'Cargo.toml'), appManifestUsingFoo); fs.writeFileSync(appLock, appLockUsingFoo('1.0.0')); execFileSync('git', ['init', '-q'], { cwd: dir }); execFileSync('git', ['add', '-A'], { cwd: dir }); @@ -207,44 +210,6 @@ describe('prepare stage', () => { expect(ctx.additionalFiles).toEqual(expect.arrayContaining([ownLock, appLock])); }); - it('should leave a lock alone that uses a different crate of the same name at another version', async () => { - // No own lock records foo's old version, so only the manifest's says which `foo` the bump is. - const dir = createTmpDir(); - const crateDir = path.join(dir, 'crates', 'foo'); - fs.mkdirSync(crateDir, { recursive: true }); - fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); - const appLock = path.join(dir, 'fixtures', 'app', 'Cargo.lock'); - const otherLock = path.join(dir, 'fixtures', 'other', 'Cargo.lock'); - for (const [lockPath, version] of [ - [appLock, '1.0.0'], - [otherLock, '0.9.0'], - ]) { - fs.mkdirSync(path.dirname(lockPath), { recursive: true }); - fs.writeFileSync(lockPath, appLockUsingFoo(version)); - } - execFileSync('git', ['init', '-q'], { cwd: dir }); - execFileSync('git', ['add', '-A'], { cwd: dir }); - - const config = getDefaultConfig(); - config.cargo.enabled = true; - const ctx = createContext({ - cwd: dir, - config, - input: { - dryRun: false, - updates: [{ packageName: 'foo', newVersion: '1.1.0', filePath: 'crates/foo/Cargo.toml' }], - changelogs: [], - tags: [], - }, - }); - - await runPrepareStage(ctx); - - expect(fs.readFileSync(appLock, 'utf-8')).toBe(appLockUsingFoo('1.1.0')); - expect(fs.readFileSync(otherLock, 'utf-8')).toBe(appLockUsingFoo('0.9.0')); - expect(ctx.additionalFiles).toEqual([appLock]); - }); - it('should not fail the publish when a lock cannot be synced', async () => { const dir = createTmpDir(); const crateDir = path.join(dir, 'crates', 'foo'); @@ -283,6 +248,7 @@ describe('prepare stage', () => { fs.writeFileSync(lockPath, fooLock('1.1.0')); const appLock = path.join(dir, 'fixtures', 'app', 'Cargo.lock'); fs.mkdirSync(path.dirname(appLock), { recursive: true }); + fs.writeFileSync(path.join(path.dirname(appLock), 'Cargo.toml'), appManifestUsingFoo); fs.writeFileSync(appLock, appLockUsingFoo('1.1.0')); execFileSync('git', ['init', '-q'], { cwd: dir }); execFileSync('git', ['add', '-A'], { cwd: dir }); diff --git a/packages/version/src/cargo/cargoHandler.ts b/packages/version/src/cargo/cargoHandler.ts index ce772cb2..39a0de78 100644 --- a/packages/version/src/cargo/cargoHandler.ts +++ b/packages/version/src/cargo/cargoHandler.ts @@ -72,8 +72,6 @@ export function updateCargoVersion(cargoPath: string, version: string, dryRun = throw new Error(`No package name found in ${cargoPath}`); } - // Not a string when the crate inherits `version.workspace = true`. - const fromVersion = typeof cargo.package?.version === 'string' ? cargo.package.version : undefined; // Update the version in the manifest if (!cargo.package) { cargo.package = { name: packageName, version }; @@ -92,7 +90,7 @@ export function updateCargoVersion(cargoPath: string, version: string, dryRun = // Track update for JSON output addPackageUpdate(packageName, version, cargoPath); - recordCargoBump({ cargoTomlPath: cargoPath, name: packageName, version, fromVersion }); + recordCargoBump({ cargoTomlPath: cargoPath, name: packageName, version }); log(`${dryRun ? '[DRY RUN] Would update' : 'Updated'} Cargo.toml at ${cargoPath} to version ${version}`, 'success'); } catch (error) { diff --git a/packages/version/test/unit/cargo/cargoHandler.spec.ts b/packages/version/test/unit/cargo/cargoHandler.spec.ts index c759d37d..b9b1932d 100644 --- a/packages/version/test/unit/cargo/cargoHandler.spec.ts +++ b/packages/version/test/unit/cargo/cargoHandler.spec.ts @@ -156,7 +156,6 @@ describe('Cargo Handler', () => { cargoTomlPath: mockCargoPath, name: 'test-package', version: '2.0.0', - fromVersion: '1.0.0', }); }); @@ -172,7 +171,6 @@ describe('Cargo Handler', () => { cargoTomlPath: mockCargoPath, name: 'test-package', version: '2.0.0', - fromVersion: '1.0.0', }); }); diff --git a/test/integration/cargo-lock-sync.spec.ts b/test/integration/cargo-lock-sync.spec.ts index d01f566d..7b93d0d3 100644 --- a/test/integration/cargo-lock-sync.spec.ts +++ b/test/integration/cargo-lock-sync.spec.ts @@ -202,6 +202,49 @@ describe.skipIf(!cargoAvailable())('Cargo.lock sync (real cargo)', () => { } }); + it('should leave another workspace’s lock alone when it uses its own crate of the same name and version', () => { + const repo = join(dir, 'repo'); + mkdirSync(join(repo, 'crates'), { recursive: true }); + writeFileSync(join(repo, 'crates', 'Cargo.toml'), '[workspace]\nmembers = ["utils"]\nresolver = "2"\n'); + writeCrate(join(repo, 'crates', 'utils'), 'utils', '0.1.0'); + writeCrate( + join(repo, 'fixtures', 'app'), + 'fixture-app', + '0.1.0', + 'publish = false\n\n[workspace]\n\n[dependencies]\nutils = { path = "utils" }\n', + ); + writeCrate(join(repo, 'fixtures', 'app', 'utils'), 'utils', '0.1.0', 'publish = false\n'); + const manifests = [join(repo, 'crates', 'Cargo.toml'), join(repo, 'fixtures', 'app', 'Cargo.toml')]; + for (const manifest of manifests) { + cargo(repo, emptyCargoHome, 'generate-lockfile', '--offline', '--manifest-path', manifest); + } + commitWithChange(repo, ['utils'], join(repo, 'crates', 'utils')); + const fixtureLock = join(repo, 'fixtures', 'app', 'Cargo.lock'); + const untouched = readFileSync(fixtureLock, 'utf-8'); + + releasekitVersion(repo, {}); + + expect(crateVersion(join(repo, 'crates', 'utils', 'Cargo.toml'))).toBe('0.2.0'); + expect(pathEntryVersions(readFileSync(join(repo, 'crates', 'Cargo.lock'), 'utf-8'), 'utils')).toEqual(['0.2.0']); + expect(readFileSync(fixtureLock, 'utf-8')).toBe(untouched); + for (const manifest of manifests) { + const lockPath = join(manifest, '..', 'Cargo.lock'); + const lock = readFileSync(lockPath, 'utf-8'); + cargo( + repo, + emptyCargoHome, + 'metadata', + '--locked', + '--offline', + '--format-version', + '1', + '--manifest-path', + manifest, + ); + expect(readFileSync(lockPath, 'utf-8')).toBe(lock); + } + }); + it('should write the same lock cargo writes, also when the bump passes other versions of the crate', () => { // Git crates also named `crate-b` make cargo spell out versions in its references. Bumping 0.1.0 to // 0.2.0 passes 0.1.5 in entry order (semver) and 0.10.0 in reference order (string), so both re-sort. From c14d10e69e2ad0bf17875c986a9c1f5a4e239f89 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 04:13:56 +0100 Subject: [PATCH 16/18] fix(core): follow package.workspace pointers, and only the root's [patch] Cargo honours [patch] only in the workspace root's manifest, so following it from every visited manifest could rewrite an unrelated same-named entry. And a member outside its workspace's directory points back with package.workspace; without following it, its real lock went stale. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/cargo.ts | 17 ++++++++++++----- packages/core/test/unit/cargo.spec.ts | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index 36084bb1..5e82f79e 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -33,14 +33,19 @@ function isExcluded(workspace: CargoWorkspace, rootDir: string, manifestPath: st } /** - * The crate's workspace root, by cargo's rule: the crate itself if its manifest has a `[workspace]`, else - * the nearest ancestor `[workspace]` that doesn't exclude it, else the crate. `package.workspace` - * pointers aren't followed. + * The crate's workspace root, by cargo's rule: its `package.workspace` pointer if set, else the crate + * itself if its manifest has a `[workspace]`, else the nearest ancestor `[workspace]` that doesn't exclude + * it, else the crate. */ function findCargoWorkspaceRoot(crateDir: string, read: ReadManifest = readManifest): string { const manifestPath = path.join(path.resolve(crateDir), 'Cargo.toml'); + const manifest = read(manifestPath); + const pointer = asTable(manifest?.package).workspace; + if (typeof pointer === 'string') { + return path.resolve(path.dirname(manifestPath), pointer); + } let root = path.dirname(manifestPath); - if (!read(manifestPath)?.workspace) { + if (!manifest?.workspace) { for (let dir = root; path.dirname(dir) !== dir; ) { dir = path.dirname(dir); const workspace = read(path.join(dir, 'Cargo.toml'))?.workspace as CargoWorkspace | undefined; @@ -84,7 +89,9 @@ function findPathCrates(lockRoot: string, manifestDirs: string[], read: ReadMani const tables = [manifest, ...Object.values(asTable(manifest.target)).map(asTable)].flatMap((scope) => kinds.map((kind) => asTable(scope[kind])), ); - for (const table of [...tables, ...Object.values(asTable(manifest.patch)).map(asTable)]) { + // Cargo honours `[patch]` only in the workspace root's manifest. + const patches = dir === lockRoot ? Object.values(asTable(manifest.patch)).map(asTable) : []; + for (const table of [...tables, ...patches]) { for (const [key, value] of Object.entries(table)) { const dependency = asTable(value); const [base, source] = dependency.workspace === true ? [root, asTable(inherited[key])] : [dir, dependency]; diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index d1599f18..51ce5127 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -48,6 +48,22 @@ describe('findCargoLockfile', () => { expect(findCargoLockfile(crate)).toBe(path.join(crate, 'Cargo.lock')); }); + it('should follow a member’s package.workspace pointer to a root outside its directory', () => { + const root = tmp(); + const workspace = path.join(root, 'workspace'); + const member = path.join(root, 'member'); + fs.mkdirSync(workspace); + fs.mkdirSync(member); + fs.writeFileSync(path.join(workspace, 'Cargo.toml'), '[workspace]\nmembers = ["../member"]\n'); + fs.writeFileSync(path.join(workspace, 'Cargo.lock'), ''); + fs.writeFileSync( + path.join(member, 'Cargo.toml'), + '[package]\nname = "member"\nversion = "0.1.0"\nworkspace = "../workspace"\n', + ); + + expect(findCargoLockfile(member)).toBe(path.join(workspace, 'Cargo.lock')); + }); + it.each([ ['an ancestor workspace excludes it', '[workspace]\nmembers = ["crates/*"]\nexclude = ["apps/x"]\n', 'own'], ['an ancestor workspace excludes a directory above it', '[workspace]\nexclude = ["./apps/"]\n', 'own'], @@ -560,6 +576,11 @@ describe('planCargoLockUpdates', () => { '[dependencies]\ncore = { path = "core" }\napi = { path = "../plugins/api" }', '[dev-dependencies]\ncore = { path = "../core" }', ], + [ + 'depends on a crate whose patch, which cargo ignores outside the root, points at the bumped crate', + '[dependencies]\ncore = { path = "core" }\napi = { path = "../plugins/api" }', + '[patch.crates-io]\ncore = { path = "../core" }', + ], ])('should leave an app’s lock alone when the app %s', async (_label, appDependencies, apiDependencies) => { const { root, pluginsLock, git, bump } = pluginsAndApp(appDependencies, apiDependencies); From 659299ed6743dc14e4fd1c29c74b9b884cc10ba4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 12:29:54 +0100 Subject: [PATCH 17/18] fix(core): refuse to edit a Cargo.lock format newer than v4 Also document that `[patch]` in `.cargo/config.toml` isn't followed. Co-Authored-By: Claude Opus 5.5 --- docs/rust.md | 2 ++ packages/core/src/cargo.ts | 22 ++++++++++++++++++++-- packages/core/test/unit/cargo.spec.ts | 8 ++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/rust.md b/docs/rust.md index 9b7f1ced..8ff63bd6 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -23,6 +23,7 @@ See [configuration.md](./configuration.md) for the full config reference. - OIDC / trusted publishing for crates.io — token-based auth only via `CARGO_REGISTRY_TOKEN` - Automatic workspace root discovery — crate directories must be listed in `version.cargo.paths` or inferred from `version.packages` +- `[patch]` in `.cargo/config.toml` — a lock whose workspace reaches a bumped crate only through it isn't synced - Comment and formatting preservation in `Cargo.toml` — the file is rewritten on version bump using `smol-toml`'s serialiser; whitespace, comments, and key order may change --- @@ -214,6 +215,7 @@ releasekit edits each lock's text the way cargo would, and fails the version ste - `it also has from another source`: the lock also lists the crate from a registry or git source at the old or the new version, so cargo would have to change how it tells the two apart. Sync that lock with cargo yourself (`cargo update` with the new version in `Cargo.toml`) and commit it, or drop the second source of the crate. - `it has path entries named `: the lock holds several local crates of that name, so releasekit can't tell which one is the bumped crate. Sync that lock with cargo yourself. +- `its format (version = ) is newer than releasekit can edit`: releasekit edits lock formats up to v4. Sync that lock with cargo yourself. - A file-system error such as `EACCES`: the lock couldn't be read or written. **`error: failed to verify package tarball`** diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index 5e82f79e..afbf4c14 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -157,6 +157,20 @@ function readCargoLockEntries(lines: string[]): CargoLockEntry[] { return entries; } +/** The top-level `version = N` header; v1 and v2 locks have none. */ +function readCargoLockFormat(lines: string[]): number | undefined { + for (const line of lines) { + if (line.startsWith('[')) { + return undefined; + } + const header = /^version = (\d+)\s*$/.exec(line); + if (header) { + return Number(header[1]); + } + } + return undefined; +} + function parseVersion(version: string): { core: number[]; pre: string[]; build: string } { const plus = version.indexOf('+'); const withoutBuild = plus === -1 ? version : version.slice(0, plus); @@ -241,11 +255,15 @@ function sortReferenceRuns(lines: string[], name: string, version: string): void * `" "` references (cargo writes the version into a reference only when the lock * holds several versions of the name), re-sorting same-name entries if their order changes. * - * Undefined when the lock has no path entry for the crate. Throws when the name is ambiguous: several - * path entries, or another source's entry at the old or new version. + * Undefined when the lock has no path entry for the crate. Throws when the name is ambiguous (several + * path entries, or another source's entry at the old or new version) or the lock format is newer than v4. */ export function setCargoLockVersion(lockContent: string, crateName: string, version: string): string | undefined { const lines = lockContent.split('\n'); + const format = readCargoLockFormat(lines); + if (format !== undefined && format > 4) { + throw new Error(`its format (version = ${format}) is newer than releasekit can edit`); + } const sameName = readCargoLockEntries(lines).filter((entry) => entry.name === crateName); const own = sameName.filter((entry) => !entry.hasSource); if (own.length === 0) { diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index 51ce5127..df14d663 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -378,6 +378,14 @@ describe('setCargoLockVersion', () => { expect(() => setCargoLockVersion(before, 'core', '2.0.0')).toThrow('it has 2 path entries named core'); }); + + it('should throw on a lock format newer than v4', () => { + const before = lock('version = 5', '', '[[package]]', 'name = "core"', 'version = "1.4.0"'); + + expect(() => setCargoLockVersion(before, 'core', '1.5.0')).toThrow( + 'its format (version = 5) is newer than releasekit can edit', + ); + }); }); describe('planCargoLockUpdates', () => { From 44c04b18b1c7b647f50e8a8cf9d8ea2a04f77f00 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 13:21:40 +0100 Subject: [PATCH 18/18] fix(core): stop following [patch] when syncing Cargo.lock Cargo drops a patch the bumped version no longer satisfies, so following it could write a lock cargo never would. Support is tracked in #706. Co-Authored-By: Claude Opus 5.5 --- docs/rust.md | 4 ++-- packages/core/src/cargo.ts | 4 +--- packages/core/test/unit/cargo.spec.ts | 7 +------ 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/docs/rust.md b/docs/rust.md index 8ff63bd6..66d75e2c 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -9,7 +9,7 @@ See [configuration.md](./configuration.md) for the full config reference. ## What's supported - `Cargo.toml` version bumping driven by conventional commits -- `Cargo.lock` sync on every bump: the crate's own lock, and every other committed lock whose workspace reaches the crate's directory through path dependencies (`workspace = true` and `[patch]` included), is updated in the same release commit, without running cargo. Gitignored locks are left alone, and so are other crates of the same name (every `tauri init` app is `app`) +- `Cargo.lock` sync on every bump: the crate's own lock, and every other committed lock whose workspace reaches the crate's directory through path dependencies (`workspace = true` included), is updated in the same release commit, without running cargo. Gitignored locks are left alone, and so are other crates of the same name (every `tauri init` app is `app`) - Per-crate git tags (via `version.packageSpecificTags`) - Publishing to crates.io with `cargo publish` - Workspace dependency ordering via topological sort (Kahn's algorithm) so path-dependent crates are published in the correct sequence @@ -23,7 +23,7 @@ See [configuration.md](./configuration.md) for the full config reference. - OIDC / trusted publishing for crates.io — token-based auth only via `CARGO_REGISTRY_TOKEN` - Automatic workspace root discovery — crate directories must be listed in `version.cargo.paths` or inferred from `version.packages` -- `[patch]` in `.cargo/config.toml` — a lock whose workspace reaches a bumped crate only through it isn't synced +- `[patch]`, in `Cargo.toml` or `.cargo/config.toml`, when syncing `Cargo.lock` — a lock whose workspace reaches a bumped crate only through a patch keeps the old version, without an error, and `cargo build --locked` there fails until you run `cargo update --workspace` in that workspace and commit the lock - Comment and formatting preservation in `Cargo.toml` — the file is rewritten on version bump using `smol-toml`'s serialiser; whitespace, comments, and key order may change --- diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index afbf4c14..3ac042d7 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -89,9 +89,7 @@ function findPathCrates(lockRoot: string, manifestDirs: string[], read: ReadMani const tables = [manifest, ...Object.values(asTable(manifest.target)).map(asTable)].flatMap((scope) => kinds.map((kind) => asTable(scope[kind])), ); - // Cargo honours `[patch]` only in the workspace root's manifest. - const patches = dir === lockRoot ? Object.values(asTable(manifest.patch)).map(asTable) : []; - for (const table of [...tables, ...patches]) { + for (const table of tables) { for (const [key, value] of Object.entries(table)) { const dependency = asTable(value); const [base, source] = dependency.workspace === true ? [root, asTable(inherited[key])] : [dir, dependency]; diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index df14d663..77235d8a 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -559,7 +559,6 @@ describe('planCargoLockUpdates', () => { '[workspace.dependencies]\ncore = { path = "../plugins/core" }\n\n[dependencies]\ncore = { workspace = true }', '', ], - ['patches it in', '[patch.crates-io]\ncore = { path = "../plugins/core" }', ''], [ 'depends on a crate that inherits it from another workspace', '[dependencies]\napi = { path = "../plugins/api" }', @@ -584,11 +583,7 @@ describe('planCargoLockUpdates', () => { '[dependencies]\ncore = { path = "core" }\napi = { path = "../plugins/api" }', '[dev-dependencies]\ncore = { path = "../core" }', ], - [ - 'depends on a crate whose patch, which cargo ignores outside the root, points at the bumped crate', - '[dependencies]\ncore = { path = "core" }\napi = { path = "../plugins/api" }', - '[patch.crates-io]\ncore = { path = "../core" }', - ], + ['patches it in', '[patch.crates-io]\ncore = { path = "../plugins/core" }', ''], ])('should leave an app’s lock alone when the app %s', async (_label, appDependencies, apiDependencies) => { const { root, pluginsLock, git, bump } = pluginsAndApp(appDependencies, apiDependencies);