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

Added a `$changelog: false` reserved frontmatter key for bump files, which omits a file's body from the changelog and release notes while still applying its version bump. Clearer than relying on a blank body, and lets you keep notes for reviewers. A per-package `changelog: false` option in the nested form suppresses the entry for just some of a file's packages.
36 changes: 35 additions & 1 deletion docs/bump-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Added user language preference to the core config.
Fixed locale fallback logic in utils.
```

> **Tip:** The description body is optional. If left blank, the bump file still contributes to the release plan (triggering version bumps and dependency propagation), but no entry will appear in the changelog for it.
> **Tip:** The description body is optional. If left blank, the bump file still contributes to the release plan (triggering version bumps and dependency propagation), but no entry will appear in the changelog for it. To keep a body as a note for reviewers while still omitting it from the changelog, set [`$changelog: false`](#omitting-an-entry-from-the-changelog) instead.

### Bump levels

Expand Down Expand Up @@ -109,6 +109,40 @@ To quickly set all changed packages to `none`:
bumpy add --none
```

## Omitting an entry from the changelog

Sometimes a change warrants a version bump but isn't worth a changelog line (an internal refactor, a dependency tidy-up, etc.). You _could_ leave the body blank, but that's easy to misread as "I forgot to write a description." The `$changelog: false` reserved key makes the intent explicit:

```markdown
---
'@myorg/core': patch
$changelog: false
---

Internal refactor of the config loader — no user-facing change.
```

The bump still happens (and still cascades normally), but this file's body is omitted from both the changelog and GitHub release notes. The body is preserved in the bump file, so you can keep notes for reviewers without them leaking into public release notes.

`$changelog` is a file-level flag — a bump file has a single shared body, so it applies to every package the file lists. The `$` prefix marks it as a reserved key; it can never collide with a package name (a package named `changelog` is still written `changelog: patch`, as a normal entry).

### Per-package opt-out

If a single bump file covers several packages and you only want to suppress the changelog entry for _some_ of them, use the nested object form's `changelog: false` instead. The shared body then renders for the other packages but is omitted for the flagged ones:

```markdown
---
'@myorg/core': patch
'@myorg/internal-tooling':
bump: patch
changelog: false
---

Reworked the build pipeline.
```

Here `@myorg/core`'s changelog gets the entry, but `@myorg/internal-tooling`'s does not. The per-package flag composes with `cascade`, and stacks with the file-level `$changelog: false` (either one suppresses).

## Cascade control (advanced)

You can explicitly push bumps to downstream packages using the nested object format:
Expand Down
27 changes: 24 additions & 3 deletions packages/bumpy/src/core/bump-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,23 @@ export function parseBumpFile(content: string, id: string): BumpFileParseResult
}

