From cc5ff596f9b6407e8e035b423d2099a3beeb31c4 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 17 Jun 2026 11:04:57 -0700 Subject: [PATCH 1/7] fix: honest no-bump-files CI comment + bundledDependencies option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #117. ci check: when the check fails because changed packages lack a bump file, the PR comment no longer says "you're good to go" (which contradicted the exit-1 status). It 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. In --no-fail mode the check passes, so the friendly wording is kept. bundledDependencies: a new per-package option listing workspace deps baked into a package's published output (commonly under devDependencies when inlined by a bundler). Any bump to a listed dep republishes the bundling package with a patch bump — shorthand for a cascadeFrom rule of { trigger: 'patch', bumpAs: 'patch' }. An explicit cascadeFrom for the same source takes precedence (e.g. bumpAs: 'match' for proportional bumps). --- .bumpy/bundled-deps-and-no-bump-comment.md | 7 ++ docs/configuration.md | 20 +++- docs/version-propagation.md | 4 +- packages/bumpy/config-schema.json | 5 + packages/bumpy/src/commands/ci.ts | 96 ++++++++++++++-- packages/bumpy/src/core/release-plan.ts | 32 +++++- packages/bumpy/src/types.ts | 10 ++ .../test/core/ci-no-bump-comment.test.ts | 45 ++++++++ .../core/release-plan-bundled-deps.test.ts | 104 ++++++++++++++++++ 9 files changed, 305 insertions(+), 18 deletions(-) create mode 100644 .bumpy/bundled-deps-and-no-bump-comment.md create mode 100644 packages/bumpy/test/core/ci-no-bump-comment.test.ts create mode 100644 packages/bumpy/test/core/release-plan-bundled-deps.test.ts 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..7a9c0d9 --- /dev/null +++ b/.bumpy/bundled-deps-and-no-bump-comment.md @@ -0,0 +1,7 @@ +--- +'@varlock/bumpy': minor +--- + +`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 `bundledDependencies` option: names/globs of workspace deps bundled into a package's published output (commonly under `devDependencies`). Any bump to a listed dep republishes the bundling package 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..90c20e9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -145,6 +145,7 @@ Per-package settings can be defined in two places: | `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 }` | +| `bundledDependencies` | `string[]` | Deps bundled into this package's output — any bump republishes it (patch) | ### Custom commands and `allowCustomCommands` @@ -222,15 +223,28 @@ Or with custom trigger/bumpAs: } ``` -### Example: cascade from a bundled dependency (consumer-side) +### Example: a bundled dependency (consumer-side) -When a package bundles a devDependency into its published output, use `cascadeFrom` so bumps to the dependency also trigger a release of the consumer: +When a package bundles a dependency into its published output (inlined by esbuild/tsup/rollup), a change to that dependency changes what consumers receive — so the bundler must be republished. This is common with deps declared under `devDependencies`, since they aren't resolved at runtime. + +The simplest way to express this is `bundledDependencies`. Any bump to a listed dep republishes this package with a **patch** bump: + +```json +{ + "name": "@myorg/astro-integration", + "bumpy": { + "bundledDependencies": ["@myorg/vite-integration"] + } +} +``` + +This is shorthand for a `cascadeFrom` rule of `{ "trigger": "patch", "bumpAs": "patch" }`. If you re-export the bundled 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: ```json { "name": "@myorg/astro-integration", "bumpy": { - "cascadeFrom": ["@myorg/vite-integration"] + "cascadeFrom": { "@myorg/vite-integration": { "trigger": "patch", "bumpAs": "match" } } } } ``` diff --git a/docs/version-propagation.md b/docs/version-propagation.md index 4547195..72a8b7e 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 **bundled** into your published output (inlined by esbuild/tsup/rollup) — often declared under `devDependencies` since it isn't runtime-resolved. List those under `bundledDependencies` (or use `cascadeFrom`) so any bump to them republishes the bundling package. See [Configuration](./configuration.md#example-a-bundled-dependency-consumer-side). + 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..6463a1d 100644 --- a/packages/bumpy/config-schema.json +++ b/packages/bumpy/config-schema.json @@ -366,6 +366,11 @@ "cascadeFrom": { "description": "Explicit cascade sources — when a matching package is bumped, cascade the bump to this package.", "$ref": "#/$defs/cascadeConfig" + }, + "bundledDependencies": { + "type": "array", + "description": "Names (or globs) of workspace deps bundled into this package's published output (e.g. inlined by esbuild/tsup/rollup). Any bump to a listed dep republishes this package with a patch bump. 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/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..df726b0 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 (bundled devDeps are handled by the + // consumer-side cascade — see applyCascadeFrom / bundledDependencies). 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 + * `bundledDependencies` sugar. + * + * `bundledDependencies` declares deps that are baked into this package's published + * output (e.g. inlined by esbuild/tsup/rollup) — often listed under `devDependencies` + * because they're not runtime-resolved. Any bump to such a dep changes what consumers + * receive, so it must republish the bundler: `{ trigger: 'patch', bumpAs: 'patch' }` + * (any bump triggers; the bundler 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 bundled = pkg.bumpy?.bundledDependencies; + const cascadeFrom = pkg.bumpy?.cascadeFrom; + if (!bundled?.length && !cascadeFrom) return {}; + + const rules: Record> = {}; + for (const name of bundled ?? []) { + rules[name] = { trigger: 'patch', bumpAs: 'patch' }; + } + // Explicit cascadeFrom overrides the bundled-dependency 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..283d727 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -181,6 +181,16 @@ export interface PackageConfig { dependencyBumpRules?: Partial>; cascadeTo?: CascadeConfig; cascadeFrom?: CascadeConfig; + /** + * Names (or globs) of workspace dependencies bundled into this package's published + * output (e.g. inlined by esbuild/tsup/rollup). Because the dep's code ships inside + * this package, *any* bump to it republishes this package — so each entry acts as a + * `cascadeFrom` source with `{ trigger: 'patch', bumpAs: 'patch' }`. This is the + * common case where a true runtime dep lives under `devDependencies` (it's not + * runtime-resolved). For proportional bumps or finer control, use `cascadeFrom` + * directly — an explicit `cascadeFrom` rule for the same source takes precedence. + */ + bundledDependencies?: string[]; } export const DEFAULT_PUBLISH_CONFIG: PublishConfig = { 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..9bccdd0 --- /dev/null +++ b/packages/bumpy/test/core/release-plan-bundled-deps.test.ts @@ -0,0 +1,104 @@ +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('bundledDependencies — 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: { bundledDependencies: ['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: { bundledDependencies: ['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: { bundledDependencies: ['@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: { bundledDependencies: ['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: { + bundledDependencies: ['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'); + }); +}); From 2f8360d5328951ab2f452c734f91366f10aa57cb Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 17 Jun 2026 11:25:47 -0700 Subject: [PATCH 2/7] feat: field-aware package.json change detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When package.json is the only changed file in a package, diff it against the base branch and only require a bump file if a publish-affecting field changed. The new `ignoredPackageJsonFields` config (default ["devDependencies"]) lists fields whose change alone doesn't count — so a dev-only dependency bump (e.g. Dependabot) no longer requires a bump file. Composes with bundledDependencies: a changed devDependencies entry that matches the package's bundledDependencies still flags the package, since it ships in the published output. Errs toward requiring a bump file when it can't compare cleanly (new/unparseable package.json). This resolves the root of #117 — the original dependency-only PR now passes without a bump file, instead of failing with a contradictory "you're good to go" comment. --- .bumpy/bundled-deps-and-no-bump-comment.md | 2 + docs/configuration.md | 19 ++ packages/bumpy/config-schema.json | 6 + packages/bumpy/src/commands/check.ts | 124 ++++++++++++- packages/bumpy/src/types.ts | 11 ++ .../test/core/check-pkgjson-fields.test.ts | 164 ++++++++++++++++++ 6 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 packages/bumpy/test/core/check-pkgjson-fields.test.ts diff --git a/.bumpy/bundled-deps-and-no-bump-comment.md b/.bumpy/bundled-deps-and-no-bump-comment.md index 7a9c0d9..a88a516 100644 --- a/.bumpy/bundled-deps-and-no-bump-comment.md +++ b/.bumpy/bundled-deps-and-no-bump-comment.md @@ -2,6 +2,8 @@ '@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 `bundledDependencies`. + `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 `bundledDependencies` option: names/globs of workspace deps bundled into a package's published output (commonly under `devDependencies`). Any bump to a listed dep republishes the bundling package 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 90c20e9..e063411 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 [`bundledDependencies`](#example-a-bundled-dependency-consumer-side) **does** flag the package, since a bundled dep ships in 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. diff --git a/packages/bumpy/config-schema.json b/packages/bumpy/config-schema.json index 6463a1d..8fbaf6a 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 bundledDependencies 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.", diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index dd15b32..c11bf74 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,34 @@ 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?.bundledDependencies)) { + changed.add(name); } } } @@ -289,6 +309,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 `bundledDependencies`, since it ships in the bundle. + * + * 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[], + bundledDependencies?: 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 bundled devDependency change still affects output. + if ( + key === 'devDependencies' && + bundledDevDepChanged( + before.devDependencies as Record | undefined, + after.devDependencies as Record | undefined, + bundledDependencies, + ) + ) { + return true; + } + } + return false; +} + +/** Whether any devDependency matching `bundledDependencies` was added/removed/changed. */ +function bundledDevDepChanged( + before: Record = {}, + after: Record = {}, + bundled?: string[], +): boolean { + if (!bundled?.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 (bundled.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/types.ts b/packages/bumpy/src/types.ts index 283d727..7845822 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 `bundledDependencies` 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) */ @@ -208,6 +218,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..590d48b --- /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: { bundledDependencies: ['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: { bundledDependencies: ['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: { bundledDependencies: ['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: { bundledDependencies: ['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'); + }); +}); From 7d8a2bc9e0605dbddfe22434d70d884adf126be5 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 17 Jun 2026 13:45:11 -0700 Subject: [PATCH 3/7] docs: unify bundledDependencies story (propagation + change detection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explain that bundledDependencies is one declaration driving two behaviors — cascade on the dep's own release (internal workspace deps only) and change-detection flagging when its range is edited (any dep). Add an explicit "internal workspace deps: bundled vs not" note, since that's the knob for which devDependencies affect published output. Rename the heading and fix the inbound anchor links. --- docs/configuration.md | 23 +++++++++++++++++------ docs/version-propagation.md | 2 +- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e063411..3827f75 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,7 +34,7 @@ A package is "changed" (and so needs a bump file) when a changed file inside it 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 [`bundledDependencies`](#example-a-bundled-dependency-consumer-side) **does** flag the package, since a bundled dep ships in the published output. +One exception keeps this safe: a changed `devDependencies` entry that matches the package's [`bundledDependencies`](#bundled-dependencies-consumer-side) **does** flag the package, since a bundled dep ships in the published output. To relax additional fields (e.g. treat `scripts` changes as non-releasing too), extend the list: @@ -242,11 +242,9 @@ Or with custom trigger/bumpAs: } ``` -### Example: a bundled dependency (consumer-side) +### Bundled dependencies (consumer-side) -When a package bundles a dependency into its published output (inlined by esbuild/tsup/rollup), a change to that dependency changes what consumers receive — so the bundler must be republished. This is common with deps declared under `devDependencies`, since they aren't resolved at runtime. - -The simplest way to express this is `bundledDependencies`. Any bump to a listed dep republishes this package with a **patch** bump: +When a package bundles a dependency into its published output (inlined by esbuild/tsup/rollup), a change to that dependency changes what consumers receive — so the bundler must be republished. This is common with deps declared under `devDependencies`, since they aren't resolved at runtime. List them in `bundledDependencies`: ```json { @@ -257,7 +255,20 @@ The simplest way to express this is `bundledDependencies`. Any bump to a listed } ``` -This is shorthand for a `cascadeFrom` rule of `{ "trigger": "patch", "bumpAs": "patch" }`. If you re-export the bundled 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: +`bundledDependencies` declares intent — "this dependency ships inside my output" — and that drives **two** behaviors: + +1. **Propagation** — when the bundled dependency 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 bundled dependency'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** bundled dep, internal or external. + +So for an **internal** bundled workspace dep, both paths fire; for an **external** bundled 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: bundled vs. 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 `bundledDependencies` 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 + +If you re-export the bundled 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 `bundledDependencies` patch default: ```json { diff --git a/docs/version-propagation.md b/docs/version-propagation.md index 72a8b7e..f09b68f 100644 --- a/docs/version-propagation.md +++ b/docs/version-propagation.md @@ -25,7 +25,7 @@ The bump type applied to the dependent depends on the dependency type: 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 **bundled** into your published output (inlined by esbuild/tsup/rollup) — often declared under `devDependencies` since it isn't runtime-resolved. List those under `bundledDependencies` (or use `cascadeFrom`) so any bump to them republishes the bundling package. See [Configuration](./configuration.md#example-a-bundled-dependency-consumer-side). +> † `devDependencies` are skipped because they normally don't ship to consumers. The exception is a dependency **bundled** into your published output (inlined by esbuild/tsup/rollup) — often declared under `devDependencies` since it isn't runtime-resolved. List those under `bundledDependencies` (or use `cascadeFrom`) so any bump to them republishes the bundling package. See [Configuration](./configuration.md#bundled-dependencies-consumer-side). This phase is a **safety net** — it cannot be skipped. It ensures that published packages always have valid dependency ranges. From a897e37ae9069c436197d827ec0bba2b059a306d Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 17 Jun 2026 14:13:10 -0700 Subject: [PATCH 4/7] chore: mark bumpy's own bundled runtime deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bumpy has no runtime `dependencies` — its libs (@clack/prompts, js-yaml, picocolors, picomatch, semver) live in devDependencies and tsdown inlines them into the published output. With field-aware change detection, a dependency-only bump to one of these would otherwise pass without a bump file even though it ships to consumers. Mark them as bundledDependencies so such updates correctly require a release. --- packages/bumpy/package.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/bumpy/package.json b/packages/bumpy/package.json index 3f982c2..51b2080 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": { + "bundledDependencies": [ + "@clack/prompts", + "js-yaml", + "picocolors", + "picomatch", + "semver" + ] } } From 9abdd97d37cca0da767d78c6fa944dddae74f478 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 17 Jun 2026 14:14:08 -0700 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20clarify=20bundled=20deps=20?= =?UTF-8?q?=E2=80=94=20call=20out=20tsup/tsdown/esbuild/etc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explain the bundler mechanism explicitly (a build step inlines imports into dist/), why bundled deps live in devDependencies, and the no-runtime-dependencies extreme (as bumpy itself is built with tsdown). Name the common bundlers in both the configuration and propagation docs. --- docs/configuration.md | 8 ++++++-- docs/version-propagation.md | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 3827f75..5444888 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -244,13 +244,17 @@ Or with custom trigger/bumpAs: ### Bundled dependencies (consumer-side) -When a package bundles a dependency into its published output (inlined by esbuild/tsup/rollup), a change to that dependency changes what consumers receive — so the bundler must be republished. This is common with deps declared under `devDependencies`, since they aren't resolved at runtime. List them in `bundledDependencies`: +Many packages ship a **bundle** — a build step (tsup, tsdown, esbuild, rolldown/rollup, Vite, `bun build`, webpack, …) inlines their imports into `dist/` so consumers get a self-contained artifact. A dependency that's bundled this way is **not** installed from the registry at consume time; its code is copied into your output. So when that dependency changes, what you publish changes too — and you need a new release. + +Because a bundled dependency isn't resolved at runtime, it's conventionally declared under `devDependencies` rather than `dependencies` (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.) + +That convention is the problem bumpy has to disambiguate: a `devDependencies` change is normally ignored (it's just dev tooling — a linter, a type package, a test runner), but a bundled one is effectively a runtime dependency. `bundledDependencies` is how you tell them apart — list the deps that are baked into your output: ```json { "name": "@myorg/astro-integration", "bumpy": { - "bundledDependencies": ["@myorg/vite-integration"] + "bundledDependencies": ["@myorg/vite-integration", "nanoid"] } } ``` diff --git a/docs/version-propagation.md b/docs/version-propagation.md index f09b68f..9fcdbff 100644 --- a/docs/version-propagation.md +++ b/docs/version-propagation.md @@ -25,7 +25,7 @@ The bump type applied to the dependent depends on the dependency type: 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 **bundled** into your published output (inlined by esbuild/tsup/rollup) — often declared under `devDependencies` since it isn't runtime-resolved. List those under `bundledDependencies` (or use `cascadeFrom`) so any bump to them republishes the bundling package. See [Configuration](./configuration.md#bundled-dependencies-consumer-side). +> † `devDependencies` are skipped because they normally don't ship to consumers. The exception is a dependency **bundled** into your published output (inlined by a build step — tsup, tsdown, esbuild, rolldown/rollup, Vite, `bun build`, …) — often declared under `devDependencies` since it isn't runtime-resolved. List those under `bundledDependencies` (or use `cascadeFrom`) so any bump to them republishes the bundling package. See [Configuration](./configuration.md#bundled-dependencies-consumer-side). This phase is a **safety net** — it cannot be skipped. It ensures that published packages always have valid dependency ranges. From 70202d53ee7b96308ef72b6aa9333ccf1933ff4b Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 17 Jun 2026 23:41:01 -0700 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20rename=20bundledDependencies=20?= =?UTF-8?q?=E2=86=92=20releaseDevDependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The option marks devDependencies that affect published output and so should trigger a release — bundling is the common reason but not the only one (committed codegen output, re-exported types). The new name describes the behavior rather than one cause, and avoids colliding with npm's own `bundledDependencies` package.json field (different mechanism). Unreleased, so renamed in place — no deprecation needed. Renames the config option, schema, internal helpers, docs (incl. heading + anchors), tests, and bumpy's own self-marking config. --- .bumpy/bundled-deps-and-no-bump-comment.md | 4 +- docs/configuration.md | 56 +++++++++---------- docs/differences-from-changesets.md | 2 +- docs/version-propagation.md | 2 +- packages/bumpy/config-schema.json | 6 +- packages/bumpy/package.json | 2 +- packages/bumpy/src/commands/check.ts | 24 ++++---- packages/bumpy/src/core/release-plan.ts | 28 +++++----- packages/bumpy/src/types.ts | 23 +++++--- .../test/core/check-pkgjson-fields.test.ts | 8 +-- .../core/release-plan-bundled-deps.test.ts | 15 +++-- 11 files changed, 89 insertions(+), 81 deletions(-) diff --git a/.bumpy/bundled-deps-and-no-bump-comment.md b/.bumpy/bundled-deps-and-no-bump-comment.md index a88a516..9a0c1db 100644 --- a/.bumpy/bundled-deps-and-no-bump-comment.md +++ b/.bumpy/bundled-deps-and-no-bump-comment.md @@ -2,8 +2,8 @@ '@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 `bundledDependencies`. +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 `releaseDevDependencies`. `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 `bundledDependencies` option: names/globs of workspace deps bundled into a package's published output (commonly under `devDependencies`). Any bump to a listed dep republishes the bundling package with a patch bump — shorthand for a `cascadeFrom` rule of `{ trigger: 'patch', bumpAs: 'patch' }`. +Add a per-package `releaseDevDependencies` 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 5444888..112c532 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,7 +34,7 @@ A package is "changed" (and so needs a bump file) when a changed file inside it 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 [`bundledDependencies`](#bundled-dependencies-consumer-side) **does** flag the package, since a bundled dep ships in the published output. +One exception keeps this safe: a changed `devDependencies` entry that matches the package's [`releaseDevDependencies`](#release-relevant-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: @@ -151,20 +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 }` | -| `bundledDependencies` | `string[]` | Deps bundled into this package's output — any bump republishes it (patch) | +| 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 }` | +| `releaseDevDependencies` | `string[]` | devDependencies that affect published output — a change requires a release (see below) | ### Custom commands and `allowCustomCommands` @@ -242,37 +242,35 @@ Or with custom trigger/bumpAs: } ``` -### Bundled dependencies (consumer-side) +### Release-relevant devDependencies -Many packages ship a **bundle** — a build step (tsup, tsdown, esbuild, rolldown/rollup, Vite, `bun build`, webpack, …) inlines their imports into `dist/` so consumers get a self-contained artifact. A dependency that's bundled this way is **not** installed from the registry at consume time; its code is copied into your output. So when that dependency changes, what you publish changes too — and you need a new release. - -Because a bundled dependency isn't resolved at runtime, it's conventionally declared under `devDependencies` rather than `dependencies` (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.) - -That convention is the problem bumpy has to disambiguate: a `devDependencies` change is normally ignored (it's just dev tooling — a linter, a type package, a test runner), but a bundled one is effectively a runtime dependency. `bundledDependencies` is how you tell them apart — list the deps that are baked into your output: +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`. `releaseDevDependencies` 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": { - "bundledDependencies": ["@myorg/vite-integration", "nanoid"] + "releaseDevDependencies": ["@myorg/vite-integration", "nanoid"] } } ``` -`bundledDependencies` declares intent — "this dependency ships inside my output" — and that drives **two** behaviors: +**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. + +`releaseDevDependencies` declares intent — "a change to this dep changes what I publish" — and that drives **two** behaviors: -1. **Propagation** — when the bundled dependency 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 bundled dependency'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** bundled dep, internal or external. +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** bundled workspace dep, both paths fire; for an **external** bundled 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. +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: bundled vs. not +#### 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 `bundledDependencies` 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. +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 `releaseDevDependencies` 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 -If you re-export the bundled 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 `bundledDependencies` patch default: +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 `releaseDevDependencies` patch default: ```json { diff --git a/docs/differences-from-changesets.md b/docs/differences-from-changesets.md index 8450a67..d37916d 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 `releaseDevDependencies`, 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 9fcdbff..76c1b16 100644 --- a/docs/version-propagation.md +++ b/docs/version-propagation.md @@ -25,7 +25,7 @@ The bump type applied to the dependent depends on the dependency type: 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 **bundled** into your published output (inlined by a build step — tsup, tsdown, esbuild, rolldown/rollup, Vite, `bun build`, …) — often declared under `devDependencies` since it isn't runtime-resolved. List those under `bundledDependencies` (or use `cascadeFrom`) so any bump to them republishes the bundling package. See [Configuration](./configuration.md#bundled-dependencies-consumer-side). +> † `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 `releaseDevDependencies` (or use `cascadeFrom`) so any bump to them republishes this package. See [Configuration](./configuration.md#release-relevant-devdependencies). This phase is a **safety net** — it cannot be skipped. It ensures that published packages always have valid dependency ranges. diff --git a/packages/bumpy/config-schema.json b/packages/bumpy/config-schema.json index 8fbaf6a..e292aa6 100644 --- a/packages/bumpy/config-schema.json +++ b/packages/bumpy/config-schema.json @@ -46,7 +46,7 @@ }, "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 bundledDependencies still counts.", + "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 releaseDevDependencies still counts.", "items": { "type": "string" }, "default": ["devDependencies"] }, @@ -373,9 +373,9 @@ "description": "Explicit cascade sources — when a matching package is bumped, cascade the bump to this package.", "$ref": "#/$defs/cascadeConfig" }, - "bundledDependencies": { + "releaseDevDependencies": { "type": "array", - "description": "Names (or globs) of workspace deps bundled into this package's published output (e.g. inlined by esbuild/tsup/rollup). Any bump to a listed dep republishes this package with a patch bump. Shorthand for a cascadeFrom rule of { trigger: 'patch', bumpAs: 'patch' }; an explicit cascadeFrom for the same source takes precedence.", + "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" } } }, diff --git a/packages/bumpy/package.json b/packages/bumpy/package.json index 51b2080..d12992c 100644 --- a/packages/bumpy/package.json +++ b/packages/bumpy/package.json @@ -54,7 +54,7 @@ "tsdown": "catalog:" }, "bumpy": { - "bundledDependencies": [ + "releaseDevDependencies": [ "@clack/prompts", "js-yaml", "picocolors", diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index c11bf74..39a8067 100644 --- a/packages/bumpy/src/commands/check.ts +++ b/packages/bumpy/src/commands/check.ts @@ -279,7 +279,9 @@ export async function findChangedPackages( // 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?.bundledDependencies)) { + if ( + await packageJsonAffectsRelease(rootDir, baseRef, pkgRelDir, ignoredFields, pkg.bumpy?.releaseDevDependencies) + ) { changed.add(name); } } @@ -314,7 +316,7 @@ export async function findChangedPackages( * 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 `bundledDependencies`, since it ships in the bundle. + * package's `releaseDevDependencies`, 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. @@ -324,7 +326,7 @@ async function packageJsonAffectsRelease( baseRef: string, pkgRelDir: string, ignoredFields: string[], - bundledDependencies?: string[], + releaseDevDependencies?: string[], ): Promise { const relPath = `${pkgRelDir}/package.json`; const beforeRaw = readFileAtRef(rootDir, baseRef, relPath); @@ -348,13 +350,13 @@ async function packageJsonAffectsRelease( 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 bundled devDependency change still affects output. + // The field is ignored — but a release-relevant devDependency change still affects output. if ( key === 'devDependencies' && - bundledDevDepChanged( + releaseDevDepChanged( before.devDependencies as Record | undefined, after.devDependencies as Record | undefined, - bundledDependencies, + releaseDevDependencies, ) ) { return true; @@ -363,17 +365,17 @@ async function packageJsonAffectsRelease( return false; } -/** Whether any devDependency matching `bundledDependencies` was added/removed/changed. */ -function bundledDevDepChanged( +/** Whether any devDependency matching `releaseDevDependencies` was added/removed/changed. */ +function releaseDevDepChanged( before: Record = {}, after: Record = {}, - bundled?: string[], + releaseDevDependencies?: string[], ): boolean { - if (!bundled?.length) return false; + if (!releaseDevDependencies?.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 (bundled.some((pattern) => matchGlob(name, pattern))) return true; + if (releaseDevDependencies.some((pattern) => matchGlob(name, pattern))) return true; } return false; } diff --git a/packages/bumpy/src/core/release-plan.ts b/packages/bumpy/src/core/release-plan.ts index df726b0..021c942 100644 --- a/packages/bumpy/src/core/release-plan.ts +++ b/packages/bumpy/src/core/release-plan.ts @@ -124,8 +124,8 @@ export function assembleReleasePlan( const dependents = depGraph.getDependents(pkgName); for (const dep of dependents) { - // Skip devDependencies in Phase A (bundled devDeps are handled by the - // consumer-side cascade — see applyCascadeFrom / bundledDependencies). + // Skip devDependencies in Phase A (release-relevant devDeps are handled by the + // consumer-side cascade — see applyCascadeFrom / releaseDevDependencies). if (dep.depType === 'devDependencies') continue; // Check if new version is out of range @@ -461,26 +461,26 @@ function shouldTrigger(bumpType: BumpType, trigger: BumpType): boolean { /** * Consumer-side cascade rules for a package: explicit `cascadeFrom` entries, plus the - * `bundledDependencies` sugar. + * `releaseDevDependencies` sugar. * - * `bundledDependencies` declares deps that are baked into this package's published - * output (e.g. inlined by esbuild/tsup/rollup) — often listed under `devDependencies` - * because they're not runtime-resolved. Any bump to such a dep changes what consumers - * receive, so it must republish the bundler: `{ trigger: 'patch', bumpAs: 'patch' }` - * (any bump triggers; the bundler 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. + * `releaseDevDependencies` 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 bundled = pkg.bumpy?.bundledDependencies; + const releaseDevDeps = pkg.bumpy?.releaseDevDependencies; const cascadeFrom = pkg.bumpy?.cascadeFrom; - if (!bundled?.length && !cascadeFrom) return {}; + if (!releaseDevDeps?.length && !cascadeFrom) return {}; const rules: Record> = {}; - for (const name of bundled ?? []) { + for (const name of releaseDevDeps ?? []) { rules[name] = { trigger: 'patch', bumpAs: 'patch' }; } - // Explicit cascadeFrom overrides the bundled-dependency default on conflict. + // Explicit cascadeFrom overrides the releaseDevDependencies default on conflict. return { ...rules, ...(cascadeFrom ? normalizeCascadeConfig(cascadeFrom) : {}) }; } diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index 7845822..d0e37bf 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -140,7 +140,7 @@ export interface BumpyConfig { * 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 `bundledDependencies` still + * `devDependencies` entry that matches the package's `releaseDevDependencies` still * counts, since it ships in the bundle. */ ignoredPackageJsonFields: string[]; @@ -192,15 +192,20 @@ export interface PackageConfig { cascadeTo?: CascadeConfig; cascadeFrom?: CascadeConfig; /** - * Names (or globs) of workspace dependencies bundled into this package's published - * output (e.g. inlined by esbuild/tsup/rollup). Because the dep's code ships inside - * this package, *any* bump to it republishes this package — so each entry acts as a - * `cascadeFrom` source with `{ trigger: 'patch', bumpAs: 'patch' }`. This is the - * common case where a true runtime dep lives under `devDependencies` (it's not - * runtime-resolved). For proportional bumps or finer control, use `cascadeFrom` - * directly — an explicit `cascadeFrom` rule for the same source takes precedence. + * 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). */ - bundledDependencies?: string[]; + releaseDevDependencies?: string[]; } export const DEFAULT_PUBLISH_CONFIG: PublishConfig = { diff --git a/packages/bumpy/test/core/check-pkgjson-fields.test.ts b/packages/bumpy/test/core/check-pkgjson-fields.test.ts index 590d48b..9c65909 100644 --- a/packages/bumpy/test/core/check-pkgjson-fields.test.ts +++ b/packages/bumpy/test/core/check-pkgjson-fields.test.ts @@ -104,13 +104,13 @@ describe('findChangedPackages — package.json field awareness', () => { test('a bundled devDependency change DOES flag the package', async () => { await setupPkg({ devDependencies: { 'bundled-lib': '^1.0.0', vitest: '^1.0.0' }, - bumpy: { bundledDependencies: ['bundled-lib'] }, + bumpy: { releaseDevDependencies: ['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: { bundledDependencies: ['bundled-lib'] }, + bumpy: { releaseDevDependencies: ['bundled-lib'] }, }); gitInDir(['commit', '-am', 'bump bundled-lib'], tmpDir); expect(await detect()).toContain('app'); @@ -119,13 +119,13 @@ describe('findChangedPackages — package.json field awareness', () => { 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: { bundledDependencies: ['bundled-lib'] }, + bumpy: { releaseDevDependencies: ['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: { bundledDependencies: ['bundled-lib'] }, + bumpy: { releaseDevDependencies: ['bundled-lib'] }, }); gitInDir(['commit', '-am', 'bump vitest only'], tmpDir); expect(await detect()).not.toContain('app'); diff --git a/packages/bumpy/test/core/release-plan-bundled-deps.test.ts b/packages/bumpy/test/core/release-plan-bundled-deps.test.ts index 9bccdd0..458fcca 100644 --- a/packages/bumpy/test/core/release-plan-bundled-deps.test.ts +++ b/packages/bumpy/test/core/release-plan-bundled-deps.test.ts @@ -4,7 +4,7 @@ import { DependencyGraph } from '../../src/core/dep-graph.ts'; import { makePkg, makeConfig } from '../helpers.ts'; import type { BumpFile } from '../../src/types.ts'; -describe('bundledDependencies — devDeps baked into published output', () => { +describe('releaseDevDependencies — 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' }]; @@ -23,7 +23,7 @@ describe('bundledDependencies — devDeps baked into published output', () => { ['core', makePkg('core', '1.0.0')], [ 'app', - makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { bundledDependencies: ['core'] } }), + makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { releaseDevDependencies: ['core'] } }), ], ]); const graph = new DependencyGraph(packages); @@ -42,7 +42,7 @@ describe('bundledDependencies — devDeps baked into published output', () => { ['core', makePkg('core', '1.0.0')], [ 'app', - makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { bundledDependencies: ['core'] } }), + makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { releaseDevDependencies: ['core'] } }), ], ]); const graph = new DependencyGraph(packages); @@ -57,7 +57,7 @@ describe('bundledDependencies — devDeps baked into published output', () => { '@myorg/app', makePkg('@myorg/app', '2.0.0', { devDependencies: { '@myorg/core': '^1.0.0' }, - bumpy: { bundledDependencies: ['@myorg/*'] }, + bumpy: { releaseDevDependencies: ['@myorg/*'] }, }), ], ]); @@ -74,7 +74,10 @@ describe('bundledDependencies — devDeps baked into published output', () => { ['core', makePkg('core', '1.0.0')], [ 'app', - makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { bundledDependencies: ['other-lib'] } }), + makePkg('app', '2.0.0', { + devDependencies: { core: '^1.0.0' }, + bumpy: { releaseDevDependencies: ['other-lib'] }, + }), ], ]); const graph = new DependencyGraph(packages); @@ -90,7 +93,7 @@ describe('bundledDependencies — devDeps baked into published output', () => { makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { - bundledDependencies: ['core'], + releaseDevDependencies: ['core'], cascadeFrom: { core: { trigger: 'patch', bumpAs: 'match' } }, }, }), From c64c15212c03d80a6e102c12764ae6f9a8d04bdb Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 17 Jun 2026 23:58:34 -0700 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20rename=20releaseDevDependencies?= =?UTF-8?q?=20=E2=86=92=20releaseTriggeringDevDeps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Triggering" names the behavior unambiguously — a change to one of these devDependencies triggers a release (and the dep's own release cascades) — avoiding the "deps used to perform a release" misreading of the bare "release" adjective. Updates the option, schema, internal helpers, docs (heading "Release-triggering devDependencies" + anchors), tests, and bumpy's own self-marking config. --- .bumpy/bundled-deps-and-no-bump-comment.md | 4 +- docs/configuration.md | 42 +++++++++---------- docs/differences-from-changesets.md | 2 +- docs/version-propagation.md | 2 +- packages/bumpy/config-schema.json | 4 +- packages/bumpy/package.json | 2 +- packages/bumpy/src/commands/check.ts | 20 ++++----- packages/bumpy/src/core/release-plan.ts | 14 +++---- packages/bumpy/src/types.ts | 4 +- .../test/core/check-pkgjson-fields.test.ts | 8 ++-- .../core/release-plan-bundled-deps.test.ts | 12 +++--- 11 files changed, 57 insertions(+), 57 deletions(-) diff --git a/.bumpy/bundled-deps-and-no-bump-comment.md b/.bumpy/bundled-deps-and-no-bump-comment.md index 9a0c1db..8a1f4e3 100644 --- a/.bumpy/bundled-deps-and-no-bump-comment.md +++ b/.bumpy/bundled-deps-and-no-bump-comment.md @@ -2,8 +2,8 @@ '@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 `releaseDevDependencies`. +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 `releaseDevDependencies` 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' }`. +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 112c532..4a5a85c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,7 +34,7 @@ A package is "changed" (and so needs a bump file) when a changed file inside it 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 [`releaseDevDependencies`](#release-relevant-devdependencies) **does** flag the package, since such a dep affects the published output. +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: @@ -151,20 +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 }` | -| `releaseDevDependencies` | `string[]` | devDependencies that affect published output — a change requires a release (see below) | +| 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` @@ -242,22 +242,22 @@ Or with custom trigger/bumpAs: } ``` -### Release-relevant devDependencies +### 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`. `releaseDevDependencies` marks those, so a change to one requires a release (and, for internal workspace deps, its own releases cascade to you): +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": { - "releaseDevDependencies": ["@myorg/vite-integration", "nanoid"] + "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. -`releaseDevDependencies` declares intent — "a change to this dep changes what I publish" — and that drives **two** behaviors: +`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. @@ -266,11 +266,11 @@ So for an **internal** workspace dep, both paths fire; for an **external** dep ( #### 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 `releaseDevDependencies` 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. +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 -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 `releaseDevDependencies` patch default: +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 { diff --git a/docs/differences-from-changesets.md b/docs/differences-from-changesets.md index d37916d..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 (opt specific ones in per-package via `releaseDevDependencies`, e.g. bundled deps) +- 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 76c1b16..78d79d1 100644 --- a/docs/version-propagation.md +++ b/docs/version-propagation.md @@ -25,7 +25,7 @@ The bump type applied to the dependent depends on the dependency type: 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 `releaseDevDependencies` (or use `cascadeFrom`) so any bump to them republishes this package. See [Configuration](./configuration.md#release-relevant-devdependencies). +> † `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. diff --git a/packages/bumpy/config-schema.json b/packages/bumpy/config-schema.json index e292aa6..e6d97cd 100644 --- a/packages/bumpy/config-schema.json +++ b/packages/bumpy/config-schema.json @@ -46,7 +46,7 @@ }, "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 releaseDevDependencies still counts.", + "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"] }, @@ -373,7 +373,7 @@ "description": "Explicit cascade sources — when a matching package is bumped, cascade the bump to this package.", "$ref": "#/$defs/cascadeConfig" }, - "releaseDevDependencies": { + "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" } diff --git a/packages/bumpy/package.json b/packages/bumpy/package.json index d12992c..9c2ad9d 100644 --- a/packages/bumpy/package.json +++ b/packages/bumpy/package.json @@ -54,7 +54,7 @@ "tsdown": "catalog:" }, "bumpy": { - "releaseDevDependencies": [ + "releaseTriggeringDevDeps": [ "@clack/prompts", "js-yaml", "picocolors", diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index 39a8067..85623af 100644 --- a/packages/bumpy/src/commands/check.ts +++ b/packages/bumpy/src/commands/check.ts @@ -280,7 +280,7 @@ export async function findChangedPackages( if (!changed.has(name) && pkgJsonOnlyTrigger) { baseRef ??= getBaseCompareRef(rootDir, config.baseBranch); if ( - await packageJsonAffectsRelease(rootDir, baseRef, pkgRelDir, ignoredFields, pkg.bumpy?.releaseDevDependencies) + await packageJsonAffectsRelease(rootDir, baseRef, pkgRelDir, ignoredFields, pkg.bumpy?.releaseTriggeringDevDeps) ) { changed.add(name); } @@ -316,7 +316,7 @@ export async function findChangedPackages( * 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 `releaseDevDependencies`, since such a dep affects the published output. + * 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. @@ -326,7 +326,7 @@ async function packageJsonAffectsRelease( baseRef: string, pkgRelDir: string, ignoredFields: string[], - releaseDevDependencies?: string[], + releaseTriggeringDevDeps?: string[], ): Promise { const relPath = `${pkgRelDir}/package.json`; const beforeRaw = readFileAtRef(rootDir, baseRef, relPath); @@ -353,10 +353,10 @@ async function packageJsonAffectsRelease( // The field is ignored — but a release-relevant devDependency change still affects output. if ( key === 'devDependencies' && - releaseDevDepChanged( + releaseTriggeringDevDepsChanged( before.devDependencies as Record | undefined, after.devDependencies as Record | undefined, - releaseDevDependencies, + releaseTriggeringDevDeps, ) ) { return true; @@ -365,17 +365,17 @@ async function packageJsonAffectsRelease( return false; } -/** Whether any devDependency matching `releaseDevDependencies` was added/removed/changed. */ -function releaseDevDepChanged( +/** Whether any devDependency matching `releaseTriggeringDevDeps` was added/removed/changed. */ +function releaseTriggeringDevDepsChanged( before: Record = {}, after: Record = {}, - releaseDevDependencies?: string[], + releaseTriggeringDevDeps?: string[], ): boolean { - if (!releaseDevDependencies?.length) return false; + 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 (releaseDevDependencies.some((pattern) => matchGlob(name, pattern))) return true; + if (releaseTriggeringDevDeps.some((pattern) => matchGlob(name, pattern))) return true; } return false; } diff --git a/packages/bumpy/src/core/release-plan.ts b/packages/bumpy/src/core/release-plan.ts index 021c942..f2b6df6 100644 --- a/packages/bumpy/src/core/release-plan.ts +++ b/packages/bumpy/src/core/release-plan.ts @@ -125,7 +125,7 @@ export function assembleReleasePlan( for (const dep of dependents) { // Skip devDependencies in Phase A (release-relevant devDeps are handled by the - // consumer-side cascade — see applyCascadeFrom / releaseDevDependencies). + // consumer-side cascade — see applyCascadeFrom / releaseTriggeringDevDeps). if (dep.depType === 'devDependencies') continue; // Check if new version is out of range @@ -461,9 +461,9 @@ function shouldTrigger(bumpType: BumpType, trigger: BumpType): boolean { /** * Consumer-side cascade rules for a package: explicit `cascadeFrom` entries, plus the - * `releaseDevDependencies` sugar. + * `releaseTriggeringDevDeps` sugar. * - * `releaseDevDependencies` lists `devDependencies` that are release-relevant — typically + * `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 @@ -472,15 +472,15 @@ function shouldTrigger(bumpType: BumpType, trigger: BumpType): boolean { * proportional bumps (`bumpAs: 'match'`) when you re-export the dep. */ function cascadeFromRules(pkg: WorkspacePackage): Record> { - const releaseDevDeps = pkg.bumpy?.releaseDevDependencies; + const releaseTriggeringDevDeps = pkg.bumpy?.releaseTriggeringDevDeps; const cascadeFrom = pkg.bumpy?.cascadeFrom; - if (!releaseDevDeps?.length && !cascadeFrom) return {}; + if (!releaseTriggeringDevDeps?.length && !cascadeFrom) return {}; const rules: Record> = {}; - for (const name of releaseDevDeps ?? []) { + for (const name of releaseTriggeringDevDeps ?? []) { rules[name] = { trigger: 'patch', bumpAs: 'patch' }; } - // Explicit cascadeFrom overrides the releaseDevDependencies default on conflict. + // Explicit cascadeFrom overrides the releaseTriggeringDevDeps default on conflict. return { ...rules, ...(cascadeFrom ? normalizeCascadeConfig(cascadeFrom) : {}) }; } diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index d0e37bf..957bb6e 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -140,7 +140,7 @@ export interface BumpyConfig { * 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 `releaseDevDependencies` still + * `devDependencies` entry that matches the package's `releaseTriggeringDevDeps` still * counts, since it ships in the bundle. */ ignoredPackageJsonFields: string[]; @@ -205,7 +205,7 @@ export interface PackageConfig { * `cascadeFrom` rule for the same source takes precedence (e.g. `bumpAs: 'match'` for * proportional bumps). */ - releaseDevDependencies?: string[]; + releaseTriggeringDevDeps?: string[]; } export const DEFAULT_PUBLISH_CONFIG: PublishConfig = { diff --git a/packages/bumpy/test/core/check-pkgjson-fields.test.ts b/packages/bumpy/test/core/check-pkgjson-fields.test.ts index 9c65909..9cf9662 100644 --- a/packages/bumpy/test/core/check-pkgjson-fields.test.ts +++ b/packages/bumpy/test/core/check-pkgjson-fields.test.ts @@ -104,13 +104,13 @@ describe('findChangedPackages — package.json field awareness', () => { test('a bundled devDependency change DOES flag the package', async () => { await setupPkg({ devDependencies: { 'bundled-lib': '^1.0.0', vitest: '^1.0.0' }, - bumpy: { releaseDevDependencies: ['bundled-lib'] }, + 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: { releaseDevDependencies: ['bundled-lib'] }, + bumpy: { releaseTriggeringDevDeps: ['bundled-lib'] }, }); gitInDir(['commit', '-am', 'bump bundled-lib'], tmpDir); expect(await detect()).toContain('app'); @@ -119,13 +119,13 @@ describe('findChangedPackages — package.json field awareness', () => { 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: { releaseDevDependencies: ['bundled-lib'] }, + 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: { releaseDevDependencies: ['bundled-lib'] }, + bumpy: { releaseTriggeringDevDeps: ['bundled-lib'] }, }); gitInDir(['commit', '-am', 'bump vitest only'], tmpDir); expect(await detect()).not.toContain('app'); diff --git a/packages/bumpy/test/core/release-plan-bundled-deps.test.ts b/packages/bumpy/test/core/release-plan-bundled-deps.test.ts index 458fcca..9201ae3 100644 --- a/packages/bumpy/test/core/release-plan-bundled-deps.test.ts +++ b/packages/bumpy/test/core/release-plan-bundled-deps.test.ts @@ -4,7 +4,7 @@ import { DependencyGraph } from '../../src/core/dep-graph.ts'; import { makePkg, makeConfig } from '../helpers.ts'; import type { BumpFile } from '../../src/types.ts'; -describe('releaseDevDependencies — devDeps baked into published output', () => { +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' }]; @@ -23,7 +23,7 @@ describe('releaseDevDependencies — devDeps baked into published output', () => ['core', makePkg('core', '1.0.0')], [ 'app', - makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { releaseDevDependencies: ['core'] } }), + makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { releaseTriggeringDevDeps: ['core'] } }), ], ]); const graph = new DependencyGraph(packages); @@ -42,7 +42,7 @@ describe('releaseDevDependencies — devDeps baked into published output', () => ['core', makePkg('core', '1.0.0')], [ 'app', - makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { releaseDevDependencies: ['core'] } }), + makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { releaseTriggeringDevDeps: ['core'] } }), ], ]); const graph = new DependencyGraph(packages); @@ -57,7 +57,7 @@ describe('releaseDevDependencies — devDeps baked into published output', () => '@myorg/app', makePkg('@myorg/app', '2.0.0', { devDependencies: { '@myorg/core': '^1.0.0' }, - bumpy: { releaseDevDependencies: ['@myorg/*'] }, + bumpy: { releaseTriggeringDevDeps: ['@myorg/*'] }, }), ], ]); @@ -76,7 +76,7 @@ describe('releaseDevDependencies — devDeps baked into published output', () => 'app', makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, - bumpy: { releaseDevDependencies: ['other-lib'] }, + bumpy: { releaseTriggeringDevDeps: ['other-lib'] }, }), ], ]); @@ -93,7 +93,7 @@ describe('releaseDevDependencies — devDeps baked into published output', () => makePkg('app', '2.0.0', { devDependencies: { core: '^1.0.0' }, bumpy: { - releaseDevDependencies: ['core'], + releaseTriggeringDevDeps: ['core'], cascadeFrom: { core: { trigger: 'patch', bumpAs: 'match' } }, }, }),