diff --git a/.bumpy/bundled-deps-and-no-bump-comment.md b/.bumpy/bundled-deps-and-no-bump-comment.md new file mode 100644 index 0000000..8a1f4e3 --- /dev/null +++ b/.bumpy/bundled-deps-and-no-bump-comment.md @@ -0,0 +1,9 @@ +--- +'@varlock/bumpy': minor +--- + +Change detection is now `package.json`-field-aware: when `package.json` is the only changed file in a package, bumpy diffs it against the base branch and only requires a bump file if a publish-affecting field changed. The new `ignoredPackageJsonFields` option (default `["devDependencies"]`) controls which fields are ignored, so a dev-only dependency bump (e.g. Dependabot) no longer requires a bump file — unless the changed dep matches the package's `releaseTriggeringDevDeps`. + +`ci check` no longer posts a "you're good to go" comment while exiting 1. When the check fails because changed packages have no bump file, the comment now matches the failing status, lists the uncovered packages, and points at an empty bump file (`bumpy add --empty`) to acknowledge an intentional no-release. + +Add a per-package `releaseTriggeringDevDeps` option: names/globs of `devDependencies` that affect a package's published output (most often because they're bundled in). A change to one requires a release, and a listed internal workspace dep's own releases cascade with a patch bump — shorthand for a `cascadeFrom` rule of `{ trigger: 'patch', bumpAs: 'patch' }`. diff --git a/docs/configuration.md b/docs/configuration.md index eb2d9ce..4a5a85c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,6 +20,7 @@ Bumpy is configured via `.bumpy/_config.json`, created by `bumpy init`. Per-pack | `dependencyBumpRules` | `object` | see below | Controls how bumps propagate through dependency types | | `versionCommitMessage` | `string` | — | Customize the version commit message (see below) | | `changedFilePatterns` | `string[]` | `["**"]` | Glob patterns to filter which changed files count toward marking a package as changed | +| `ignoredPackageJsonFields` | `string[]` | `["devDependencies"]` | `package.json` fields whose change alone doesn't require a bump file (see below) | | `publish` | `object` | see below | Publishing pipeline config | | `gitUser` | `{ name, email }` | bumpy-bot | Git identity for CI commits | | `versionPr` | `{ title, branch, preamble }` | see below | Customize the version PR | @@ -27,6 +28,24 @@ Bumpy is configured via `.bumpy/_config.json`, created by `bumpy init`. Per-pack | `packages` | `object` | `{}` | Per-package config overrides (keyed by package name) | | `channels` | `object` | `{}` | Prerelease channels, keyed by channel name (see below) | +### Change detection and `package.json` fields + +A package is "changed" (and so needs a bump file) when a changed file inside it matches `changedFilePatterns`. `package.json` is a special case: editing it shouldn't always demand a release — a `devDependencies` bump from Dependabot, for example, doesn't affect what consumers install. + +So when `package.json` is the **only** changed file in a package, bumpy diffs it against the base branch and only flags the package if a field **outside** `ignoredPackageJsonFields` changed. The default ignore list is `["devDependencies"]`, meaning dev-only dependency updates don't require a bump file. Every other field — `dependencies`, `exports`, `bin`, `files`, `description`, `scripts`, etc. — still counts. + +One exception keeps this safe: a changed `devDependencies` entry that matches the package's [`releaseTriggeringDevDeps`](#release-triggering-devdependencies) **does** flag the package, since such a dep affects the published output. + +To relax additional fields (e.g. treat `scripts` changes as non-releasing too), extend the list: + +```json +{ + "ignoredPackageJsonFields": ["devDependencies", "scripts"] +} +``` + +bumpy errs toward requiring a bump file whenever it can't compare cleanly — a brand-new `package.json`, or one it can't parse. + ### Dependency bump rules Controls how a version bump in one package propagates to packages that depend on it. Set globally in `dependencyBumpRules` or per-package. @@ -132,19 +151,20 @@ Per-package settings can be defined in two places: `package.json` settings take precedence over global config. -| Option | Type | Description | -| --------------------- | -------------------------- | ---------------------------------------------------------------------------- | -| `managed` | `boolean` | Opt this package in or out of versioning | -| `access` | `"public" \| "restricted"` | Override the global access level | -| `publishCommand` | `string \| string[]` | Custom command(s) to publish this package (replaces npm publish) | -| `buildCommand` | `string` | Command to run before publishing | -| `registry` | `string` | Custom npm registry URL | -| `skipNpmPublish` | `boolean` | Don't publish to npm (still creates git tags) | -| `checkPublished` | `string` | Custom command that outputs the currently published version | -| `changedFilePatterns` | `string[]` | Glob patterns for changed-file detection (replaces root setting, not merged) | -| `dependencyBumpRules` | `object` | Per-package override for dependency propagation rules | -| `cascadeTo` | `object` | Explicit cascade targets — glob pattern mapped to `{ trigger, bumpAs }` | -| `cascadeFrom` | `object` | Explicit cascade sources — glob pattern mapped to `{ trigger, bumpAs }` | +| Option | Type | Description | +| -------------------------- | -------------------------- | -------------------------------------------------------------------------------------- | +| `managed` | `boolean` | Opt this package in or out of versioning | +| `access` | `"public" \| "restricted"` | Override the global access level | +| `publishCommand` | `string \| string[]` | Custom command(s) to publish this package (replaces npm publish) | +| `buildCommand` | `string` | Command to run before publishing | +| `registry` | `string` | Custom npm registry URL | +| `skipNpmPublish` | `boolean` | Don't publish to npm (still creates git tags) | +| `checkPublished` | `string` | Custom command that outputs the currently published version | +| `changedFilePatterns` | `string[]` | Glob patterns for changed-file detection (replaces root setting, not merged) | +| `dependencyBumpRules` | `object` | Per-package override for dependency propagation rules | +| `cascadeTo` | `object` | Explicit cascade targets — glob pattern mapped to `{ trigger, bumpAs }` | +| `cascadeFrom` | `object` | Explicit cascade sources — glob pattern mapped to `{ trigger, bumpAs }` | +| `releaseTriggeringDevDeps` | `string[]` | devDependencies that affect published output — a change requires a release (see below) | ### Custom commands and `allowCustomCommands` @@ -222,15 +242,41 @@ Or with custom trigger/bumpAs: } ``` -### Example: cascade from a bundled dependency (consumer-side) +### Release-triggering devDependencies + +By default a `devDependencies` change doesn't require a release — it's usually dev tooling (a linter, a type package, a test runner). But sometimes a dependency that affects your **published output** lives under `devDependencies`. `releaseTriggeringDevDeps` marks those, so a change to one requires a release (and, for internal workspace deps, its own releases cascade to you): + +```json +{ + "name": "@myorg/astro-integration", + "bumpy": { + "releaseTriggeringDevDeps": ["@myorg/vite-integration", "nanoid"] + } +} +``` + +**The usual reason is bundling.** A build step (tsup, tsdown, esbuild, rolldown/rollup, Vite, `bun build`, webpack, …) inlines imports into `dist/`, so consumers get a self-contained artifact. A bundled dependency isn't installed from the registry at consume time — its code is copied into your output — so it's conventionally declared under `devDependencies` (you don't want consumers to also install it). Taken to the extreme, a fully-bundled package can have **no runtime `dependencies` at all**; every library it imports sits in `devDependencies`. (bumpy itself is built this way with tsdown.) Other cases that fit: a dependency whose output you commit and ship (codegen), or a re-exported types-only package. + +`releaseTriggeringDevDeps` declares intent — "a change to this dep changes what I publish" — and that drives **two** behaviors: + +1. **Propagation** — when the dep gets its **own release**, this package is cascaded a **patch** bump (shorthand for a `cascadeFrom` rule of `{ "trigger": "patch", "bumpAs": "patch" }`). This only applies to **internal workspace** deps, since bumpy only releases packages in your workspace. +2. **Change detection** — when the dep's version is edited in **this** package's `package.json` (e.g. a Dependabot PR, or a manual bump), this package is flagged as changed and needs a bump file — even though `devDependencies` edits are normally ignored (see [Change detection](#change-detection-and-packagejson-fields)). This applies to **any** listed dep, internal or external. + +So for an **internal** workspace dep, both paths fire; for an **external** dep (e.g. a published npm package you inline), only change detection applies — listing it is still useful, and is a harmless no-op for propagation. + +#### Internal workspace deps: release-relevant or not + +This is exactly the knob for "which `devDependencies` affect my published output." An internal workspace package listed under `devDependencies` is, by default, treated as dev-only — its releases don't cascade ([`dependencyBumpRules.devDependencies` is `false`](#dependency-bump-rules)) and bumping its range doesn't flag you. Add it to `releaseTriggeringDevDeps` to flip both: now its releases republish you, and editing its range flags you. Leave it out and it stays dev-only. No global setting is involved — it's per-dependency, per-consumer. + +#### Proportional bumps -When a package bundles a devDependency into its published output, use `cascadeFrom` so bumps to the dependency also trigger a release of the consumer: +If you re-export the dependency's API and want **proportional** bumps (a minor in the dep → a minor here), use `cascadeFrom` directly instead — an explicit `cascadeFrom` rule for the same source takes precedence over the `releaseTriggeringDevDeps` patch default: ```json { "name": "@myorg/astro-integration", "bumpy": { - "cascadeFrom": ["@myorg/vite-integration"] + "cascadeFrom": { "@myorg/vite-integration": { "trigger": "patch", "bumpAs": "match" } } } } ``` diff --git a/docs/differences-from-changesets.md b/docs/differences-from-changesets.md index 8450a67..afceae0 100644 --- a/docs/differences-from-changesets.md +++ b/docs/differences-from-changesets.md @@ -19,7 +19,7 @@ Bumpy splits propagation into three phases inside an iterative loop: Key differences from changesets: - Out-of-range peer dep bumps match the triggering bump level (not always major) — a minor bump on `core` → minor bump on `plugin`, not major -- Dev deps never propagate by default (configurable per-package for bundled devDeps) +- Dev deps never propagate by default (opt specific ones in per-package via `releaseTriggeringDevDeps`, e.g. bundled deps) - `cascadeTo` config for source-side "when I change, cascade to these packages" - Per-bump-file `none` to acknowledge changes without triggering a direct bump - Warns about `^0.x` caret range gotchas and `workspace:*` on peer deps diff --git a/docs/version-propagation.md b/docs/version-propagation.md index 4547195..78d79d1 100644 --- a/docs/version-propagation.md +++ b/docs/version-propagation.md @@ -21,10 +21,12 @@ The bump type applied to the dependent depends on the dependency type: | `peerDependencies` | matches the triggering bump | Proportional — a minor bump on the dep → minor bump on the dependent | | `dependencies` | `patch` | Internal detail — consumers don't see it | | `optionalDependencies` | `patch` | Internal detail — consumers don't see it | -| `devDependencies` | _(skipped)_ | Doesn't affect published consumers | +| `devDependencies` | _(skipped)_ | Doesn't affect published consumers† | For peer deps, "matches the triggering bump" means if `core` gets a minor bump that breaks the range, `plugin` also gets a minor bump. This keeps version bumps proportional — especially important for `0.x` packages where `^` ranges cause minor bumps to go out of range frequently. +> † `devDependencies` are skipped because they normally don't ship to consumers. The exception is a dependency that affects your published output — most often one **bundled** in by a build step (tsup, tsdown, esbuild, rolldown/rollup, Vite, `bun build`, …), declared under `devDependencies` since it isn't runtime-resolved. List those under `releaseTriggeringDevDeps` (or use `cascadeFrom`) so any bump to them republishes this package. See [Configuration](./configuration.md#release-triggering-devdependencies). + This phase is a **safety net** — it cannot be skipped. It ensures that published packages always have valid dependency ranges. ### `workspace:` protocol resolution diff --git a/packages/bumpy/config-schema.json b/packages/bumpy/config-schema.json index 35b774d..e6d97cd 100644 --- a/packages/bumpy/config-schema.json +++ b/packages/bumpy/config-schema.json @@ -44,6 +44,12 @@ "items": { "type": "string" }, "default": ["**"] }, + "ignoredPackageJsonFields": { + "type": "array", + "description": "Top-level package.json fields whose change alone does not mark a package as changed. When package.json is the only changed file, it's diffed against the base branch and changes confined to these fields are ignored. Default: ['devDependencies']. A changed devDependencies entry matching the package's releaseTriggeringDevDeps still counts.", + "items": { "type": "string" }, + "default": ["devDependencies"] + }, "fixed": { "type": "array", "description": "Package groups that always bump together to the same version. Each element is an array of package name globs.", @@ -366,6 +372,11 @@ "cascadeFrom": { "description": "Explicit cascade sources — when a matching package is bumped, cascade the bump to this package.", "$ref": "#/$defs/cascadeConfig" + }, + "releaseTriggeringDevDeps": { + "type": "array", + "description": "Names (or globs) of devDependencies that are release-relevant: a change to one requires a release of this package, and (for internal workspace deps) its own releases cascade here. devDependencies are ignored for versioning by default — this opts specific entries back in. The usual reason is a dep bundled into the published output (inlined by tsup/tsdown/esbuild/rollup/…). Shorthand for a cascadeFrom rule of { trigger: 'patch', bumpAs: 'patch' }; an explicit cascadeFrom for the same source takes precedence.", + "items": { "type": "string" } } }, "additionalProperties": false diff --git a/packages/bumpy/package.json b/packages/bumpy/package.json index 3f982c2..9c2ad9d 100644 --- a/packages/bumpy/package.json +++ b/packages/bumpy/package.json @@ -52,5 +52,14 @@ "picomatch": "^4.0.4", "semver": "^7.7.2", "tsdown": "catalog:" + }, + "bumpy": { + "releaseTriggeringDevDeps": [ + "@clack/prompts", + "js-yaml", + "picocolors", + "picomatch", + "semver" + ] } } diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index dd15b32..85623af 100644 --- a/packages/bumpy/src/commands/check.ts +++ b/packages/bumpy/src/commands/check.ts @@ -1,7 +1,7 @@ import { relative, resolve } from 'node:path'; import picomatch from 'picomatch'; import { log, colorize } from '../utils/logger.ts'; -import { loadConfig, loadPackageConfig, getBumpyDir } from '../core/config.ts'; +import { loadConfig, loadPackageConfig, getBumpyDir, matchGlob } from '../core/config.ts'; import { discoverWorkspace } from '../core/workspace.ts'; import { readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts'; import { getChangedFiles, getFileStatuses, getBaseCompareRef, readFileAtRef } from '../core/git.ts'; @@ -253,14 +253,36 @@ export async function findChangedPackages( matchers.set(name, picomatch(patterns)); } - for (const file of changedFiles) { - for (const [name, pkg] of packages) { - const pkgRelDir = relative(rootDir, pkg.dir); - if (file.startsWith(pkgRelDir + '/')) { - const relToPackage = file.slice(pkgRelDir.length + 1); - if (matchers.get(name)!(relToPackage)) { - changed.add(name); - } + const ignoredFields = config.ignoredPackageJsonFields ?? ['devDependencies']; + let baseRef: string | null = null; // resolved lazily, only when a package.json needs diffing + + for (const [name, pkg] of packages) { + const pkgRelDir = relative(rootDir, pkg.dir); + if (!pkgRelDir) continue; // root-level package: not flagged by file globs (unchanged behavior) + const prefix = `${pkgRelDir}/`; + const matcher = matchers.get(name)!; + + let pkgJsonOnlyTrigger = false; + for (const file of changedFiles) { + if (!file.startsWith(prefix)) continue; + const relToPackage = file.slice(prefix.length); + if (!matcher(relToPackage)) continue; + if (relToPackage === 'package.json') { + pkgJsonOnlyTrigger = true; // defer — refine by inspecting which fields changed + } else { + changed.add(name); // any other matched file is a definite change + break; + } + } + + // package.json was the only thing that matched — only flag if a publish-affecting + // field actually changed (a dev-only dependency bump shouldn't require a release). + if (!changed.has(name) && pkgJsonOnlyTrigger) { + baseRef ??= getBaseCompareRef(rootDir, config.baseBranch); + if ( + await packageJsonAffectsRelease(rootDir, baseRef, pkgRelDir, ignoredFields, pkg.bumpy?.releaseTriggeringDevDeps) + ) { + changed.add(name); } } } @@ -289,6 +311,92 @@ export async function findChangedPackages( return [...changed]; } +/** + * Decide whether a package's `package.json` change is release-affecting — i.e. whether + * a bump file should be required for it. Diffs the file against the base ref and returns + * true if any top-level field changed *other* than the ignored ones (default: + * `devDependencies`). A changed `devDependencies` entry still counts when it matches the + * package's `releaseTriggeringDevDeps`, since such a dep affects the published output. + * + * Errs toward `true` (require a bump file) whenever the comparison can't be made — a new + * file, a deleted file, or unparseable JSON. + */ +async function packageJsonAffectsRelease( + rootDir: string, + baseRef: string, + pkgRelDir: string, + ignoredFields: string[], + releaseTriggeringDevDeps?: string[], +): Promise { + const relPath = `${pkgRelDir}/package.json`; + const beforeRaw = readFileAtRef(rootDir, baseRef, relPath); + if (beforeRaw == null) return true; // not present at base (new package) → release-affecting + + const afterPath = resolve(rootDir, relPath); + const afterRaw = (await exists(afterPath)) ? await readText(afterPath) : null; + if (afterRaw == null) return true; // removed in the working tree → be conservative + + let before: Record; + let after: Record; + try { + before = JSON.parse(beforeRaw); + after = JSON.parse(afterRaw); + } catch { + return true; // unparseable → can't tell, so require a bump file + } + + const ignored = new Set(ignoredFields); + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + for (const key of keys) { + if (deepEqual(before[key], after[key])) continue; + if (!ignored.has(key)) return true; // a publish-affecting field changed + // The field is ignored — but a release-relevant devDependency change still affects output. + if ( + key === 'devDependencies' && + releaseTriggeringDevDepsChanged( + before.devDependencies as Record | undefined, + after.devDependencies as Record | undefined, + releaseTriggeringDevDeps, + ) + ) { + return true; + } + } + return false; +} + +/** Whether any devDependency matching `releaseTriggeringDevDeps` was added/removed/changed. */ +function releaseTriggeringDevDepsChanged( + before: Record = {}, + after: Record = {}, + releaseTriggeringDevDeps?: string[], +): boolean { + if (!releaseTriggeringDevDeps?.length) return false; + const names = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]); + for (const name of names) { + if (before?.[name] === after?.[name]) continue; + if (releaseTriggeringDevDeps.some((pattern) => matchGlob(name, pattern))) return true; + } + return false; +} + +/** Structural equality for parsed JSON values (objects compared key-insensitive to order). */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (typeof a !== 'object') return false; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((v, i) => deepEqual(v, b[i])); + } + const ao = a as Record; + const bo = b as Record; + const ak = Object.keys(ao); + const bk = Object.keys(bo); + if (ak.length !== bk.length) return false; + return ak.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && deepEqual(ao[k], bo[k])); +} + /** * Compute which catalog entries changed between the base ref and HEAD. * Returns Map>. Empty if no catalog files changed. diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 6bfff62..9d5ce12 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -174,6 +174,10 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi : 'No bump files found in this PR.'; if (willFail) log.error(msg); else log.warn(msg); + // Point at the empty-bump-file escape hatch so the CLI matches the PR comment. + if (parseErrors.length === 0 && willFail) { + log.dim('Run `bumpy add` to declare a release, or `bumpy add --empty` to acknowledge that no release is needed.'); + } if (shouldComment && prNumber) { const prBranch = detectPrBranch(rootDir); @@ -181,7 +185,7 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi prNumber, parseErrors.length > 0 ? formatBumpFileErrorsComment(parseErrors, prBranch, pm) - : formatNoBumpFilesComment(prBranch, pm), + : formatNoBumpFilesComment(prBranch, pm, willFail, changedPackages), rootDir, ); } @@ -1105,6 +1109,18 @@ function buildAddBumpFileLink(prBranch: string | null): string | null { return `https://github.com/${repo}/new/${prBranch}?filename=${encodeURIComponent(filename)}&value=${encodeURIComponent(template)}`; } +/** Link to create an empty bump file on GitHub — acknowledges that no release is needed */ +function buildAddEmptyBumpFileLink(prBranch: string | null): string | null { + if (!prBranch) return null; + const repo = process.env.GITHUB_REPOSITORY; + if (!repo) return null; + + // An empty bump file is just empty frontmatter (`---\n---`) — see `bumpy add --empty`. + const template = ['---', '---', ''].join('\n'); + const filename = `.bumpy/${randomName()}.md`; + return `https://github.com/${repo}/new/${prBranch}?filename=${encodeURIComponent(filename)}&value=${encodeURIComponent(template)}`; +} + function pmRunCommand(pm: PackageManager): string { if (pm === 'bun') return 'bunx bumpy'; if (pm === 'pnpm') return 'pnpm exec bumpy'; @@ -1302,23 +1318,81 @@ function formatEmptyBumpFileComment(emptyBumpFileIds: string[], prNumber: string return lines.join('\n'); } -function formatNoBumpFilesComment(prBranch: string | null, pm: PackageManager): string { +/** + * Comment for a PR with no bump files. + * + * When the check will fail (the default/strict modes, given changed packages), the + * comment must NOT say "you're good to go" — that contradicts the failing status. + * Instead it explains that a bump file is required and points at the empty bump file + * as the way to acknowledge an intentional no-release (e.g. a dependency-only PR). + * In `--no-fail` mode the check passes, so the friendlier wording is accurate. + */ +export function formatNoBumpFilesComment( + prBranch: string | null, + pm: PackageManager, + willFail = false, + changedPackages: string[] = [], +): string { const runCmd = pmRunCommand(pm); + const addLink = buildAddBumpFileLink(prBranch); + const emptyLink = buildAddEmptyBumpFileLink(prBranch); + + if (!willFail) { + const lines = [ + `bumpy-frog`, + '', + "Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a bump file.**", + '
\n', + 'You can add a bump file by running:\n', + '```bash', + `${runCmd} add`, + '```', + ]; + if (addLink) { + lines.push(''); + lines.push(`Or [click here to add a bump file](${addLink}) directly on GitHub.`); + } + lines.push('\n---'); + lines.push(`_This comment is maintained by [bumpy](https://bumpy.varlock.dev)._`); + return lines.join('\n'); + } + + // Failing case — the wording matches the failing status check. + const headline = + changedPackages.length > 0 + ? `**This PR changes ${changedPackages.length} package${changedPackages.length === 1 ? '' : 's'} but has no bump file, so this check is failing.**` + : '**This PR has no bump file, so this check is failing.**'; const lines = [ - `bumpy-frog`, + `bumpy-frog`, '', - "Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a bump file.**", + headline, '
\n', - 'You can add a bump file by running:\n', - '```bash', - `${runCmd} add`, - '```', ]; - const addLink = buildAddBumpFileLink(prBranch); - if (addLink) { + if (changedPackages.length > 0) { + lines.push('Changed package(s) without a bump file:\n'); + for (const name of changedPackages) { + lines.push(`- \`${name}\``); + } lines.push(''); - lines.push(`Or [click here to add a bump file](${addLink}) directly on GitHub.`); + } + + lines.push( + '**If these changes should be released**, add a bump file describing the version bump. ' + + '**If no release is needed** (e.g. a dependency-only or dev-only change), add an _empty_ ' + + 'bump file to acknowledge that intent — that satisfies this check without bumping any package.\n', + ); + lines.push('```bash'); + lines.push(`${runCmd} add # describe a release`); + lines.push(`${runCmd} add --empty # acknowledge no release is needed`); + lines.push('```'); + + if (addLink || emptyLink) { + const parts: string[] = []; + if (addLink) parts.push(`[add a bump file](${addLink})`); + if (emptyLink) parts.push(`[add an empty bump file](${emptyLink})`); + lines.push(''); + lines.push(`Or directly on GitHub: ${parts.join(' · ')}.`); } lines.push('\n---'); diff --git a/packages/bumpy/src/core/release-plan.ts b/packages/bumpy/src/core/release-plan.ts index bfddb6b..f2b6df6 100644 --- a/packages/bumpy/src/core/release-plan.ts +++ b/packages/bumpy/src/core/release-plan.ts @@ -6,6 +6,7 @@ import { type BumpType, type BumpFile, type DependencyBumpRule, + type CascadeRule, normalizeCascadeConfig, type DepType, type PlannedRelease, @@ -123,7 +124,8 @@ export function assembleReleasePlan( const dependents = depGraph.getDependents(pkgName); for (const dep of dependents) { - // Skip devDependencies in Phase A + // Skip devDependencies in Phase A (release-relevant devDeps are handled by the + // consumer-side cascade — see applyCascadeFrom / releaseTriggeringDevDeps). if (dep.depType === 'devDependencies') continue; // Check if new version is out of range @@ -439,8 +441,7 @@ function applyCascadeFrom( ): boolean { let changed = false; for (const [targetName, targetPkg] of packages) { - if (!targetPkg.bumpy?.cascadeFrom) continue; - const rules = normalizeCascadeConfig(targetPkg.bumpy.cascadeFrom); + const rules = cascadeFromRules(targetPkg); for (const [pattern, rule] of Object.entries(rules)) { if (!matchGlob(sourceName, pattern)) continue; if (!shouldTrigger(sourceType, rule.trigger)) continue; @@ -458,6 +459,31 @@ function shouldTrigger(bumpType: BumpType, trigger: BumpType): boolean { return bumpLevel(bumpType) >= bumpLevel(trigger); } +/** + * Consumer-side cascade rules for a package: explicit `cascadeFrom` entries, plus the + * `releaseTriggeringDevDeps` sugar. + * + * `releaseTriggeringDevDeps` lists `devDependencies` that are release-relevant — typically + * because they're bundled into this package's published output (inlined by a build step) + * and so aren't runtime-resolved. Any bump to such a dep changes what consumers receive, + * so it must republish this package: `{ trigger: 'patch', bumpAs: 'patch' }` (any bump + * triggers; this package gets a patch, since its own public API hasn't necessarily + * changed). An explicit `cascadeFrom` rule for the same source wins, so you can opt into + * proportional bumps (`bumpAs: 'match'`) when you re-export the dep. + */ +function cascadeFromRules(pkg: WorkspacePackage): Record> { + const releaseTriggeringDevDeps = pkg.bumpy?.releaseTriggeringDevDeps; + const cascadeFrom = pkg.bumpy?.cascadeFrom; + if (!releaseTriggeringDevDeps?.length && !cascadeFrom) return {}; + + const rules: Record> = {}; + for (const name of releaseTriggeringDevDeps ?? []) { + rules[name] = { trigger: 'patch', bumpAs: 'patch' }; + } + // Explicit cascadeFrom overrides the releaseTriggeringDevDeps default on conflict. + return { ...rules, ...(cascadeFrom ? normalizeCascadeConfig(cascadeFrom) : {}) }; +} + /** * Resolve the dependency bump rule for a specific dependent + dep type. * Priority: per-package depType rules > global depType rules > defaults diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index 8be57d0..957bb6e 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -134,6 +134,16 @@ export interface BumpyConfig { linked: string[][]; /** Glob patterns to filter which changed files count toward marking a package as changed */ changedFilePatterns: string[]; + /** + * Top-level `package.json` fields whose change alone does NOT mark a package as + * changed (i.e. doesn't require a bump file). When `package.json` is the only changed + * file in a package, bumpy diffs it against the base branch and ignores changes + * confined to these fields. Default: `["devDependencies"]` — dev-only dependency + * updates (e.g. Dependabot) don't affect published output. Exception: a changed + * `devDependencies` entry that matches the package's `releaseTriggeringDevDeps` still + * counts, since it ships in the bundle. + */ + ignoredPackageJsonFields: string[]; /** Package names/globs to exclude from version management */ ignore: string[]; /** Package names/globs to explicitly include (overrides private + ignore) */ @@ -181,6 +191,21 @@ export interface PackageConfig { dependencyBumpRules?: Partial>; cascadeTo?: CascadeConfig; cascadeFrom?: CascadeConfig; + /** + * Names (or globs) of `devDependencies` that should be treated as release-relevant: + * a change to one requires a release of this package, and (for internal workspace + * deps) its own releases cascade here. `devDependencies` are ignored for versioning by + * default — this opts specific entries back in. + * + * The usual reason is a dependency **bundled** into the published output (inlined by a + * build step — tsup/tsdown/esbuild/rollup/…), which is why it lives under + * `devDependencies` rather than `dependencies`. Other cases: a tool whose output is + * committed and shipped (codegen), or a re-exported types package. Each entry acts as a + * `cascadeFrom` source with `{ trigger: 'patch', bumpAs: 'patch' }`; an explicit + * `cascadeFrom` rule for the same source takes precedence (e.g. `bumpAs: 'match'` for + * proportional bumps). + */ + releaseTriggeringDevDeps?: string[]; } export const DEFAULT_PUBLISH_CONFIG: PublishConfig = { @@ -198,6 +223,7 @@ export const DEFAULT_CONFIG: BumpyConfig = { channels: {}, versionCommitMessage: undefined, changedFilePatterns: ['**'], + ignoredPackageJsonFields: ['devDependencies'], changelog: 'default', fixed: [], linked: [], diff --git a/packages/bumpy/test/core/check-pkgjson-fields.test.ts b/packages/bumpy/test/core/check-pkgjson-fields.test.ts new file mode 100644 index 0000000..9cf9662 --- /dev/null +++ b/packages/bumpy/test/core/check-pkgjson-fields.test.ts @@ -0,0 +1,164 @@ +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; +import { resolve } from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { findChangedPackages } from '../../src/commands/check.ts'; +import { discoverWorkspace } from '../../src/core/workspace.ts'; +import { getChangedFiles } from '../../src/core/git.ts'; +import { loadConfig } from '../../src/core/config.ts'; +import { createTempGitRepo, cleanupTempDir, gitInDir } from '../helpers.ts'; + +/** + * Integration tests for field-aware package.json change detection. Each test sets up a + * temp git repo on `main`, branches off, edits a single package's package.json, and + * checks whether that package is flagged as changed (i.e. requires a bump file). + */ +describe('findChangedPackages — package.json field awareness', () => { + let tmpDir: string; + const teardown: Array<() => Promise> = []; + + beforeEach(async () => { + tmpDir = await createTempGitRepo(); + gitInDir(['branch', '-M', 'main'], tmpDir); + gitInDir(['config', 'user.email', 'test@example.com'], tmpDir); + gitInDir(['config', 'user.name', 'Test'], tmpDir); + }); + + afterEach(async () => { + for (const fn of teardown) await fn(); + teardown.length = 0; + await cleanupTempDir(tmpDir); + }); + + async function setupOriginMain(): Promise { + const { mkdtemp, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const remote = await mkdtemp(resolve(tmpdir(), 'bumpy-remote-')); + gitInDir(['init', '--bare'], remote); + gitInDir(['remote', 'add', 'origin', remote], tmpDir); + gitInDir(['push', '-u', 'origin', 'main'], tmpDir); + teardown.push(() => rm(remote, { recursive: true, force: true })); + } + + async function writeJson(path: string, data: unknown): Promise { + await mkdir(resolve(tmpDir, path, '..'), { recursive: true }); + await writeFile(resolve(tmpDir, path), `${JSON.stringify(data, null, 2)}\n`); + } + + /** Set up a single-package monorepo on main, then branch to "feature". */ + async function setupPkg(pkgJson: Record): Promise { + await writeJson('package.json', { name: 'root', private: true, workspaces: ['packages/*'] }); + await writeJson('packages/app/package.json', { name: 'app', version: '1.0.0', ...pkgJson }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init'], tmpDir); + await setupOriginMain(); + gitInDir(['checkout', '-b', 'feature'], tmpDir); + } + + async function detect(): Promise { + const config = await loadConfig(tmpDir); + const { packages } = await discoverWorkspace(tmpDir, config); + const changedFiles = getChangedFiles(tmpDir, config.baseBranch); + return findChangedPackages(changedFiles, packages, tmpDir, config); + } + + test('a devDependencies-only change does NOT flag the package', async () => { + await setupPkg({ devDependencies: { vitest: '^1.0.0' } }); + await writeJson('packages/app/package.json', { + name: 'app', + version: '1.0.0', + devDependencies: { vitest: '^2.0.0' }, + }); + gitInDir(['commit', '-am', 'bump vitest (dev only)'], tmpDir); + expect(await detect()).not.toContain('app'); + }); + + test('a dependencies change flags the package', async () => { + await setupPkg({ dependencies: { lodash: '^4.0.0' } }); + await writeJson('packages/app/package.json', { + name: 'app', + version: '1.0.0', + dependencies: { lodash: '^4.17.0' }, + }); + gitInDir(['commit', '-am', 'bump lodash (runtime)'], tmpDir); + expect(await detect()).toContain('app'); + }); + + test('a metadata change (description) still flags the package', async () => { + await setupPkg({ description: 'old' }); + await writeJson('packages/app/package.json', { name: 'app', version: '1.0.0', description: 'new' }); + gitInDir(['commit', '-am', 'edit description'], tmpDir); + expect(await detect()).toContain('app'); + }); + + test('an exports change flags the package', async () => { + await setupPkg({ exports: { '.': './index.js' } }); + await writeJson('packages/app/package.json', { + name: 'app', + version: '1.0.0', + exports: { '.': './dist/index.js' }, + }); + gitInDir(['commit', '-am', 'change exports'], tmpDir); + expect(await detect()).toContain('app'); + }); + + test('a bundled devDependency change DOES flag the package', async () => { + await setupPkg({ + devDependencies: { 'bundled-lib': '^1.0.0', vitest: '^1.0.0' }, + bumpy: { releaseTriggeringDevDeps: ['bundled-lib'] }, + }); + await writeJson('packages/app/package.json', { + name: 'app', + version: '1.0.0', + devDependencies: { 'bundled-lib': '^2.0.0', vitest: '^1.0.0' }, + bumpy: { releaseTriggeringDevDeps: ['bundled-lib'] }, + }); + gitInDir(['commit', '-am', 'bump bundled-lib'], tmpDir); + expect(await detect()).toContain('app'); + }); + + test('a non-bundled devDependency change alongside a bundled marker stays unflagged', async () => { + await setupPkg({ + devDependencies: { 'bundled-lib': '^1.0.0', vitest: '^1.0.0' }, + bumpy: { releaseTriggeringDevDeps: ['bundled-lib'] }, + }); + await writeJson('packages/app/package.json', { + name: 'app', + version: '1.0.0', + devDependencies: { 'bundled-lib': '^1.0.0', vitest: '^2.0.0' }, + bumpy: { releaseTriggeringDevDeps: ['bundled-lib'] }, + }); + gitInDir(['commit', '-am', 'bump vitest only'], tmpDir); + expect(await detect()).not.toContain('app'); + }); + + test('a source file change flags the package regardless of package.json', async () => { + await setupPkg({ devDependencies: { vitest: '^1.0.0' } }); + await writeFile(resolve(tmpDir, 'packages/app/index.ts'), 'export const x = 1;\n'); + // also a dev-only package.json change that on its own wouldn't flag + await writeJson('packages/app/package.json', { + name: 'app', + version: '1.0.0', + devDependencies: { vitest: '^2.0.0' }, + }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'real code change + dev bump'], tmpDir); + expect(await detect()).toContain('app'); + }); + + test('ignoredPackageJsonFields can relax additional fields (e.g. scripts)', async () => { + await writeJson('package.json', { name: 'root', private: true, workspaces: ['packages/*'] }); + await writeJson('.bumpy/_config.json', { ignoredPackageJsonFields: ['devDependencies', 'scripts'] }); + await mkdir(resolve(tmpDir, '.bumpy'), { recursive: true }); + await writeFile(resolve(tmpDir, '.bumpy/README.md'), ''); + await writeJson('packages/app/package.json', { name: 'app', version: '1.0.0', scripts: { build: 'tsc' } }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init'], tmpDir); + await setupOriginMain(); + gitInDir(['checkout', '-b', 'feature'], tmpDir); + + await writeJson('packages/app/package.json', { name: 'app', version: '1.0.0', scripts: { build: 'tsdown' } }); + gitInDir(['commit', '-am', 'change build script'], tmpDir); + + expect(await detect()).not.toContain('app'); + }); +}); diff --git a/packages/bumpy/test/core/ci-no-bump-comment.test.ts b/packages/bumpy/test/core/ci-no-bump-comment.test.ts new file mode 100644 index 0000000..89fc7cd --- /dev/null +++ b/packages/bumpy/test/core/ci-no-bump-comment.test.ts @@ -0,0 +1,45 @@ +import { describe, test, expect } from 'bun:test'; +import { formatNoBumpFilesComment } from '../../src/commands/ci.ts'; + +describe('formatNoBumpFilesComment — passing (--no-fail)', () => { + const comment = formatNoBumpFilesComment('feature-branch', 'npm', false, ['@myorg/core']); + + test('keeps the friendly "you\'re good to go" wording', () => { + expect(comment).toContain("you're good to go"); + expect(comment).not.toContain('this check is failing'); + }); + + test('uses the warning frog, not the error frog', () => { + expect(comment).toContain('frog-warning.png'); + expect(comment).not.toContain('frog-error.png'); + }); +}); + +describe('formatNoBumpFilesComment — failing (default / strict)', () => { + const comment = formatNoBumpFilesComment('feature-branch', 'pnpm', true, ['@myorg/core', '@myorg/utils']); + + test('does not contradict the failing status with "good to go"', () => { + expect(comment).not.toContain("you're good to go"); + expect(comment).toContain('this check is failing'); + }); + + test('reports the changed packages that lack a bump file', () => { + expect(comment).toContain('2 packages but has no bump file'); + expect(comment).toContain('- `@myorg/core`'); + expect(comment).toContain('- `@myorg/utils`'); + }); + + test('offers the empty bump file as the no-release acknowledgment', () => { + expect(comment).toContain('pnpm exec bumpy add --empty'); + expect(comment).toContain('acknowledge'); + }); + + test('uses the error frog to match the failing status', () => { + expect(comment).toContain('frog-error.png'); + }); + + test('singularizes the headline for a single changed package', () => { + const single = formatNoBumpFilesComment('feature-branch', 'npm', true, ['@myorg/core']); + expect(single).toContain('1 package but has no bump file'); + }); +}); diff --git a/packages/bumpy/test/core/release-plan-bundled-deps.test.ts b/packages/bumpy/test/core/release-plan-bundled-deps.test.ts new file mode 100644 index 0000000..9201ae3 --- /dev/null +++ b/packages/bumpy/test/core/release-plan-bundled-deps.test.ts @@ -0,0 +1,107 @@ +import { test, expect, describe } from 'bun:test'; +import { assembleReleasePlan } from '../../src/core/release-plan.ts'; +import { DependencyGraph } from '../../src/core/dep-graph.ts'; +import { makePkg, makeConfig } from '../helpers.ts'; +import type { BumpFile } from '../../src/types.ts'; + +describe('releaseTriggeringDevDeps — devDeps baked into published output', () => { + const coreMinor: BumpFile[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'minor' }], summary: 'Feature' }]; + const corePatch: BumpFile[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'patch' }], summary: 'Fix' }]; + + test('without the marker, a devDep bump does not propagate', () => { + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['app', makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' } })], + ]); + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(coreMinor, packages, graph, makeConfig()); + expect(plan.releases.map((r) => r.name)).toEqual(['core']); + }); + + test('marking a devDep as bundled republishes the consumer with a patch bump', () => { + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + [ + 'app', + makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { releaseTriggeringDevDeps: ['core'] } }), + ], + ]); + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(coreMinor, packages, graph, makeConfig()); + + const app = plan.releases.find((r) => r.name === 'app'); + expect(app?.type).toBe('patch'); + expect(app?.newVersion).toBe('2.0.1'); + expect(app?.isCascadeBump).toBe(true); + }); + + test('cascades even when the bump stays in range (any change to bundled code ships)', () => { + // 1.0.0 → 1.0.1 still satisfies `^1.0.0`, so Phase A would never fire — but the + // bundled output changed, so the consumer must still be republished. + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + [ + 'app', + makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { releaseTriggeringDevDeps: ['core'] } }), + ], + ]); + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(corePatch, packages, graph, makeConfig()); + expect(plan.releases.find((r) => r.name === 'app')?.newVersion).toBe('2.0.1'); + }); + + test('glob patterns match bundled deps', () => { + const packages = new Map([ + ['@myorg/core', makePkg('@myorg/core', '1.0.0')], + [ + '@myorg/app', + makePkg('@myorg/app', '2.0.0', { + devDependencies: { '@myorg/core': '^1.0.0' }, + bumpy: { releaseTriggeringDevDeps: ['@myorg/*'] }, + }), + ], + ]); + const graph = new DependencyGraph(packages); + const bumpFiles: BumpFile[] = [ + { id: 'cs1', releases: [{ name: '@myorg/core', type: 'minor' }], summary: 'Feature' }, + ]; + const plan = assembleReleasePlan(bumpFiles, packages, graph, makeConfig()); + expect(plan.releases.find((r) => r.name === '@myorg/app')?.newVersion).toBe('2.0.1'); + }); + + test('an unbundled devDep alongside a bundled one stays skipped', () => { + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + [ + 'app', + makePkg('app', '2.0.0', { + devDependencies: { core: '^1.0.0' }, + bumpy: { releaseTriggeringDevDeps: ['other-lib'] }, + }), + ], + ]); + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(coreMinor, packages, graph, makeConfig()); + expect(plan.releases.map((r) => r.name)).toEqual(['core']); + }); + + test('an explicit cascadeFrom rule wins over the bundled default (proportional bump)', () => { + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + [ + 'app', + makePkg('app', '2.0.0', { + devDependencies: { core: '^1.0.0' }, + bumpy: { + releaseTriggeringDevDeps: ['core'], + cascadeFrom: { core: { trigger: 'patch', bumpAs: 'match' } }, + }, + }), + ], + ]); + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(coreMinor, packages, graph, makeConfig()); + // bumpAs 'match' → minor on core cascades a minor (not the bundled-default patch) + expect(plan.releases.find((r) => r.name === 'app')?.newVersion).toBe('2.1.0'); + }); +});