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/package-source-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@varlock/bumpy': minor
---

New per-package `sourcePaths` option: globs (relative to the package dir like other per-package globs, `../` to reach out, `!` to exclude) for sources outside a package's directory. A changed file matching one marks the package as changed, so `bumpy check` requires a bump file for it and `bumpy generate` attributes the commit to it — e.g. a native-binary package built from a separate source package.
25 changes: 24 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ These are two different things, and bumpy treats them differently:

### 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.
A package is "changed" (and so needs a bump file) when a changed file inside it matches `changedFilePatterns`, or a changed file anywhere in the repo matches its [`sourcePaths`](#sourcepaths--sources-outside-the-package-directory). `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.

Expand Down Expand Up @@ -176,6 +176,8 @@ Per-package settings can be defined in two places:

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

Globs in per-package options (`changedFilePatterns`, `sourcePaths`) are always relative to the package's directory, wherever they're declared.

| Option | Type | Description |
| -------------------------- | -------------------------- | ---------------------------------------------------------------------------------------- |
| `managed` | `boolean` | Opt this package in or out of versioning |
Expand All @@ -187,11 +189,32 @@ Per-package settings can be defined in two places:
| `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) |
| `sourcePaths` | `string[]` | Globs for sources outside the package dir (see below) |
| `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) |

### `sourcePaths` — sources outside the package directory

Change detection normally only looks at files inside a package's directory. When a package is built from sources that live elsewhere — e.g. a published native-binary package whose binary is compiled from a separate (private) package — list those sources in `sourcePaths`. A changed file matching one marks the package as changed exactly as if it lived inside it, so `bumpy check` (and the CI check / PR comment) requires a bump file for it and `bumpy generate` attributes the commit to it.

```jsonc
{
"packages": {
"@mycli/native-darwin": {
// @mycli/native-darwin lives in packages/native/darwin
"sourcePaths": ["../../native-src-swift/**", "!../../native-src-swift/**/*.md"],
},
},
}
```

- Globs are relative to the **package's directory**, like every per-package glob (`changedFilePatterns` too), whether declared in the root `packages` map (by name or glob) or in the package's `package.json` `"bumpy"` field. Use `../` to reach sources outside it.
- Patterns prefixed with `!` exclude — a file counts when it matches any positive pattern and no negated one. Exclusions are package-relative too, so `"!**/*.md"` would only exclude markdown inside the package itself; anchor them to the source dir as above.
- `sourcePaths` is additive: `changedFilePatterns` still filters only the files inside the package's own directory and does not apply to `sourcePaths` matches.
- A file can mark several packages as changed (the source package itself, if versioned, and every package listing it).

### `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`).
Expand Down
5 changes: 5 additions & 0 deletions packages/bumpy/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,11 @@
"description": "Glob patterns to filter which changed files count toward marking this package as changed (overrides root setting)",
"items": { "type": "string" }
},
"sourcePaths": {
"type": "array",
"description": "Extra change-detection globs for sources outside this package's directory, relative to the package dir like every per-package glob (use ../ to reach out). A changed file matching one marks this package as changed. Additive to changedFilePatterns (which only applies inside the package). Prefix with ! to exclude.",
"items": { "type": "string" }
},
"dependencyBumpRules": {
"type": "object",
"description": "Per-package override for dependency propagation rules",
Expand Down
24 changes: 21 additions & 3 deletions packages/bumpy/src/commands/check.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { relative, resolve } from 'node:path';
import picomatch from 'picomatch';
import { log, colorize } from '../utils/logger.ts';
import { loadConfig, loadPackageConfig, getBumpyDir, matchGlob, resolveFixedGroups } from '../core/config.ts';
import {
loadConfig,
loadPackageConfig,
getBumpyDir,
matchGlob,
makeSourcePathsMatcher,
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 @@ -215,7 +222,9 @@ export async function checkCommand(rootDir: string, opts: CheckOptions = {}): Pr
} else {
log.dim('Run `bumpy add` to create a bump file, or `bumpy add --empty` if no release is needed.');
}
log.dim('To adjust which files trigger change detection, set `changedFilePatterns` in .bumpy/_config.json.');
log.dim(
'To adjust which files trigger change detection, set `changedFilePatterns` (or per-package `sourcePaths`) in .bumpy/_config.json.',
);
if (willFail) process.exit(1);
}

