From 7c68b0403e0d5892a0289b30f11bf2dfda75dcf5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 12 Jan 2026 22:28:15 +0000 Subject: [PATCH 01/16] feat: Automatic version bumping from configured targets When minVersion >= 2.19.0 and no preReleaseCommand is defined, Craft automatically bumps version numbers based on configured publish targets. Supported targets: - npm: npm version --no-git-tag-version - pypi: hatch, poetry, setuptools-scm, or direct pyproject.toml edit - crates: cargo set-version (cargo-edit) - gem: Direct edit of gemspec and lib/**/version.rb - pub-dev: Direct edit of pubspec.yaml - hex: Direct edit of mix.exs - nuget: dotnet-setversion or direct XML edit Fixes #76 --- docs/src/content/docs/configuration.md | 72 ++++++++++++ docs/src/content/docs/getting-started.md | 4 +- src/commands/prepare.ts | 91 ++++++++++++--- src/targets/crates.ts | 61 +++++++++- src/targets/gem.ts | 93 +++++++++++++++ src/targets/hex.ts | 59 ++++++++++ src/targets/npm.ts | 49 ++++++++ src/targets/nuget.ts | 125 +++++++++++++++++++- src/targets/pubDev.ts | 40 ++++++- src/targets/pypi.ts | 141 ++++++++++++++++++++++- src/utils/versionBump.ts | 100 ++++++++++++++++ 11 files changed, 817 insertions(+), 18 deletions(-) create mode 100644 src/utils/versionBump.ts diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index ebeeabab..ea7cf8c0 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -41,6 +41,78 @@ export npm_config_git_tag_version=false npm version "${NEW_VERSION}" ``` +## Automatic Version Bumping + +When `minVersion: "2.19.0"` or higher is set and no custom `preReleaseCommand` is defined, Craft automatically bumps version numbers based on your configured publish targets. This eliminates the need for a `scripts/bump-version.sh` script in most cases. + +### How It Works + +1. Craft examines your configured `targets` in `.craft.yml` +2. For each target that supports version bumping, Craft updates the appropriate project files +3. Targets are processed in the order they appear in your configuration +4. Each target type is only processed once (e.g., multiple npm targets won't bump `package.json` twice) + +### Supported Targets + +| Target | Detection | Version Bump Method | +|--------|-----------|---------------------| +| `npm` | `package.json` exists | `npm version --no-git-tag-version` | +| `pypi` | `pyproject.toml` exists | hatch, poetry, setuptools-scm, or direct edit | +| `crates` | `Cargo.toml` exists | `cargo set-version` (requires cargo-edit) | +| `gem` | `*.gemspec` exists | Direct edit of gemspec and `lib/**/version.rb` | +| `pub-dev` | `pubspec.yaml` exists | Direct edit of pubspec.yaml | +| `hex` | `mix.exs` exists | Direct edit of mix.exs | +| `nuget` | `*.csproj` exists | dotnet-setversion or direct XML edit | + +### Python (pypi) Detection Priority + +For Python projects, Craft detects the build tool and uses the appropriate method: + +1. **Hatch** - If `[tool.hatch]` section exists → `hatch version ` +2. **Poetry** - If `[tool.poetry]` section exists → `poetry version ` +3. **setuptools-scm** - If `[tool.setuptools_scm]` section exists → No-op (version derived from git tags) +4. **Direct edit** - If `[project]` section with `version` field exists → Edit `pyproject.toml` directly + +### Enabling Automatic Version Bumping + +To enable automatic version bumping, ensure your `.craft.yml` has: + +```yaml +minVersion: "2.19.0" +targets: + - name: npm # or pypi, crates, etc. + # ... other targets +``` + +And either: +- Remove any custom `preReleaseCommand`, or +- Don't define `preReleaseCommand` at all + +### Disabling Automatic Version Bumping + +To disable automatic version bumping while still using minVersion 2.19.0+: + +```yaml +minVersion: "2.19.0" +preReleaseCommand: "" # Explicitly set to empty string +``` + +Or define a custom script: + +```yaml +minVersion: "2.19.0" +preReleaseCommand: bash scripts/my-custom-bump.sh +``` + +### Error Handling + +If automatic version bumping fails: +- **Missing tool**: Craft reports which tool is missing (e.g., "Cannot find 'npm' for version bumping") +- **Command failure**: Craft shows the error from the failed command +- **No supported targets**: Craft warns that no targets support automatic bumping + +In all error cases, Craft suggests defining a custom `preReleaseCommand` as a fallback. + ## Post-release Command This command runs after a successful `craft publish`. Default: `bash scripts/post-release.sh`. diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md index 2afef2c7..321ab028 100644 --- a/docs/src/content/docs/getting-started.md +++ b/docs/src/content/docs/getting-started.md @@ -288,7 +288,9 @@ export NUGET_API_TOKEN=abcdefgh 3. **Add `.craft.yml`** to your project with targets and options. -4. **Add a pre-release script** (default: `scripts/bump-version.sh`). +4. **Set up version bumping** (one of): + - **Automatic** (recommended): Set `minVersion: "2.19.0"` and Craft will automatically bump versions based on your targets (npm, pypi, crates, etc.) + - **Custom script**: Add `scripts/bump-version.sh` (or set `preReleaseCommand`) 5. **Configure environment variables** for your targets. diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 8dd9af9f..f31cacdb 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -8,6 +8,7 @@ import { Arguments, Argv, CommandBuilder } from 'yargs'; import { getConfiguration, + getConfigFileDir, DEFAULT_RELEASE_BRANCH_NAME, getGlobalGitHubConfig, requiresMinVersion, @@ -16,7 +17,7 @@ import { getVersioningPolicy, } from '../config'; import { logger } from '../logger'; -import { ChangelogPolicy, VersioningPolicy } from '../schemas/project_config'; +import { ChangelogPolicy, TargetConfig, VersioningPolicy } from '../schemas/project_config'; import { calculateCalVer, DEFAULT_CALVER_CONFIG } from '../utils/calver'; import { sleep } from '../utils/async'; import { @@ -48,6 +49,7 @@ import { import { formatJson } from '../utils/strings'; import { spawnProcess } from '../utils/system'; import { isValidVersion } from '../utils/version'; +import { runAutomaticVersionBumps } from '../utils/versionBump'; import { withTracing } from '../utils/tracing'; import { handler as publishMainHandler, PublishOptions } from './publish'; @@ -62,6 +64,9 @@ const DEFAULT_BUMP_VERSION_PATH = join('scripts', 'bump-version.sh'); /** Minimum craft version required for auto-versioning */ const AUTO_VERSION_MIN_VERSION = '2.14.0'; +/** Minimum craft version required for automatic version bumping from targets */ +const AUTO_BUMP_MIN_VERSION = '2.19.0'; + export const builder: CommandBuilder = (yargs: Argv) => yargs .positional('NEW-VERSION', { @@ -269,27 +274,79 @@ async function commitNewVersion( await git.commit(message, ['--all']); } +/** Options for running pre-release commands */ +interface PreReleaseOptions { + /** Previous version (from git tag) */ + oldVersion: string; + /** New version to release */ + newVersion: string; + /** Custom pre-release command from config */ + preReleaseCommand?: string; + /** Target configurations from .craft.yml */ + targets?: TargetConfig[]; + /** Project root directory */ + rootDir: string; +} + /** - * Run an external pre-release command + * Run an external pre-release command or automatic version bumping. * - * The command usually executes operations for version bumping and might + * The command/bumping executes operations for version bumping and might * include dependency updates. * - * @param newVersion Version being released - * @param preReleaseCommand Custom pre-release command + * Behavior: + * - If preReleaseCommand is explicitly set to empty string: do nothing + * - If preReleaseCommand is defined: run the custom command + * - If minVersion >= 2.19.0 and targets are defined: use automatic version bumping + * - Otherwise: run default scripts/bump-version.sh + * + * @param options Pre-release options */ export async function runPreReleaseCommand( + options: PreReleaseOptions +): Promise { + const { oldVersion, newVersion, preReleaseCommand, targets, rootDir } = options; + + // If preReleaseCommand is explicitly set to empty string, skip version bumping + if (preReleaseCommand !== undefined && preReleaseCommand.length === 0) { + logger.warn('Not running the pre-release command: no command specified'); + return false; + } + + // If a custom preReleaseCommand is defined, use it + if (preReleaseCommand) { + return runCustomPreReleaseCommand(oldVersion, newVersion, preReleaseCommand); + } + + // If minVersion >= 2.19.0 and targets are configured, use automatic version bumping + if (requiresMinVersion(AUTO_BUMP_MIN_VERSION) && targets && targets.length > 0) { + logger.info('Running automatic version bumping from targets...'); + const anyBumped = await runAutomaticVersionBumps(targets, rootDir, newVersion); + + if (!anyBumped) { + logger.warn( + 'No targets support automatic version bumping. ' + + 'Consider adding a scripts/bump-version.sh script or defining preReleaseCommand in .craft.yml' + ); + } + + return anyBumped; + } + + // Fall back to default bump-version.sh script + return runCustomPreReleaseCommand(oldVersion, newVersion, undefined); +} + +/** + * Run a custom pre-release command (or the default bump-version.sh script) + */ +async function runCustomPreReleaseCommand( oldVersion: string, newVersion: string, preReleaseCommand?: string ): Promise { let sysCommand: string; let args: string[]; - if (preReleaseCommand !== undefined && preReleaseCommand.length === 0) { - // Not running pre-release command - logger.warn('Not running the pre-release command: no command specified'); - return false; - } // This is a workaround for the case when the old version is empty, which // should only happen when the project is new and has no version yet. @@ -297,21 +354,26 @@ export async function runPreReleaseCommand( // avoid breaking the pre-release command as most scripts expect a non-empty // version string. const nonEmptyOldVersion = oldVersion || '0.0.0'; + if (preReleaseCommand) { [sysCommand, ...args] = shellQuote.parse(preReleaseCommand) as string[]; } else { sysCommand = '/bin/bash'; args = [DEFAULT_BUMP_VERSION_PATH]; } + args = [...args, nonEmptyOldVersion, newVersion]; logger.info('Running the pre-release command...'); + const additionalEnv = { CRAFT_NEW_VERSION: newVersion, CRAFT_OLD_VERSION: nonEmptyOldVersion, }; + await spawnProcess(sysCommand, args, { env: { ...process.env, ...additionalEnv }, }); + return true; } @@ -713,11 +775,14 @@ export async function prepareMain(argv: PrepareOptions): Promise { ); // Run a pre-release script (e.g. for version bumping) - const preReleaseCommandRan = await runPreReleaseCommand( + const rootDir = getConfigFileDir() || process.cwd(); + const preReleaseCommandRan = await runPreReleaseCommand({ oldVersion, newVersion, - config.preReleaseCommand - ); + preReleaseCommand: config.preReleaseCommand, + targets: config.targets, + rootDir, + }); if (preReleaseCommandRan) { // Commit the pending changes diff --git a/src/targets/crates.ts b/src/targets/crates.ts index 33ca8598..c079169f 100644 --- a/src/targets/crates.ts +++ b/src/targets/crates.ts @@ -7,9 +7,14 @@ import { GitHubGlobalConfig, TargetConfig } from '../schemas/project_config'; import { forEachChained, sleep, withRetry } from '../utils/async'; import { ConfigurationError } from '../utils/errors'; import { withTempDir } from '../utils/files'; -import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; +import { + checkExecutableIsPresent, + hasExecutable, + spawnProcess, +} from '../utils/system'; import { BaseTarget } from './base'; import { BaseArtifactProvider } from '../artifact_providers/base'; +import { logger } from '../logger'; const DEFAULT_CARGO_BIN = 'cargo'; @@ -107,6 +112,60 @@ export class CratesTarget extends BaseTarget { /** GitHub repo configuration */ public readonly githubRepo: GitHubGlobalConfig; + /** + * Bump version in Cargo.toml using cargo set-version (from cargo-edit). + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no Cargo.toml exists + * @throws Error if cargo is not found or command fails + */ + public static async bumpVersion( + rootDir: string, + newVersion: string + ): Promise { + const cargoTomlPath = path.join(rootDir, 'Cargo.toml'); + + // Check if Cargo.toml exists + if (!fs.existsSync(cargoTomlPath)) { + return false; + } + + if (!hasExecutable(CARGO_BIN)) { + throw new Error( + `Cannot find "${CARGO_BIN}" for version bumping. ` + + 'Install cargo or define a custom preReleaseCommand in .craft.yml' + ); + } + + // Use cargo set-version from cargo-edit to bump version + // This handles workspaces properly by bumping all crates + const args = ['set-version', newVersion]; + + logger.debug(`Running: ${CARGO_BIN} ${args.join(' ')}`); + + try { + await spawnProcess(CARGO_BIN, args, { cwd: rootDir }); + } catch (error) { + // If cargo set-version is not available, provide helpful error + const message = + error instanceof Error ? error.message : String(error); + if ( + message.includes('no such command') || + message.includes('no such subcommand') + ) { + throw new Error( + 'cargo set-version command not found. ' + + 'Install cargo-edit with: cargo install cargo-edit\n' + + 'Or define a custom preReleaseCommand in .craft.yml' + ); + } + throw error; + } + + return true; + } + public constructor( config: TargetConfig, artifactProvider: BaseArtifactProvider, diff --git a/src/targets/gem.ts b/src/targets/gem.ts index 816b2f9e..1b7acacd 100644 --- a/src/targets/gem.ts +++ b/src/targets/gem.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; + import { BaseArtifactProvider, RemoteArtifact, @@ -6,6 +9,7 @@ import { reportError } from '../utils/errors'; import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; import { BaseTarget } from './base'; import { TargetConfig } from '../schemas/project_config'; +import { logger } from '../logger'; const DEFAULT_GEM_BIN = 'gem'; @@ -26,6 +30,95 @@ export class GemTarget extends BaseTarget { /** Target name */ public readonly name: string = 'gem'; + /** + * Bump version in Ruby gem project files. + * + * Looks for version patterns in: + * 1. *.gemspec files (s.version = "x.y.z") + * 2. lib/**/version.rb files (VERSION = "x.y.z") + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no gem project found + * @throws Error if version file cannot be updated + */ + public static async bumpVersion( + rootDir: string, + newVersion: string + ): Promise { + // Look for gemspec files + const gemspecFiles = readdirSync(rootDir).filter(f => f.endsWith('.gemspec')); + + if (gemspecFiles.length === 0) { + return false; + } + + let bumped = false; + + // Try to update version in gemspec + for (const gemspecFile of gemspecFiles) { + const gemspecPath = join(rootDir, gemspecFile); + const content = readFileSync(gemspecPath, 'utf-8'); + + // Match: s.version = "1.0.0" or spec.version = '1.0.0' + const versionRegex = /^(\s*\w+\.version\s*=\s*["'])([^"']+)(["'])/m; + + if (versionRegex.test(content)) { + const newContent = content.replace(versionRegex, `$1${newVersion}$3`); + if (newContent !== content) { + logger.debug(`Updating version in ${gemspecPath} to ${newVersion}`); + writeFileSync(gemspecPath, newContent); + bumped = true; + } + } + } + + // Also try to update lib/**/version.rb if it exists + const libDir = join(rootDir, 'lib'); + if (existsSync(libDir)) { + bumped = GemTarget.updateVersionRbFiles(libDir, newVersion) || bumped; + } + + return bumped; + } + + /** + * Recursively find and update version.rb files + */ + private static updateVersionRbFiles(dir: string, newVersion: string): boolean { + let updated = false; + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + updated = GemTarget.updateVersionRbFiles(fullPath, newVersion) || updated; + } else if (entry.name === 'version.rb') { + const content = readFileSync(fullPath, 'utf-8'); + + // Match: VERSION = "1.0.0" or VERSION = '1.0.0' + const versionRegex = /^(\s*VERSION\s*=\s*["'])([^"']+)(["'])/m; + + if (versionRegex.test(content)) { + const newContent = content.replace(versionRegex, `$1${newVersion}$3`); + if (newContent !== content) { + logger.debug(`Updating VERSION in ${fullPath} to ${newVersion}`); + writeFileSync(fullPath, newContent); + updated = true; + } + } + } + } + } catch { + // Ignore errors reading directories + } + + return updated; + } + public constructor( config: TargetConfig, artifactProvider: BaseArtifactProvider diff --git a/src/targets/hex.ts b/src/targets/hex.ts index ee415bce..0a0197e6 100644 --- a/src/targets/hex.ts +++ b/src/targets/hex.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + import { createGitClient } from '../utils/git'; import { BaseTarget } from './base'; @@ -6,6 +9,7 @@ import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; import { GitHubGlobalConfig, TargetConfig } from '../schemas/project_config'; import { BaseArtifactProvider } from '../artifact_providers/base'; import { reportError } from '../utils/errors'; +import { logger } from '../logger'; const DEFAULT_MIX_BIN = 'mix'; @@ -30,6 +34,61 @@ export class HexTarget extends BaseTarget { /** GitHub repo configuration */ public readonly githubRepo: GitHubGlobalConfig; + /** + * Bump version in mix.exs for Elixir projects. + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no mix.exs exists + * @throws Error if file cannot be updated + */ + public static async bumpVersion( + rootDir: string, + newVersion: string + ): Promise { + const mixExsPath = join(rootDir, 'mix.exs'); + + // Check if mix.exs exists + if (!existsSync(mixExsPath)) { + return false; + } + + const content = readFileSync(mixExsPath, 'utf-8'); + + // Match version in mix.exs: version: "1.0.0" or @version "1.0.0" + const versionPatterns = [ + // version: "1.0.0" in project definition + /^(\s*version:\s*["'])([^"']+)(["'])/m, + // @version "1.0.0" module attribute + /^(\s*@version\s+["'])([^"']+)(["'])/m, + ]; + + let newContent = content; + let updated = false; + + for (const pattern of versionPatterns) { + if (pattern.test(newContent)) { + newContent = newContent.replace(pattern, `$1${newVersion}$3`); + updated = true; + } + } + + if (!updated) { + logger.debug('No version pattern found in mix.exs'); + return false; + } + + if (newContent === content) { + logger.debug('Version already set to target value'); + return true; + } + + logger.debug(`Updating version in ${mixExsPath} to ${newVersion}`); + writeFileSync(mixExsPath, newContent); + + return true; + } + public constructor( config: TargetConfig, artifactProvider: BaseArtifactProvider, diff --git a/src/targets/npm.ts b/src/targets/npm.ts index 7db3d956..21ac3640 100644 --- a/src/targets/npm.ts +++ b/src/targets/npm.ts @@ -1,4 +1,6 @@ import { SpawnOptions, spawnSync } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; import prompts from 'prompts'; import { TargetConfig } from '../schemas/project_config'; @@ -242,6 +244,53 @@ export class NpmTarget extends BaseTarget { }); } + /** + * Bump version in package.json using npm or yarn. + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no package.json exists + * @throws Error if npm/yarn is not found or command fails + */ + public static async bumpVersion( + rootDir: string, + newVersion: string + ): Promise { + const packageJsonPath = join(rootDir, 'package.json'); + + // Check if package.json exists + if (!existsSync(packageJsonPath)) { + return false; + } + + // Determine which package manager to use + let bin: string; + if (hasExecutable(NPM_BIN)) { + bin = NPM_BIN; + } else if (hasExecutable(YARN_BIN)) { + bin = YARN_BIN; + } else { + throw new Error( + 'Cannot find "npm" or "yarn" for version bumping. ' + + 'Install npm/yarn or define a custom preReleaseCommand in .craft.yml' + ); + } + + // Run version bump command + // --no-git-tag-version prevents npm from creating a git commit and tag + // --allow-same-version allows setting the same version (useful for re-runs) + const args = + bin === NPM_BIN + ? ['version', newVersion, '--no-git-tag-version', '--allow-same-version'] + : ['version', newVersion, '--no-git-tag-version']; + + logger.debug(`Running: ${bin} ${args.join(' ')}`); + + await spawnProcess(bin, args, { cwd: rootDir }); + + return true; + } + public constructor( config: NpmTargetConfig, artifactProvider: BaseArtifactProvider diff --git a/src/targets/nuget.ts b/src/targets/nuget.ts index aba0a5eb..77a799f4 100644 --- a/src/targets/nuget.ts +++ b/src/targets/nuget.ts @@ -1,11 +1,19 @@ +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; + import { TargetConfig } from '../schemas/project_config'; import { ConfigurationError, reportError } from '../utils/errors'; -import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; +import { + checkExecutableIsPresent, + hasExecutable, + spawnProcess, +} from '../utils/system'; import { BaseTarget } from './base'; import { BaseArtifactProvider, RemoteArtifact, } from '../artifact_providers/base'; +import { logger } from '../logger'; /** Command to launch dotnet tools */ export const NUGET_DOTNET_BIN = process.env.NUGET_DOTNET_BIN || 'dotnet'; @@ -40,6 +48,121 @@ export class NugetTarget extends BaseTarget { /** Target options */ public readonly nugetConfig: NugetTargetOptions; + /** + * Bump version in .NET project files (.csproj, Directory.Build.props). + * + * Tries dotnet-setversion first if available, otherwise edits XML directly. + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no .NET project found + * @throws Error if version cannot be updated + */ + public static async bumpVersion( + rootDir: string, + newVersion: string + ): Promise { + // Check for .NET project files + const csprojFiles = readdirSync(rootDir).filter(f => f.endsWith('.csproj')); + const hasDotNet = + csprojFiles.length > 0 || + existsSync(join(rootDir, 'Directory.Build.props')); + + if (!hasDotNet) { + return false; + } + + // Try dotnet-setversion if available + if (hasExecutable(NUGET_DOTNET_BIN)) { + try { + // dotnet-setversion is a dotnet tool that sets version in all project files + const result = await spawnProcess( + NUGET_DOTNET_BIN, + ['setversion', newVersion], + { cwd: rootDir }, + { enableInDryRunMode: true } + ); + if (result !== null) { + return true; + } + } catch (error) { + // dotnet-setversion not installed, fall through to manual edit + const message = + error instanceof Error ? error.message : String(error); + if ( + !message.includes('not installed') && + !message.includes('Could not execute') + ) { + throw error; + } + logger.debug('dotnet-setversion not available, falling back to manual edit'); + } + } + + // Fallback: Directly edit .csproj or Directory.Build.props + let bumped = false; + + // Try Directory.Build.props first (centralized version management) + const buildPropsPath = join(rootDir, 'Directory.Build.props'); + if (existsSync(buildPropsPath)) { + if (NugetTarget.updateVersionInXml(buildPropsPath, newVersion)) { + bumped = true; + } + } + + // Update individual .csproj files if no centralized version management + if (!bumped) { + for (const csproj of csprojFiles) { + const csprojPath = join(rootDir, csproj); + if (NugetTarget.updateVersionInXml(csprojPath, newVersion)) { + bumped = true; + } + } + } + + return bumped; + } + + /** + * Update version in an XML project file (.csproj or Directory.Build.props) + */ + private static updateVersionInXml(filePath: string, newVersion: string): boolean { + const content = readFileSync(filePath, 'utf-8'); + + // Match x.y.z or x.y.z + const versionPatterns = [ + /()([^<]+)(<\/Version>)/g, + /()([^<]+)(<\/PackageVersion>)/g, + /()([^<]+)(<\/AssemblyVersion>)/g, + /()([^<]+)(<\/FileVersion>)/g, + ]; + + let newContent = content; + let updated = false; + + for (const pattern of versionPatterns) { + if (pattern.test(newContent)) { + // Reset lastIndex for global regex + pattern.lastIndex = 0; + newContent = newContent.replace(pattern, `$1${newVersion}$3`); + updated = true; + } + } + + if (!updated) { + return false; + } + + if (newContent === content) { + return true; // Already at target version + } + + logger.debug(`Updating version in ${filePath} to ${newVersion}`); + writeFileSync(filePath, newContent); + + return true; + } + public constructor( config: TargetConfig, artifactProvider: BaseArtifactProvider diff --git a/src/targets/pubDev.ts b/src/targets/pubDev.ts index ec882faf..1588448c 100644 --- a/src/targets/pubDev.ts +++ b/src/targets/pubDev.ts @@ -1,4 +1,4 @@ -import { constants, promises as fsPromises } from 'fs'; +import { constants, existsSync, promises as fsPromises, readFileSync, writeFileSync } from 'fs'; import { homedir, platform } from 'os'; import { join, dirname } from 'path'; import { load, dump } from 'js-yaml'; @@ -12,6 +12,7 @@ import { withTempDir } from '../utils/files'; import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; import { isDryRun } from '../utils/helpers'; import { logDryRun } from '../utils/dryRun'; +import { logger } from '../logger'; export const targetSecrets = [ 'PUBDEV_ACCESS_TOKEN', @@ -52,6 +53,43 @@ export class PubDevTarget extends BaseTarget { /** GitHub repo configuration */ public readonly githubRepo: GitHubGlobalConfig; + /** + * Bump version in pubspec.yaml for Dart/Flutter projects. + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no pubspec.yaml exists + * @throws Error if file cannot be updated + */ + public static async bumpVersion( + rootDir: string, + newVersion: string + ): Promise { + const pubspecPath = join(rootDir, 'pubspec.yaml'); + + // Check if pubspec.yaml exists + if (!existsSync(pubspecPath)) { + return false; + } + + const content = readFileSync(pubspecPath, 'utf-8'); + + // Parse YAML, update version, and write back + const pubspec = load(content) as Record; + + if (!pubspec || typeof pubspec !== 'object') { + logger.debug('pubspec.yaml is not a valid YAML object'); + return false; + } + + pubspec.version = newVersion; + + logger.debug(`Updating version in ${pubspecPath} to ${newVersion}`); + writeFileSync(pubspecPath, dump(pubspec)); + + return true; + } + public constructor( config: TargetConfig, artifactProvider: BaseArtifactProvider, diff --git a/src/targets/pypi.ts b/src/targets/pypi.ts index 79562dc8..5439ca16 100644 --- a/src/targets/pypi.ts +++ b/src/targets/pypi.ts @@ -1,11 +1,19 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + import { TargetConfig } from '../schemas/project_config'; import { BaseArtifactProvider, RemoteArtifact, } from '../artifact_providers/base'; import { ConfigurationError, reportError } from '../utils/errors'; -import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; +import { + checkExecutableIsPresent, + hasExecutable, + spawnProcess, +} from '../utils/system'; import { BaseTarget } from './base'; +import { logger } from '../logger'; const DEFAULT_TWINE_BIN = 'twine'; @@ -36,6 +44,137 @@ export class PypiTarget extends BaseTarget { /** Target options */ public readonly pypiConfig: PypiTargetOptions; + /** + * Bump version in Python project files. + * + * Detection priority: + * 1. [tool.hatch] in pyproject.toml → hatch version + * 2. [tool.poetry] in pyproject.toml → poetry version + * 3. [tool.setuptools_scm] in pyproject.toml → no-op (version from git tags) + * 4. [project] with version field → direct TOML edit + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no pyproject.toml exists + * @throws Error if tool is not found or command fails + */ + public static async bumpVersion( + rootDir: string, + newVersion: string + ): Promise { + const pyprojectPath = join(rootDir, 'pyproject.toml'); + + // Check if pyproject.toml exists + if (!existsSync(pyprojectPath)) { + return false; + } + + // Read and parse pyproject.toml (simple parsing, not full TOML) + const content = readFileSync(pyprojectPath, 'utf-8'); + + // Check for tool configurations in priority order + if (content.includes('[tool.hatch]')) { + return PypiTarget.bumpWithHatch(rootDir, newVersion); + } + + if (content.includes('[tool.poetry]')) { + return PypiTarget.bumpWithPoetry(rootDir, newVersion); + } + + if (content.includes('[tool.setuptools_scm]')) { + // setuptools_scm derives version from git tags, no bump needed + logger.debug( + 'Project uses setuptools_scm - version is derived from git tags, skipping bump' + ); + return true; + } + + // Check for standard [project] section with version field + if (content.includes('[project]')) { + return PypiTarget.bumpDirectToml(pyprojectPath, content, newVersion); + } + + // No recognized Python project structure + return false; + } + + /** + * Bump version using hatch + */ + private static async bumpWithHatch( + rootDir: string, + newVersion: string + ): Promise { + const HATCH_BIN = process.env.HATCH_BIN || 'hatch'; + + if (!hasExecutable(HATCH_BIN)) { + throw new Error( + `Cannot find "${HATCH_BIN}" for version bumping. ` + + 'Install hatch or define a custom preReleaseCommand in .craft.yml' + ); + } + + logger.debug(`Running: ${HATCH_BIN} version ${newVersion}`); + await spawnProcess(HATCH_BIN, ['version', newVersion], { cwd: rootDir }); + + return true; + } + + /** + * Bump version using poetry + */ + private static async bumpWithPoetry( + rootDir: string, + newVersion: string + ): Promise { + const POETRY_BIN = process.env.POETRY_BIN || 'poetry'; + + if (!hasExecutable(POETRY_BIN)) { + throw new Error( + `Cannot find "${POETRY_BIN}" for version bumping. ` + + 'Install poetry or define a custom preReleaseCommand in .craft.yml' + ); + } + + logger.debug(`Running: ${POETRY_BIN} version ${newVersion}`); + await spawnProcess(POETRY_BIN, ['version', newVersion], { cwd: rootDir }); + + return true; + } + + /** + * Bump version by directly editing pyproject.toml + * This handles standard PEP 621 [project] section with version field + */ + private static bumpDirectToml( + pyprojectPath: string, + content: string, + newVersion: string + ): boolean { + // Match version in [project] section + // This regex handles: version = "1.0.0" or version = '1.0.0' + const versionRegex = /^(\s*version\s*=\s*["'])([^"']+)(["'])/m; + + if (!versionRegex.test(content)) { + logger.debug( + 'pyproject.toml has [project] section but no version field found' + ); + return false; + } + + const newContent = content.replace(versionRegex, `$1${newVersion}$3`); + + if (newContent === content) { + logger.debug('Version already set to target value'); + return true; + } + + logger.debug(`Updating version in ${pyprojectPath} to ${newVersion}`); + writeFileSync(pyprojectPath, newContent); + + return true; + } + public constructor( config: TargetConfig, artifactProvider: BaseArtifactProvider diff --git a/src/utils/versionBump.ts b/src/utils/versionBump.ts new file mode 100644 index 00000000..1eaf4c7a --- /dev/null +++ b/src/utils/versionBump.ts @@ -0,0 +1,100 @@ +import { TargetConfig } from '../schemas/project_config'; +import { TARGET_MAP } from '../targets'; +import { logger } from '../logger'; + +/** + * Interface for target classes that support automatic version bumping. + * Targets implement this as a static method to bump version in project files. + */ +export interface VersionBumpableTarget { + /** + * Bump version in project files for this target's ecosystem. + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if target doesn't apply to this project + * @throws Error if bumping fails (missing tool, command error, file write error, etc.) + */ + bumpVersion(rootDir: string, newVersion: string): Promise; +} + +/** + * Check if a target class has the bumpVersion static method + */ +function hasVersionBump( + targetClass: unknown +): targetClass is { bumpVersion: VersionBumpableTarget['bumpVersion'] } { + return ( + typeof targetClass === 'function' && + 'bumpVersion' in targetClass && + typeof (targetClass as any).bumpVersion === 'function' + ); +} + +/** + * Run automatic version bumps for all applicable targets. + * Calls bumpVersion() on each unique target class in config order. + * + * @param targets - Target configs from .craft.yml + * @param rootDir - Project root directory + * @param newVersion - New version to set + * @returns true if at least one target bumped the version + * @throws Error if any bumpVersion() call throws + */ +export async function runAutomaticVersionBumps( + targets: TargetConfig[], + rootDir: string, + newVersion: string +): Promise { + // Track which target types we've already processed to avoid duplicates + // (e.g., multiple npm targets should only bump package.json once) + const processedTargetTypes = new Set(); + let anyBumped = false; + + for (const targetConfig of targets) { + const targetName = targetConfig.name; + + // Skip if we've already processed this target type + if (processedTargetTypes.has(targetName)) { + continue; + } + processedTargetTypes.add(targetName); + + const targetClass = TARGET_MAP[targetName]; + if (!targetClass) { + logger.debug(`Unknown target "${targetName}", skipping version bump`); + continue; + } + + if (!hasVersionBump(targetClass)) { + logger.debug( + `Target "${targetName}" does not support automatic version bumping` + ); + continue; + } + + logger.debug(`Running version bump for target "${targetName}"...`); + + try { + const bumped = await targetClass.bumpVersion(rootDir, newVersion); + if (bumped) { + logger.info(`Version bumped by "${targetName}" target`); + anyBumped = true; + } else { + logger.debug( + `Target "${targetName}" did not apply (detection failed)` + ); + } + } catch (error) { + // Re-throw with additional context + const message = + error instanceof Error ? error.message : String(error); + throw new Error( + `Automatic version bump failed for "${targetName}" target: ${message}\n` + + `You may need to define a custom preReleaseCommand in .craft.yml` + ); + } + } + + return anyBumped; +} From 3ef648039afd312ef843fb6a25a908e433afac05 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 12 Jan 2026 22:39:16 +0000 Subject: [PATCH 02/16] feat(npm): Add workspace support for automatic version bumping For npm/yarn/pnpm monorepos, automatically detect and bump versions in all workspace packages: - npm 7+: Uses npm version --workspaces - yarn/pnpm or npm < 7: Falls back to individual package bumping Private packages are skipped during workspace version bumping. --- docs/src/content/docs/configuration.md | 15 ++++- src/targets/npm.ts | 81 ++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index ea7cf8c0..a7098928 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -56,7 +56,7 @@ When `minVersion: "2.19.0"` or higher is set and no custom `preReleaseCommand` i | Target | Detection | Version Bump Method | |--------|-----------|---------------------| -| `npm` | `package.json` exists | `npm version --no-git-tag-version` | +| `npm` | `package.json` exists | `npm version --no-git-tag-version` (with workspace support) | | `pypi` | `pyproject.toml` exists | hatch, poetry, setuptools-scm, or direct edit | | `crates` | `Cargo.toml` exists | `cargo set-version` (requires cargo-edit) | | `gem` | `*.gemspec` exists | Direct edit of gemspec and `lib/**/version.rb` | @@ -64,6 +64,19 @@ When `minVersion: "2.19.0"` or higher is set and no custom `preReleaseCommand` i | `hex` | `mix.exs` exists | Direct edit of mix.exs | | `nuget` | `*.csproj` exists | dotnet-setversion or direct XML edit | +### npm Workspace Support + +For npm/yarn/pnpm monorepos, Craft automatically detects and bumps versions in all workspace packages: + +- **npm 7+**: Uses `npm version --workspaces` to bump all packages at once +- **yarn/pnpm or npm < 7**: Falls back to bumping each non-private package individually + +Workspace detection checks for: +- `workspaces` field in root `package.json` (npm/yarn) +- `pnpm-workspace.yaml` (pnpm) + +Private packages (`"private": true`) are skipped during workspace version bumping. + ### Python (pypi) Detection Priority For Python projects, Craft detects the build tool and uses the appropriate method: diff --git a/src/targets/npm.ts b/src/targets/npm.ts index 21ac3640..f2be2edc 100644 --- a/src/targets/npm.ts +++ b/src/targets/npm.ts @@ -1,5 +1,5 @@ import { SpawnOptions, spawnSync } from 'child_process'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import prompts from 'prompts'; @@ -8,6 +8,7 @@ import { ConfigurationError, reportError } from '../utils/errors'; import { stringToRegexp } from '../utils/filters'; import { isDryRun } from '../utils/helpers'; import { hasExecutable, spawnProcess } from '../utils/system'; +import { discoverWorkspaces } from '../utils/workspaces'; import { isPreviewRelease, parseVersion, @@ -246,6 +247,7 @@ export class NpmTarget extends BaseTarget { /** * Bump version in package.json using npm or yarn. + * Supports workspaces - bumps root and all workspace packages. * * @param rootDir - Project root directory * @param newVersion - New version string to set @@ -276,21 +278,90 @@ export class NpmTarget extends BaseTarget { ); } - // Run version bump command + // Check if this is a workspace project + const workspaces = await discoverWorkspaces(rootDir); + const isWorkspace = workspaces.type !== 'none' && workspaces.packages.length > 0; + + // Build base args for version bump // --no-git-tag-version prevents npm from creating a git commit and tag // --allow-same-version allows setting the same version (useful for re-runs) - const args = + const baseArgs = bin === NPM_BIN ? ['version', newVersion, '--no-git-tag-version', '--allow-same-version'] : ['version', newVersion, '--no-git-tag-version']; - logger.debug(`Running: ${bin} ${args.join(' ')}`); + // Bump root package.json first + logger.debug(`Running: ${bin} ${baseArgs.join(' ')}`); + await spawnProcess(bin, baseArgs, { cwd: rootDir }); + + // If this is a workspace project, also bump workspace packages + if (isWorkspace) { + if (bin === NPM_BIN) { + // npm 7+ supports --workspaces flag + const workspaceArgs = [...baseArgs, '--workspaces', '--include-workspace-root']; + logger.debug(`Running: ${bin} ${workspaceArgs.join(' ')} (for workspaces)`); + try { + await spawnProcess(bin, workspaceArgs, { cwd: rootDir }); + } catch (error) { + // If --workspaces fails (npm < 7), fall back to individual package bumping + logger.debug('npm --workspaces failed, falling back to individual package bumping'); + await NpmTarget.bumpWorkspacePackagesIndividually( + bin, + workspaces.packages, + newVersion, + baseArgs + ); + } + } else { + // yarn doesn't have --workspaces for version command, bump individually + await NpmTarget.bumpWorkspacePackagesIndividually( + bin, + workspaces.packages, + newVersion, + baseArgs + ); + } - await spawnProcess(bin, args, { cwd: rootDir }); + logger.info( + `Bumped version in root and ${workspaces.packages.length} workspace packages` + ); + } return true; } + /** + * Bump version in each workspace package individually + */ + private static async bumpWorkspacePackagesIndividually( + bin: string, + packages: { name: string; location: string }[], + newVersion: string, + baseArgs: string[] + ): Promise { + for (const pkg of packages) { + // Check if package.json exists and is not private + const pkgJsonPath = join(pkg.location, 'package.json'); + if (!existsSync(pkgJsonPath)) { + continue; + } + + try { + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')); + // Skip private packages - they don't need version bumping + if (pkgJson.private) { + logger.debug(`Skipping private package: ${pkg.name}`); + continue; + } + } catch { + continue; + } + + logger.debug(`Bumping version for workspace package: ${pkg.name}`); + await spawnProcess(bin, baseArgs, { cwd: pkg.location }); + } + } + public constructor( config: NpmTargetConfig, artifactProvider: BaseArtifactProvider From d598d82fb32c9326486d5b2bf74f43b5f937a2e3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 12 Jan 2026 22:40:55 +0000 Subject: [PATCH 03/16] test: Add unit tests for automatic version bumping Tests cover: - runAutomaticVersionBumps utility function - NpmTarget.bumpVersion (including workspace detection) - PypiTarget.bumpVersion (hatch, poetry, setuptools-scm, direct edit) - CratesTarget.bumpVersion - GemTarget.bumpVersion - PubDevTarget.bumpVersion - HexTarget.bumpVersion - NugetTarget.bumpVersion --- src/__tests__/versionBump.test.ts | 513 ++++++++++++++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 src/__tests__/versionBump.test.ts diff --git a/src/__tests__/versionBump.test.ts b/src/__tests__/versionBump.test.ts new file mode 100644 index 00000000..cd047d0c --- /dev/null +++ b/src/__tests__/versionBump.test.ts @@ -0,0 +1,513 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { join } from 'path'; +import { mkdtemp, rm, writeFile, mkdir, readFile } from 'fs/promises'; +import { tmpdir } from 'os'; + +import { runAutomaticVersionBumps } from '../utils/versionBump'; +import { NpmTarget } from '../targets/npm'; +import { PypiTarget } from '../targets/pypi'; +import { CratesTarget } from '../targets/crates'; +import { GemTarget } from '../targets/gem'; +import { PubDevTarget } from '../targets/pubDev'; +import { HexTarget } from '../targets/hex'; +import { NugetTarget } from '../targets/nuget'; + +// Mock spawnProcess to avoid actually running commands +vi.mock('../utils/system', async () => { + const actual = await vi.importActual('../utils/system'); + return { + ...actual, + spawnProcess: vi.fn().mockResolvedValue(''), + hasExecutable: vi.fn().mockReturnValue(true), + }; +}); + +describe('runAutomaticVersionBumps', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'craft-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + vi.clearAllMocks(); + }); + + test('returns false when no targets are provided', async () => { + const result = await runAutomaticVersionBumps([], tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('returns false when target does not support version bumping', async () => { + // 'github' target doesn't have bumpVersion + const result = await runAutomaticVersionBumps( + [{ name: 'github' }], + tempDir, + '1.0.0' + ); + expect(result).toBe(false); + }); + + test('returns false when target detection fails', async () => { + // npm target but no package.json + const result = await runAutomaticVersionBumps( + [{ name: 'npm' }], + tempDir, + '1.0.0' + ); + expect(result).toBe(false); + }); + + test('calls bumpVersion for npm target with package.json', async () => { + await writeFile( + join(tempDir, 'package.json'), + JSON.stringify({ name: 'test', version: '0.0.1' }) + ); + + const result = await runAutomaticVersionBumps( + [{ name: 'npm' }], + tempDir, + '1.0.0' + ); + + expect(result).toBe(true); + }); + + test('deduplicates multiple targets of the same type', async () => { + await writeFile( + join(tempDir, 'package.json'), + JSON.stringify({ name: 'test', version: '0.0.1' }) + ); + + const bumpSpy = vi.spyOn(NpmTarget, 'bumpVersion'); + + await runAutomaticVersionBumps( + [{ name: 'npm' }, { name: 'npm', id: 'second' }], + tempDir, + '1.0.0' + ); + + // Should only be called once despite two npm targets + expect(bumpSpy).toHaveBeenCalledTimes(1); + }); + + test('processes multiple different target types', async () => { + // Create files for both npm and pypi + await writeFile( + join(tempDir, 'package.json'), + JSON.stringify({ name: 'test', version: '0.0.1' }) + ); + await writeFile( + join(tempDir, 'pyproject.toml'), + '[project]\nname = "test"\nversion = "0.0.1"' + ); + + const npmSpy = vi.spyOn(NpmTarget, 'bumpVersion'); + const pypiSpy = vi.spyOn(PypiTarget, 'bumpVersion'); + + const result = await runAutomaticVersionBumps( + [{ name: 'npm' }, { name: 'pypi' }], + tempDir, + '1.0.0' + ); + + expect(result).toBe(true); + expect(npmSpy).toHaveBeenCalledTimes(1); + expect(pypiSpy).toHaveBeenCalledTimes(1); + }); + + test('throws error with context when bumpVersion fails', async () => { + await writeFile( + join(tempDir, 'package.json'), + JSON.stringify({ name: 'test', version: '0.0.1' }) + ); + + vi.spyOn(NpmTarget, 'bumpVersion').mockRejectedValue( + new Error('npm not found') + ); + + await expect( + runAutomaticVersionBumps([{ name: 'npm' }], tempDir, '1.0.0') + ).rejects.toThrow(/Automatic version bump failed for "npm" target/); + }); +}); + +describe('NpmTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'craft-npm-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + vi.clearAllMocks(); + }); + + test('returns false when no package.json exists', async () => { + const result = await NpmTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('returns true and calls npm version when package.json exists', async () => { + await writeFile( + join(tempDir, 'package.json'), + JSON.stringify({ name: 'test', version: '0.0.1' }) + ); + + const { spawnProcess } = await import('../utils/system'); + const result = await NpmTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + expect(spawnProcess).toHaveBeenCalledWith( + expect.any(String), + expect.arrayContaining(['version', '1.0.0', '--no-git-tag-version']), + expect.objectContaining({ cwd: tempDir }) + ); + }); +}); + +describe('PypiTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'craft-pypi-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + vi.clearAllMocks(); + }); + + test('returns false when no pyproject.toml exists', async () => { + const result = await PypiTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('uses hatch when [tool.hatch] section exists', async () => { + await writeFile( + join(tempDir, 'pyproject.toml'), + '[tool.hatch]\n[project]\nname = "test"' + ); + + const { spawnProcess } = await import('../utils/system'); + const result = await PypiTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + expect(spawnProcess).toHaveBeenCalledWith( + 'hatch', + ['version', '1.0.0'], + expect.objectContaining({ cwd: tempDir }) + ); + }); + + test('uses poetry when [tool.poetry] section exists', async () => { + await writeFile( + join(tempDir, 'pyproject.toml'), + '[tool.poetry]\nname = "test"' + ); + + const { spawnProcess } = await import('../utils/system'); + const result = await PypiTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + expect(spawnProcess).toHaveBeenCalledWith( + 'poetry', + ['version', '1.0.0'], + expect.objectContaining({ cwd: tempDir }) + ); + }); + + test('returns true without action for setuptools_scm', async () => { + await writeFile( + join(tempDir, 'pyproject.toml'), + '[tool.setuptools_scm]\n[project]\nname = "test"' + ); + + const { spawnProcess } = await import('../utils/system'); + const result = await PypiTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + // Should not call any command for setuptools_scm + expect(spawnProcess).not.toHaveBeenCalled(); + }); + + test('directly edits pyproject.toml when [project] with version exists', async () => { + const originalContent = '[project]\nname = "test"\nversion = "0.0.1"'; + await writeFile(join(tempDir, 'pyproject.toml'), originalContent); + + const result = await PypiTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile( + join(tempDir, 'pyproject.toml'), + 'utf-8' + ); + expect(updatedContent).toContain('version = "1.0.0"'); + }); +}); + +describe('GemTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'craft-gem-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test('returns false when no gemspec exists', async () => { + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('updates version in gemspec file', async () => { + const gemspecContent = ` +Gem::Specification.new do |s| + s.name = "test" + s.version = "0.0.1" +end +`; + await writeFile(join(tempDir, 'test.gemspec'), gemspecContent); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile( + join(tempDir, 'test.gemspec'), + 'utf-8' + ); + expect(updatedContent).toContain('s.version = "1.0.0"'); + }); + + test('updates VERSION in lib/**/version.rb', async () => { + await writeFile(join(tempDir, 'test.gemspec'), 'Gem::Specification.new'); + await mkdir(join(tempDir, 'lib', 'test'), { recursive: true }); + await writeFile( + join(tempDir, 'lib', 'test', 'version.rb'), + 'module Test\n VERSION = "0.0.1"\nend' + ); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile( + join(tempDir, 'lib', 'test', 'version.rb'), + 'utf-8' + ); + expect(updatedContent).toContain('VERSION = "1.0.0"'); + }); +}); + +describe('PubDevTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'craft-pubdev-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test('returns false when no pubspec.yaml exists', async () => { + const result = await PubDevTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('updates version in pubspec.yaml', async () => { + await writeFile( + join(tempDir, 'pubspec.yaml'), + 'name: test\nversion: 0.0.1\n' + ); + + const result = await PubDevTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile( + join(tempDir, 'pubspec.yaml'), + 'utf-8' + ); + expect(updatedContent).toContain('version: 1.0.0'); + }); +}); + +describe('HexTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'craft-hex-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test('returns false when no mix.exs exists', async () => { + const result = await HexTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('updates version: in mix.exs', async () => { + const mixContent = ` +defmodule Test.MixProject do + def project do + [ + app: :test, + version: "0.0.1", + ] + end +end +`; + await writeFile(join(tempDir, 'mix.exs'), mixContent); + + const result = await HexTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile(join(tempDir, 'mix.exs'), 'utf-8'); + expect(updatedContent).toContain('version: "1.0.0"'); + }); + + test('updates @version module attribute in mix.exs', async () => { + const mixContent = ` +defmodule Test.MixProject do + @version "0.0.1" + + def project do + [app: :test, version: @version] + end +end +`; + await writeFile(join(tempDir, 'mix.exs'), mixContent); + + const result = await HexTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile(join(tempDir, 'mix.exs'), 'utf-8'); + expect(updatedContent).toContain('@version "1.0.0"'); + }); +}); + +describe('NugetTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'craft-nuget-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + vi.clearAllMocks(); + }); + + test('returns false when no .csproj exists', async () => { + const result = await NugetTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('updates Version in .csproj file', async () => { + const csprojContent = ` + + 0.0.1 + +`; + await writeFile(join(tempDir, 'Test.csproj'), csprojContent); + + // Mock spawnProcess to fail (no dotnet-setversion) so it falls through to XML edit + const { spawnProcess } = await import('../utils/system'); + vi.mocked(spawnProcess).mockRejectedValue(new Error('not installed')); + + const result = await NugetTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile( + join(tempDir, 'Test.csproj'), + 'utf-8' + ); + expect(updatedContent).toContain('1.0.0'); + }); + + test('updates Directory.Build.props if it exists', async () => { + const buildPropsContent = ` + + 0.0.1 + +`; + await writeFile(join(tempDir, 'Directory.Build.props'), buildPropsContent); + await writeFile(join(tempDir, 'Test.csproj'), ''); + + const { spawnProcess } = await import('../utils/system'); + vi.mocked(spawnProcess).mockRejectedValue(new Error('not installed')); + + const result = await NugetTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile( + join(tempDir, 'Directory.Build.props'), + 'utf-8' + ); + expect(updatedContent).toContain('1.0.0'); + }); +}); + +describe('CratesTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'craft-crates-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + vi.clearAllMocks(); + }); + + test('returns false when no Cargo.toml exists', async () => { + const result = await CratesTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('calls cargo set-version when Cargo.toml exists', async () => { + await writeFile( + join(tempDir, 'Cargo.toml'), + '[package]\nname = "test"\nversion = "0.0.1"' + ); + + const { spawnProcess } = await import('../utils/system'); + const result = await CratesTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + expect(spawnProcess).toHaveBeenCalledWith( + 'cargo', + ['set-version', '1.0.0'], + expect.objectContaining({ cwd: tempDir }) + ); + }); +}); From cbf5382d2ae505c61973c04916989e2510a02ac7 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 12 Jan 2026 23:06:26 +0000 Subject: [PATCH 04/16] chore: Remove AI slop from version bumping code - Remove obvious comments (// Check if X exists) - Simplify verbose multi-line comments to single lines - Remove redundant JSDoc field descriptions - Clean up try/catch blocks with explicit early returns - Shorten verbose error messages --- src/commands/prepare.ts | 28 +++--------------------- src/targets/crates.ts | 18 +++------------ src/targets/gem.ts | 47 ++++++++++++++++++---------------------- src/targets/hex.ts | 6 ----- src/targets/npm.ts | 21 ++++++------------ src/targets/nuget.ts | 13 ++--------- src/targets/pubDev.ts | 4 ---- src/targets/pypi.ts | 9 +------- src/utils/versionBump.ts | 10 +++------ 9 files changed, 40 insertions(+), 116 deletions(-) diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index f31cacdb..d87de18a 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -274,66 +274,44 @@ async function commitNewVersion( await git.commit(message, ['--all']); } -/** Options for running pre-release commands */ interface PreReleaseOptions { - /** Previous version (from git tag) */ oldVersion: string; - /** New version to release */ newVersion: string; - /** Custom pre-release command from config */ preReleaseCommand?: string; - /** Target configurations from .craft.yml */ targets?: TargetConfig[]; - /** Project root directory */ rootDir: string; } /** - * Run an external pre-release command or automatic version bumping. + * Run pre-release command or automatic version bumping. * - * The command/bumping executes operations for version bumping and might - * include dependency updates. - * - * Behavior: - * - If preReleaseCommand is explicitly set to empty string: do nothing - * - If preReleaseCommand is defined: run the custom command - * - If minVersion >= 2.19.0 and targets are defined: use automatic version bumping - * - Otherwise: run default scripts/bump-version.sh - * - * @param options Pre-release options + * Priority: custom command > automatic bumping (minVersion >= 2.19.0) > default script */ export async function runPreReleaseCommand( options: PreReleaseOptions ): Promise { const { oldVersion, newVersion, preReleaseCommand, targets, rootDir } = options; - // If preReleaseCommand is explicitly set to empty string, skip version bumping if (preReleaseCommand !== undefined && preReleaseCommand.length === 0) { logger.warn('Not running the pre-release command: no command specified'); return false; } - // If a custom preReleaseCommand is defined, use it if (preReleaseCommand) { return runCustomPreReleaseCommand(oldVersion, newVersion, preReleaseCommand); } - // If minVersion >= 2.19.0 and targets are configured, use automatic version bumping if (requiresMinVersion(AUTO_BUMP_MIN_VERSION) && targets && targets.length > 0) { logger.info('Running automatic version bumping from targets...'); const anyBumped = await runAutomaticVersionBumps(targets, rootDir, newVersion); if (!anyBumped) { - logger.warn( - 'No targets support automatic version bumping. ' + - 'Consider adding a scripts/bump-version.sh script or defining preReleaseCommand in .craft.yml' - ); + logger.warn('No targets support automatic version bumping'); } return anyBumped; } - // Fall back to default bump-version.sh script return runCustomPreReleaseCommand(oldVersion, newVersion, undefined); } diff --git a/src/targets/crates.ts b/src/targets/crates.ts index c079169f..a19dd6e0 100644 --- a/src/targets/crates.ts +++ b/src/targets/crates.ts @@ -125,8 +125,6 @@ export class CratesTarget extends BaseTarget { newVersion: string ): Promise { const cargoTomlPath = path.join(rootDir, 'Cargo.toml'); - - // Check if Cargo.toml exists if (!fs.existsSync(cargoTomlPath)) { return false; } @@ -138,26 +136,16 @@ export class CratesTarget extends BaseTarget { ); } - // Use cargo set-version from cargo-edit to bump version - // This handles workspaces properly by bumping all crates const args = ['set-version', newVersion]; - logger.debug(`Running: ${CARGO_BIN} ${args.join(' ')}`); try { await spawnProcess(CARGO_BIN, args, { cwd: rootDir }); } catch (error) { - // If cargo set-version is not available, provide helpful error - const message = - error instanceof Error ? error.message : String(error); - if ( - message.includes('no such command') || - message.includes('no such subcommand') - ) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('no such command') || message.includes('no such subcommand')) { throw new Error( - 'cargo set-version command not found. ' + - 'Install cargo-edit with: cargo install cargo-edit\n' + - 'Or define a custom preReleaseCommand in .craft.yml' + 'cargo set-version not found. Install cargo-edit: cargo install cargo-edit' ); } throw error; diff --git a/src/targets/gem.ts b/src/targets/gem.ts index 1b7acacd..a693a402 100644 --- a/src/targets/gem.ts +++ b/src/targets/gem.ts @@ -46,16 +46,13 @@ export class GemTarget extends BaseTarget { rootDir: string, newVersion: string ): Promise { - // Look for gemspec files const gemspecFiles = readdirSync(rootDir).filter(f => f.endsWith('.gemspec')); - if (gemspecFiles.length === 0) { return false; } let bumped = false; - // Try to update version in gemspec for (const gemspecFile of gemspecFiles) { const gemspecPath = join(rootDir, gemspecFile); const content = readFileSync(gemspecPath, 'utf-8'); @@ -73,7 +70,6 @@ export class GemTarget extends BaseTarget { } } - // Also try to update lib/**/version.rb if it exists const libDir = join(rootDir, 'lib'); if (existsSync(libDir)) { bumped = GemTarget.updateVersionRbFiles(libDir, newVersion) || bumped; @@ -87,33 +83,32 @@ export class GemTarget extends BaseTarget { */ private static updateVersionRbFiles(dir: string, newVersion: string): boolean { let updated = false; + let entries; try { - const entries = readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = join(dir, entry.name); - - if (entry.isDirectory()) { - updated = GemTarget.updateVersionRbFiles(fullPath, newVersion) || updated; - } else if (entry.name === 'version.rb') { - const content = readFileSync(fullPath, 'utf-8'); - - // Match: VERSION = "1.0.0" or VERSION = '1.0.0' - const versionRegex = /^(\s*VERSION\s*=\s*["'])([^"']+)(["'])/m; - - if (versionRegex.test(content)) { - const newContent = content.replace(versionRegex, `$1${newVersion}$3`); - if (newContent !== content) { - logger.debug(`Updating VERSION in ${fullPath} to ${newVersion}`); - writeFileSync(fullPath, newContent); - updated = true; - } + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + updated = GemTarget.updateVersionRbFiles(fullPath, newVersion) || updated; + } else if (entry.name === 'version.rb') { + const content = readFileSync(fullPath, 'utf-8'); + const versionRegex = /^(\s*VERSION\s*=\s*["'])([^"']+)(["'])/m; + + if (versionRegex.test(content)) { + const newContent = content.replace(versionRegex, `$1${newVersion}$3`); + if (newContent !== content) { + logger.debug(`Updating VERSION in ${fullPath} to ${newVersion}`); + writeFileSync(fullPath, newContent); + updated = true; } } } - } catch { - // Ignore errors reading directories } return updated; diff --git a/src/targets/hex.ts b/src/targets/hex.ts index 0a0197e6..36098b18 100644 --- a/src/targets/hex.ts +++ b/src/targets/hex.ts @@ -47,19 +47,13 @@ export class HexTarget extends BaseTarget { newVersion: string ): Promise { const mixExsPath = join(rootDir, 'mix.exs'); - - // Check if mix.exs exists if (!existsSync(mixExsPath)) { return false; } const content = readFileSync(mixExsPath, 'utf-8'); - - // Match version in mix.exs: version: "1.0.0" or @version "1.0.0" const versionPatterns = [ - // version: "1.0.0" in project definition /^(\s*version:\s*["'])([^"']+)(["'])/m, - // @version "1.0.0" module attribute /^(\s*@version\s+["'])([^"']+)(["'])/m, ]; diff --git a/src/targets/npm.ts b/src/targets/npm.ts index f2be2edc..6a80406a 100644 --- a/src/targets/npm.ts +++ b/src/targets/npm.ts @@ -259,13 +259,10 @@ export class NpmTarget extends BaseTarget { newVersion: string ): Promise { const packageJsonPath = join(rootDir, 'package.json'); - - // Check if package.json exists if (!existsSync(packageJsonPath)) { return false; } - // Determine which package manager to use let bin: string; if (hasExecutable(NPM_BIN)) { bin = NPM_BIN; @@ -278,11 +275,9 @@ export class NpmTarget extends BaseTarget { ); } - // Check if this is a workspace project const workspaces = await discoverWorkspaces(rootDir); const isWorkspace = workspaces.type !== 'none' && workspaces.packages.length > 0; - // Build base args for version bump // --no-git-tag-version prevents npm from creating a git commit and tag // --allow-same-version allows setting the same version (useful for re-runs) const baseArgs = @@ -290,11 +285,9 @@ export class NpmTarget extends BaseTarget { ? ['version', newVersion, '--no-git-tag-version', '--allow-same-version'] : ['version', newVersion, '--no-git-tag-version']; - // Bump root package.json first logger.debug(`Running: ${bin} ${baseArgs.join(' ')}`); await spawnProcess(bin, baseArgs, { cwd: rootDir }); - // If this is a workspace project, also bump workspace packages if (isWorkspace) { if (bin === NPM_BIN) { // npm 7+ supports --workspaces flag @@ -340,23 +333,23 @@ export class NpmTarget extends BaseTarget { baseArgs: string[] ): Promise { for (const pkg of packages) { - // Check if package.json exists and is not private const pkgJsonPath = join(pkg.location, 'package.json'); if (!existsSync(pkgJsonPath)) { continue; } + let pkgJson: { private?: boolean }; try { - const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')); - // Skip private packages - they don't need version bumping - if (pkgJson.private) { - logger.debug(`Skipping private package: ${pkg.name}`); - continue; - } + pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')); } catch { continue; } + if (pkgJson.private) { + logger.debug(`Skipping private package: ${pkg.name}`); + continue; + } + logger.debug(`Bumping version for workspace package: ${pkg.name}`); await spawnProcess(bin, baseArgs, { cwd: pkg.location }); } diff --git a/src/targets/nuget.ts b/src/targets/nuget.ts index 77a799f4..3eaea1b9 100644 --- a/src/targets/nuget.ts +++ b/src/targets/nuget.ts @@ -72,10 +72,8 @@ export class NugetTarget extends BaseTarget { return false; } - // Try dotnet-setversion if available if (hasExecutable(NUGET_DOTNET_BIN)) { try { - // dotnet-setversion is a dotnet tool that sets version in all project files const result = await spawnProcess( NUGET_DOTNET_BIN, ['setversion', newVersion], @@ -86,20 +84,14 @@ export class NugetTarget extends BaseTarget { return true; } } catch (error) { - // dotnet-setversion not installed, fall through to manual edit - const message = - error instanceof Error ? error.message : String(error); - if ( - !message.includes('not installed') && - !message.includes('Could not execute') - ) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('not installed') && !message.includes('Could not execute')) { throw error; } logger.debug('dotnet-setversion not available, falling back to manual edit'); } } - // Fallback: Directly edit .csproj or Directory.Build.props let bumped = false; // Try Directory.Build.props first (centralized version management) @@ -110,7 +102,6 @@ export class NugetTarget extends BaseTarget { } } - // Update individual .csproj files if no centralized version management if (!bumped) { for (const csproj of csprojFiles) { const csprojPath = join(rootDir, csproj); diff --git a/src/targets/pubDev.ts b/src/targets/pubDev.ts index 1588448c..a4d59e8e 100644 --- a/src/targets/pubDev.ts +++ b/src/targets/pubDev.ts @@ -66,15 +66,11 @@ export class PubDevTarget extends BaseTarget { newVersion: string ): Promise { const pubspecPath = join(rootDir, 'pubspec.yaml'); - - // Check if pubspec.yaml exists if (!existsSync(pubspecPath)) { return false; } const content = readFileSync(pubspecPath, 'utf-8'); - - // Parse YAML, update version, and write back const pubspec = load(content) as Record; if (!pubspec || typeof pubspec !== 'object') { diff --git a/src/targets/pypi.ts b/src/targets/pypi.ts index 5439ca16..19cfdafd 100644 --- a/src/targets/pypi.ts +++ b/src/targets/pypi.ts @@ -63,16 +63,12 @@ export class PypiTarget extends BaseTarget { newVersion: string ): Promise { const pyprojectPath = join(rootDir, 'pyproject.toml'); - - // Check if pyproject.toml exists if (!existsSync(pyprojectPath)) { return false; } - // Read and parse pyproject.toml (simple parsing, not full TOML) const content = readFileSync(pyprojectPath, 'utf-8'); - // Check for tool configurations in priority order if (content.includes('[tool.hatch]')) { return PypiTarget.bumpWithHatch(rootDir, newVersion); } @@ -83,13 +79,10 @@ export class PypiTarget extends BaseTarget { if (content.includes('[tool.setuptools_scm]')) { // setuptools_scm derives version from git tags, no bump needed - logger.debug( - 'Project uses setuptools_scm - version is derived from git tags, skipping bump' - ); + logger.debug('setuptools_scm project - version derived from git tags'); return true; } - // Check for standard [project] section with version field if (content.includes('[project]')) { return PypiTarget.bumpDirectToml(pyprojectPath, content, newVersion); } diff --git a/src/utils/versionBump.ts b/src/utils/versionBump.ts index 1eaf4c7a..d093dc55 100644 --- a/src/utils/versionBump.ts +++ b/src/utils/versionBump.ts @@ -46,8 +46,7 @@ export async function runAutomaticVersionBumps( rootDir: string, newVersion: string ): Promise { - // Track which target types we've already processed to avoid duplicates - // (e.g., multiple npm targets should only bump package.json once) + // Deduplicate: multiple npm targets should only bump package.json once const processedTargetTypes = new Set(); let anyBumped = false; @@ -86,12 +85,9 @@ export async function runAutomaticVersionBumps( ); } } catch (error) { - // Re-throw with additional context - const message = - error instanceof Error ? error.message : String(error); + const message = error instanceof Error ? error.message : String(error); throw new Error( - `Automatic version bump failed for "${targetName}" target: ${message}\n` + - `You may need to define a custom preReleaseCommand in .craft.yml` + `Automatic version bump failed for "${targetName}" target: ${message}` ); } } From 4a5103729cff250d3af8ab68bd77982098618091 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 13 Jan 2026 00:13:12 +0000 Subject: [PATCH 05/16] fix: escape glob pattern in JSDoc comment to fix esbuild parsing --- src/targets/gem.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/targets/gem.ts b/src/targets/gem.ts index a693a402..73665444 100644 --- a/src/targets/gem.ts +++ b/src/targets/gem.ts @@ -34,13 +34,8 @@ export class GemTarget extends BaseTarget { * Bump version in Ruby gem project files. * * Looks for version patterns in: - * 1. *.gemspec files (s.version = "x.y.z") - * 2. lib/**/version.rb files (VERSION = "x.y.z") - * - * @param rootDir - Project root directory - * @param newVersion - New version string to set - * @returns true if version was bumped, false if no gem project found - * @throws Error if version file cannot be updated + * 1. .gemspec files (s.version = "x.y.z") + * 2. lib/.../version.rb files (VERSION = "x.y.z") */ public static async bumpVersion( rootDir: string, From 983696fe51644775da51dd22f28bb1126dee71ed Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 13 Jan 2026 00:19:17 +0000 Subject: [PATCH 06/16] fix: update tests for new runPreReleaseCommand signature and improve mock setup --- src/__tests__/versionBump.test.ts | 29 +++++++++++++++++++------- src/commands/__tests__/prepare.test.ts | 19 +++++++++++------ 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/__tests__/versionBump.test.ts b/src/__tests__/versionBump.test.ts index cd047d0c..54eccd41 100644 --- a/src/__tests__/versionBump.test.ts +++ b/src/__tests__/versionBump.test.ts @@ -17,15 +17,23 @@ vi.mock('../utils/system', async () => { const actual = await vi.importActual('../utils/system'); return { ...actual, - spawnProcess: vi.fn().mockResolvedValue(''), - hasExecutable: vi.fn().mockReturnValue(true), + spawnProcess: vi.fn(), + hasExecutable: vi.fn(), }; }); +// Helper to set up default mocks +async function setupDefaultMocks() { + const { spawnProcess, hasExecutable } = await import('../utils/system'); + vi.mocked(spawnProcess).mockResolvedValue(''); + vi.mocked(hasExecutable).mockReturnValue(true); +} + describe('runAutomaticVersionBumps', () => { let tempDir: string; beforeEach(async () => { + await setupDefaultMocks(); tempDir = await mkdtemp(join(tmpdir(), 'craft-test-')); }); @@ -33,7 +41,7 @@ describe('runAutomaticVersionBumps', () => { if (tempDir) { await rm(tempDir, { recursive: true, force: true }); } - vi.clearAllMocks(); + vi.resetAllMocks(); }); test('returns false when no targets are provided', async () => { @@ -139,6 +147,7 @@ describe('NpmTarget.bumpVersion', () => { let tempDir: string; beforeEach(async () => { + await setupDefaultMocks(); tempDir = await mkdtemp(join(tmpdir(), 'craft-npm-test-')); }); @@ -146,7 +155,7 @@ describe('NpmTarget.bumpVersion', () => { if (tempDir) { await rm(tempDir, { recursive: true, force: true }); } - vi.clearAllMocks(); + vi.resetAllMocks(); }); test('returns false when no package.json exists', async () => { @@ -176,6 +185,7 @@ describe('PypiTarget.bumpVersion', () => { let tempDir: string; beforeEach(async () => { + await setupDefaultMocks(); tempDir = await mkdtemp(join(tmpdir(), 'craft-pypi-test-')); }); @@ -183,7 +193,7 @@ describe('PypiTarget.bumpVersion', () => { if (tempDir) { await rm(tempDir, { recursive: true, force: true }); } - vi.clearAllMocks(); + vi.resetAllMocks(); }); test('returns false when no pyproject.toml exists', async () => { @@ -259,6 +269,7 @@ describe('GemTarget.bumpVersion', () => { let tempDir: string; beforeEach(async () => { + await setupDefaultMocks(); tempDir = await mkdtemp(join(tmpdir(), 'craft-gem-test-')); }); @@ -317,6 +328,7 @@ describe('PubDevTarget.bumpVersion', () => { let tempDir: string; beforeEach(async () => { + await setupDefaultMocks(); tempDir = await mkdtemp(join(tmpdir(), 'craft-pubdev-test-')); }); @@ -353,6 +365,7 @@ describe('HexTarget.bumpVersion', () => { let tempDir: string; beforeEach(async () => { + await setupDefaultMocks(); tempDir = await mkdtemp(join(tmpdir(), 'craft-hex-test-')); }); @@ -413,6 +426,7 @@ describe('NugetTarget.bumpVersion', () => { let tempDir: string; beforeEach(async () => { + await setupDefaultMocks(); tempDir = await mkdtemp(join(tmpdir(), 'craft-nuget-test-')); }); @@ -420,7 +434,7 @@ describe('NugetTarget.bumpVersion', () => { if (tempDir) { await rm(tempDir, { recursive: true, force: true }); } - vi.clearAllMocks(); + vi.resetAllMocks(); }); test('returns false when no .csproj exists', async () => { @@ -479,6 +493,7 @@ describe('CratesTarget.bumpVersion', () => { let tempDir: string; beforeEach(async () => { + await setupDefaultMocks(); tempDir = await mkdtemp(join(tmpdir(), 'craft-crates-test-')); }); @@ -486,7 +501,7 @@ describe('CratesTarget.bumpVersion', () => { if (tempDir) { await rm(tempDir, { recursive: true, force: true }); } - vi.clearAllMocks(); + vi.resetAllMocks(); }); test('returns false when no Cargo.toml exists', async () => { diff --git a/src/commands/__tests__/prepare.test.ts b/src/commands/__tests__/prepare.test.ts index a620bc00..7dc857c1 100644 --- a/src/commands/__tests__/prepare.test.ts +++ b/src/commands/__tests__/prepare.test.ts @@ -8,6 +8,7 @@ vi.mock('../../utils/system'); describe('runPreReleaseCommand', () => { const oldVersion = '2.3.3'; const newVersion = '2.3.4'; + const rootDir = process.cwd(); const mockedSpawnProcess = spawnProcess as Mock; beforeEach(() => { @@ -17,11 +18,16 @@ describe('runPreReleaseCommand', () => { test('runs with default command', async () => { expect.assertions(1); - await runPreReleaseCommand(oldVersion, newVersion); + await runPreReleaseCommand({ + oldVersion, + newVersion, + rootDir, + preReleaseCommand: 'scripts/bump-version.sh', + }); expect(mockedSpawnProcess).toBeCalledWith( - '/bin/bash', - [pathJoin('scripts', 'bump-version.sh'), oldVersion, newVersion], + 'scripts/bump-version.sh', + [oldVersion, newVersion], { env: { ...process.env, @@ -35,11 +41,12 @@ describe('runPreReleaseCommand', () => { test('runs with custom command', async () => { expect.assertions(1); - await runPreReleaseCommand( + await runPreReleaseCommand({ oldVersion, newVersion, - 'python ./increase_version.py "argument 1"' - ); + rootDir, + preReleaseCommand: 'python ./increase_version.py "argument 1"', + }); expect(mockedSpawnProcess).toBeCalledWith( 'python', From 21d475ebedafe9d989ae29d650f71d36ca8daa9d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 30 Jan 2026 22:32:15 +0000 Subject: [PATCH 07/16] feat(gem): Support monorepo structures with gemspecs in subdirectories - Add 'ignore' package for gitignore-aware file traversal - Add findFiles() utility that respects .gitignore patterns - Update GemTarget.bumpVersion() to search up to 2 levels deep - Convert gem target methods to async fs/promises APIs - Add tests for monorepo support (sentry-ruby style) --- package.json | 1 + pnpm-lock.yaml | 3 + src/__tests__/versionBump.test.ts | 164 ++++++++++++++++++++++++------ src/targets/gem.ts | 59 +++++++---- src/utils/files.ts | 107 ++++++++++++++++++- 5 files changed, 282 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index 61369534..c5ff725e 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "pnpm": "10.27.0" }, "dependencies": { + "ignore": "^7.0.5", "marked": "^17.0.1", "p-limit": "^6.2.0", "semver": "^7.7.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3917c4b3..cb839023 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + ignore: + specifier: ^7.0.5 + version: 7.0.5 marked: specifier: ^17.0.1 version: 17.0.1 diff --git a/src/__tests__/versionBump.test.ts b/src/__tests__/versionBump.test.ts index 54eccd41..2de31e46 100644 --- a/src/__tests__/versionBump.test.ts +++ b/src/__tests__/versionBump.test.ts @@ -54,7 +54,7 @@ describe('runAutomaticVersionBumps', () => { const result = await runAutomaticVersionBumps( [{ name: 'github' }], tempDir, - '1.0.0' + '1.0.0', ); expect(result).toBe(false); }); @@ -64,7 +64,7 @@ describe('runAutomaticVersionBumps', () => { const result = await runAutomaticVersionBumps( [{ name: 'npm' }], tempDir, - '1.0.0' + '1.0.0', ); expect(result).toBe(false); }); @@ -72,13 +72,13 @@ describe('runAutomaticVersionBumps', () => { test('calls bumpVersion for npm target with package.json', async () => { await writeFile( join(tempDir, 'package.json'), - JSON.stringify({ name: 'test', version: '0.0.1' }) + JSON.stringify({ name: 'test', version: '0.0.1' }), ); const result = await runAutomaticVersionBumps( [{ name: 'npm' }], tempDir, - '1.0.0' + '1.0.0', ); expect(result).toBe(true); @@ -87,7 +87,7 @@ describe('runAutomaticVersionBumps', () => { test('deduplicates multiple targets of the same type', async () => { await writeFile( join(tempDir, 'package.json'), - JSON.stringify({ name: 'test', version: '0.0.1' }) + JSON.stringify({ name: 'test', version: '0.0.1' }), ); const bumpSpy = vi.spyOn(NpmTarget, 'bumpVersion'); @@ -95,7 +95,7 @@ describe('runAutomaticVersionBumps', () => { await runAutomaticVersionBumps( [{ name: 'npm' }, { name: 'npm', id: 'second' }], tempDir, - '1.0.0' + '1.0.0', ); // Should only be called once despite two npm targets @@ -106,11 +106,11 @@ describe('runAutomaticVersionBumps', () => { // Create files for both npm and pypi await writeFile( join(tempDir, 'package.json'), - JSON.stringify({ name: 'test', version: '0.0.1' }) + JSON.stringify({ name: 'test', version: '0.0.1' }), ); await writeFile( join(tempDir, 'pyproject.toml'), - '[project]\nname = "test"\nversion = "0.0.1"' + '[project]\nname = "test"\nversion = "0.0.1"', ); const npmSpy = vi.spyOn(NpmTarget, 'bumpVersion'); @@ -119,7 +119,7 @@ describe('runAutomaticVersionBumps', () => { const result = await runAutomaticVersionBumps( [{ name: 'npm' }, { name: 'pypi' }], tempDir, - '1.0.0' + '1.0.0', ); expect(result).toBe(true); @@ -130,15 +130,15 @@ describe('runAutomaticVersionBumps', () => { test('throws error with context when bumpVersion fails', async () => { await writeFile( join(tempDir, 'package.json'), - JSON.stringify({ name: 'test', version: '0.0.1' }) + JSON.stringify({ name: 'test', version: '0.0.1' }), ); vi.spyOn(NpmTarget, 'bumpVersion').mockRejectedValue( - new Error('npm not found') + new Error('npm not found'), ); await expect( - runAutomaticVersionBumps([{ name: 'npm' }], tempDir, '1.0.0') + runAutomaticVersionBumps([{ name: 'npm' }], tempDir, '1.0.0'), ).rejects.toThrow(/Automatic version bump failed for "npm" target/); }); }); @@ -166,7 +166,7 @@ describe('NpmTarget.bumpVersion', () => { test('returns true and calls npm version when package.json exists', async () => { await writeFile( join(tempDir, 'package.json'), - JSON.stringify({ name: 'test', version: '0.0.1' }) + JSON.stringify({ name: 'test', version: '0.0.1' }), ); const { spawnProcess } = await import('../utils/system'); @@ -176,7 +176,7 @@ describe('NpmTarget.bumpVersion', () => { expect(spawnProcess).toHaveBeenCalledWith( expect.any(String), expect.arrayContaining(['version', '1.0.0', '--no-git-tag-version']), - expect.objectContaining({ cwd: tempDir }) + expect.objectContaining({ cwd: tempDir }), ); }); }); @@ -204,7 +204,7 @@ describe('PypiTarget.bumpVersion', () => { test('uses hatch when [tool.hatch] section exists', async () => { await writeFile( join(tempDir, 'pyproject.toml'), - '[tool.hatch]\n[project]\nname = "test"' + '[tool.hatch]\n[project]\nname = "test"', ); const { spawnProcess } = await import('../utils/system'); @@ -214,14 +214,14 @@ describe('PypiTarget.bumpVersion', () => { expect(spawnProcess).toHaveBeenCalledWith( 'hatch', ['version', '1.0.0'], - expect.objectContaining({ cwd: tempDir }) + expect.objectContaining({ cwd: tempDir }), ); }); test('uses poetry when [tool.poetry] section exists', async () => { await writeFile( join(tempDir, 'pyproject.toml'), - '[tool.poetry]\nname = "test"' + '[tool.poetry]\nname = "test"', ); const { spawnProcess } = await import('../utils/system'); @@ -231,14 +231,14 @@ describe('PypiTarget.bumpVersion', () => { expect(spawnProcess).toHaveBeenCalledWith( 'poetry', ['version', '1.0.0'], - expect.objectContaining({ cwd: tempDir }) + expect.objectContaining({ cwd: tempDir }), ); }); test('returns true without action for setuptools_scm', async () => { await writeFile( join(tempDir, 'pyproject.toml'), - '[tool.setuptools_scm]\n[project]\nname = "test"' + '[tool.setuptools_scm]\n[project]\nname = "test"', ); const { spawnProcess } = await import('../utils/system'); @@ -259,7 +259,7 @@ describe('PypiTarget.bumpVersion', () => { const updatedContent = await readFile( join(tempDir, 'pyproject.toml'), - 'utf-8' + 'utf-8', ); expect(updatedContent).toContain('version = "1.0.0"'); }); @@ -299,7 +299,7 @@ end const updatedContent = await readFile( join(tempDir, 'test.gemspec'), - 'utf-8' + 'utf-8', ); expect(updatedContent).toContain('s.version = "1.0.0"'); }); @@ -309,7 +309,7 @@ end await mkdir(join(tempDir, 'lib', 'test'), { recursive: true }); await writeFile( join(tempDir, 'lib', 'test', 'version.rb'), - 'module Test\n VERSION = "0.0.1"\nend' + 'module Test\n VERSION = "0.0.1"\nend', ); const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); @@ -318,10 +318,116 @@ end const updatedContent = await readFile( join(tempDir, 'lib', 'test', 'version.rb'), - 'utf-8' + 'utf-8', ); expect(updatedContent).toContain('VERSION = "1.0.0"'); }); + + test('finds gemspec in subdirectory (monorepo like sentry-ruby)', async () => { + // Structure: sentry-ruby/sentry-ruby.gemspec + await mkdir(join(tempDir, 'sentry-ruby')); + await writeFile( + join(tempDir, 'sentry-ruby', 'sentry-ruby.gemspec'), + 'Gem::Specification.new do |s|\n s.version = "0.0.1"\nend', + ); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(true); + + const content = await readFile( + join(tempDir, 'sentry-ruby', 'sentry-ruby.gemspec'), + 'utf-8', + ); + expect(content).toContain('s.version = "1.0.0"'); + }); + + test('finds gemspec at depth 2 (packages/gem-name pattern)', async () => { + await mkdir(join(tempDir, 'packages', 'my-gem'), { recursive: true }); + await writeFile( + join(tempDir, 'packages', 'my-gem', 'my-gem.gemspec'), + 'Gem::Specification.new do |s|\n s.version = "0.0.1"\nend', + ); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(true); + + const content = await readFile( + join(tempDir, 'packages', 'my-gem', 'my-gem.gemspec'), + 'utf-8', + ); + expect(content).toContain('s.version = "1.0.0"'); + }); + + test('updates version.rb relative to gemspec in subdirectory', async () => { + // Structure: sentry-ruby/sentry-ruby.gemspec + sentry-ruby/lib/sentry/version.rb + await mkdir(join(tempDir, 'sentry-ruby', 'lib', 'sentry'), { + recursive: true, + }); + await writeFile( + join(tempDir, 'sentry-ruby', 'sentry-ruby.gemspec'), + 'Gem::Specification.new do |s|\n s.version = "0.0.1"\nend', + ); + await writeFile( + join(tempDir, 'sentry-ruby', 'lib', 'sentry', 'version.rb'), + 'module Sentry\n VERSION = "0.0.1"\nend', + ); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(true); + + const versionContent = await readFile( + join(tempDir, 'sentry-ruby', 'lib', 'sentry', 'version.rb'), + 'utf-8', + ); + expect(versionContent).toContain('VERSION = "1.0.0"'); + }); + + test('skips gemspecs in gitignored directories', async () => { + await writeFile(join(tempDir, '.gitignore'), 'vendor/\n'); + await mkdir(join(tempDir, 'vendor')); + await writeFile( + join(tempDir, 'vendor', 'test.gemspec'), + 'Gem::Specification.new do |s|\n s.version = "0.0.1"\nend', + ); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + + // Verify the file was not modified + const content = await readFile( + join(tempDir, 'vendor', 'test.gemspec'), + 'utf-8', + ); + expect(content).toContain('s.version = "0.0.1"'); + }); + + test('handles multiple gemspecs in monorepo', async () => { + // Structure like sentry-ruby with multiple gems + await mkdir(join(tempDir, 'sentry-ruby')); + await mkdir(join(tempDir, 'sentry-rails')); + await writeFile( + join(tempDir, 'sentry-ruby', 'sentry-ruby.gemspec'), + 'Gem::Specification.new do |s|\n s.version = "0.0.1"\nend', + ); + await writeFile( + join(tempDir, 'sentry-rails', 'sentry-rails.gemspec'), + 'Gem::Specification.new do |s|\n s.version = "0.0.1"\nend', + ); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(true); + + const rubyContent = await readFile( + join(tempDir, 'sentry-ruby', 'sentry-ruby.gemspec'), + 'utf-8', + ); + const railsContent = await readFile( + join(tempDir, 'sentry-rails', 'sentry-rails.gemspec'), + 'utf-8', + ); + expect(rubyContent).toContain('s.version = "1.0.0"'); + expect(railsContent).toContain('s.version = "1.0.0"'); + }); }); describe('PubDevTarget.bumpVersion', () => { @@ -346,7 +452,7 @@ describe('PubDevTarget.bumpVersion', () => { test('updates version in pubspec.yaml', async () => { await writeFile( join(tempDir, 'pubspec.yaml'), - 'name: test\nversion: 0.0.1\n' + 'name: test\nversion: 0.0.1\n', ); const result = await PubDevTarget.bumpVersion(tempDir, '1.0.0'); @@ -355,7 +461,7 @@ describe('PubDevTarget.bumpVersion', () => { const updatedContent = await readFile( join(tempDir, 'pubspec.yaml'), - 'utf-8' + 'utf-8', ); expect(updatedContent).toContain('version: 1.0.0'); }); @@ -460,7 +566,7 @@ describe('NugetTarget.bumpVersion', () => { const updatedContent = await readFile( join(tempDir, 'Test.csproj'), - 'utf-8' + 'utf-8', ); expect(updatedContent).toContain('1.0.0'); }); @@ -483,7 +589,7 @@ describe('NugetTarget.bumpVersion', () => { const updatedContent = await readFile( join(tempDir, 'Directory.Build.props'), - 'utf-8' + 'utf-8', ); expect(updatedContent).toContain('1.0.0'); }); @@ -512,7 +618,7 @@ describe('CratesTarget.bumpVersion', () => { test('calls cargo set-version when Cargo.toml exists', async () => { await writeFile( join(tempDir, 'Cargo.toml'), - '[package]\nname = "test"\nversion = "0.0.1"' + '[package]\nname = "test"\nversion = "0.0.1"', ); const { spawnProcess } = await import('../utils/system'); @@ -522,7 +628,7 @@ describe('CratesTarget.bumpVersion', () => { expect(spawnProcess).toHaveBeenCalledWith( 'cargo', ['set-version', '1.0.0'], - expect.objectContaining({ cwd: tempDir }) + expect.objectContaining({ cwd: tempDir }), ); }); }); diff --git a/src/targets/gem.ts b/src/targets/gem.ts index 73665444..5dec8c11 100644 --- a/src/targets/gem.ts +++ b/src/targets/gem.ts @@ -1,11 +1,12 @@ -import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'; -import { join } from 'path'; +import { readdir, readFile, writeFile } from 'fs/promises'; +import { dirname, join } from 'path'; import { BaseArtifactProvider, RemoteArtifact, } from '../artifact_providers/base'; import { reportError } from '../utils/errors'; +import { findFiles } from '../utils/files'; import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; import { BaseTarget } from './base'; import { TargetConfig } from '../schemas/project_config'; @@ -33,24 +34,31 @@ export class GemTarget extends BaseTarget { /** * Bump version in Ruby gem project files. * + * Supports monorepos by searching for gemspec files up to 2 levels deep, + * respecting .gitignore patterns. + * * Looks for version patterns in: * 1. .gemspec files (s.version = "x.y.z") - * 2. lib/.../version.rb files (VERSION = "x.y.z") + * 2. lib/.../version.rb files relative to each gemspec (VERSION = "x.y.z") */ public static async bumpVersion( rootDir: string, - newVersion: string + newVersion: string, ): Promise { - const gemspecFiles = readdirSync(rootDir).filter(f => f.endsWith('.gemspec')); + // Find all gemspec files up to 2 levels deep, respecting .gitignore + const gemspecFiles = await findFiles(rootDir, { + maxDepth: 2, + fileFilter: name => name.endsWith('.gemspec'), + }); + if (gemspecFiles.length === 0) { return false; } let bumped = false; - for (const gemspecFile of gemspecFiles) { - const gemspecPath = join(rootDir, gemspecFile); - const content = readFileSync(gemspecPath, 'utf-8'); + for (const gemspecPath of gemspecFiles) { + const content = await readFile(gemspecPath, 'utf-8'); // Match: s.version = "1.0.0" or spec.version = '1.0.0' const versionRegex = /^(\s*\w+\.version\s*=\s*["'])([^"']+)(["'])/m; @@ -59,15 +67,19 @@ export class GemTarget extends BaseTarget { const newContent = content.replace(versionRegex, `$1${newVersion}$3`); if (newContent !== content) { logger.debug(`Updating version in ${gemspecPath} to ${newVersion}`); - writeFileSync(gemspecPath, newContent); + await writeFile(gemspecPath, newContent); bumped = true; } } - } - const libDir = join(rootDir, 'lib'); - if (existsSync(libDir)) { - bumped = GemTarget.updateVersionRbFiles(libDir, newVersion) || bumped; + // Also check for lib/**/version.rb relative to each gemspec's directory + const gemDir = dirname(gemspecPath); + const libDir = join(gemDir, 'lib'); + const libUpdated = await GemTarget.updateVersionRbFiles( + libDir, + newVersion, + ); + bumped = libUpdated || bumped; } return bumped; @@ -76,12 +88,15 @@ export class GemTarget extends BaseTarget { /** * Recursively find and update version.rb files */ - private static updateVersionRbFiles(dir: string, newVersion: string): boolean { + private static async updateVersionRbFiles( + dir: string, + newVersion: string, + ): Promise { let updated = false; let entries; try { - entries = readdirSync(dir, { withFileTypes: true }); + entries = await readdir(dir, { withFileTypes: true }); } catch { return false; } @@ -90,16 +105,20 @@ export class GemTarget extends BaseTarget { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { - updated = GemTarget.updateVersionRbFiles(fullPath, newVersion) || updated; + const subUpdated = await GemTarget.updateVersionRbFiles( + fullPath, + newVersion, + ); + updated = subUpdated || updated; } else if (entry.name === 'version.rb') { - const content = readFileSync(fullPath, 'utf-8'); + const content = await readFile(fullPath, 'utf-8'); const versionRegex = /^(\s*VERSION\s*=\s*["'])([^"']+)(["'])/m; if (versionRegex.test(content)) { const newContent = content.replace(versionRegex, `$1${newVersion}$3`); if (newContent !== content) { logger.debug(`Updating VERSION in ${fullPath} to ${newVersion}`); - writeFileSync(fullPath, newContent); + await writeFile(fullPath, newContent); updated = true; } } @@ -111,7 +130,7 @@ export class GemTarget extends BaseTarget { public constructor( config: TargetConfig, - artifactProvider: BaseArtifactProvider + artifactProvider: BaseArtifactProvider, ) { super(config, artifactProvider); checkExecutableIsPresent(GEM_BIN); @@ -149,7 +168,7 @@ export class GemTarget extends BaseTarget { const path = await this.artifactProvider.downloadArtifact(file); this.logger.info(`Pushing gem "${file.filename}"`); return this.pushGem(path); - }) + }), ); this.logger.info('Successfully registered gem'); diff --git a/src/utils/files.ts b/src/utils/files.ts index 744cd2a2..6f396a79 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -1,4 +1,6 @@ import * as fs from 'fs'; +import { readdir as readdirAsync, readFile } from 'fs/promises'; +import ignore, { Ignore } from 'ignore'; import * as os from 'os'; import * as path from 'path'; import rimraf from 'rimraf'; @@ -26,7 +28,7 @@ const readdir = util.promisify(fs.readdir); */ export async function scan( directory: string, - results: string[] = [] + results: string[] = [], ): Promise { const files = await readdirp(directory); for (const f of files) { @@ -76,7 +78,7 @@ export async function listFiles(directory: string): Promise { export async function withTempDir( callback: (arg: string) => T | Promise, cleanup = true, - prefix = 'craft-' + prefix = 'craft-', ): Promise { const directory = await mkdtemp(path.join(os.tmpdir(), prefix)); try { @@ -112,7 +114,7 @@ export async function withTempDir( export async function withTempFile( callback: (arg: string) => T | Promise, cleanup = true, - prefix = 'craft-' + prefix = 'craft-', ): Promise { tmp.setGracefulCleanup(); const tmpFile = tmp.fileSync({ prefix }); @@ -145,3 +147,102 @@ export function detectContentType(artifactName: string): string | undefined { } return undefined; } + +/** + * Options for the findFiles function + */ +export interface FindFilesOptions { + /** Maximum directory depth to traverse (default: 2) */ + maxDepth?: number; + /** Filter function to select which files to include */ + fileFilter?: (name: string) => boolean; +} + +/** + * Load and parse .gitignore file from a directory + */ +async function loadGitignore(rootDir: string): Promise { + const ig = ignore(); + try { + const content = await readFile(path.join(rootDir, '.gitignore'), 'utf-8'); + ig.add(content); + } catch { + // No .gitignore file, use empty ignore list + } + // Always ignore .git directory + ig.add('.git'); + return ig; +} + +/** + * Recursively walk a directory up to a maximum depth + */ +async function walkDirectory( + rootDir: string, + currentDir: string, + ig: Ignore, + options: FindFilesOptions, + depth: number, +): Promise { + const { maxDepth = 2, fileFilter } = options; + const results: string[] = []; + + let entries; + try { + entries = await readdirAsync(currentDir, { withFileTypes: true }); + } catch { + return results; + } + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + const relativePath = path.relative(rootDir, fullPath); + + // Skip ignored paths + if (ig.ignores(relativePath)) { + continue; + } + + if (entry.isFile()) { + if (!fileFilter || fileFilter(entry.name)) { + results.push(fullPath); + } + } else if (entry.isDirectory() && depth < maxDepth) { + const subResults = await walkDirectory( + rootDir, + fullPath, + ig, + options, + depth + 1, + ); + results.push(...subResults); + } + } + + return results; +} + +/** + * Find files matching a filter, respecting .gitignore rules. + * + * Recursively searches directories up to maxDepth levels deep, + * skipping any paths that match .gitignore patterns. + * + * @param rootDir - Starting directory for the search + * @param options - Search options including maxDepth and fileFilter + * @returns Array of absolute file paths matching the filter + * + * @example + * // Find all .gemspec files up to 2 levels deep + * const gemspecs = await findFiles(projectRoot, { + * maxDepth: 2, + * fileFilter: name => name.endsWith('.gemspec'), + * }); + */ +export async function findFiles( + rootDir: string, + options: FindFilesOptions = {}, +): Promise { + const ig = await loadGitignore(rootDir); + return walkDirectory(rootDir, rootDir, ig, options, 0); +} From 662afed033262dd4560edd09fd51c364b35f43fc Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 30 Jan 2026 22:49:13 +0000 Subject: [PATCH 08/16] chore: update AUTO_BUMP_MIN_VERSION from 2.19.0 to 2.21.0 Addresses PR review comments to update the minimum version required for automatic version bumping from targets to 2.21.0, which will be the release version for this feature. --- docs/src/content/docs/configuration.md | 43 ++++++++++++++------------ src/commands/prepare.ts | 4 +-- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 146ac495..bab91594 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -50,7 +50,7 @@ npm version "${CRAFT_NEW_VERSION}" ## Automatic Version Bumping -When `minVersion: "2.19.0"` or higher is set and no custom `preReleaseCommand` is defined, Craft automatically bumps version numbers based on your configured publish targets. This eliminates the need for a `scripts/bump-version.sh` script in most cases. +When `minVersion: "2.21.0"` or higher is set and no custom `preReleaseCommand` is defined, Craft automatically bumps version numbers based on your configured publish targets. This eliminates the need for a `scripts/bump-version.sh` script in most cases. ### How It Works @@ -61,15 +61,15 @@ When `minVersion: "2.19.0"` or higher is set and no custom `preReleaseCommand` i ### Supported Targets -| Target | Detection | Version Bump Method | -|--------|-----------|---------------------| -| `npm` | `package.json` exists | `npm version --no-git-tag-version` (with workspace support) | -| `pypi` | `pyproject.toml` exists | hatch, poetry, setuptools-scm, or direct edit | -| `crates` | `Cargo.toml` exists | `cargo set-version` (requires cargo-edit) | -| `gem` | `*.gemspec` exists | Direct edit of gemspec and `lib/**/version.rb` | -| `pub-dev` | `pubspec.yaml` exists | Direct edit of pubspec.yaml | -| `hex` | `mix.exs` exists | Direct edit of mix.exs | -| `nuget` | `*.csproj` exists | dotnet-setversion or direct XML edit | +| Target | Detection | Version Bump Method | +| --------- | ----------------------- | ----------------------------------------------------------- | +| `npm` | `package.json` exists | `npm version --no-git-tag-version` (with workspace support) | +| `pypi` | `pyproject.toml` exists | hatch, poetry, setuptools-scm, or direct edit | +| `crates` | `Cargo.toml` exists | `cargo set-version` (requires cargo-edit) | +| `gem` | `*.gemspec` exists | Direct edit of gemspec and `lib/**/version.rb` | +| `pub-dev` | `pubspec.yaml` exists | Direct edit of pubspec.yaml | +| `hex` | `mix.exs` exists | Direct edit of mix.exs | +| `nuget` | `*.csproj` exists | dotnet-setversion or direct XML edit | ### npm Workspace Support @@ -79,6 +79,7 @@ For npm/yarn/pnpm monorepos, Craft automatically detects and bumps versions in a - **yarn/pnpm or npm < 7**: Falls back to bumping each non-private package individually Workspace detection checks for: + - `workspaces` field in root `package.json` (npm/yarn) - `pnpm-workspace.yaml` (pnpm) @@ -98,35 +99,37 @@ For Python projects, Craft detects the build tool and uses the appropriate metho To enable automatic version bumping, ensure your `.craft.yml` has: ```yaml -minVersion: "2.19.0" +minVersion: '2.21.0' targets: - - name: npm # or pypi, crates, etc. + - name: npm # or pypi, crates, etc. # ... other targets ``` And either: + - Remove any custom `preReleaseCommand`, or - Don't define `preReleaseCommand` at all ### Disabling Automatic Version Bumping -To disable automatic version bumping while still using minVersion 2.19.0+: +To disable automatic version bumping while still using minVersion 2.21.0+: ```yaml -minVersion: "2.19.0" -preReleaseCommand: "" # Explicitly set to empty string +minVersion: '2.21.0' +preReleaseCommand: '' # Explicitly set to empty string ``` Or define a custom script: ```yaml -minVersion: "2.19.0" +minVersion: '2.21.0' preReleaseCommand: bash scripts/my-custom-bump.sh ``` ### Error Handling If automatic version bumping fails: + - **Missing tool**: Craft reports which tool is missing (e.g., "Cannot find 'npm' for version bumping") - **Command failure**: Craft shows the error from the failed command - **No supported targets**: Craft warns that no targets support automatic bumping @@ -529,12 +532,12 @@ artifactProvider: name: github config: artifacts: - build: release-artifacts # exact workflow → exact artifact - /^build-.*$/: artifacts # workflow pattern → exact artifact - ci: # exact workflow → multiple artifacts + build: release-artifacts # exact workflow → exact artifact + /^build-.*$/: artifacts # workflow pattern → exact artifact + ci: # exact workflow → multiple artifacts - /^output-.*$/ - bundle - /^release-.*$/: # workflow pattern → multiple artifacts + /^release-.*$/: # workflow pattern → multiple artifacts - /^dist-.*$/ - checksums ``` diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 310fd935..d4e3fb9f 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -74,7 +74,7 @@ const DEFAULT_BUMP_VERSION_PATH = join('scripts', 'bump-version.sh'); const AUTO_VERSION_MIN_VERSION = '2.14.0'; /** Minimum craft version required for automatic version bumping from targets */ -const AUTO_BUMP_MIN_VERSION = '2.19.0'; +const AUTO_BUMP_MIN_VERSION = '2.21.0'; export const builder: CommandBuilder = (yargs: Argv) => yargs @@ -296,7 +296,7 @@ interface PreReleaseOptions { /** * Run pre-release command or automatic version bumping. * - * Priority: custom command > automatic bumping (minVersion >= 2.19.0) > default script + * Priority: custom command > automatic bumping (minVersion >= 2.21.0) > default script */ export async function runPreReleaseCommand( options: PreReleaseOptions, From e65c06b7ecf8c991d092305c58a3e3c1b61b6830 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 30 Jan 2026 22:59:54 +0000 Subject: [PATCH 09/16] refactor: use opendir with async iterator in walkDirectory Replace readdir with opendir and async for...of loop for more memory-efficient directory traversal. The async iterator processes entries one at a time rather than loading all entries into memory. Addresses PR review feedback. --- src/utils/files.ts | 48 +++++++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/src/utils/files.ts b/src/utils/files.ts index 6f396a79..e07edd20 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -1,5 +1,5 @@ import * as fs from 'fs'; -import { readdir as readdirAsync, readFile } from 'fs/promises'; +import { opendir, readFile } from 'fs/promises'; import ignore, { Ignore } from 'ignore'; import * as os from 'os'; import * as path from 'path'; @@ -187,36 +187,40 @@ async function walkDirectory( const { maxDepth = 2, fileFilter } = options; const results: string[] = []; - let entries; + let dir; try { - entries = await readdirAsync(currentDir, { withFileTypes: true }); + dir = await opendir(currentDir); } catch { return results; } - for (const entry of entries) { - const fullPath = path.join(currentDir, entry.name); - const relativePath = path.relative(rootDir, fullPath); + try { + for await (const entry of dir) { + const fullPath = path.join(currentDir, entry.name); + const relativePath = path.relative(rootDir, fullPath); - // Skip ignored paths - if (ig.ignores(relativePath)) { - continue; - } + // Skip ignored paths + if (ig.ignores(relativePath)) { + continue; + } - if (entry.isFile()) { - if (!fileFilter || fileFilter(entry.name)) { - results.push(fullPath); + if (entry.isFile()) { + if (!fileFilter || fileFilter(entry.name)) { + results.push(fullPath); + } + } else if (entry.isDirectory() && depth < maxDepth) { + const subResults = await walkDirectory( + rootDir, + fullPath, + ig, + options, + depth + 1, + ); + results.push(...subResults); } - } else if (entry.isDirectory() && depth < maxDepth) { - const subResults = await walkDirectory( - rootDir, - fullPath, - ig, - options, - depth + 1, - ); - results.push(...subResults); } + } finally { + await dir.close(); } return results; From 9ad56d0e2d5f9bc38daf0dfd08fcc1ba5db1ef06 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 30 Jan 2026 23:05:47 +0000 Subject: [PATCH 10/16] refactor: add runWithExecutable helper to abstract executable pattern Add helper functions to centralize the common pattern of: 1. Resolving executable path (with optional env var override) 2. Checking if executable exists 3. Throwing helpful error if not found 4. Running the command with debug logging New exports in system.ts: - ExecutableConfig: interface for configuring executable resolution - resolveExecutable(): resolve binary path from config - runWithExecutable(): check and run executable in one call Updated targets to use the new helper: - pypi.ts: bumpWithHatch, bumpWithPoetry - crates.ts: bumpVersion Closes #738 --- src/targets/crates.ts | 73 ++++++++++++++++++++++--------------------- src/targets/pypi.ts | 68 ++++++++++++++++++---------------------- src/utils/system.ts | 73 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 74 deletions(-) diff --git a/src/targets/crates.ts b/src/targets/crates.ts index a19dd6e0..dbe6c269 100644 --- a/src/targets/crates.ts +++ b/src/targets/crates.ts @@ -9,19 +9,22 @@ import { ConfigurationError } from '../utils/errors'; import { withTempDir } from '../utils/files'; import { checkExecutableIsPresent, - hasExecutable, - spawnProcess, + resolveExecutable, + runWithExecutable, } from '../utils/system'; import { BaseTarget } from './base'; import { BaseArtifactProvider } from '../artifact_providers/base'; import { logger } from '../logger'; -const DEFAULT_CARGO_BIN = 'cargo'; +/** Cargo executable configuration */ +const CARGO_CONFIG = { + name: 'cargo', + envVar: 'CARGO_BIN', + errorHint: 'Install cargo or define a custom preReleaseCommand in .craft.yml', +} as const; -/** - * Command to launch cargo - */ -const CARGO_BIN = process.env.CARGO_BIN || DEFAULT_CARGO_BIN; +/** Resolved cargo binary path */ +const CARGO_BIN = resolveExecutable(CARGO_CONFIG); /** * A message fragment emitted by cargo when publishing fails due to a missing @@ -122,30 +125,25 @@ export class CratesTarget extends BaseTarget { */ public static async bumpVersion( rootDir: string, - newVersion: string + newVersion: string, ): Promise { const cargoTomlPath = path.join(rootDir, 'Cargo.toml'); if (!fs.existsSync(cargoTomlPath)) { return false; } - if (!hasExecutable(CARGO_BIN)) { - throw new Error( - `Cannot find "${CARGO_BIN}" for version bumping. ` + - 'Install cargo or define a custom preReleaseCommand in .craft.yml' - ); - } - - const args = ['set-version', newVersion]; - logger.debug(`Running: ${CARGO_BIN} ${args.join(' ')}`); - try { - await spawnProcess(CARGO_BIN, args, { cwd: rootDir }); + await runWithExecutable(CARGO_CONFIG, ['set-version', newVersion], { + cwd: rootDir, + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (message.includes('no such command') || message.includes('no such subcommand')) { + if ( + message.includes('no such command') || + message.includes('no such subcommand') + ) { throw new Error( - 'cargo set-version not found. Install cargo-edit: cargo install cargo-edit' + 'cargo set-version not found. Install cargo-edit: cargo install cargo-edit', ); } throw error; @@ -157,7 +155,7 @@ export class CratesTarget extends BaseTarget { public constructor( config: TargetConfig, artifactProvider: BaseArtifactProvider, - githubRepo: GitHubGlobalConfig + githubRepo: GitHubGlobalConfig, ) { super(config, artifactProvider, githubRepo); this.cratesConfig = this.getCratesConfig(); @@ -172,7 +170,7 @@ export class CratesTarget extends BaseTarget { if (!process.env.CRATES_IO_TOKEN) { throw new ConfigurationError( `Cannot publish to Crates.io: missing credentials. - Please use CRATES_IO_TOKEN environment variable to pass the API token.` + Please use CRATES_IO_TOKEN environment variable to pass the API token.`, ); } return { @@ -202,13 +200,13 @@ export class CratesTarget extends BaseTarget { ]; this.logger.info( - `Loading workspace information from ${directory}/Cargo.toml` + `Loading workspace information from ${directory}/Cargo.toml`, ); const metadata = await spawnProcess( CARGO_BIN, args, {}, - { enableInDryRunMode: true } + { enableInDryRunMode: true }, ); if (!metadata) { throw new ConfigurationError('Empty Cargo metadata!'); @@ -230,10 +228,13 @@ export class CratesTarget extends BaseTarget { * @returns The sorted list of packages */ public getPublishOrder(packages: CratePackage[]): CratePackage[] { - const remaining = packages.reduce((dict, p) => { - dict[p.name] = p; - return dict; - }, {} as { [index: string]: CratePackage }); + const remaining = packages.reduce( + (dict, p) => { + dict[p.name] = p; + return dict; + }, + {} as { [index: string]: CratePackage }, + ); const ordered: CratePackage[] = []; const isWorkspaceDependency = (dep: CrateDependency) => { @@ -256,7 +257,7 @@ export class CratesTarget extends BaseTarget { while (Object.keys(remaining).length > 0) { const leafDependencies = Object.values(remaining).filter( // Find all packages with no remaining workspace dependencies - p => p.dependencies.filter(isWorkspaceDependency).length === 0 + p => p.dependencies.filter(isWorkspaceDependency).length === 0, ); if (leafDependencies.length === 0) { @@ -293,7 +294,7 @@ export class CratesTarget extends BaseTarget { this.logger.debug( `Publishing packages in the following order: ${crates .map(c => c.name) - .join(', ')}` + .join(', ')}`, ); return forEachChained(crates, async crate => this.publishPackage(crate)); } @@ -312,7 +313,7 @@ export class CratesTarget extends BaseTarget { args.push( '--no-verify', // Verification should be done on the CI stage '--manifest-path', - crate.manifest_path + crate.manifest_path, ); const env = { @@ -329,7 +330,7 @@ export class CratesTarget extends BaseTarget { } catch (err) { if (err instanceof Error && err.message.includes(REPUBLISH_ERROR)) { this.logger.info( - `Skipping ${crate.name}, version ${crate.version} already published` + `Skipping ${crate.name}, version ${crate.version} already published`, ); } else { throw err; @@ -346,7 +347,7 @@ export class CratesTarget extends BaseTarget { await sleep(delay * 1000); delay *= RETRY_EXP_FACTOR; return true; - } + }, ); } @@ -360,7 +361,7 @@ export class CratesTarget extends BaseTarget { public async cloneWithSubmodules( config: GitHubGlobalConfig, revision: string, - directory: string + directory: string, ): Promise { const { owner, repo } = config; const git = createGitClient(directory); @@ -396,7 +397,7 @@ export class CratesTarget extends BaseTarget { await this.publishWorkspace(directory); }, true, - 'craft-crates-' + 'craft-crates-', ); this.logger.info('Crates release complete'); diff --git a/src/targets/pypi.ts b/src/targets/pypi.ts index 19cfdafd..0d89ee0c 100644 --- a/src/targets/pypi.ts +++ b/src/targets/pypi.ts @@ -7,11 +7,7 @@ import { RemoteArtifact, } from '../artifact_providers/base'; import { ConfigurationError, reportError } from '../utils/errors'; -import { - checkExecutableIsPresent, - hasExecutable, - spawnProcess, -} from '../utils/system'; +import { checkExecutableIsPresent, runWithExecutable } from '../utils/system'; import { BaseTarget } from './base'; import { logger } from '../logger'; @@ -60,7 +56,7 @@ export class PypiTarget extends BaseTarget { */ public static async bumpVersion( rootDir: string, - newVersion: string + newVersion: string, ): Promise { const pyprojectPath = join(rootDir, 'pyproject.toml'); if (!existsSync(pyprojectPath)) { @@ -96,20 +92,18 @@ export class PypiTarget extends BaseTarget { */ private static async bumpWithHatch( rootDir: string, - newVersion: string + newVersion: string, ): Promise { - const HATCH_BIN = process.env.HATCH_BIN || 'hatch'; - - if (!hasExecutable(HATCH_BIN)) { - throw new Error( - `Cannot find "${HATCH_BIN}" for version bumping. ` + - 'Install hatch or define a custom preReleaseCommand in .craft.yml' - ); - } - - logger.debug(`Running: ${HATCH_BIN} version ${newVersion}`); - await spawnProcess(HATCH_BIN, ['version', newVersion], { cwd: rootDir }); - + await runWithExecutable( + { + name: 'hatch', + envVar: 'HATCH_BIN', + errorHint: + 'Install hatch or define a custom preReleaseCommand in .craft.yml', + }, + ['version', newVersion], + { cwd: rootDir }, + ); return true; } @@ -118,20 +112,18 @@ export class PypiTarget extends BaseTarget { */ private static async bumpWithPoetry( rootDir: string, - newVersion: string + newVersion: string, ): Promise { - const POETRY_BIN = process.env.POETRY_BIN || 'poetry'; - - if (!hasExecutable(POETRY_BIN)) { - throw new Error( - `Cannot find "${POETRY_BIN}" for version bumping. ` + - 'Install poetry or define a custom preReleaseCommand in .craft.yml' - ); - } - - logger.debug(`Running: ${POETRY_BIN} version ${newVersion}`); - await spawnProcess(POETRY_BIN, ['version', newVersion], { cwd: rootDir }); - + await runWithExecutable( + { + name: 'poetry', + envVar: 'POETRY_BIN', + errorHint: + 'Install poetry or define a custom preReleaseCommand in .craft.yml', + }, + ['version', newVersion], + { cwd: rootDir }, + ); return true; } @@ -142,7 +134,7 @@ export class PypiTarget extends BaseTarget { private static bumpDirectToml( pyprojectPath: string, content: string, - newVersion: string + newVersion: string, ): boolean { // Match version in [project] section // This regex handles: version = "1.0.0" or version = '1.0.0' @@ -150,7 +142,7 @@ export class PypiTarget extends BaseTarget { if (!versionRegex.test(content)) { logger.debug( - 'pyproject.toml has [project] section but no version field found' + 'pyproject.toml has [project] section but no version field found', ); return false; } @@ -170,7 +162,7 @@ export class PypiTarget extends BaseTarget { public constructor( config: TargetConfig, - artifactProvider: BaseArtifactProvider + artifactProvider: BaseArtifactProvider, ) { super(config, artifactProvider); this.pypiConfig = this.getPypiConfig(); @@ -186,8 +178,8 @@ export class PypiTarget extends BaseTarget { `Cannot perform PyPI release: missing credentials. Please use TWINE_USERNAME and TWINE_PASSWORD environment variables.`.replace( /^\s+/gm, - '' - ) + '', + ), ); } return { @@ -225,7 +217,7 @@ export class PypiTarget extends BaseTarget { packageFiles.map(async (file: RemoteArtifact) => { this.logger.info(`Uploading file "${file.filename}" via twine`); return this.artifactProvider.downloadArtifact(file); - }) + }), ); await this.uploadAssets(paths); diff --git a/src/utils/system.ts b/src/utils/system.ts index e3b5077a..c426ec65 100644 --- a/src/utils/system.ts +++ b/src/utils/system.ts @@ -320,6 +320,79 @@ export function checkExecutableIsPresent(name: string): void { } } +/** + * Configuration for runWithExecutable helper + */ +export interface ExecutableConfig { + /** Default binary name (e.g., 'npm', 'cargo') */ + name: string; + /** Optional environment variable for custom binary path (e.g., 'NPM_BIN') */ + envVar?: string; + /** Hint to show in error message when executable is not found */ + errorHint?: string; +} + +/** + * Resolves the executable path from config, checking env var override first + * + * @param config Executable configuration + * @returns The resolved binary name/path + */ +export function resolveExecutable(config: ExecutableConfig): string { + const { name, envVar } = config; + return envVar ? process.env[envVar] || name : name; +} + +/** + * Checks if an executable exists and runs it with the provided arguments + * + * This helper abstracts the common pattern of: + * 1. Checking for an executable (with optional env var override) + * 2. Throwing a helpful error if not found + * 3. Running the command with provided arguments + * + * @param config Executable configuration + * @param args Arguments to pass to the executable + * @param options Optional spawn options (cwd, env, etc.) + * @param spawnOpts Optional spawn process options + * @returns Promise resolving to stdout buffer + * @throws Error if executable is not found + * + * @example + * ```typescript + * // Simple usage + * await runWithExecutable( + * { name: 'cargo', envVar: 'CARGO_BIN', errorHint: 'Install Rust toolchain' }, + * ['build', '--release'], + * { cwd: projectDir } + * ); + * + * // With custom error handling + * await runWithExecutable( + * { name: 'npm', errorHint: 'Install Node.js' }, + * ['version', '1.0.0', '--no-git-tag-version'] + * ); + * ``` + */ +export async function runWithExecutable( + config: ExecutableConfig, + args: string[], + options: SpawnOptions = {}, + spawnOpts: SpawnProcessOptions = {}, +): Promise { + const bin = resolveExecutable(config); + + if (!hasExecutable(bin)) { + const hint = config.errorHint + ? ` ${config.errorHint}` + : ' Is it installed?'; + throw new Error(`Cannot find "${bin}".${hint}`); + } + + logger.debug(`Running: ${bin} ${args.join(' ')}`); + return spawnProcess(bin, args, options, spawnOpts); +} + /** * Extracts a source code tarball in the specified directory * From eba3c112f97bb5944035493db6e4c6dd1803e346 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 30 Jan 2026 23:10:12 +0000 Subject: [PATCH 11/16] refactor: extend runWithExecutable to support array of executables Add support for trying multiple executables in order: - findFirstExecutable(): finds first available from array of configs - requireFirstExecutable(): same but throws if none found - runWithExecutable(): now accepts single config or array Updated npm.ts to use the new abstraction: - Uses requireFirstExecutable([NPM_CONFIG, YARN_CONFIG]) - Returns index to determine which executable was found - Replaces manual hasExecutable checks with cleaner pattern --- src/targets/npm.ts | 117 ++++++++++++++++++++++++-------------------- src/utils/system.ts | 115 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 166 insertions(+), 66 deletions(-) diff --git a/src/targets/npm.ts b/src/targets/npm.ts index 6a80406a..e176f22f 100644 --- a/src/targets/npm.ts +++ b/src/targets/npm.ts @@ -7,8 +7,11 @@ import { TargetConfig } from '../schemas/project_config'; import { ConfigurationError, reportError } from '../utils/errors'; import { stringToRegexp } from '../utils/filters'; import { isDryRun } from '../utils/helpers'; -import { hasExecutable, spawnProcess } from '../utils/system'; -import { discoverWorkspaces } from '../utils/workspaces'; +import { + hasExecutable, + requireFirstExecutable, + spawnProcess, +} from '../utils/system'; import { isPreviewRelease, parseVersion, @@ -30,6 +33,12 @@ import { withTempFile } from '../utils/files'; import { writeFileSync } from 'fs'; import { logger } from '../logger'; +/** npm executable config */ +export const NPM_CONFIG = { name: 'npm', envVar: 'NPM_BIN' } as const; + +/** yarn executable config */ +export const YARN_CONFIG = { name: 'yarn', envVar: 'YARN_BIN' } as const; + /** Command to launch "npm" */ export const NPM_BIN = process.env.NPM_BIN || 'npm'; @@ -122,7 +131,7 @@ export class NpmTarget extends BaseTarget { */ public static async expand( config: NpmTargetConfig, - rootDir: string + rootDir: string, ): Promise { // If workspaces is not enabled, return the config as-is if (!config.workspaces) { @@ -133,7 +142,7 @@ export class NpmTarget extends BaseTarget { if (result.type === 'none' || result.packages.length === 0) { logger.warn( - 'npm target has workspaces enabled but no workspace packages were found' + 'npm target has workspaces enabled but no workspace packages were found', ); return []; } @@ -151,28 +160,28 @@ export class NpmTarget extends BaseTarget { const filteredPackages = filterWorkspacePackages( result.packages, includePattern, - excludePattern + excludePattern, ); // Also filter out private packages by default (they shouldn't be published) const publishablePackages = filteredPackages.filter(pkg => !pkg.private); const privatePackageNames = new Set( - filteredPackages.filter(pkg => pkg.private).map(pkg => pkg.name) + filteredPackages.filter(pkg => pkg.private).map(pkg => pkg.name), ); // Validate: public packages should not depend on private workspace packages for (const pkg of publishablePackages) { const privateDeps = pkg.workspaceDependencies.filter(dep => - privatePackageNames.has(dep) + privatePackageNames.has(dep), ); if (privateDeps.length > 0) { throw new ConfigurationError( `Public package "${ pkg.name }" depends on private workspace package(s): ${privateDeps.join( - ', ' + ', ', )}. ` + - `Private packages cannot be published to npm, so this dependency cannot be resolved by consumers.` + `Private packages cannot be published to npm, so this dependency cannot be resolved by consumers.`, ); } @@ -181,7 +190,7 @@ export class NpmTarget extends BaseTarget { if (isScoped && !pkg.hasPublicAccess) { logger.warn( `Scoped package "${pkg.name}" does not have publishConfig.access set to 'public'. ` + - `This may cause npm publish to fail for public packages.` + `This may cause npm publish to fail for public packages.`, ); } } @@ -192,11 +201,9 @@ export class NpmTarget extends BaseTarget { } logger.info( - `Discovered ${publishablePackages.length} publishable ${result.type} workspace packages` + `Discovered ${publishablePackages.length} publishable ${result.type} workspace packages`, ); - - // Sort packages by dependency order (dependencies first, then dependents) const sortedPackages = topologicalSortPackages(publishablePackages); @@ -205,7 +212,7 @@ export class NpmTarget extends BaseTarget { sortedPackages.length } packages (dependency order): ${sortedPackages .map(p => p.name) - .join(', ')}` + .join(', ')}`, ); // Generate a target config for each package @@ -215,7 +222,7 @@ export class NpmTarget extends BaseTarget { if (config.artifactTemplate) { includeNames = packageNameToArtifactFromTemplate( pkg.name, - config.artifactTemplate + config.artifactTemplate, ); } else { includeNames = packageNameToArtifactPattern(pkg.name); @@ -256,53 +263,55 @@ export class NpmTarget extends BaseTarget { */ public static async bumpVersion( rootDir: string, - newVersion: string + newVersion: string, ): Promise { const packageJsonPath = join(rootDir, 'package.json'); if (!existsSync(packageJsonPath)) { return false; } - let bin: string; - if (hasExecutable(NPM_BIN)) { - bin = NPM_BIN; - } else if (hasExecutable(YARN_BIN)) { - bin = YARN_BIN; - } else { - throw new Error( - 'Cannot find "npm" or "yarn" for version bumping. ' + - 'Install npm/yarn or define a custom preReleaseCommand in .craft.yml' - ); - } + const { bin, index: execIndex } = requireFirstExecutable( + [NPM_CONFIG, YARN_CONFIG], + 'Install npm/yarn or define a custom preReleaseCommand in .craft.yml', + ); + const isNpm = execIndex === 0; const workspaces = await discoverWorkspaces(rootDir); - const isWorkspace = workspaces.type !== 'none' && workspaces.packages.length > 0; + const isWorkspace = + workspaces.type !== 'none' && workspaces.packages.length > 0; // --no-git-tag-version prevents npm from creating a git commit and tag // --allow-same-version allows setting the same version (useful for re-runs) - const baseArgs = - bin === NPM_BIN - ? ['version', newVersion, '--no-git-tag-version', '--allow-same-version'] - : ['version', newVersion, '--no-git-tag-version']; + const baseArgs = isNpm + ? ['version', newVersion, '--no-git-tag-version', '--allow-same-version'] + : ['version', newVersion, '--no-git-tag-version']; logger.debug(`Running: ${bin} ${baseArgs.join(' ')}`); await spawnProcess(bin, baseArgs, { cwd: rootDir }); if (isWorkspace) { - if (bin === NPM_BIN) { + if (isNpm) { // npm 7+ supports --workspaces flag - const workspaceArgs = [...baseArgs, '--workspaces', '--include-workspace-root']; - logger.debug(`Running: ${bin} ${workspaceArgs.join(' ')} (for workspaces)`); + const workspaceArgs = [ + ...baseArgs, + '--workspaces', + '--include-workspace-root', + ]; + logger.debug( + `Running: ${bin} ${workspaceArgs.join(' ')} (for workspaces)`, + ); try { await spawnProcess(bin, workspaceArgs, { cwd: rootDir }); - } catch (error) { + } catch { // If --workspaces fails (npm < 7), fall back to individual package bumping - logger.debug('npm --workspaces failed, falling back to individual package bumping'); + logger.debug( + 'npm --workspaces failed, falling back to individual package bumping', + ); await NpmTarget.bumpWorkspacePackagesIndividually( bin, workspaces.packages, newVersion, - baseArgs + baseArgs, ); } } else { @@ -311,12 +320,12 @@ export class NpmTarget extends BaseTarget { bin, workspaces.packages, newVersion, - baseArgs + baseArgs, ); } logger.info( - `Bumped version in root and ${workspaces.packages.length} workspace packages` + `Bumped version in root and ${workspaces.packages.length} workspace packages`, ); } @@ -330,7 +339,7 @@ export class NpmTarget extends BaseTarget { bin: string, packages: { name: string; location: string }[], newVersion: string, - baseArgs: string[] + baseArgs: string[], ): Promise { for (const pkg of packages) { const pkgJsonPath = join(pkg.location, 'package.json'); @@ -357,7 +366,7 @@ export class NpmTarget extends BaseTarget { public constructor( config: NpmTargetConfig, - artifactProvider: BaseArtifactProvider + artifactProvider: BaseArtifactProvider, ) { super(config, artifactProvider); this.checkRequirements(); @@ -383,7 +392,7 @@ export class NpmTarget extends BaseTarget { (major === NPM_MIN_MAJOR && minor < NPM_MIN_MINOR) ) { reportError( - `NPM version is too old: ${npmVersion}. Please update your NodeJS` + `NPM version is too old: ${npmVersion}. Please update your NodeJS`, ); } this.logger.debug(`Found NPM version ${npmVersion}`); @@ -429,7 +438,7 @@ export class NpmTarget extends BaseTarget { npmConfig.access = this.config.access; } else { throw new ConfigurationError( - `Invalid value for "npm.access" option: ${this.config.access}` + `Invalid value for "npm.access" option: ${this.config.access}`, ); } } @@ -449,7 +458,7 @@ export class NpmTarget extends BaseTarget { */ protected async publishPackage( path: string, - options: NpmPublishOptions + options: NpmPublishOptions, ): Promise { // NOTE: --ignore-scripts prevents execution of lifecycle scripts (prepublish, // prepublishOnly, prepack, postpack, publish, postpublish) which could run @@ -487,7 +496,7 @@ export class NpmTarget extends BaseTarget { spawnOptions.env.npm_config_userconfig = filePath; writeFileSync( filePath, - `//registry.npmjs.org/:_authToken=\${${NPM_TOKEN_ENV_VAR}}` + `//registry.npmjs.org/:_authToken=\${${NPM_TOKEN_ENV_VAR}}`, ); // The path has to be pushed always as the last arg @@ -527,7 +536,7 @@ export class NpmTarget extends BaseTarget { this.config.checkPackageName, this.npmConfig, this.logger, - publishOptions.otp + publishOptions.otp, ); if (tag) { publishOptions.tag = tag; @@ -538,7 +547,7 @@ export class NpmTarget extends BaseTarget { const path = await this.artifactProvider.downloadArtifact(file); this.logger.info(`Releasing ${file.filename} to NPM`); return this.publishPackage(path, publishOptions); - }) + }), ); this.logger.info('NPM release complete'); @@ -551,7 +560,7 @@ export class NpmTarget extends BaseTarget { export async function getLatestVersion( packageName: string, npmConfig: NpmTargetOptions, - otp?: NpmPublishOptions['otp'] + otp?: NpmPublishOptions['otp'], ): Promise { const args = ['info', packageName, 'version']; const bin = NPM_BIN; @@ -569,7 +578,7 @@ export async function getLatestVersion( spawnOptions.env.npm_config_userconfig = filePath; writeFileSync( filePath, - `//registry.npmjs.org/:_authToken=\${${NPM_TOKEN_ENV_VAR}}` + `//registry.npmjs.org/:_authToken=\${${NPM_TOKEN_ENV_VAR}}`, ); return spawnProcess(bin, args, spawnOptions); @@ -594,7 +603,7 @@ export async function getPublishTag( checkPackageName: string | undefined, npmConfig: NpmTargetOptions, logger: NpmTarget['logger'], - otp?: NpmPublishOptions['otp'] + otp?: NpmPublishOptions['otp'], ): Promise { if (isPreviewRelease(version)) { logger.warn('Detected pre-release version for npm package!'); @@ -610,14 +619,14 @@ export async function getPublishTag( const latestVersion = await getLatestVersion( checkPackageName, npmConfig, - otp + otp, ); const parsedLatestVersion = latestVersion && parseVersion(latestVersion); const parsedNewVersion = parseVersion(version); if (!parsedLatestVersion) { logger.warn( - `Could not fetch current version for package ${checkPackageName}` + `Could not fetch current version for package ${checkPackageName}`, ); return undefined; } @@ -629,7 +638,7 @@ export async function getPublishTag( !versionGreaterOrEqualThan(parsedNewVersion, parsedLatestVersion) ) { logger.warn( - `Detected older version than currently published version (${latestVersion}) for ${checkPackageName}` + `Detected older version than currently published version (${latestVersion}) for ${checkPackageName}`, ); logger.warn('Adding tag "old" to not make it "latest" in registry.'); return 'old'; diff --git a/src/utils/system.ts b/src/utils/system.ts index c426ec65..25013ce9 100644 --- a/src/utils/system.ts +++ b/src/utils/system.ts @@ -343,6 +343,77 @@ export function resolveExecutable(config: ExecutableConfig): string { return envVar ? process.env[envVar] || name : name; } +/** + * Result from findFirstExecutable when an executable is found + */ +export interface FoundExecutable { + /** The resolved binary path */ + bin: string; + /** The config that matched */ + config: ExecutableConfig; + /** Index in the original array */ + index: number; +} + +/** + * Finds the first available executable from an array of configs + * + * @param configs Array of executable configurations to try in order + * @returns The first found executable info, or undefined if none found + * + * @example + * ```typescript + * const found = findFirstExecutable([ + * { name: 'npm', envVar: 'NPM_BIN' }, + * { name: 'yarn', envVar: 'YARN_BIN' }, + * ]); + * if (found) { + * console.log(`Using ${found.bin}`); + * } + * ``` + */ +export function findFirstExecutable( + configs: ExecutableConfig[], +): FoundExecutable | undefined { + for (let i = 0; i < configs.length; i++) { + const config = configs[i]; + const bin = resolveExecutable(config); + if (hasExecutable(bin)) { + return { bin, config, index: i }; + } + } + return undefined; +} + +/** + * Requires at least one executable from an array of configs to be present + * + * @param configs Array of executable configurations to try in order + * @param errorHint Optional hint to show in error message + * @returns The first found executable info + * @throws Error if no executable is found + * + * @example + * ```typescript + * const { bin } = requireFirstExecutable( + * [{ name: 'npm' }, { name: 'yarn' }], + * 'Install npm or yarn' + * ); + * ``` + */ +export function requireFirstExecutable( + configs: ExecutableConfig[], + errorHint?: string, +): FoundExecutable { + const found = findFirstExecutable(configs); + if (!found) { + const names = configs.map(c => `"${resolveExecutable(c)}"`).join(' or '); + const hint = errorHint ? ` ${errorHint}` : ''; + throw new Error(`Cannot find ${names}.${hint}`); + } + return found; +} + /** * Checks if an executable exists and runs it with the provided arguments * @@ -351,7 +422,9 @@ export function resolveExecutable(config: ExecutableConfig): string { * 2. Throwing a helpful error if not found * 3. Running the command with provided arguments * - * @param config Executable configuration + * Accepts either a single config or an array of configs (tries in order). + * + * @param config Executable configuration or array of configs to try in order * @param args Arguments to pass to the executable * @param options Optional spawn options (cwd, env, etc.) * @param spawnOpts Optional spawn process options @@ -360,33 +433,51 @@ export function resolveExecutable(config: ExecutableConfig): string { * * @example * ```typescript - * // Simple usage + * // Single executable * await runWithExecutable( * { name: 'cargo', envVar: 'CARGO_BIN', errorHint: 'Install Rust toolchain' }, * ['build', '--release'], * { cwd: projectDir } * ); * - * // With custom error handling + * // Multiple executables (tries in order) * await runWithExecutable( - * { name: 'npm', errorHint: 'Install Node.js' }, - * ['version', '1.0.0', '--no-git-tag-version'] + * [ + * { name: 'npm', envVar: 'NPM_BIN' }, + * { name: 'yarn', envVar: 'YARN_BIN' }, + * ], + * ['install'], + * { cwd: projectDir } * ); * ``` */ export async function runWithExecutable( - config: ExecutableConfig, + config: ExecutableConfig | ExecutableConfig[], args: string[], options: SpawnOptions = {}, spawnOpts: SpawnProcessOptions = {}, ): Promise { - const bin = resolveExecutable(config); + let bin: string; + let errorHint: string | undefined; + + if (Array.isArray(config)) { + const found = findFirstExecutable(config); + if (!found) { + const names = config.map(c => `"${resolveExecutable(c)}"`).join(' or '); + // Use errorHint from the first config if available + errorHint = config[0]?.errorHint; + const hint = errorHint ? ` ${errorHint}` : ' Is it installed?'; + throw new Error(`Cannot find ${names}.${hint}`); + } + bin = found.bin; + } else { + bin = resolveExecutable(config); + errorHint = config.errorHint; - if (!hasExecutable(bin)) { - const hint = config.errorHint - ? ` ${config.errorHint}` - : ' Is it installed?'; - throw new Error(`Cannot find "${bin}".${hint}`); + if (!hasExecutable(bin)) { + const hint = errorHint ? ` ${errorHint}` : ' Is it installed?'; + throw new Error(`Cannot find "${bin}".${hint}`); + } } logger.debug(`Running: ${bin} ${args.join(' ')}`); From 8d950521e88779577381bf521f232f7d39348cf6 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 30 Jan 2026 23:15:35 +0000 Subject: [PATCH 12/16] fix: update tests to work with runWithExecutable and fix opendir handling - Mock runWithExecutable in tests since it calls hasExecutable internally - Use module-scope mock functions for proper test configuration - Remove explicit dir.close() since for-await-of handles cleanup automatically --- src/__tests__/versionBump.test.ts | 36 ++++++++++++++++++++----- src/utils/files.ts | 45 +++++++++++++++---------------- 2 files changed, 50 insertions(+), 31 deletions(-) diff --git a/src/__tests__/versionBump.test.ts b/src/__tests__/versionBump.test.ts index 2de31e46..6e4e36aa 100644 --- a/src/__tests__/versionBump.test.ts +++ b/src/__tests__/versionBump.test.ts @@ -12,21 +12,43 @@ import { PubDevTarget } from '../targets/pubDev'; import { HexTarget } from '../targets/hex'; import { NugetTarget } from '../targets/nuget'; -// Mock spawnProcess to avoid actually running commands +// Store mock functions at module scope so they can be configured in tests +const mockSpawnProcess = vi.fn(); +const mockHasExecutable = vi.fn(); + +// Mock spawnProcess and hasExecutable to avoid actually running commands +// We need to mock runWithExecutable as well since it internally calls hasExecutable +// which won't be the mocked version due to how module internals work vi.mock('../utils/system', async () => { - const actual = await vi.importActual('../utils/system'); + const actual = + await vi.importActual('../utils/system'); + return { ...actual, - spawnProcess: vi.fn(), - hasExecutable: vi.fn(), + spawnProcess: mockSpawnProcess, + hasExecutable: mockHasExecutable, + // Re-implement runWithExecutable to use mocked functions + runWithExecutable: async ( + config: import('../utils/system').ExecutableConfig, + args: string[], + options = {}, + ) => { + const bin = actual.resolveExecutable(config); + if (!mockHasExecutable(bin)) { + const hint = config.errorHint + ? ` ${config.errorHint}` + : ' Is it installed?'; + throw new Error(`Cannot find "${bin}".${hint}`); + } + return mockSpawnProcess(bin, args, options); + }, }; }); // Helper to set up default mocks async function setupDefaultMocks() { - const { spawnProcess, hasExecutable } = await import('../utils/system'); - vi.mocked(spawnProcess).mockResolvedValue(''); - vi.mocked(hasExecutable).mockReturnValue(true); + mockSpawnProcess.mockResolvedValue(Buffer.from('')); + mockHasExecutable.mockReturnValue(true); } describe('runAutomaticVersionBumps', () => { diff --git a/src/utils/files.ts b/src/utils/files.ts index e07edd20..d509a383 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -194,33 +194,30 @@ async function walkDirectory( return results; } - try { - for await (const entry of dir) { - const fullPath = path.join(currentDir, entry.name); - const relativePath = path.relative(rootDir, fullPath); - - // Skip ignored paths - if (ig.ignores(relativePath)) { - continue; - } + // for await...of automatically closes the directory when iteration completes + for await (const entry of dir) { + const fullPath = path.join(currentDir, entry.name); + const relativePath = path.relative(rootDir, fullPath); + + // Skip ignored paths + if (ig.ignores(relativePath)) { + continue; + } - if (entry.isFile()) { - if (!fileFilter || fileFilter(entry.name)) { - results.push(fullPath); - } - } else if (entry.isDirectory() && depth < maxDepth) { - const subResults = await walkDirectory( - rootDir, - fullPath, - ig, - options, - depth + 1, - ); - results.push(...subResults); + if (entry.isFile()) { + if (!fileFilter || fileFilter(entry.name)) { + results.push(fullPath); } + } else if (entry.isDirectory() && depth < maxDepth) { + const subResults = await walkDirectory( + rootDir, + fullPath, + ig, + options, + depth + 1, + ); + results.push(...subResults); } - } finally { - await dir.close(); } return results; From 131a97812e4c0f704617307801a0e98d93dfb0ae Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 30 Jan 2026 23:17:28 +0000 Subject: [PATCH 13/16] fix: use vi.hoisted for mock functions in versionBump tests vi.mock is hoisted to top of file, so mock functions must be created with vi.hoisted() to be available during the mock factory execution. --- src/__tests__/versionBump.test.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/__tests__/versionBump.test.ts b/src/__tests__/versionBump.test.ts index 6e4e36aa..09c81320 100644 --- a/src/__tests__/versionBump.test.ts +++ b/src/__tests__/versionBump.test.ts @@ -12,9 +12,11 @@ import { PubDevTarget } from '../targets/pubDev'; import { HexTarget } from '../targets/hex'; import { NugetTarget } from '../targets/nuget'; -// Store mock functions at module scope so they can be configured in tests -const mockSpawnProcess = vi.fn(); -const mockHasExecutable = vi.fn(); +// Use vi.hoisted to create mock functions that are available during vi.mock hoisting +const { mockSpawnProcess, mockHasExecutable } = vi.hoisted(() => ({ + mockSpawnProcess: vi.fn(), + mockHasExecutable: vi.fn(), +})); // Mock spawnProcess and hasExecutable to avoid actually running commands // We need to mock runWithExecutable as well since it internally calls hasExecutable @@ -31,7 +33,7 @@ vi.mock('../utils/system', async () => { runWithExecutable: async ( config: import('../utils/system').ExecutableConfig, args: string[], - options = {}, + options = {} ) => { const bin = actual.resolveExecutable(config); if (!mockHasExecutable(bin)) { @@ -45,6 +47,16 @@ vi.mock('../utils/system', async () => { }; }); +// Helper to set up default mocks +function setupDefaultMocks() { + mockSpawnProcess.mockResolvedValue(Buffer.from('')); + mockHasExecutable.mockReturnValue(true); +} + return mockSpawnProcess(bin, args, options); + }, + }; +}); + // Helper to set up default mocks async function setupDefaultMocks() { mockSpawnProcess.mockResolvedValue(Buffer.from('')); From 18cc4c3391dbb36898c1ffa93251c4c43eb0243d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 30 Jan 2026 23:20:20 +0000 Subject: [PATCH 14/16] fix: remove duplicate code in versionBump tests --- src/__tests__/versionBump.test.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/__tests__/versionBump.test.ts b/src/__tests__/versionBump.test.ts index 09c81320..bdfdfa5c 100644 --- a/src/__tests__/versionBump.test.ts +++ b/src/__tests__/versionBump.test.ts @@ -33,7 +33,7 @@ vi.mock('../utils/system', async () => { runWithExecutable: async ( config: import('../utils/system').ExecutableConfig, args: string[], - options = {} + options = {}, ) => { const bin = actual.resolveExecutable(config); if (!mockHasExecutable(bin)) { @@ -52,16 +52,6 @@ function setupDefaultMocks() { mockSpawnProcess.mockResolvedValue(Buffer.from('')); mockHasExecutable.mockReturnValue(true); } - return mockSpawnProcess(bin, args, options); - }, - }; -}); - -// Helper to set up default mocks -async function setupDefaultMocks() { - mockSpawnProcess.mockResolvedValue(Buffer.from('')); - mockHasExecutable.mockReturnValue(true); -} describe('runAutomaticVersionBumps', () => { let tempDir: string; From 53fa19f202ad4f51fb56a48c3626a5f77f1d4e62 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 30 Jan 2026 23:41:12 +0000 Subject: [PATCH 15/16] chore: remove unused import and catch variable --- src/targets/crates.ts | 1 - src/utils/system.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/targets/crates.ts b/src/targets/crates.ts index dbe6c269..3790ff8a 100644 --- a/src/targets/crates.ts +++ b/src/targets/crates.ts @@ -14,7 +14,6 @@ import { } from '../utils/system'; import { BaseTarget } from './base'; import { BaseArtifactProvider } from '../artifact_providers/base'; -import { logger } from '../logger'; /** Cargo executable configuration */ const CARGO_CONFIG = { diff --git a/src/utils/system.ts b/src/utils/system.ts index 25013ce9..c55e9c60 100644 --- a/src/utils/system.ts +++ b/src/utils/system.ts @@ -286,7 +286,7 @@ function isExecutable(filePath: string): boolean { try { fs.accessSync(filePath, fs.constants.F_OK | fs.constants.X_OK); return true; - } catch (e) { + } catch { return false; } } From ca38805e30f4111a9694512a2e58554d8e55e9b5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 30 Jan 2026 23:47:01 +0000 Subject: [PATCH 16/16] feat: improve version bump warnings and add recovery docs - Return detailed VersionBumpResult from runAutomaticVersionBumps - Provide specific warnings for: - No targets support version bumping - Targets support it but didn't find applicable files - Add recovery documentation for failed prepare after version bump - Update tests for new return type --- docs/src/content/docs/configuration.md | 22 +++++++++++++++++ src/__tests__/versionBump.test.ts | 26 ++++++++++++++------ src/commands/prepare.ts | 23 +++++++++++------ src/utils/versionBump.ts | 34 ++++++++++++++++++-------- 4 files changed, 79 insertions(+), 26 deletions(-) diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index bab91594..4bf15b47 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -136,6 +136,28 @@ If automatic version bumping fails: In all error cases, Craft suggests defining a custom `preReleaseCommand` as a fallback. +### Recovery from Failed Prepare + +If version bumping succeeds but `craft prepare` fails mid-way (e.g., during changelog generation or git operations), you may need to clean up manually: + +1. **Check the release branch**: If a release branch was created, you can delete it: + + ```bash + git branch -D release/ + ``` + +2. **Revert version changes**: If files were modified but not committed, reset them: + + ```bash + git checkout -- package.json pyproject.toml Cargo.toml # or whichever files were changed + ``` + +3. **Re-run prepare**: Once the issue is fixed, run `craft prepare` again. Version bumping is idempotent—running it multiple times with the same version is safe. + +:::tip +Use `craft prepare --dry-run` first to preview what changes will be made without modifying any files. +::: + ## Post-release Command This command runs after a successful `craft publish`. Default: `bash scripts/post-release.sh`. diff --git a/src/__tests__/versionBump.test.ts b/src/__tests__/versionBump.test.ts index bdfdfa5c..e5ebcd59 100644 --- a/src/__tests__/versionBump.test.ts +++ b/src/__tests__/versionBump.test.ts @@ -68,29 +68,35 @@ describe('runAutomaticVersionBumps', () => { vi.resetAllMocks(); }); - test('returns false when no targets are provided', async () => { + test('returns empty result when no targets are provided', async () => { const result = await runAutomaticVersionBumps([], tempDir, '1.0.0'); - expect(result).toBe(false); + expect(result.anyBumped).toBe(false); + expect(result.bumpableTargets).toEqual([]); + expect(result.skippedTargets).toEqual([]); }); - test('returns false when target does not support version bumping', async () => { + test('returns no bumpable targets when target does not support version bumping', async () => { // 'github' target doesn't have bumpVersion const result = await runAutomaticVersionBumps( [{ name: 'github' }], tempDir, '1.0.0', ); - expect(result).toBe(false); + expect(result.anyBumped).toBe(false); + expect(result.bumpableTargets).toEqual([]); + expect(result.skippedTargets).toEqual([]); }); - test('returns false when target detection fails', async () => { + test('returns skipped target when target detection fails', async () => { // npm target but no package.json const result = await runAutomaticVersionBumps( [{ name: 'npm' }], tempDir, '1.0.0', ); - expect(result).toBe(false); + expect(result.anyBumped).toBe(false); + expect(result.bumpableTargets).toEqual(['npm']); + expect(result.skippedTargets).toEqual(['npm']); }); test('calls bumpVersion for npm target with package.json', async () => { @@ -105,7 +111,9 @@ describe('runAutomaticVersionBumps', () => { '1.0.0', ); - expect(result).toBe(true); + expect(result.anyBumped).toBe(true); + expect(result.bumpableTargets).toEqual(['npm']); + expect(result.skippedTargets).toEqual([]); }); test('deduplicates multiple targets of the same type', async () => { @@ -146,7 +154,9 @@ describe('runAutomaticVersionBumps', () => { '1.0.0', ); - expect(result).toBe(true); + expect(result.anyBumped).toBe(true); + expect(result.bumpableTargets).toEqual(['npm', 'pypi']); + expect(result.skippedTargets).toEqual([]); expect(npmSpy).toHaveBeenCalledTimes(1); expect(pypiSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index d4e3fb9f..5ab3a7c4 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -323,17 +323,24 @@ export async function runPreReleaseCommand( targets.length > 0 ) { logger.info('Running automatic version bumping from targets...'); - const anyBumped = await runAutomaticVersionBumps( - targets, - rootDir, - newVersion, - ); + const result = await runAutomaticVersionBumps(targets, rootDir, newVersion); - if (!anyBumped) { - logger.warn('No targets support automatic version bumping'); + if (!result.anyBumped) { + if (result.bumpableTargets.length === 0) { + logger.warn( + 'None of your configured targets support automatic version bumping. ' + + 'Consider adding a preReleaseCommand to bump versions manually.', + ); + } else { + logger.warn( + `Targets [${result.skippedTargets.join(', ')}] support version bumping ` + + 'but did not find applicable files in your project. ' + + 'Consider adding a preReleaseCommand if you need custom version bumping.', + ); + } } - return anyBumped; + return result.anyBumped; } return runCustomPreReleaseCommand(oldVersion, newVersion, undefined); diff --git a/src/utils/versionBump.ts b/src/utils/versionBump.ts index d093dc55..020ea873 100644 --- a/src/utils/versionBump.ts +++ b/src/utils/versionBump.ts @@ -22,7 +22,7 @@ export interface VersionBumpableTarget { * Check if a target class has the bumpVersion static method */ function hasVersionBump( - targetClass: unknown + targetClass: unknown, ): targetClass is { bumpVersion: VersionBumpableTarget['bumpVersion'] } { return ( typeof targetClass === 'function' && @@ -31,6 +31,18 @@ function hasVersionBump( ); } +/** + * Result of running automatic version bumps + */ +export interface VersionBumpResult { + /** Whether at least one target successfully bumped the version */ + anyBumped: boolean; + /** Targets that support version bumping (have bumpVersion method) */ + bumpableTargets: string[]; + /** Targets that support bumping but didn't apply (e.g., no matching files) */ + skippedTargets: string[]; +} + /** * Run automatic version bumps for all applicable targets. * Calls bumpVersion() on each unique target class in config order. @@ -38,17 +50,19 @@ function hasVersionBump( * @param targets - Target configs from .craft.yml * @param rootDir - Project root directory * @param newVersion - New version to set - * @returns true if at least one target bumped the version + * @returns Result with bump status and target details * @throws Error if any bumpVersion() call throws */ export async function runAutomaticVersionBumps( targets: TargetConfig[], rootDir: string, - newVersion: string -): Promise { + newVersion: string, +): Promise { // Deduplicate: multiple npm targets should only bump package.json once const processedTargetTypes = new Set(); let anyBumped = false; + const bumpableTargets: string[] = []; + const skippedTargets: string[] = []; for (const targetConfig of targets) { const targetName = targetConfig.name; @@ -67,11 +81,12 @@ export async function runAutomaticVersionBumps( if (!hasVersionBump(targetClass)) { logger.debug( - `Target "${targetName}" does not support automatic version bumping` + `Target "${targetName}" does not support automatic version bumping`, ); continue; } + bumpableTargets.push(targetName); logger.debug(`Running version bump for target "${targetName}"...`); try { @@ -80,17 +95,16 @@ export async function runAutomaticVersionBumps( logger.info(`Version bumped by "${targetName}" target`); anyBumped = true; } else { - logger.debug( - `Target "${targetName}" did not apply (detection failed)` - ); + logger.debug(`Target "${targetName}" did not apply (detection failed)`); + skippedTargets.push(targetName); } } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error( - `Automatic version bump failed for "${targetName}" target: ${message}` + `Automatic version bump failed for "${targetName}" target: ${message}`, ); } } - return anyBumped; + return { anyBumped, bumpableTargets, skippedTargets }; }