const releases: BumpFileRelease[] = [];
let noChangelog = false;
for (const [name, value] of Object.entries(parsed)) {
// Reserved meta keys are sigil-prefixed (`$`) so they can never collide with
// a package name (`$` is rejected by validatePackageName).
if (name.startsWith('$')) {
if (name === '$changelog') {
if (typeof value !== 'boolean') {
errors.push(`Reserved key "$changelog" in bump file "${id}" must be true or false`);
} else if (value === false) {
noChangelog = true;
}
} else {
errors.push(`Unknown reserved key "${name}" in bump file "${id}" (expected: $changelog)`);
}
continue;
}

if (!validatePackageName(name)) {
errors.push(`Invalid package name "${name}" in bump file "${id}"`);
continue;
Expand All @@ -180,18 +196,23 @@ export function parseBumpFile(content: string, id: string): BumpFileParseResult
// Simple format: "pkg-name": minor
releases.push({ name, type: value as BumpTypeWithNone });
} else if (value && typeof value === 'object') {
// Nested format: "pkg-name": { bump: minor, cascade: { ... } }
const obj = value as { bump: BumpTypeWithNone; cascade?: Record<string, BumpType> };
// Nested format: "pkg-name": { bump: minor, cascade: { ... }, changelog: false }
const obj = value as { bump: BumpTypeWithNone; cascade?: Record<string, BumpType>; changelog?: boolean };
if (!VALID_BUMP_TYPES.has(obj.bump)) {
errors.push(
`Unknown bump type "${obj.bump}" for "${name}" in bump file "${id}" (expected: major, minor, patch, or none)`,
);
continue;
}
if (obj.changelog !== undefined && typeof obj.changelog !== 'boolean') {
errors.push(`"changelog" for "${name}" in bump file "${id}" must be true or false`);
continue;
}
const release: BumpFileReleaseCascade = {
name,
type: obj.bump,
cascade: obj.cascade || {},
...(obj.changelog === false && { noChangelog: true }),
};
releases.push(release);
} else {
Expand All @@ -204,7 +225,7 @@ export function parseBumpFile(content: string, id: string): BumpFileParseResult
return { bumpFile: null, errors };
}

const bumpFile = releases.length > 0 ? { id, releases, summary } : null;
const bumpFile = releases.length > 0 ? { id, releases, summary, ...(noChangelog && { noChangelog }) } : null;
return { bumpFile, errors };
}

Expand Down
10 changes: 8 additions & 2 deletions packages/bumpy/src/core/changelog-github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { tryRunArgs } from '../utils/shell.ts';
import type { BumpType } from '../types.ts';
import { maxBump } from '../types.ts';
import type { ChangelogContext, ChangelogFormatter } from './changelog.ts';
import { getBumpTypeForPackage, sortBumpFilesByType, summaryNeedsBlockLayout, trimBlankEdges } from './changelog.ts';
import {
getBumpTypeForPackage,
isChangelogSuppressed,
sortBumpFilesByType,
summaryNeedsBlockLayout,
trimBlankEdges,
} from './changelog.ts';

/** Authors filtered from "Thanks" attribution by default (e.g. bots) */
/** Authors filtered from "Thanks" attribution by default (e.g. AI/automation bots) */
Expand Down Expand Up @@ -61,7 +67,7 @@ export function createGithubFormatter(options: GithubChangelogOptions = {}): Cha
const sorted = sortBumpFilesByType(relevantBumpFiles, release.name);

for (const bf of sorted) {
if (!bf.summary) continue;
if (!bf.summary || isChangelogSuppressed(bf, release.name)) continue;

const type = getBumpTypeForPackage(bf, release.name);
const tag = ` *(${type})*`;
Expand Down
12 changes: 11 additions & 1 deletion packages/bumpy/src/core/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ export function getBumpTypeForPackage(bf: BumpFile, packageName: string): BumpTy
return rel?.type === 'none' || !rel?.type ? 'patch' : rel.type;
}

/**
* Whether a bump file's summary should be omitted from a given package's
* changelog entry — either the whole file is flagged (`$changelog: false`) or
* just this package is (`{ bump, changelog: false }`).
*/
export function isChangelogSuppressed(bf: BumpFile, packageName: string): boolean {
if (bf.noChangelog) return true;
return bf.releases.find((r) => r.name === packageName)?.noChangelog === true;
}