Expand Down Expand Up @@ -289,10 +298,14 @@ export async function findChangedPackages(

// Build per-package matchers (per-package patterns override root patterns)
const matchers = new Map<string, picomatch.Matcher>();
// `sourcePaths` matchers — repo-root-relative globs for sources outside the package dir
const sourceMatchers = new Map<string, (file: string) => boolean>();
for (const [name, pkg] of packages) {
const pkgConfig = await loadPackageConfig(pkg.dir, config, name);
const pkgConfig = await loadPackageConfig(pkg.dir, config, name, rootDir);
const patterns = pkgConfig.changedFilePatterns ?? config.changedFilePatterns;
matchers.set(name, picomatch(patterns));
const sourceMatcher = makeSourcePathsMatcher(pkgConfig.sourcePaths);
if (sourceMatcher) sourceMatchers.set(name, sourceMatcher);
}

const ignoredFields = config.ignoredPackageJsonFields ?? ['devDependencies'];
Expand All @@ -306,9 +319,14 @@ export async function findChangedPackages(
const isRoot = pkgRelDir === '';
const prefix = `${pkgRelDir}/`;
const matcher = matchers.get(name)!;
const sourceMatcher = sourceMatchers.get(name);

let pkgJsonOnlyTrigger = false;
for (const file of changedFiles) {
if (sourceMatcher?.(file)) {
changed.add(name);
break;
}
let relToPackage: string;
if (isRoot) {
relToPackage = file;
Expand Down
13 changes: 9 additions & 4 deletions packages/bumpy/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tryRunArgs } from '../utils/shell.ts';
import { loadConfig } from '../core/config.ts';
import { discoverPackages } from '../core/workspace.ts';
import { writeBumpFile } from '../core/bump-file.ts';
import { getBumpyDir } from '../core/config.ts';
import { getBumpyDir, makeSourcePathsMatcher } from '../core/config.ts';
import { ensureDir } from '../utils/fs.ts';
import { slugify, randomName } from '../utils/names.ts';
import { getBaseCompareRef, getBranchCommits, getCurrentBranch, getFilesChangedInCommit } from '../core/git.ts';
Expand Down Expand Up @@ -264,19 +264,24 @@ export async function mapCommitToPackages(
return [...matched];
}

/** Map file paths to package names based on directory containment */
/** Map file paths to package names based on directory containment (plus each package's `sourcePaths`) */
export function mapFilesToPackages(
files: string[],
packages: Map<string, WorkspacePackage>,
rootDir: string,
): string[] {
const matched = new Set<string>();
// `pkg.bumpy.sourcePaths` was resolved to repo-root-relative globs by loadPackageConfig
const sourceMatchers = new Map(
[...packages].map(([name, pkg]) => [name, makeSourcePathsMatcher(pkg.bumpy?.sourcePaths)]),
);
for (const file of files) {
for (const [name, pkg] of packages) {
const pkgRelDir = relative(rootDir, pkg.dir);
// Root package (single-package repo) has an empty relative dir, so every
// file belongs to it. Otherwise the file must live under the package dir.
if (pkgRelDir === '' || file.startsWith(pkgRelDir + '/')) {
// file belongs to it. Otherwise the file must live under the package dir
// or match one of its `sourcePaths`.
if (pkgRelDir === '' || file.startsWith(pkgRelDir + '/') || sourceMatchers.get(name)?.(file)) {
matched.add(name);
}
}
Expand Down
37 changes: 35 additions & 2 deletions packages/bumpy/src/core/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolve } from 'node:path';
import { relative, resolve, sep } from 'node:path';
import picomatch from 'picomatch';
import { readJson, readJsonc, exists } from '../utils/fs.ts';
import { type BumpyConfig, type PackageConfig, DEFAULT_CONFIG, normalizeCascadeConfig } from '../types.ts';

Expand Down Expand Up @@ -42,6 +43,7 @@ export async function loadPackageConfig(
pkgDir: string,
rootConfig: BumpyConfig,
pkgName: string,
rootDir: string,
): Promise<PackageConfig> {
// Start with what's in the root config's packages map
const rootPkgConfig = findPackageConfig(rootConfig, pkgName);
Expand Down Expand Up @@ -74,7 +76,23 @@ export async function loadPackageConfig(
}

// Merge: root packages map → package.json["bumpy"] (later wins)
return mergePackageConfig(rootPkgConfig, pkgJsonConfig);
return withRootRelativeSourcePaths(mergePackageConfig(rootPkgConfig, pkgJsonConfig), pkgDir, rootDir);
}

/**
* `sourcePaths` are written relative to the package dir (like every per-package glob,
* wherever it's declared) — resolve them to repo-root-relative globs once, so they can be
* matched directly against changed files. A leading `!` is preserved.
*/
function withRootRelativeSourcePaths(cfg: PackageConfig, pkgDir: string, rootDir: string): PackageConfig {
if (!cfg.sourcePaths) return cfg;
const sourcePaths = cfg.sourcePaths.map((pattern) => {
const negated = pattern.startsWith('!');
const body = negated ? pattern.slice(1) : pattern;
const rootRelative = relative(rootDir, resolve(pkgDir, body)).split(sep).join('/');
return (negated ? '!' : '') + rootRelative;
});
return { ...cfg, sourcePaths };
}

/** Find a package config from the root config, supporting glob patterns */
Expand All @@ -97,6 +115,21 @@ export function resolveFixedGroups(config: BumpyConfig, packageNames: Iterable<s
return config.fixed.map((group) => names.filter((name) => group.some((pattern) => matchGlob(name, pattern))));
}

/**
* Build a matcher for a package's resolved `sourcePaths` (repo-root-relative globs, see
* `loadPackageConfig`). Patterns
* starting with `!` exclude — a file counts when it matches any positive pattern and no
* negated one. Returns null when there are no positive patterns.
*/
export function makeSourcePathsMatcher(sourcePaths: string[] | undefined): ((file: string) => boolean) | null {
const include = (sourcePaths ?? []).filter((p) => !p.startsWith('!'));
if (include.length === 0) return null;
const exclude = (sourcePaths ?? []).filter((p) => p.startsWith('!')).map((p) => p.slice(1));
const isIncluded = picomatch(include);
const isExcluded = exclude.length > 0 ? picomatch(exclude) : () => false;
return (file) => isIncluded(file) && !isExcluded(file);
}

/** Simple glob matching for package names (supports * and **) */
export function matchGlob(name: string, pattern: string): boolean {
// Exact match
Expand Down
2 changes: 1 addition & 1 deletion packages/bumpy/src/core/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ async function loadWorkspacePackage(
const name = pkg.name as string;
if (!name) return null;

const bumpy = await loadPackageConfig(dir, config, name);
const bumpy = await loadPackageConfig(dir, config, name, rootDir);

return {
name,
Expand Down
9 changes: 9 additions & 0 deletions packages/bumpy/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,15 @@ export interface PackageConfig {
checkPublished?: string;
/** Glob patterns to filter which changed files count toward marking this package as changed */
changedFilePatterns?: string[];
/**
* Extra change-detection globs for sources that live outside this package's directory
* (e.g. a binary built from a sibling package). Like every per-package glob, they are
* relative to the **package dir** wherever they're declared — use `../` to reach out
* (e.g. `"../encryption-binary-swift/**"`). A changed file matching one marks this
* package as changed, exactly as if it were inside it. Additive — `changedFilePatterns`
* still applies only to files inside the package. Prefix with `!` to exclude.
*/
sourcePaths?: string[];
dependencyBumpRules?: Partial<Record<DepType, DependencyBumpRule | false>>;
cascadeTo?: CascadeConfig;
cascadeFrom?: CascadeConfig;
Expand Down
62 changes: 62 additions & 0 deletions packages/bumpy/test/core/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,68 @@ describe('findChangedPackages', () => {
const result = await findChangedPackages(changed, packages, rootDir, makeConfig());
expect(result).toEqual(['pkg-a']);
});

describe('sourcePaths', () => {
// sourcePaths are package-relative, so loadPackageConfig reads package.json from disk
const tmpRoot = resolve(import.meta.dir, '../../.test-tmp-source-paths');
const helperDir = resolve(tmpRoot, 'packages/helpers/darwin');
const packages = new Map([
['@x/helper-darwin', makePkg('@x/helper-darwin', '1.0.0', { dir: helperDir })],
['@x/helper-linux', makePkg('@x/helper-linux', '1.0.0', { dir: resolve(tmpRoot, 'packages/helpers/linux') })],
['swift-src', makePkg('swift-src', '1.0.0', { dir: resolve(tmpRoot, 'packages/swift-src') })],
]);
const check = (files: string[], config = makeConfig()) =>
findChangedPackages(files, packages, tmpRoot, config).then((r) => r.sort());

afterEach(async () => {
await rm(tmpRoot, { recursive: true, force: true });
});

test('a file outside the package matching sourcePaths marks it changed', async () => {
const config = makeConfig({ packages: { '@x/helper-darwin': { sourcePaths: ['../../swift-src/**'] } } });
expect(await check(['packages/swift-src/Sources/main.swift'], config)).toEqual(['@x/helper-darwin', 'swift-src']);
});

test('a non-matching file does not mark it changed', async () => {
const config = makeConfig({ packages: { '@x/helper-darwin': { sourcePaths: ['../../swift-src/**'] } } });
expect(await check(['packages/other/index.ts'], config)).toEqual([]);
});

test('globs are package-relative, not repo-root-relative', async () => {
const config = makeConfig({ packages: { '@x/helper-darwin': { sourcePaths: ['packages/swift-src/**'] } } });
expect(await check(['packages/swift-src/main.swift'], config)).toEqual(['swift-src']);
});

test('negated patterns exclude matches', async () => {
const config = makeConfig({
packages: { '@x/helper-darwin': { sourcePaths: ['../../swift-src/**', '!../../swift-src/**/*.md'] } },
});
expect(await check(['packages/swift-src/README.md'], config)).toEqual(['swift-src']);
expect(await check(['packages/swift-src/main.swift'], config)).toEqual(['@x/helper-darwin', 'swift-src']);
});

test('works with glob package keys (resolved per package)', async () => {
const config = makeConfig({ packages: { '@x/helper-*': { sourcePaths: ['../shared/**'] } } });
expect(await check(['packages/helpers/shared/x.c'], config)).toEqual(['@x/helper-darwin', '@x/helper-linux']);
});

test('works when declared in package.json', async () => {
await mkdir(helperDir, { recursive: true });
await writeFile(
resolve(helperDir, 'package.json'),
JSON.stringify({ name: '@x/helper-darwin', bumpy: { sourcePaths: ['../../swift-src/**'] } }),
);
expect(await check(['packages/swift-src/main.swift'])).toEqual(['@x/helper-darwin', 'swift-src']);
});

test('changedFilePatterns does not filter sourcePaths matches', async () => {
const config = makeConfig({
changedFilePatterns: ['src/**'],
packages: { '@x/helper-darwin': { sourcePaths: ['../../swift-src/**'] } },
});
expect(await check(['packages/swift-src/Package.swift'], config)).toEqual(['@x/helper-darwin']);
});
});
});

describe('resolveDirectBumpCoverage', () => {
Expand Down
19 changes: 19 additions & 0 deletions packages/bumpy/test/core/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ describe('mapFilesToPackages', () => {
const result = mapFilesToPackages(['packages/pkg-b/src/index.ts'], packages, rootDir);
expect(result).toEqual(['pkg-b']);
});

test('attributes files matching sourcePaths to the package', () => {
// pkg.bumpy holds sourcePaths already resolved to repo-root-relative by loadPackageConfig
const packages = new Map([
[
'helper',
makePkg('helper', '1.0.0', {
dir: `${rootDir}/packages/helper`,
bumpy: { sourcePaths: ['packages/swift-src/**', '!**/*.md'] },
}),
],
['swift-src', makePkg('swift-src', '1.0.0', { dir: `${rootDir}/packages/swift-src` })],
]);
expect(mapFilesToPackages(['packages/swift-src/main.swift'], packages, rootDir).sort()).toEqual([
'helper',
'swift-src',
]);
expect(mapFilesToPackages(['packages/swift-src/README.md'], packages, rootDir)).toEqual(['swift-src']);
});
});

describe('mapCommitToPackages — catalog changes', () => {
Expand Down
Loading