Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/direct-bump-and-fixed-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@varlock/bumpy': minor
---

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.
50 changes: 36 additions & 14 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,20 +174,42 @@ 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 }` |
| `releaseTriggeringDevDeps` | `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 |
| `directBump` | `boolean` | When `false`, the package only receives propagated bumps — never direct ones (see below) |
| `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) |

### `directBump: false` — packages that only follow

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`).

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:

```jsonc
{
"fixed": [["mycli", "@mycli/bin-*"]],
"packages": {
"@mycli/bin-*": { "directBump": false },
},
}
```

Effects:

- `bumpy add` and `bumpy generate` never select or suggest them — you only ever bump `mycli`, and the fixed group pulls the binaries along.
- A bump file that directly names one (with a type other than `none`) is an error at plan time.
- `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.

### Custom commands and `allowCustomCommands`

Expand Down
4 changes: 4 additions & 0 deletions docs/version-propagation.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ Packages in a `fixed` group always share the **same version number**. When any p

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

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.

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.

### Linked groups

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.
Expand Down
4 changes: 4 additions & 0 deletions packages/bumpy/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,10 @@
"type": "boolean",
"description": "Explicitly opt this package in or out of version management"
},
"directBump": {
"type": "boolean",
"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."
},
"access": {
"type": "string",
"enum": ["public", "restricted"],
Expand Down
23 changes: 20 additions & 3 deletions packages/bumpy/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
if (opts.none) {
const { packages } = await discoverWorkspace(rootDir, config);
const changedFiles = getChangedFiles(rootDir, config.baseBranch);
const changedPackages = await findChangedPackages(changedFiles, packages, rootDir, config);
const changedPackages = (await findChangedPackages(changedFiles, packages, rootDir, config)).filter(
(name) => packages.get(name)?.bumpy?.directBump !== false,
);

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

// Build items for the bump select prompt
const bumpSelectItems: BumpSelectItem[] = [...pkgs.values()].map((pkg) => {
// Build items for the bump select prompt (directBump: false packages are never
// directly bumpable — they only follow their group/cascade sources)
const selectablePkgs = [...pkgs.values()].filter((pkg) => pkg.bumpy?.directBump !== false);
if (selectablePkgs.length === 0) {
p.cancel('All managed packages have "directBump": false — nothing to select.');
process.exit(1);
}
const bumpSelectItems: BumpSelectItem[] = selectablePkgs.map((pkg) => {
const item: BumpSelectItem = {
name: pkg.name,
version: pkg.version,
Expand Down
52 changes: 48 additions & 4 deletions packages/bumpy/src/commands/check.ts
Original file line number Diff line number Diff line change
@@ -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, matchGlob } from '../core/config.ts';
import { loadConfig, loadPackageConfig, getBumpyDir, matchGlob, resolveFixedGroups } 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';
Expand Down Expand Up @@ -163,8 +163,15 @@ export async function checkCommand(rootDir: string, opts: CheckOptions = {}): Pr
return;
}

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

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

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

if (effectiveBumpFiles.length > 0) {
Expand Down Expand Up @@ -237,6 +245,42 @@ function printBumpFileList(
}
}

/**
* Coverage adjustment for `directBump: false` packages. Such a package never gets its
* own bump file — its changes ship by bumping another member of its fixed group. So a
* missing directBump package counts as covered when any other fixed-group member is
* covered; otherwise it stays missing, with a hint pointing at the bumpable members.
*/
export function resolveDirectBumpCoverage(
missing: string[],
covered: Set<string>,
packages: Map<string, WorkspacePackage>,
config: BumpyConfig,
): { missing: string[]; hints: Map<string, string> } {
const fixedGroups = resolveFixedGroups(config, packages.keys());
const stillMissing: string[] = [];
const hints = new Map<string, string>();

for (const name of missing) {
if (packages.get(name)?.bumpy?.directBump !== false) {
stillMissing.push(name);
continue;
}
const group = fixedGroups.find((members) => members.includes(name));
if (group?.some((member) => member !== name && covered.has(member))) continue;

stillMissing.push(name);
const bumpable = (group ?? []).filter((m) => m !== name && packages.get(m)?.bumpy?.directBump !== false);
hints.set(
name,
bumpable.length > 0
? `has directBump: false — add a bump for its fixed-group member ${bumpable.join(' or ')} instead`
: 'has directBump: false — it only receives propagated bumps; add it to a fixed group or bump its cascade source',
);
}
return { missing: stillMissing, hints };
}

/** Map changed files to the packages they belong to */
export async function findChangedPackages(
changedFiles: string[],
Expand Down
9 changes: 6 additions & 3 deletions packages/bumpy/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P
// Build scope → package name mapping for CC resolution
const scopeMap = buildScopeMap(packages, config);

// Packages with directBump: false only receive propagated bumps — never suggest them
const isDirectlyBumpable = (name: string) => packages.get(name)?.bumpy?.directBump !== false;

// Collect releases from all commits
const releaseMap = new Map<string, { type: BumpType; messages: string[] }>();

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

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

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

if (touchedPkgs.length > 0) {
fileBasedCount++;
Expand Down
9 changes: 9 additions & 0 deletions packages/bumpy/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ function findPackageConfig(config: BumpyConfig, pkgName: string): PackageConfig
return {};
}

/**
* Resolve `config.fixed` glob groups to concrete package-name groups.
* Returns one entry per configured group (possibly empty if nothing matches).
*/
export function resolveFixedGroups(config: BumpyConfig, packageNames: Iterable<string>): string[][] {
const names = [...packageNames];
return config.fixed.map((group) => names.filter((name) => group.some((pattern) => matchGlob(name, pattern))));
}

/** Simple glob matching for package names (supports * and **) */
export function matchGlob(name: string, pattern: string): boolean {
// Exact match
Expand Down
Loading
Loading