diff --git a/.bumpy/add-changelog-false-flag.md b/.bumpy/add-changelog-false-flag.md new file mode 100644 index 0000000..83e283f --- /dev/null +++ b/.bumpy/add-changelog-false-flag.md @@ -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. diff --git a/docs/bump-files.md b/docs/bump-files.md index dad7af5..761432c 100644 --- a/docs/bump-files.md +++ b/docs/bump-files.md @@ -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 @@ -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: diff --git a/packages/bumpy/src/core/bump-file.ts b/packages/bumpy/src/core/bump-file.ts index a9966ae..b33bcab 100644 --- a/packages/bumpy/src/core/bump-file.ts +++ b/packages/bumpy/src/core/bump-file.ts @@ -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; @@ -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 }; + // Nested format: "pkg-name": { bump: minor, cascade: { ... }, changelog: false } + const obj = value as { bump: BumpTypeWithNone; cascade?: Record; 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 { @@ -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 }; } diff --git a/packages/bumpy/src/core/changelog-github.ts b/packages/bumpy/src/core/changelog-github.ts index c6aebb4..fa4bca7 100644 --- a/packages/bumpy/src/core/changelog-github.ts +++ b/packages/bumpy/src/core/changelog-github.ts @@ -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) */ @@ -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})*`; diff --git a/packages/bumpy/src/core/changelog.ts b/packages/bumpy/src/core/changelog.ts index fd4564a..05c5222 100644 --- a/packages/bumpy/src/core/changelog.ts +++ b/packages/bumpy/src/core/changelog.ts @@ -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) => { @@ -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; diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index 957bb6e..1c1bd45 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -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; // glob pattern → bump type + /** See {@link BumpFileReleaseSimple.noChangelog}. */ + noChangelog?: boolean; } export type BumpFileRelease = BumpFileReleaseSimple | BumpFileReleaseCascade; @@ -273,6 +281,13 @@ export interface BumpFile { summary: string; // markdown body /** Channel directory this file lives in (`.bumpy//`), 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 ---- diff --git a/packages/bumpy/test/core/bump-file.test.ts b/packages/bumpy/test/core/bump-file.test.ts index 9b291b9..ba404cd 100644 --- a/packages/bumpy/test/core/bump-file.test.ts +++ b/packages/bumpy/test/core/bump-file.test.ts @@ -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 diff --git a/packages/bumpy/test/core/changelog.test.ts b/packages/bumpy/test/core/changelog.test.ts index 97f292b..bcd95ad 100644 --- a/packages/bumpy/test/core/changelog.test.ts +++ b/packages/bumpy/test/core/changelog.test.ts @@ -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, diff --git a/packages/bumpy/test/helpers.ts b/packages/bumpy/test/helpers.ts index b60be51..846d60a 100644 --- a/packages/bumpy/test/helpers.ts +++ b/packages/bumpy/test/helpers.ts @@ -78,8 +78,9 @@ export function makeBumpFile( id: string, releases: { name: string; type: BumpTypeWithNone }[], summary = 'Test change', + extra: Partial> = {}, ): BumpFile { - return { id, releases, summary }; + return { id, releases, summary, ...extra }; } /** Create a ReleasePlan for testing */ diff --git a/skills/add-change/SKILL.md b/skills/add-change/SKILL.md index ff20710..fb21f2f 100644 --- a/skills/add-change/SKILL.md +++ b/skills/add-change/SKILL.md @@ -103,6 +103,23 @@ Added new encryption provider. Plugins need a patch bump for compatibility. EOF ``` +## Advanced: omitting an entry from the changelog + +If a change needs a version bump but shouldn't appear in the changelog (internal refactor, dependency tidy-up, etc.), add the `$changelog: false` reserved key. The bump still applies and cascades normally, but this file's body is omitted from the changelog and release notes — write the body anyway as a note for reviewers: + +```bash +cat > .bumpy/.md << 'EOF' +--- +"@myorg/core": patch +$changelog: false +--- + +Internal refactor of the config loader — no user-facing change. +EOF +``` + +It's a file-level flag (a bump file has one shared body), so it applies to every package the file lists. + ## Important notes - Only include packages that have **actual code changes** — bumpy handles dependency propagation automatically