From 95724bcbdf62f2fdb69986f28dab73cc25d7d810 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 5 May 2026 13:14:31 -0700 Subject: [PATCH 1/2] Add `cascadeFrom` config and simplify cascade API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add consumer-side `cascadeFrom` as the complement to `cascadeTo`, allowing packages to declare cascade relationships from either direction. Both now support an array shorthand with sensible defaults (trigger: "patch", bumpAs: "match") alongside the existing object form for custom rules. This solves the case where a package bundles a devDependency (e.g., astro-integration bundling vite-integration) and needs to re-release when that dependency is bumped — previously impossible with the default `updateInternalDependencies: "out-of-range"` setting since devDeps are skipped in Phase A and Phase C was gated on the global setting. --- docs/configuration.md | 27 ++- docs/version-propagation.md | 37 +++- packages/bumpy/config-schema.json | 50 ++++-- packages/bumpy/src/core/config.ts | 13 +- packages/bumpy/src/core/release-plan.ts | 94 +++++++--- packages/bumpy/src/types.ts | 28 ++- packages/bumpy/test/core/release-plan.test.ts | 168 ++++++++++++++++++ 7 files changed, 367 insertions(+), 50 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 2a20684..99976f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -100,6 +100,7 @@ Per-package settings can be defined in two places: | `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 }` | ### Custom commands and `allowCustomCommands` @@ -153,7 +154,18 @@ Or in the package's `package.json` (requires `allowCustomCommands`): } ``` -### Example: cascade from core to plugins +### Example: cascade from core to plugins (source-side) + +```json +{ + "name": "@myorg/core", + "bumpy": { + "cascadeTo": ["@myorg/plugin-*", "@myorg/cli"] + } +} +``` + +Or with custom trigger/bumpAs: ```json { @@ -166,6 +178,19 @@ Or in the package's `package.json` (requires `allowCustomCommands`): } ``` +### Example: cascade from 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: + +```json +{ + "name": "@myorg/astro-integration", + "bumpy": { + "cascadeFrom": ["@myorg/vite-integration"] + } +} +``` + ## Changelog formatters Set `changelog` in config to control how changelog entries are generated. Built-in options are `"default"` and `"github"`, or you can provide a path to a custom formatter module. Set to `false` to disable changelog generation entirely. diff --git a/docs/version-propagation.md b/docs/version-propagation.md index 2348f92..4547195 100644 --- a/docs/version-propagation.md +++ b/docs/version-propagation.md @@ -140,24 +140,49 @@ For example, a private app might want devDeps to propagate because they're bundl 2. `dependencyBumpRules[depType]` in root config 3. Built-in defaults _(least specific)_ -#### `cascadeTo` config +#### `cascadeTo` and `cascadeFrom` config -Configured on the source package in `package.json["bumpy"]` to push bumps to other packages when it bumps. Keys are package names or glob patterns: +These let you declare explicit cascade relationships between packages. Both always apply regardless of the `updateInternalDependencies` setting. + +- **`cascadeTo`** — configured on the _source_ package: "when I'm bumped, cascade to these packages" +- **`cascadeFrom`** — configured on the _consumer_ package: "when these packages are bumped, cascade to me" + +The simplest form is an array of package names or glob patterns. By default, any bump in the source triggers a matching bump in the target (e.g., minor→minor, patch→patch): + +```json +{ + "bumpy": { + "cascadeTo": ["@myorg/plugin-*", "@myorg/cli"] + } +} +``` + +```json +{ + "bumpy": { + "cascadeFrom": ["@myorg/vite-integration"] + } +} +``` + +For more control, use the object form with per-entry rules. Both `trigger` (default: `"patch"`) and `bumpAs` (default: `"match"`) are optional: ```json { "bumpy": { "cascadeTo": { - "@myorg/plugin-*": { "trigger": "minor", "bumpAs": "patch" }, - "@myorg/cli": { "trigger": "patch", "bumpAs": "patch" } + "@myorg/plugin-*": { "trigger": "minor", "bumpAs": "patch" } } } } ``` -Unlike dependency bump rules (configured on the _dependent_), `cascadeTo` is configured on the _source_ — useful for expressing "when I change, these downstream packages should also release." +| Field | Default | Description | +| --------- | --------- | -------------------------------------------------------------------------- | +| `trigger` | `"patch"` | Minimum bump level in the source that activates the cascade | +| `bumpAs` | `"match"` | What bump to apply to the target (`"match"` mirrors the source bump level) | -`cascadeTo` is checked separately from dependency bump rules and can add bumps beyond what the rules produce. All keys support glob patterns (`*`, `**`). +`cascadeFrom` is useful when a package bundles a dependency at build time (e.g., a devDependency that ends up in the published output) and should be re-released whenever that dependency changes. ### Per-bump-file overrides diff --git a/packages/bumpy/config-schema.json b/packages/bumpy/config-schema.json index 989b51a..a4359e7 100644 --- a/packages/bumpy/config-schema.json +++ b/packages/bumpy/config-schema.json @@ -222,6 +222,33 @@ "type": "string", "enum": ["major", "minor", "patch"] }, + "cascadeConfig": { + "oneOf": [ + { + "type": "array", + "description": "List of package names/glob patterns (defaults: trigger \"patch\", bumpAs \"match\")", + "items": { "type": "string" } + }, + { + "type": "object", + "description": "Package name/glob patterns mapped to cascade rules", + "additionalProperties": { + "type": "object", + "properties": { + "trigger": { + "$ref": "#/$defs/bumpType", + "description": "Minimum bump level that triggers the cascade (default: \"patch\")" + }, + "bumpAs": { + "description": "What level to bump the target (default: \"match\")", + "oneOf": [{ "$ref": "#/$defs/bumpType" }, { "type": "string", "const": "match" }] + } + }, + "additionalProperties": false + } + } + ] + }, "dependencyBumpRule": { "oneOf": [ { @@ -302,23 +329,12 @@ "additionalProperties": false }, "cascadeTo": { - "type": "object", - "description": "Explicit cascade targets — glob pattern mapped to { trigger, bumpAs }", - "additionalProperties": { - "type": "object", - "properties": { - "trigger": { - "$ref": "#/$defs/bumpType", - "description": "Minimum bump level that triggers the cascade" - }, - "bumpAs": { - "description": "What level to bump the target", - "oneOf": [{ "$ref": "#/$defs/bumpType" }, { "type": "string", "const": "match" }] - } - }, - "required": ["trigger", "bumpAs"], - "additionalProperties": false - } + "description": "Explicit cascade targets — when this package is bumped, cascade to matching packages.", + "$ref": "#/$defs/cascadeConfig" + }, + "cascadeFrom": { + "description": "Explicit cascade sources — when a matching package is bumped, cascade the bump to this package.", + "$ref": "#/$defs/cascadeConfig" } }, "additionalProperties": false diff --git a/packages/bumpy/src/core/config.ts b/packages/bumpy/src/core/config.ts index cb89d64..548206e 100644 --- a/packages/bumpy/src/core/config.ts +++ b/packages/bumpy/src/core/config.ts @@ -1,6 +1,6 @@ import { resolve } from 'node:path'; import { readJson, readJsonc, exists } from '../utils/fs.ts'; -import { type BumpyConfig, type PackageConfig, DEFAULT_CONFIG } from '../types.ts'; +import { type BumpyConfig, type PackageConfig, DEFAULT_CONFIG, normalizeCascadeConfig } from '../types.ts'; const BUMPY_DIR = '.bumpy'; const CONFIG_FILE = '_config.json'; @@ -134,7 +134,16 @@ function mergePackageConfig(...configs: PackageConfig[]): PackageConfig { result.dependencyBumpRules = { ...result.dependencyBumpRules, ...cfg.dependencyBumpRules }; } if (cfg.cascadeTo) { - result.cascadeTo = { ...result.cascadeTo, ...cfg.cascadeTo }; + result.cascadeTo = { + ...(result.cascadeTo ? normalizeCascadeConfig(result.cascadeTo) : {}), + ...normalizeCascadeConfig(cfg.cascadeTo), + }; + } + if (cfg.cascadeFrom) { + result.cascadeFrom = { + ...(result.cascadeFrom ? normalizeCascadeConfig(result.cascadeFrom) : {}), + ...normalizeCascadeConfig(cfg.cascadeFrom), + }; } } return result; diff --git a/packages/bumpy/src/core/release-plan.ts b/packages/bumpy/src/core/release-plan.ts index a30ee3f..b1ad8b6 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, + normalizeCascadeConfig, type DepType, type PlannedRelease, type ReleasePlan, @@ -250,20 +251,17 @@ export function assembleReleasePlan( // C2: Apply source-side cascadeTo config const pkg = packages.get(pkgName); - const cascadeTo = pkg?.bumpy?.cascadeTo; - if (cascadeTo) { - for (const [pattern, rule] of Object.entries(cascadeTo)) { - if (!shouldTrigger(bump.type, rule.trigger)) continue; - const cascadeBump = rule.bumpAs === 'match' ? bump.type : rule.bumpAs; - for (const [targetName] of packages) { - if (!matchGlob(targetName, pattern)) continue; - if (applyBump(planned, targetName, cascadeBump, false, true, pkgName)) { - changed = true; - } - } + if (pkg?.bumpy?.cascadeTo) { + if (applyCascadeRules(normalizeCascadeConfig(pkg.bumpy.cascadeTo), pkgName, bump.type, packages, planned)) { + changed = true; } } + // C2b: Apply consumer-side cascadeFrom config + if (applyCascadeFrom(pkgName, bump.type, packages, planned)) { + changed = true; + } + // C3: Apply dependency graph proactive propagation const dependents = depGraph.getDependents(pkgName); for (const dep of dependents) { @@ -278,7 +276,7 @@ export function assembleReleasePlan( } } } else { - // Even in out-of-range mode, still apply bump file cascades and cascadeTo + // Even in out-of-range mode, still apply bump file cascades, cascadeTo, and cascadeFrom for (const [pkgName, bump] of planned) { // Bump-file-level cascade overrides always apply const bfOverrides = cascadeOverrides.get(pkgName); @@ -295,19 +293,16 @@ export function assembleReleasePlan( // Source-side cascadeTo config always applies const pkg = packages.get(pkgName); - const cascadeTo = pkg?.bumpy?.cascadeTo; - if (cascadeTo) { - for (const [pattern, rule] of Object.entries(cascadeTo)) { - if (!shouldTrigger(bump.type, rule.trigger)) continue; - const cascadeBump = rule.bumpAs === 'match' ? bump.type : rule.bumpAs; - for (const [targetName] of packages) { - if (!matchGlob(targetName, pattern)) continue; - if (applyBump(planned, targetName, cascadeBump, false, true, pkgName)) { - changed = true; - } - } + if (pkg?.bumpy?.cascadeTo) { + if (applyCascadeRules(normalizeCascadeConfig(pkg.bumpy.cascadeTo), pkgName, bump.type, packages, planned)) { + changed = true; } } + + // Consumer-side cascadeFrom config always applies + if (applyCascadeFrom(pkgName, bump.type, packages, planned)) { + changed = true; + } } } } @@ -388,6 +383,59 @@ function applyBump( return true; } +/** + * Apply normalized cascade rules (used for both cascadeTo and cascadeFrom). + * Keys in `rules` are target package name/glob patterns. + * Returns true if any bump was applied. + */ +function applyCascadeRules( + rules: Record>, + sourceName: string, + sourceType: BumpType, + packages: Map, + planned: Map, +): boolean { + let changed = false; + for (const [pattern, rule] of Object.entries(rules)) { + if (!shouldTrigger(sourceType, rule.trigger)) continue; + const cascadeBump = rule.bumpAs === 'match' ? sourceType : rule.bumpAs; + for (const [targetName] of packages) { + if (!matchGlob(targetName, pattern)) continue; + if (applyBump(planned, targetName, cascadeBump, false, true, sourceName)) { + changed = true; + } + } + } + return changed; +} + +/** + * Apply consumer-side cascadeFrom rules. + * Scans all packages for cascadeFrom entries where the pattern matches the bumped source. + * Returns true if any bump was applied. + */ +function applyCascadeFrom( + sourceName: string, + sourceType: BumpType, + packages: Map, + planned: Map, +): boolean { + let changed = false; + for (const [targetName, targetPkg] of packages) { + if (!targetPkg.bumpy?.cascadeFrom) continue; + const rules = normalizeCascadeConfig(targetPkg.bumpy.cascadeFrom); + for (const [pattern, rule] of Object.entries(rules)) { + if (!matchGlob(sourceName, pattern)) continue; + if (!shouldTrigger(sourceType, rule.trigger)) continue; + const cascadeBump: BumpType = rule.bumpAs === 'match' ? sourceType : rule.bumpAs; + if (applyBump(planned, targetName, cascadeBump, false, true, sourceName)) { + changed = true; + } + } + } + return changed; +} + /** Check if a bump level meets the trigger threshold */ function shouldTrigger(bumpType: BumpType, trigger: BumpType): boolean { return bumpLevel(bumpType) >= bumpLevel(trigger); diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index d017ce9..5181ab6 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -27,6 +27,31 @@ export interface DependencyBumpRule { bumpAs: BumpType | 'match'; } +export interface CascadeRule { + /** What bump level in the source triggers the cascade. Default: "patch" (any bump) */ + trigger?: BumpType; + /** What bump to apply to the target. Default: "match" (same as the source bump level) */ + bumpAs?: BumpType | 'match'; +} + +/** Input type for cascadeTo/cascadeFrom — array of names/globs, or object with per-entry rules */ +export type CascadeConfig = string[] | Record; + +/** Normalize CascadeConfig into a consistent Record form with defaults applied */ +export function normalizeCascadeConfig(config: CascadeConfig): Record> { + const result: Record> = {}; + if (Array.isArray(config)) { + for (const name of config) { + result[name] = { trigger: 'patch', bumpAs: 'match' }; + } + } else { + for (const [name, rule] of Object.entries(config)) { + result[name] = { trigger: rule.trigger ?? 'patch', bumpAs: rule.bumpAs ?? 'match' }; + } + } + return result; +} + export const DEFAULT_BUMP_RULES: Record = { dependencies: { trigger: 'patch', bumpAs: 'patch' }, peerDependencies: { trigger: 'major', bumpAs: 'match' }, @@ -124,7 +149,8 @@ export interface PackageConfig { /** Glob patterns to filter which changed files count toward marking this package as changed */ changedFilePatterns?: string[]; dependencyBumpRules?: Partial>; - cascadeTo?: Record; + cascadeTo?: CascadeConfig; + cascadeFrom?: CascadeConfig; } export const DEFAULT_PUBLISH_CONFIG: PublishConfig = { diff --git a/packages/bumpy/test/core/release-plan.test.ts b/packages/bumpy/test/core/release-plan.test.ts index c82b6c8..89c9f5f 100644 --- a/packages/bumpy/test/core/release-plan.test.ts +++ b/packages/bumpy/test/core/release-plan.test.ts @@ -774,6 +774,174 @@ describe('assembleReleasePlan', () => { }); }); + // ---- cascadeFrom ---- + + describe('cascadeFrom', () => { + test('cascadeFrom array shorthand (bundled devDep scenario)', () => { + const packages = new Map([ + ['@scope/vite-integration', makePkg('@scope/vite-integration', '1.1.0')], + [ + '@scope/astro-integration', + makePkg('@scope/astro-integration', '1.0.0', { + devDependencies: { '@scope/vite-integration': 'workspace:*' }, + bumpy: { + cascadeFrom: ['@scope/vite-integration'], + }, + }), + ], + // package without cascadeFrom should NOT be affected + [ + '@scope/other-pkg', + makePkg('@scope/other-pkg', '1.0.0', { + devDependencies: { '@scope/vite-integration': 'workspace:*' }, + }), + ], + ]); + + const bumpFiles: BumpFile[] = [ + { id: 'cs1', releases: [{ name: '@scope/vite-integration', type: 'patch' }], summary: 'Fix' }, + ]; + + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(bumpFiles, packages, graph, makeConfig()); + + expect(plan.releases).toHaveLength(2); + const astroRelease = plan.releases.find((r) => r.name === '@scope/astro-integration')!; + expect(astroRelease).toBeDefined(); + expect(astroRelease.type).toBe('patch'); // match (default) → patch + expect(astroRelease.isCascadeBump).toBe(true); + expect(astroRelease.bumpSources).toEqual([ + { name: '@scope/vite-integration', newVersion: '1.1.1', bumpType: 'patch' }, + ]); + expect(plan.releases.find((r) => r.name === '@scope/other-pkg')).toBeUndefined(); + }); + + test('cascadeFrom array shorthand defaults to match (minor → minor)', () => { + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + [ + 'wrapper', + makePkg('wrapper', '1.0.0', { + bumpy: { + cascadeFrom: ['core'], + }, + }), + ], + ]); + + const bumpFiles: BumpFile[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'minor' }], summary: 'Feature' }]; + + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(bumpFiles, packages, graph, makeConfig()); + + expect(plan.releases).toHaveLength(2); + expect(plan.releases.find((r) => r.name === 'wrapper')!.type).toBe('minor'); + }); + + test('cascadeFrom respects trigger threshold', () => { + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + [ + 'wrapper', + makePkg('wrapper', '1.0.0', { + bumpy: { + cascadeFrom: { + core: { trigger: 'minor', bumpAs: 'patch' }, + }, + }, + }), + ], + ]); + + // Patch bump — below minor trigger threshold + const bumpFiles: BumpFile[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'patch' }], summary: 'Fix' }]; + + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(bumpFiles, packages, graph, makeConfig()); + + expect(plan.releases).toHaveLength(1); + expect(plan.releases[0]!.name).toBe('core'); + }); + + test('cascadeFrom with glob pattern', () => { + const packages = new Map([ + ['@scope/core', makePkg('@scope/core', '1.0.0')], + ['@scope/utils', makePkg('@scope/utils', '1.0.0')], + [ + 'meta-pkg', + makePkg('meta-pkg', '1.0.0', { + bumpy: { + cascadeFrom: { + '@scope/*': { trigger: 'patch', bumpAs: 'patch' }, + }, + }, + }), + ], + ]); + + const bumpFiles: BumpFile[] = [{ id: 'cs1', releases: [{ name: '@scope/core', type: 'patch' }], summary: 'Fix' }]; + + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(bumpFiles, packages, graph, makeConfig()); + + expect(plan.releases).toHaveLength(2); + expect(plan.releases.find((r) => r.name === 'meta-pkg')).toBeDefined(); + }); + + test('cascadeFrom works with proactive propagation mode too', () => { + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + [ + 'wrapper', + makePkg('wrapper', '1.0.0', { + bumpy: { + cascadeFrom: ['core'], + }, + }), + ], + ]); + + const bumpFiles: BumpFile[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'minor' }], summary: 'Feature' }]; + + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(bumpFiles, packages, graph, makeConfig({ updateInternalDependencies: 'patch' })); + + expect(plan.releases).toHaveLength(2); + expect(plan.releases.find((r) => r.name === 'wrapper')!.type).toBe('minor'); + }); + + test('cascadeFrom and cascadeTo can coexist', () => { + const packages = new Map([ + [ + 'core', + makePkg('core', '1.0.0', { + bumpy: { + cascadeTo: ['plugin-a'], + }, + }), + ], + ['plugin-a', makePkg('plugin-a', '1.0.0')], + [ + 'plugin-b', + makePkg('plugin-b', '1.0.0', { + bumpy: { + cascadeFrom: ['core'], + }, + }), + ], + ]); + + const bumpFiles: BumpFile[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'patch' }], summary: 'Fix' }]; + + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(bumpFiles, packages, graph, makeConfig()); + + expect(plan.releases).toHaveLength(3); + expect(plan.releases.find((r) => r.name === 'plugin-a')).toBeDefined(); + expect(plan.releases.find((r) => r.name === 'plugin-b')).toBeDefined(); + }); + }); + // ---- none edge cases ---- describe('none edge cases', () => { From 24ede3cae650d01670913582931fd0c52b5fad20 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 5 May 2026 13:14:52 -0700 Subject: [PATCH 2/2] Add bump file for cascadeFrom feature --- .bumpy/cascade-from.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .bumpy/cascade-from.md diff --git a/.bumpy/cascade-from.md b/.bumpy/cascade-from.md new file mode 100644 index 0000000..00cdddc --- /dev/null +++ b/.bumpy/cascade-from.md @@ -0,0 +1,7 @@ +--- +'@varlock/bumpy': minor +--- + +Add `cascadeFrom` config and simplify cascade API + +Added consumer-side `cascadeFrom` as the complement to `cascadeTo`, allowing packages to declare cascade relationships from either direction. Both now support an array shorthand with sensible defaults (trigger: "patch", bumpAs: "match") alongside the object form for custom rules.