/** Sort bump files by bump type for a specific package (major → minor → patch) */
export function sortBumpFilesByType(bumpFiles: BumpFile[], packageName: string): BumpFile[] {
return [...bumpFiles].sort((a, b) => {
Expand Down Expand Up @@ -89,7 +99,7 @@ export const defaultFormatter: ChangelogFormatter = (ctx) => {
const sorted = sortBumpFilesByType(relevantBumpFiles, release.name);

for (const bf of sorted) {
if (!bf.summary) continue;
if (!bf.summary || isChangelogSuppressed(bf, release.name)) continue;
const type = getBumpTypeForPackage(bf, release.name);
const summaryLines = trimBlankEdges(bf.summary.split('\n'));
if (summaryLines.length === 0) continue;
Expand Down
15 changes: 15 additions & 0 deletions packages/bumpy/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,12 +253,20 @@ export const DEFAULT_CONFIG: BumpyConfig = {
export interface BumpFileReleaseSimple {
name: string;
type: BumpTypeWithNone;
/**
* When true, omit this package's entry from changelogs / release notes while
* still applying its bump. Per-package override of the file-level
* `$changelog: false` flag — set via the nested form `{ bump, changelog: false }`.
*/
noChangelog?: boolean;
}

export interface BumpFileReleaseCascade {
name: string;
type: BumpTypeWithNone;
cascade: Record<string, BumpType>; // glob pattern → bump type
/** See {@link BumpFileReleaseSimple.noChangelog}. */
noChangelog?: boolean;
}

export type BumpFileRelease = BumpFileReleaseSimple | BumpFileReleaseCascade;
Expand All @@ -273,6 +281,13 @@ export interface BumpFile {
summary: string; // markdown body
/** Channel directory this file lives in (`.bumpy/<channel>/`), if any. Undefined = `.bumpy/` root. */
channel?: string;
/**
* When true, this file's summary is omitted from changelogs / release notes
* (still applies its version bumps). Set via the `$changelog: false` reserved
* frontmatter key — useful for internal changes not worth a changelog line,
* while keeping the body as a note for reviewers.
*/
noChangelog?: boolean;
}

// ---- Workspace ----
Expand Down
127 changes: 127 additions & 0 deletions packages/bumpy/test/core/bump-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,133 @@ Mixed
expect(errors[0]).toContain('"bogus"');
});

test('parses $changelog: false reserved key', () => {
const content = `---
"pkg-a": patch
$changelog: false
---

Internal refactor — not worth a changelog line
`;
const { bumpFile: bf, errors } = parseBumpFile(content, 'test-bf');
expect(errors).toHaveLength(0);
expect(bf!.releases).toHaveLength(1);
expect(bf!.releases[0]!.name).toBe('pkg-a');
expect(bf!.releases[0]!.type).toBe('patch');
expect(bf!.noChangelog).toBe(true);
// body is preserved as a note even though it won't be rendered
expect(bf!.summary).toBe('Internal refactor — not worth a changelog line');
});

test('parses per-package changelog: false in nested format', () => {
const content = `---
"pkg-a": patch
"pkg-b":
bump: patch
changelog: false
---

Shared body
`;
const { bumpFile: bf, errors } = parseBumpFile(content, 'test-bf');
expect(errors).toHaveLength(0);
expect(bf!.noChangelog).toBeUndefined();
expect(bf!.releases).toHaveLength(2);
expect(bf!.releases[0]!.name).toBe('pkg-a');
expect(bf!.releases[0]!.noChangelog).toBeUndefined();
expect(bf!.releases[1]!.name).toBe('pkg-b');
expect(bf!.releases[1]!.noChangelog).toBe(true);
});

test('per-package changelog flag coexists with cascade', () => {
const content = `---
"@myorg/core":
bump: minor
changelog: false
cascade:
"plugins/*": patch
---

Body
`;
const { bumpFile: bf, errors } = parseBumpFile(content, 'test-bf');
expect(errors).toHaveLength(0);
const rel = bf!.releases[0]! as any;
expect(rel.noChangelog).toBe(true);
expect(rel.cascade).toEqual({ 'plugins/*': 'patch' });
});

test('errors on non-boolean per-package changelog value', () => {
const content = `---
"pkg-a":
bump: patch
changelog: nope
---

Body
`;
const { bumpFile: bf, errors } = parseBumpFile(content, 'test-bf');
expect(bf).toBeNull();
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('"changelog" for "pkg-a"');
expect(errors[0]).toContain('true or false');
});

test('$changelog: true leaves noChangelog unset', () => {
const content = `---
"pkg-a": patch
$changelog: true
---

A change
`;
const { bumpFile: bf, errors } = parseBumpFile(content, 'test-bf');
expect(errors).toHaveLength(0);
expect(bf!.noChangelog).toBeUndefined();
});

test('a package named "changelog" is unaffected by the reserved key', () => {
const content = `---
"changelog": patch
---

Bumped the changelog package
`;
const { bumpFile: bf, errors } = parseBumpFile(content, 'test-bf');
expect(errors).toHaveLength(0);
expect(bf!.releases).toHaveLength(1);
expect(bf!.releases[0]!.name).toBe('changelog');
expect(bf!.noChangelog).toBeUndefined();
});

test('errors on non-boolean $changelog value', () => {
const content = `---
"pkg-a": patch
$changelog: nope
---

A change
`;
const { bumpFile: bf, errors } = parseBumpFile(content, 'test-bf');
expect(bf!.releases).toHaveLength(1);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('"$changelog"');
expect(errors[0]).toContain('true or false');
});

test('errors on unknown reserved key', () => {
const content = `---
"pkg-a": patch
$bogus: false
---

A change
`;
const { errors } = parseBumpFile(content, 'test-bf');
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('Unknown reserved key "$bogus"');
});

test('handles multi-line summary', () => {
const content = `---
"pkg-a": minor
Expand Down
38 changes: 38 additions & 0 deletions packages/bumpy/test/core/changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,44 @@ describe('defaultFormatter', () => {
expect(result.indexOf('Added new feature')).toBeLessThan(result.indexOf('Fixed a bug'));
});

test('omits summary from bump files flagged $changelog: false', async () => {
const release = makeRelease('pkg-a', '1.0.2', {
type: 'patch',
oldVersion: '1.0.1',
bumpFiles: ['cs1', 'cs2'],
});
const bumpFiles = [
makeBumpFile('cs1', [{ name: 'pkg-a', type: 'patch' }], 'User-facing fix'),
makeBumpFile('cs2', [{ name: 'pkg-a', type: 'patch' }], 'Internal refactor', { noChangelog: true }),
];

const result = await defaultFormatter({ release, bumpFiles, date: '2026-04-14' });

expect(result).toContain('- *(patch)* User-facing fix');
expect(result).not.toContain('Internal refactor');
});

test('per-package changelog: false suppresses only the flagged package', async () => {
// One bump file covers both packages with a shared body, but opts pkg-a out.
const bumpFile = {
id: 'cs1',
summary: 'Shared internal change',
releases: [
{ name: 'pkg-a', type: 'patch' as const, noChangelog: true },
{ name: 'pkg-b', type: 'patch' as const },
],
};

const releaseA = makeRelease('pkg-a', '1.0.2', { type: 'patch', oldVersion: '1.0.1', bumpFiles: ['cs1'] });
const releaseB = makeRelease('pkg-b', '2.0.2', { type: 'patch', oldVersion: '2.0.1', bumpFiles: ['cs1'] });

const resultA = await defaultFormatter({ release: releaseA, bumpFiles: [bumpFile], date: '2026-04-14' });
const resultB = await defaultFormatter({ release: releaseB, bumpFiles: [bumpFile], date: '2026-04-14' });

expect(resultA).not.toContain('Shared internal change');
expect(resultB).toContain('- *(patch)* Shared internal change');
});

test('formats dependency bump with source packages', async () => {
const release = makeRelease('pkg-a', '1.0.1', {
isDependencyBump: true,
Expand Down
3 changes: 2 additions & 1 deletion packages/bumpy/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ export function makeBumpFile(
id: string,
releases: { name: string; type: BumpTypeWithNone }[],
summary = 'Test change',
extra: Partial<Pick<BumpFile, 'noChangelog' | 'channel'>> = {},
): BumpFile {
return { id, releases, summary };
return { id, releases, summary, ...extra };
}

/** Create a ReleasePlan for testing */
Expand Down
Loading