Skip to content

Commit 2e91e3a

Browse files
authored
feat: directBump:false package flag + fixed-group sync-to-highest (#155)
## What Two related changes for modeling platform-specific binary packages that version in lockstep with a core package (the esbuild/napi-rs pattern): ### `directBump: false` per-package config A package marked `directBump: false` can only receive **propagated** bumps (fixed/linked group, cascade, dependency) — never direct ones: - `bumpy add` excludes it from the interactive prompt, `--none` generation, and rejects it in `--packages` - `bumpy generate` never suggests it from commit scopes or changed files - a bump file that directly names it (with a type other than `none`) is an error at plan time, pointing at the driving package - `bumpy check` counts its changes as covered when any other fixed-group member is covered, and the missing-package output hints at the bumpable group members Typical config: ```jsonc { "fixed": [["mycli", "@mycli/bin-*"]], "packages": { "@mycli/bin-*": { "directBump": false } } } ``` ### Fixed groups sync to highest Previously Phase B only equalized the bump *type* — each member bumped from its own current version, so a drifted group (botched manual publish, package added late) never reconverged. Members now version from the group's **highest** current version: Phase A range checks, planned versions, and bump-source reporting all use the synced target, and the plan warns when drift is detected so the version jump is explained. ## Testing - New release-plan tests: drift convergence, no-drift no-warning, Phase A range checks against synced versions, group bumps flowing to `directBump: false` members, direct-bump rejection, `none` allowed - New check tests for the coverage-remap helper - 427 tests pass; `tsc --noEmit` clean; `bumpy check` dogfooded on this repo Docs updated (configuration.md, version-propagation.md, config-schema.json, add-change skill).
1 parent c97d3d3 commit 2e91e3a

13 files changed

Lines changed: 347 additions & 30 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@varlock/bumpy': minor
3+
---
4+
5+
Added `directBump: false` per-package config for packages that only receive propagated bumps (e.g. platform binary packages in a fixed group with their core package) — they are excluded from `bumpy add`/`bumpy generate`, rejected when a bump file names them directly, and `bumpy check` points at their fixed-group members instead. Fixed groups now sync drifted members to a bump of the group's highest version so they reconverge.

docs/configuration.md

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -174,20 +174,42 @@ Per-package settings can be defined in two places:
174174

175175
`package.json` settings take precedence over global config.
176176

177-
| Option | Type | Description |
178-
| -------------------------- | -------------------------- | -------------------------------------------------------------------------------------- |
179-
| `managed` | `boolean` | Opt this package in or out of versioning |
180-
| `access` | `"public" \| "restricted"` | Override the global access level |
181-
| `publishCommand` | `string \| string[]` | Custom command(s) to publish this package (replaces npm publish) |
182-
| `buildCommand` | `string` | Command to run before publishing |
183-
| `registry` | `string` | Custom npm registry URL |
184-
| `skipNpmPublish` | `boolean` | Don't publish to npm (still creates git tags) |
185-
| `checkPublished` | `string` | Custom command that outputs the currently published version |
186-
| `changedFilePatterns` | `string[]` | Glob patterns for changed-file detection (replaces root setting, not merged) |
187-
| `dependencyBumpRules` | `object` | Per-package override for dependency propagation rules |
188-
| `cascadeTo` | `object` | Explicit cascade targets — glob pattern mapped to `{ trigger, bumpAs }` |
189-
| `cascadeFrom` | `object` | Explicit cascade sources — glob pattern mapped to `{ trigger, bumpAs }` |
190-
| `releaseTriggeringDevDeps` | `string[]` | devDependencies that affect published output — a change requires a release (see below) |
177+
| Option | Type | Description |
178+
| -------------------------- | -------------------------- | ---------------------------------------------------------------------------------------- |
179+
| `managed` | `boolean` | Opt this package in or out of versioning |
180+
| `directBump` | `boolean` | When `false`, the package only receives propagated bumps — never direct ones (see below) |
181+
| `access` | `"public" \| "restricted"` | Override the global access level |
182+
| `publishCommand` | `string \| string[]` | Custom command(s) to publish this package (replaces npm publish) |
183+
| `buildCommand` | `string` | Command to run before publishing |
184+
| `registry` | `string` | Custom npm registry URL |
185+
| `skipNpmPublish` | `boolean` | Don't publish to npm (still creates git tags) |
186+
| `checkPublished` | `string` | Custom command that outputs the currently published version |
187+
| `changedFilePatterns` | `string[]` | Glob patterns for changed-file detection (replaces root setting, not merged) |
188+
| `dependencyBumpRules` | `object` | Per-package override for dependency propagation rules |
189+
| `cascadeTo` | `object` | Explicit cascade targets — glob pattern mapped to `{ trigger, bumpAs }` |
190+
| `cascadeFrom` | `object` | Explicit cascade sources — glob pattern mapped to `{ trigger, bumpAs }` |
191+
| `releaseTriggeringDevDeps` | `string[]` | devDependencies that affect published output — a change requires a release (see below) |
192+
193+
### `directBump: false` — packages that only follow
194+
195+
Some packages are derived artifacts of another package and should never be bumped on their own — the typical case is platform-specific binary packages published alongside a core package (the esbuild/napi-rs pattern, where the core references each binary via exact-version `optionalDependencies`).
196+
197+
Put the binaries in a `fixed` group with the core so they version in lockstep, and mark them `directBump: false` so they can only receive propagated bumps:
198+
199+
```jsonc
200+
{
201+
"fixed": [["mycli", "@mycli/bin-*"]],
202+
"packages": {
203+
"@mycli/bin-*": { "directBump": false },
204+
},
205+
}
206+
```
207+
208+
Effects:
209+
210+
- `bumpy add` and `bumpy generate` never select or suggest them — you only ever bump `mycli`, and the fixed group pulls the binaries along.
211+
- A bump file that directly names one (with a type other than `none`) is an error at plan time.
212+
- `bumpy check` treats changes in a `directBump: false` package as covered when a bump file covers any other member of its fixed group, and points there when one is missing.
191213

192214
### Custom commands and `allowCustomCommands`
193215

docs/version-propagation.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ Packages in a `fixed` group always share the **same version number**. When any p
7373

7474
Example: propagation bumps `@myorg/types` as patch → `@myorg/core` also gets a patch bump to stay in sync.
7575

76+
Group members version from the group's **highest current version**, not each package's own. If versions have drifted (a botched manual publish, a package added to the group late), the next release syncs every member to a bump of the highest version and reconverges the group — with a warning in the plan explaining the jump.
77+
78+
For groups where one package drives and the others only follow (e.g. platform binary packages alongside a core package), mark the followers with [`directBump: false`](configuration.md#directbump-false--packages-that-only-follow) so they can never be bumped directly — only pulled along by the group.
79+
7680
### Linked groups
7781

7882
Packages in a `linked` group share the **same bump level** but keep independent version numbers. Only packages already in the release plan are affected — linked groups don't pull in packages that have no bump files. Entries can be specific names or glob patterns.

packages/bumpy/config-schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,10 @@
318318
"type": "boolean",
319319
"description": "Explicitly opt this package in or out of version management"
320320
},
321+
"directBump": {
322+
"type": "boolean",
323+
"description": "When false, this package can never be bumped directly by a bump file — it only receives propagated bumps (fixed/linked group, cascade, dependency). Use for derived artifacts like platform binary packages kept in a fixed group with their core package."
324+
},
321325
"access": {
322326
"type": "string",
323327
"enum": ["public", "restricted"],

packages/bumpy/src/commands/add.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
4040
if (opts.none) {
4141
const { packages } = await discoverWorkspace(rootDir, config);
4242
const changedFiles = getChangedFiles(rootDir, config.baseBranch);
43-
const changedPackages = await findChangedPackages(changedFiles, packages, rootDir, config);
43+
const changedPackages = (await findChangedPackages(changedFiles, packages, rootDir, config)).filter(
44+
(name) => packages.get(name)?.bumpy?.directBump !== false,
45+
);
4446

4547
if (changedPackages.length === 0) {
4648
log.info('No changed packages detected.');
@@ -71,6 +73,15 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
7173
if (opts.packages) {
7274
// Non-interactive mode
7375
releases = parsePackagesFlag(opts.packages);
76+
const { packages: pkgs } = await discoverWorkspace(rootDir, config);
77+
for (const r of releases) {
78+
if (r.type !== 'none' && pkgs.get(r.name)?.bumpy?.directBump === false) {
79+
throw new Error(
80+
`"${r.name}" has "directBump": false — it only receives propagated bumps ` +
81+
'(fixed/linked group, cascade, dependency). Bump the package that drives it instead.',
82+
);
83+
}
84+
}
7485
summary = opts.message || '';
7586
filename = opts.name ? slugify(opts.name) : randomName();
7687
} else {
@@ -97,8 +108,14 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
97108
}
98109
}
99110

100-
// Build items for the bump select prompt
101-
const bumpSelectItems: BumpSelectItem[] = [...pkgs.values()].map((pkg) => {
111+
// Build items for the bump select prompt (directBump: false packages are never
112+
// directly bumpable — they only follow their group/cascade sources)
113+
const selectablePkgs = [...pkgs.values()].filter((pkg) => pkg.bumpy?.directBump !== false);
114+
if (selectablePkgs.length === 0) {
115+
p.cancel('All managed packages have "directBump": false — nothing to select.');
116+
process.exit(1);
117+
}
118+
const bumpSelectItems: BumpSelectItem[] = selectablePkgs.map((pkg) => {
102119
const item: BumpSelectItem = {
103120
name: pkg.name,
104121
version: pkg.version,

packages/bumpy/src/commands/check.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { relative, resolve } from 'node:path';
22
import picomatch from 'picomatch';
33
import { log, colorize } from '../utils/logger.ts';
4-
import { loadConfig, loadPackageConfig, getBumpyDir, matchGlob } from '../core/config.ts';
4+
import { loadConfig, loadPackageConfig, getBumpyDir, matchGlob, resolveFixedGroups } from '../core/config.ts';
55
import { discoverWorkspace } from '../core/workspace.ts';
66
import { readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts';
77
import { getChangedFiles, getFileStatuses, getBaseCompareRef, readFileAtRef } from '../core/git.ts';
@@ -163,8 +163,15 @@ export async function checkCommand(rootDir: string, opts: CheckOptions = {}): Pr
163163
return;
164164
}
165165

166-
// Check which changed packages are missing bump files
167-
const missing = changedPackages.filter((name) => !coveredPackages.has(name));
166+
// Check which changed packages are missing bump files. Packages with
167+
// directBump: false can't have their own bump file — they count as covered
168+
// when a fixed-group member is covered, and otherwise point there.
169+
const { missing, hints } = resolveDirectBumpCoverage(
170+
changedPackages.filter((name) => !coveredPackages.has(name)),
171+
coveredPackages,
172+
packages,
173+
config,
174+
);
168175

169176
// An empty bump file covers all remaining packages (in non-strict mode)
170177
// It acts as a blanket acknowledgment that non-publishable changes are expected
@@ -195,7 +202,8 @@ export async function checkCommand(rootDir: string, opts: CheckOptions = {}): Pr
195202

196203
(willFail ? log.error : log.warn)(`${missing.length} changed package(s) missing bump files:\n`);
197204
for (const name of missing) {
198-
console.log(` ${colorize(name, 'yellow')}`);
205+
const hint = hints.get(name);
206+
console.log(` ${colorize(name, 'yellow')}${hint ? ` — ${hint}` : ''}`);
199207
}
200208

201209
if (effectiveBumpFiles.length > 0) {
@@ -237,6 +245,42 @@ function printBumpFileList(
237245
}
238246
}
239247

248+
/**
249+
* Coverage adjustment for `directBump: false` packages. Such a package never gets its
250+
* own bump file — its changes ship by bumping another member of its fixed group. So a
251+
* missing directBump package counts as covered when any other fixed-group member is
252+
* covered; otherwise it stays missing, with a hint pointing at the bumpable members.
253+
*/
254+
export function resolveDirectBumpCoverage(
255+
missing: string[],
256+
covered: Set<string>,
257+
packages: Map<string, WorkspacePackage>,
258+
config: BumpyConfig,
259+
): { missing: string[]; hints: Map<string, string> } {
260+
const fixedGroups = resolveFixedGroups(config, packages.keys());
261+
const stillMissing: string[] = [];
262+
const hints = new Map<string, string>();
263+
264+
for (const name of missing) {
265+
if (packages.get(name)?.bumpy?.directBump !== false) {
266+
stillMissing.push(name);
267+
continue;
268+
}
269+
const group = fixedGroups.find((members) => members.includes(name));
270+
if (group?.some((member) => member !== name && covered.has(member))) continue;
271+
272+
stillMissing.push(name);
273+
const bumpable = (group ?? []).filter((m) => m !== name && packages.get(m)?.bumpy?.directBump !== false);
274+
hints.set(
275+
name,
276+
bumpable.length > 0
277+
? `has directBump: false — add a bump for its fixed-group member ${bumpable.join(' or ')} instead`
278+
: 'has directBump: false — it only receives propagated bumps; add it to a fixed group or bump its cascade source',
279+
);
280+
}
281+
return { missing: stillMissing, hints };
282+
}
283+
240284
/** Map changed files to the packages they belong to */
241285
export async function findChangedPackages(
242286
changedFiles: string[],

packages/bumpy/src/commands/generate.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P
6868
// Build scope → package name mapping for CC resolution
6969
const scopeMap = buildScopeMap(packages, config);
7070

71+
// Packages with directBump: false only receive propagated bumps — never suggest them
72+
const isDirectlyBumpable = (name: string) => packages.get(name)?.bumpy?.directBump !== false;
73+
7174
// Collect releases from all commits
7275
const releaseMap = new Map<string, { type: BumpType; messages: string[] }>();
7376

@@ -84,7 +87,7 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P
8487

8588
let pkgNames: string[] = [];
8689
if (cc.scope) {
87-
const resolved = resolveScope(cc.scope, scopeMap, packages);
90+
const resolved = resolveScope(cc.scope, scopeMap, packages).filter(isDirectlyBumpable);
8891
if (resolved.length > 0) {
8992
pkgNames = resolved;
9093
}
@@ -101,7 +104,7 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P
101104
// CC commit but scope didn't resolve (or no scope) — use file-based detection
102105
// with the CC-derived bump level
103106
const files = getFilesChangedInCommit(commit.hash, { cwd: rootDir });
104-
const touchedPkgs = mapFilesToPackages(files, packages, rootDir);
107+
const touchedPkgs = mapFilesToPackages(files, packages, rootDir).filter(isDirectlyBumpable);
105108

106109
if (touchedPkgs.length > 0) {
107110
for (const name of touchedPkgs) {
@@ -113,7 +116,7 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P
113116
} else {
114117
// Non-conventional commit — use file paths to detect packages, default to patch
115118
const files = getFilesChangedInCommit(commit.hash, { cwd: rootDir });
116-
const touchedPkgs = mapFilesToPackages(files, packages, rootDir);
119+
const touchedPkgs = mapFilesToPackages(files, packages, rootDir).filter(isDirectlyBumpable);
117120

118121
if (touchedPkgs.length > 0) {
119122
fileBasedCount++;

packages/bumpy/src/core/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ function findPackageConfig(config: BumpyConfig, pkgName: string): PackageConfig
8888
return {};
8989
}
9090

91+
/**
92+
* Resolve `config.fixed` glob groups to concrete package-name groups.
93+
* Returns one entry per configured group (possibly empty if nothing matches).
94+
*/
95+
export function resolveFixedGroups(config: BumpyConfig, packageNames: Iterable<string>): string[][] {
96+
const names = [...packageNames];
97+
return config.fixed.map((group) => names.filter((name) => group.some((pattern) => matchGlob(name, pattern))));
98+
}
99+
91100
/** Simple glob matching for package names (supports * and **) */
92101
export function matchGlob(name: string, pattern: string): boolean {
93102
// Exact match

0 commit comments

Comments
 (0)