@williamthorsen/release-kit 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +69 -25
  2. package/README.md +238 -44
  3. package/cliff.toml.template +4 -39
  4. package/dist/esm/.cache +1 -1
  5. package/dist/esm/bin/release-kit.js +59 -0
  6. package/dist/esm/buildChangelogEntries.js +6 -0
  7. package/dist/esm/buildEmptyReleaseEntry.d.ts +2 -0
  8. package/dist/esm/buildEmptyReleaseEntry.js +16 -0
  9. package/dist/esm/changelogJsonFile.d.ts +2 -0
  10. package/dist/esm/changelogJsonFile.js +11 -1
  11. package/dist/esm/changelogJsonUtils.d.ts +2 -1
  12. package/dist/esm/changelogJsonUtils.js +9 -0
  13. package/dist/esm/changelogOverrides.d.ts +53 -0
  14. package/dist/esm/changelogOverrides.js +424 -0
  15. package/dist/esm/checkWorkTypesDrift.js +3 -2
  16. package/dist/esm/createGithubReleaseCommand.js +0 -9
  17. package/dist/esm/defaults.js +1 -1
  18. package/dist/esm/generateChangelogs.d.ts +0 -3
  19. package/dist/esm/generateChangelogs.js +1 -35
  20. package/dist/esm/index.d.ts +2 -0
  21. package/dist/esm/index.js +8 -0
  22. package/dist/esm/loadConfig.d.ts +1 -1
  23. package/dist/esm/releasePrepare.js +68 -11
  24. package/dist/esm/releasePrepareMono.js +103 -56
  25. package/dist/esm/releasePrepareProject.d.ts +4 -1
  26. package/dist/esm/releasePrepareProject.js +74 -18
  27. package/dist/esm/renderChangelogMarkdown.d.ts +12 -0
  28. package/dist/esm/renderChangelogMarkdown.js +32 -0
  29. package/dist/esm/renderReleaseNotes.js +6 -1
  30. package/dist/esm/resolveReleaseNotesConfig.js +3 -1
  31. package/dist/esm/runGitCliff.d.ts +1 -0
  32. package/dist/esm/runGitCliff.js +7 -0
  33. package/dist/esm/syncWorkTypes.js +3 -2
  34. package/dist/esm/types.d.ts +98 -31
  35. package/dist/esm/types.js +60 -0
  36. package/dist/esm/validateConfig.js +84 -345
  37. package/dist/esm/validateOverridesCommand.d.ts +13 -0
  38. package/dist/esm/validateOverridesCommand.js +119 -0
  39. package/dist/esm/work-types.json +23 -17
  40. package/dist/esm/work-types.schema.json +28 -1
  41. package/dist/esm/workTypesData.d.ts +8 -0
  42. package/dist/esm/workTypesData.js +20 -17
  43. package/dist/esm/workTypesUtils.d.ts +1 -0
  44. package/dist/esm/workTypesUtils.js +8 -0
  45. package/package.json +4 -3
  46. package/dist/esm/writeSyntheticChangelog.d.ts +0 -9
  47. package/dist/esm/writeSyntheticChangelog.js +0 -27
package/README.md CHANGED
@@ -2,42 +2,44 @@
2
2
 
3
3
  Version-bumping and changelog-generation toolkit for release workflows.
4
4
 
5
- Provides a self-contained CLI that auto-discovers workspaces from `pnpm-workspace.yaml`, parses conventional commits, determines version bumps, updates `package.json` files, and generates changelogs with `git-cliff`.
5
+ Provides a self-contained CLI that auto-discovers workspaces from `pnpm-workspace.yaml`, parses conventional commits, determines version bumps, updates `package.json` files, and generates changelogs from `git-cliff --context` output rendered in-process (with optional [editorial overrides](#editorial-overrides)).
6
6
 
7
7
  <!-- section:release-notes -->
8
- ## Release notes — v5.2.0 (2026-05-04)
8
+ ## Release notes — v5.3.0 (2026-05-10)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - Add emojis to changelog and release-note headings (#352)
12
+ - Enable editorial overrides for changelog entries (#387)
13
13
 
14
- Adds emoji prefixes to the section headings rendered in `CHANGELOG.md` and release notes generated by `@williamthorsen/release-kit`. Each of the 13 default work types gets a single decorative emoji so its section is easier to spot when skimming a release: 🐛 Bug fixes, 🎉 Features, 📚 Documentation, ♻️ Refactoring, ⚡ Performance, 🔒 Security, 🧪 Tests, ⚙️ Tooling, 👷 CI, 📦 Dependencies, 🏗️ Internal, 🗑️ Deprecated, and 🤖 Agentic support. Matching of `changelogJson.devOnlySections` is emoji-tolerant: existing consumer overrides written as bare names continue to work without modification.
14
+ Allows `release-kit` consumers to skip or correct historical changelog entries by means of an overrides file.
15
15
 
16
- - Surface bang violations in release prepare reports (#359)
16
+ - Decentralize changelog overrides to per-scope .meta/ files (#391)
17
17
 
18
- Release-prepare flows now surface `!`-policy violations as warnings in the prepare report. Each workspace's and project's commit window is parsed against the default policy table — `internal!` is rejected as contradictory, bare `drop:` is rejected for missing the required `!`, and so on — and any violations appear under the workspace's section in the report alongside short hash, truncated subject, type, and surface (prefix or body). A new `breakingPolicies` config field lets consumers override individual entries or pass `{}` to disable enforcement entirely. Release-time enforcement remains tolerant: violations are warnings, never failures, so a single legacy commit cannot block a release.
18
+ Adds support for workspace-scoped editorial-override files for `release-kit`-generated changelogs. A repo-root file applies overrides to every workspace's changelog; a workspace-tier file applies only to that workspace.
19
19
 
20
- ### 🐛 Bug fixes
20
+ - Add section markers and authenticated upstream fetch (#393)
21
+
22
+ A new `markers` block in `work-types.json` describes the breaking-changes emoji and label, making them available for use by consumers.
21
23
 
22
- - Restrict publish to publishable workspaces (#345)
24
+ `work-types check` and `work-types sync` now authenticate when `GITHUB_TOKEN` is set, so they can reach private upstream repositories.
23
25
 
24
- Fixes an issue where `release-kit publish` failed for workspaces marked `package.json#private: true`. The command now operates only on publishable workspaces — those where `private` is absent or `false` — and the rest of the release pipeline (`tag`, `create-github-release`, `prepare`, changelog) continues to handle private workspaces unchanged. This preserves the "versioned but not published" workflow: a private workspace can still be versioned, tagged, and published as a GitHub Release; only the registry-publish step is skipped. Without `--tags`, unpublishable tags are silently filtered (an empty result prints `Nothing to publish.` and exits 0). With `--tags` naming an unpublishable workspace, `release-kit publish` exits 1 with one error per offending tag, citing `package.json#private` and the workspace path.
26
+ - Validate changelog overrides from the command line (#395)
25
27
 
26
- - Skip tooling-only releases instead of failing (#347)
28
+ Adds a `release-kit overrides validate` subcommand that audits every `.meta/changelog-overrides.json` file across the project root and per-workspace scopes in one pass. The command reports schema errors, ambiguous-prefix collisions, and stale-key warnings with tiered exit codes so CI can choose its own failure threshold. The same validation is also available via a library function exported by the package.
27
29
 
28
- Fixes an issue where `release-kit create-github-release --tags <tag>` exited 1 whenever the tag's changelog had no all-audience content. The reusable `create-github-release.reusable.yaml` workflow forwards `github.ref_name` into `--tags`, so tooling-only releases consistently produced failed workflow runs even though no failure occurred. The command now exits 1 only when a requested tag has no changelog entry; intentional skip reasons (`no-audience-content`, `empty-body`) are informational. A typoed tag still surfaces an error even when batched alongside successful tags, and the info summary reports the per-tag skip reason for diagnostic visibility.
30
+ ### 🐛 Bug fixes
29
31
 
30
- - Establish canonical work-types SSOT and restore changelog section ordering (#358)
32
+ - Suppress git-cliff stale-version warnings on prepare (#373)
31
33
 
32
- Restores canonical section ordering in changelogs and release notes sections were appearing in unpredictable order after the previous release added emoji prefixes to section headers. Sections now follow a stable priority: public-facing types (Features, Fixes, Security, …) first, then internal types, then process types. Release-note bullets for breaking changes carry a `🚨 **Breaking:**` prefix so they stand out at a glance. Documentation entries move out of public release notes they continue to appear in dev changelogs.
34
+ Fixes an issue where `release-kit prepare` repeatedly printed git-cliff's "A new version of git-cliff is available" notice twice per release unit, so 2 × N times for an N-package monorepo run while never updating the locally cached git-cliff binary. Each `prepare` run now revalidates the npm cache once before any cliff work, so the binary stays current with upstream releases and the notice no longer surfaces on every per-workspace invocation.
33
35
 
34
- Closes #355.
36
+ - Use synthetic changelogs for forced empty-range releases (#376)
35
37
 
36
- ### Performance
38
+ Fixes an issue where `release-kit prepare` with `--force`, `--bump=X`, or `--set-version` would invoke git-cliff against units that had no commits since their last tag, surfacing confusing `WARN git_cliff > There is already a tag (...)` lines (twice per affected unit) and silently leaving `CHANGELOG.md` and `.meta/changelog.json` stale. Empty-range bumps now write a synthetic `Notes / Forced version bump.` entry to both files instead of invoking git-cliff. Applies to all three release stages: single-package, per-workspace, and project. Prior changelog history is preserved on every path.
37
39
 
38
- - Skip npx registry revalidation when running git-cliff (#361)
40
+ - Accept `breakingPolicies` field in config files (#394)
39
41
 
40
- Speeds up `release-kit prepare` by skipping the npm registry cache-revalidation HTTP request that ran on every `git-cliff` invocation. Per-invocation overhead drops from ~4.6 s to ~2.0 s; in a four-workspace monorepo this saves about 10 seconds per run. Also suppresses a transient stderr spinner that briefly appeared during package resolution and looked like a half-complete log message. Network fallback is preserved runs on machines with an empty npx cache still resolve `git-cliff` over the network.
42
+ Fixes an issue where setting `breakingPolicies` in `release-kit.config.ts` was rejected as an unknown field, leaving per-work-type breaking-policy configuration unreachable from the config file. Each entry accepts `'forbidden'`, `'optional'`, or `'required'`; an empty object opts out of enforcement.
41
43
  <!-- /section:release-notes -->
42
44
 
43
45
  ## Installation
@@ -68,7 +70,7 @@ Example output from `prepare --dry-run` in a monorepo:
68
70
  📦 1.2.0 → 1.3.0 (minor)
69
71
  [dry-run] Would bump packages/arrays/package.json
70
72
  Generating changelogs...
71
- [dry-run] Would run: npx --yes git-cliff ... --output packages/arrays/CHANGELOG.md
73
+ [dry-run] Would write packages/arrays/CHANGELOG.md
72
74
  🏷️ arrays-v1.3.0
73
75
 
74
76
  ── strings ─────────────────────────────────────
@@ -78,7 +80,7 @@ Example output from `prepare --dry-run` in a monorepo:
78
80
  📦 0.5.1 → 0.5.2 (patch)
79
81
  [dry-run] Would bump packages/strings/package.json
80
82
  Generating changelogs...
81
- [dry-run] Would run: npx --yes git-cliff ... --output packages/strings/CHANGELOG.md
83
+ [dry-run] Would write packages/strings/CHANGELOG.md
82
84
  🏷️ strings-v0.5.2
83
85
 
84
86
  ✅ Release preparation complete.
@@ -93,7 +95,7 @@ That's it for most repos. The CLI auto-discovers workspaces and applies sensible
93
95
  1. **Workspace discovery**: reads `pnpm-workspace.yaml` and resolves its `packages` globs to find workspace directories. Each directory containing a `package.json` becomes a workspace. If no workspace file is found, the repo is treated as a single-package project.
94
96
  2. **Config loading**: loads `.config/release-kit.config.ts` (if present) via [jiti](https://github.com/unjs/jiti) and merges it with discovered defaults.
95
97
  3. **Commit analysis**: for each workspace, finds commits since the last version tag, parses them for type and scope, and determines the appropriate version bump.
96
- 4. **Version bump + changelog**: bumps `package.json` versions and generates changelogs via `git-cliff`.
98
+ 4. **Version bump + changelog**: bumps `package.json` versions, builds structured `ChangelogEntry[]` from `git-cliff --context`, applies any [editorial overrides](#editorial-overrides) from per-scope `.meta/changelog-overrides.json` files, and renders both `CHANGELOG.md` and `.meta/changelog.json` from that single source. `git-cliff` is invoked only for its `--context` JSON; markdown rendering happens in-process so `.meta/changelog.json` and `CHANGELOG.md` always agree.
97
99
  5. **Release tags file**: writes computed tags to `tmp/.release-tags` for the release workflow to read when tagging and pushing.
98
100
 
99
101
  ## Commit format
@@ -236,7 +238,7 @@ When configured, each `release-kit prepare` run additionally:
236
238
 
237
239
  - Computes commits since the last project tag (`<tagPrefix><version>`), filtered to the union of every contributing workspace's paths.
238
240
  - Bumps the root `package.json`'s `version` field using the same bump-derivation rules as workspaces (or the `--bump=...` override).
239
- - Regenerates the root `./CHANGELOG.md` via `git-cliff`, scoped to the project's `tagPrefix` and contributing paths.
241
+ - Regenerates the root `./CHANGELOG.md` from the structured `ChangelogEntry[]` produced by `git-cliff --context` (scoped to the project's `tagPrefix` and contributing paths) and any matching editorial overrides.
240
242
  - Emits `./.meta/changelog.json` (when `changelogJson.enabled`).
241
243
  - With `--with-release-notes`, additionally emits `./docs/RELEASE_NOTES.v<version>.md`.
242
244
  - Appends the project tag to `tmp/.release-tags` so `release-kit commit` and `release-kit tag` pick it up alongside per-workspace tags.
@@ -310,33 +312,33 @@ The canonical taxonomy lives in `packages/release-kit/src/work-types.json` and i
310
312
 
311
313
  | Tier | Key | Header | Aliases | `!` policy |
312
314
  | -------- | ----------- | ------------------------- | ------------- | ------------ |
313
- | Public | `feat` | 🎉 Features | `feature` | optional |
314
- | Public | `drop` | 🪦 Removed | | **required** |
315
- | Public | `deprecate` | 🗑️ Deprecated | | forbidden |
316
- | Public | `fix` | 🐛 Bug fixes | `bugfix` | forbidden |
317
- | Public | `sec` | 🔒 Security | `security` | optional |
318
- | Public | `perf` | ⚡ Performance | `performance` | forbidden |
319
- | Internal | `internal` | 🏗️ Internal features | `utility` | forbidden |
320
- | Internal | `refactor` | ♻️ Refactoring | | forbidden |
321
- | Internal | `tests` | 🧪 Tests | `test` | forbidden |
322
- | Process | `tooling` | ⚙️ Tooling | | forbidden |
323
- | Process | `ci` | 👷 CI | | forbidden |
324
- | Process | `deps` | 📦 Dependencies | `dep` | forbidden |
325
- | Process | `ai` | 🤖 Agentic support | | forbidden |
326
- | Process | `docs` | 📚 Documentation | `doc` | forbidden |
327
- | Process | `fmt` | (excluded from changelog) | | forbidden |
315
+ | public | `feat` | 🎉 Features | `feature` | optional |
316
+ | public | `drop` | 🪦 Removed | | **required** |
317
+ | public | `deprecate` | 🗑️ Deprecated | | forbidden |
318
+ | public | `fix` | 🐛 Bug fixes | `bugfix` | forbidden |
319
+ | public | `sec` | 🔒 Security | `security` | optional |
320
+ | public | `perf` | ⚡ Performance | `performance` | forbidden |
321
+ | internal | `internal` | 🏗️ Internal features | `utility` | forbidden |
322
+ | internal | `refactor` | ♻️ Refactoring | | forbidden |
323
+ | internal | `tests` | 🧪 Tests | `test` | forbidden |
324
+ | process | `tooling` | ⚙️ Tooling | | forbidden |
325
+ | process | `ci` | 👷 CI | | forbidden |
326
+ | process | `deps` | 📦 Dependencies | `dep` | forbidden |
327
+ | process | `ai` | 🤖 Agentic support | | forbidden |
328
+ | process | `docs` | 📚 Documentation | `doc` | forbidden |
329
+ | process | `fmt` | (excluded from changelog) | | forbidden |
328
330
 
329
331
  #### Tier semantics
330
332
 
331
- - **Public** — visible to all audiences. Public-tier sections appear in both public release notes and dev changelogs.
332
- - **Internal** — dev-only. Internal-tier sections appear in dev changelogs but not in public-facing release notes.
333
- - **Process** — dev-only. Same audience treatment as Internal.
333
+ - **`public`** — visible to all audiences. `public`-tier sections appear in both public release notes and dev changelogs.
334
+ - **`internal`** — dev-only. `internal`-tier sections appear in dev changelogs but not in public-facing release notes.
335
+ - **`process`** — dev-only. Same audience treatment as `internal`.
334
336
 
335
- Section render order is **tier order (PublicInternalProcess), then row order within tier**. The bundled `cliff.toml.template` encodes this order via hidden `<!-- NN -->` HTML-comment prefixes on each parser's `group` value; tera's `group_by` filter sorts groups lexicographically (now monotonic by row number), and the body template's `striptags` filter erases the prefix from rendered headings.
337
+ Section render order is **tier order (`public``internal``process`), then row order within tier**. The bundled `cliff.toml.template` encodes this order via hidden `<!-- NN -->` HTML-comment prefixes on each parser's `group` value; tera's `group_by` filter sorts groups lexicographically (now monotonic by row number), and the body template's `striptags` filter erases the prefix from rendered headings.
336
338
 
337
339
  #### `docs` reclassification
338
340
 
339
- `docs`/Documentation has moved from the all-audience tier (where it lived before this taxonomy was formalised) to the dev-only Process tier. **Documentation commits no longer appear in public-facing release notes.** They still appear in `CHANGELOG.md` and `changelog.json` under the `audience: 'dev'` classification.
341
+ `docs`/Documentation has moved from the all-audience tier (where it lived before this taxonomy was formalised) to the dev-only `process` tier. **Documentation commits no longer appear in public-facing release notes.** They still appear in `CHANGELOG.md` and `changelog.json` under the `audience: 'dev'` classification.
340
342
 
341
343
  #### `utility` alias
342
344
 
@@ -382,6 +384,22 @@ Items whose commit subject carries the `!` prefix (e.g. `feat!`, `drop!`, `feat(
382
384
 
383
385
  Only the prefix `!` triggers this marker. A `BREAKING CHANGE:` body footer on its own does **not** retroactively mark a changelog item as breaking — the changelog signal is tied to the commit-prefix policy. This avoids surprise breaking-marker appearances for older commits written under earlier conventions.
384
386
 
387
+ The emoji and label of this marker are sourced from the `markers.breaking` entry in `work-types.json` (see [Section markers](#section-markers)) so consumers that render their own breaking-changes section draw from the same SSOT.
388
+
389
+ ### Section markers
390
+
391
+ Alongside `tiers` and `types`, `work-types.json` exposes a top-level `markers` object for cross-cutting section markers — visual indicators that aren't tied to a specific work type. Today the canonical entry is `breaking`; additional keys (e.g., security advisories, migration notices) can be added without a schema change.
392
+
393
+ ```jsonc
394
+ {
395
+ "markers": {
396
+ "breaking": { "emoji": "🚨", "label": "Breaking" },
397
+ },
398
+ }
399
+ ```
400
+
401
+ Entries store plain text only — the SSOT is format-agnostic, so consumers apply their own emphasis (Markdown bold, ANSI escape, HTML `<strong>`) when constructing the rendered form. release-kit's own renderer constructs the per-bullet prefix as `${emoji} **${label}:** ` from this entry.
402
+
385
403
  #### `fmt`
386
404
 
387
405
  `fmt:` commits are recognized by `parseCommitMessage` (they contribute to a patch bump) but `fmt` carries `excludedFromChangelog: true`. The bundled `cliff.toml.template` skips `fmt:` commits at the parser level, so they never appear in `CHANGELOG.md`, `changelog.json`, or release notes. The label and emoji are present in `work-types.json` for schema parity with the codeassembly upstream but never render.
@@ -390,7 +408,169 @@ Only the prefix `!` triggers this marker. A `BREAKING CHANGE:` body footer on it
390
408
 
391
409
  Work types from your config are merged with these defaults by key — your entries override or extend, they don't replace the full set. Release-notes sections are rendered in the declaration order of the merged work-types record, with any unknown titles trailing the known ones.
392
410
 
393
- The default `devOnlySections` (excluded from public release notes but still written to `CHANGELOG.md`) are derived from the Internal and Process tiers (excluding `fmt`). Override via `changelogJson.devOnlySections` in your config; matching is decorator-tolerant, so a bare-name override like `['Internal features']` keeps working against the emoji-prefixed and prefix-decorated default titles.
411
+ The default `devOnlySections` (excluded from public release notes but still written to `CHANGELOG.md`) are derived from the `internal` and `process` tiers (excluding `fmt`). Override via `changelogJson.devOnlySections` in your config; matching is decorator-tolerant, so a bare-name override like `['Internal features']` keeps working against the emoji-prefixed and prefix-decorated default titles.
412
+
413
+ ## Editorial overrides
414
+
415
+ Generated changelogs occasionally need editorial correction — typos, redacted scope, reworded entries, or historical commits whose bodies carry verbatim PR-template scaffolding (`## What`, `## Why`, etc.) that renders as literal text in user-facing release notes. Rewriting git history is not viable, and any in-place edit to `CHANGELOG.md` or `.meta/changelog.json` is overwritten on the next release because release-kit regenerates both artifacts from scratch.
416
+
417
+ Override files are the supported escape hatch. Drop a checked-in JSON file at the conventional path for the scope you want to influence, keyed by commit hash, and `release-kit prepare` applies the overrides between `buildChangelogEntries` and serialization. Both `CHANGELOG.md` and `.meta/changelog.json` reflect the post-override view, so downstream consumers (the GitHub Release body, the in-app release-notes page, etc.) see the same content.
418
+
419
+ ### File-location convention
420
+
421
+ | Scope | Path | Applies to |
422
+ | ------------------- | ---------------------------------------------- | ------------------------------------------------------- |
423
+ | Project (root) | `.meta/changelog-overrides.json` | The project changelog and every workspace's changelog |
424
+ | Workspace | `packages/<ws>/.meta/changelog-overrides.json` | Only that workspace's changelog |
425
+ | Single-package mode | `.meta/changelog-overrides.json` | The package's changelog (collapses to the project case) |
426
+
427
+ Filenames have no leading dot — the `.meta/` directory already provides the visibility property and parallels its sibling artifacts (`changelog.json`, `label-map.json`).
428
+
429
+ ### Composition: per-key shadowing
430
+
431
+ When a workspace's changelog is rendered, both files are consulted:
432
+
433
+ - The root file's overrides apply globally.
434
+ - The workspace file's overrides apply only to that workspace.
435
+ - When the **same hash key** (string-equal, byte-for-byte) appears in both files, the **workspace entry wins entirely** for that workspace's changelog — no field-level merge, the workspace entry replaces the root entry.
436
+ - Different prefix strings that happen to resolve to the same commit do **not** shadow; they fall through to the existing ambiguous-prefix error so you can correct your override file.
437
+ - Other keys in the root file still apply for that workspace.
438
+
439
+ The project-level changelog applies only the root file. Per-workspace files describe per-workspace editorial intent and have no meaning at the aggregated project tier.
440
+
441
+ ### Stale-key warnings
442
+
443
+ A key that doesn't match any commit gets a stale-reference warning. The warning's scope mirrors the file's scope:
444
+
445
+ - **Per-workspace files** are warned against their own apply context. A key in `packages/foo/.meta/changelog-overrides.json` that doesn't match any commit in foo's changelog is unambiguously stale and is warned immediately.
446
+ - **Root file** keys are aggregated globally — a root key that matches in any workspace or in the project changelog is non-stale; a root key matched nowhere is warned exactly once after all batches complete.
447
+
448
+ ### File shape
449
+
450
+ ```json
451
+ {
452
+ "82962311": {
453
+ "audience": "skip"
454
+ },
455
+ "abc1234d": {
456
+ "body": "Cleaned-up prose without the original PR-template scaffolding."
457
+ },
458
+ "ef567890": {
459
+ "description": "Rewritten headline that fixes the typo",
460
+ "body": "Optional replacement body."
461
+ }
462
+ }
463
+ ```
464
+
465
+ Per-entry fields are all optional, but at least one must be present per entry:
466
+
467
+ | Field | Type | Effect |
468
+ | ------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
469
+ | `audience` | `'all' \| 'dev' \| 'skip'` | `'skip'` removes the entry entirely. `'all'` and `'dev'` are reserved for a future audience-reclassification feature (see below). |
470
+ | `description` | `string` | Replaces the entry's bullet headline. Other fields are preserved. |
471
+ | `body` | `string` | Replaces the entry's body (the prose that renders below the bullet). Other fields are preserved. |
472
+ | `breaking` | `boolean` | Toggles the `🚨 **Breaking:** ` marker on the bullet. |
473
+
474
+ ### Hash-prefix matching
475
+
476
+ Keys can be either the full 40-character commit SHA or a non-ambiguous prefix. The matcher walks every `ChangelogItem.hash` value present in the entry tree and resolves each override key to its set of matching hashes:
477
+
478
+ - **Exact prefix match (1 hit)** — the override applies. A 7-character prefix is usually unambiguous within a single repo's history; longer prefixes are always safe.
479
+ - **No matches (0 hits)** — the override is treated as a stale reference (probably from a rebase or branch deletion) and a warning is logged. The release continues.
480
+ - **Ambiguous prefix (2+ hits)** — the release aborts with an error naming the key and the matching hashes. Lengthen the prefix or use the full SHA.
481
+
482
+ Override application errors abort the run with a non-zero exit; warnings (zero-match keys) are non-fatal and surface on `PrepareResult.warnings`.
483
+
484
+ ### Validation
485
+
486
+ The override file is validated when `release-kit prepare` loads it. Each error names the offending key so you can locate it in your file:
487
+
488
+ - Missing file → empty map, no error (the no-op default — projects that do not need overrides skip the file entirely).
489
+ - Malformed JSON → error.
490
+ - Wrong top-level shape (e.g., array, primitive) → error.
491
+ - Unknown fields on an entry → error.
492
+ - Wrong field types (e.g., `description` as a number) → error.
493
+ - An entry with no fields set → error (a copy-paste mistake more often than not).
494
+ - `audience: 'all'` or `audience: 'dev'` → error in the current release: only `'skip'` is supported (see below).
495
+
496
+ #### Standalone validation: `release-kit overrides validate`
497
+
498
+ For a fast overrides-only health check (locally or as a CI gate), run:
499
+
500
+ ```sh
501
+ pnpm exec release-kit overrides validate
502
+ ```
503
+
504
+ This walks every `.meta/changelog-overrides.json` file across the project tier and per-workspace tier, reporting three classes of finding:
505
+
506
+ | Class | Examples | Exit code |
507
+ | ------------------- | ------------------------------------------------------------------------------------------- | --------- |
508
+ | Schema/parse errors | malformed JSON, unknown fields, wrong field types, no-field entries, unsupported `audience` | `2` |
509
+ | Ambiguous-prefix | an override key resolves to 2+ commit hashes | `2` |
510
+ | Stale-key warnings | an override key resolves to no commit in its applicable scope | `1` |
511
+
512
+ Exit code semantics:
513
+
514
+ - `0` — clean (no errors, no stale keys).
515
+ - `1` — only stale-key warnings.
516
+ - `2` — schema/parse or ambiguous-prefix errors (errors dominate when both classes are present).
517
+
518
+ Tier-aware stale-key semantics match `release-kit prepare`'s match-set exactly: a workspace-tier key is stale if it does not match in its own workspace's history; a root-tier key is stale only if it matches in **no** scope (no workspace AND not the project release window).
519
+
520
+ The same logic is also exposed programmatically via the `validateAllChangelogOverrides` function exported from `@williamthorsen/release-kit`, for callers that want to integrate the check into their own tooling.
521
+
522
+ ### Audience semantics: v1 supports `'skip'` only
523
+
524
+ The on-disk format declares the full `'all' | 'dev' | 'skip'` audience vocabulary so the file format will not need to change when the v2 reclassification feature ships. In the current release, only `'skip'` is supported at runtime; `'all'` and `'dev'` are rejected with an explicit "not yet supported" error.
525
+
526
+ The eventual v2 behavior will let an override move a single item to a different audience section (e.g., reclassifying a `Documentation` entry as `Internal features` to keep it out of public-facing release notes). v1 deliberately leaves that as a separate change so the override mechanism can ship now and the section-split logic can land additively later.
527
+
528
+ ### Worked example 1: cleaning up scaffolded historical commits (root file)
529
+
530
+ Suppose a year-old commit `82962311` was authored from a PR template that left `## What` / `## Why` headings in the body, and that commit now appears in your in-app release notes as literal Markdown headings. Add an override at the project tier:
531
+
532
+ ```json
533
+ // .meta/changelog-overrides.json
534
+ {
535
+ "82962311": {
536
+ "body": "Add the in-app release-notes page with version-aware navigation."
537
+ }
538
+ }
539
+ ```
540
+
541
+ On the next `release-kit prepare` run, the matched item's body is replaced before the JSON and Markdown artifacts are written. The original git history is untouched.
542
+
543
+ ### Worked example 2: suppressing a cross-attribution spillover (workspace file)
544
+
545
+ Release-kit attributes commits to workspaces by file path, so a commit that primarily belongs to one workspace can land in another's changelog if it touched files there. Suppose commit `1ce3d2f` renamed the `audit-deps` package to `v11y-check` (scope `v11y-check`) but also edited `packages/nmr/src/default-scripts.ts` and `packages/nmr/README.md`. The commit correctly appears in `packages/v11y-check/CHANGELOG.md`, but it also spills into `packages/nmr/CHANGELOG.md` where it isn't the right editorial framing.
546
+
547
+ Drop a workspace-tier override at `packages/nmr/.meta/changelog-overrides.json`:
548
+
549
+ ```json
550
+ // packages/nmr/.meta/changelog-overrides.json
551
+ {
552
+ "1ce3d2f": {
553
+ "audience": "skip"
554
+ }
555
+ }
556
+ ```
557
+
558
+ The commit is now suppressed in nmr's changelog only — it still appears in v11y-check's, where it belongs. A root-tier `'skip'` would have removed it from both, which is the wrong outcome.
559
+
560
+ ### Rendering pipeline change
561
+
562
+ Prior versions of release-kit shelled out to `git-cliff` for both structured `--context` JSON and rendered Markdown (cliff's body template). After this change, `git-cliff` is invoked only for `--context` JSON; release-kit's in-process `renderChangelogMarkdown` produces `CHANGELOG.md` from the same `ChangelogEntry[]` that drives `.meta/changelog.json`. The two artifacts can no longer disagree.
563
+
564
+ The bundled `cliff.toml.template`'s body template has been emptied (the `[git].commit_parsers` section is still load-bearing for `--context` group assignment); custom `.config/git-cliff.toml` files no longer need a body template.
565
+
566
+ Observable output differences from the prior cliff-rendered format:
567
+
568
+ - Trailers (`Signed-off-by:`, `Co-authored-by:`, `Closes #N`, GitHub PR URLs) are stripped from rendered bodies.
569
+ - Items whose commit subject carries the `!` breaking marker render with a `🚨 **Breaking:** ` prefix.
570
+ - Empty version entries (releases with no commits routed to a section) are omitted; the prior cliff template rendered them as bare headings.
571
+ - The footer comment is now `<!-- Generated by release-kit. Do not edit this file. Use .meta/changelog-overrides.json to override entries. -->`.
572
+
573
+ The first release that ships under the new renderer will produce a one-time noisy diff in `CHANGELOG.md` (whitespace, trailers, breaking markers). Subsequent releases stabilize.
394
574
 
395
575
  ## CLI reference
396
576
 
@@ -550,6 +730,18 @@ The check is non-blocking initially: until codeassembly publishes its `work-type
550
730
 
551
731
  These commands are also exposed as `nmr work-types:check` / `nmr work-types:sync` from any package directory.
552
732
 
733
+ #### Authenticated fetches
734
+
735
+ When the upstream codeassembly repo is private, both `check` and `sync` need a GitHub token to fetch the canonical `work-types.json`. Set `GITHUB_TOKEN` in the environment and the commands send `Authorization: Bearer <token>` automatically; without it, requests are unauthenticated and a private upstream will return 404.
736
+
737
+ ```sh
738
+ # Source from `gh auth` for local runs:
739
+ export GITHUB_TOKEN=$(gh auth token)
740
+ pnpm exec release-kit work-types check
741
+ ```
742
+
743
+ The token needs `contents: read` on the codeassembly repo (fine-grained PAT scope) or the equivalent classic-PAT scope. A token without sufficient scope still produces a 404 — same response as a missing upstream — so a misconfigured token degrades to the transitional-warning path rather than failing loudly. CI wiring against private upstream is deferred until either codeassembly is publicly readable or a cross-repo PAT is provisioned as a workflow secret.
744
+
553
745
  ### `release-kit sync-labels`
554
746
 
555
747
  Manage GitHub label definitions via config-driven YAML files.
@@ -621,9 +813,11 @@ The bundled template provides a generic git-cliff configuration that:
621
813
 
622
814
  - Strips issue-ticket prefixes matching `^[A-Z]+-\d+\s+` (e.g., `TOOL-123 `, `AFG-456 `)
623
815
  - Handles both `type: description` and `workspace|type: description` commit formats
624
- - Groups commits by work type into changelog sections
816
+ - Groups commits by work type via `[git].commit_parsers`
817
+
818
+ The body template is intentionally empty: release-kit reads cliff's `--context` JSON output and renders `CHANGELOG.md` in-process via `renderChangelogMarkdown` (see [Editorial overrides](#editorial-overrides) for the rationale). The `[git].commit_parsers` section remains load-bearing for `--context` group assignment.
625
819
 
626
- To customize, scaffold a local copy with `release-kit init --with-config` and edit `.config/git-cliff.toml`.
820
+ To customize, scaffold a local copy with `release-kit init --with-config` and edit `.config/git-cliff.toml`. Edit only the `[git]` section — body-template changes have no effect.
627
821
 
628
822
  ## External dependencies
629
823
 
@@ -18,45 +18,10 @@
18
18
  # unique group (not per parser entry) — all parsers routing to the same group
19
19
  # share the same prefix.
20
20
 
21
- [changelog]
22
- header = """
23
- # Changelog
24
-
25
- All notable changes to this project will be documented in this file."""
26
- # Render each commit as a bullet with the description (stripped of ticket ID
27
- # and scope|type prefix). If the commit has a body, render it as indented
28
- # continuation paragraphs under the bullet.
29
- body = """{%- if version %}
30
- ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
31
- {%- else %}
32
- ## [unreleased]
33
- {%- endif -%}
34
- {%- set non_merge = commits | filter(attribute="merge_commit", value=false) -%}
35
- {%- for group, group_commits in non_merge | group_by(attribute="group") %}
36
-
37
- ### {{ group | striptags | trim | upper_first }}
38
-
39
- {% for commit in group_commits -%}
40
- {%- set subject = commit.message | split(pat="\n") | first -%}
41
- {%- set desc = subject | split(pat=": ") | slice(start=1) | join(sep=": ") -%}
42
- {%- set body_lines = commit.message | split(pat="\n") | slice(start=2) -%}
43
- {%- set body_text = body_lines | join(sep="\n") | trim -%}
44
- {%- if not loop.first %}
45
-
46
- {% endif -%}
47
- - {{ desc | upper_first }}
48
- {%- if body_text %}
49
-
50
- {{ body_text | split(pat="\n") | join(sep="\n ") }}
51
- {%- endif -%}
52
- {%- endfor -%}
53
- {%- endfor %}
54
- """
55
- footer = """
56
-
57
- <!-- generated by git-cliff -->
58
- """
59
- trim = false
21
+ # Markdown rendering is handled in-process by `renderChangelogMarkdown.ts`. cliff is invoked
22
+ # only for `--context` JSON output (see `buildChangelogEntries.ts`), so no `[changelog]` block
23
+ # is needed. The `[git].commit_parsers` section below remains load-bearing for group
24
+ # assignment in the `--context` payload.
60
25
 
61
26
  [git]
62
27
  conventional_commits = false
package/dist/esm/.cache CHANGED
@@ -1 +1 @@
1
- 64d26b06f4a6e205faa07a507fd64938899e4d635dd02b333016caf1d937a14e
1
+ 18bd566d86a692ebdc23649a475e7d5e01562a58df99b6091e222e890cef8f80
@@ -13,6 +13,7 @@ import { syncLabelsInitCommand } from "../sync-labels/initCommand.js";
13
13
  import { syncLabelsCommand } from "../sync-labels/syncCommand.js";
14
14
  import { syncWorkTypes } from "../syncWorkTypes.js";
15
15
  import { tagCommand } from "../tagCommand.js";
16
+ import { validateOverridesCommand } from "../validateOverridesCommand.js";
16
17
  const VERSION = readPackageVersion(import.meta.url);
17
18
  function showUsage() {
18
19
  console.info(`
@@ -27,6 +28,7 @@ Commands:
27
28
  create-github-release Create GitHub Releases from changelog.json for tags on HEAD
28
29
  show-tag-prefixes Show derived and declared legacy tag prefixes per workspace
29
30
  init Initialize release-kit in the current repository
31
+ overrides Manage editorial changelog overrides
30
32
  sync-labels Manage GitHub label synchronization
31
33
  work-types Check for or sync work-type taxonomy drift against the upstream canonical
32
34
 
@@ -174,6 +176,35 @@ legacy tag prefixes. Surfaces any release-shaped tags whose prefix is neither a
174
176
  derived prefix nor declared in \`legacyIdentities\`, with a copy-pasteable
175
177
  config snippet.
176
178
 
179
+ Options:
180
+ --help, -h Show this help message
181
+ `);
182
+ }
183
+ function showOverridesHelp() {
184
+ console.info(`
185
+ Usage: release-kit overrides <subcommand> [options]
186
+
187
+ Manage editorial changelog overrides.
188
+
189
+ Subcommands:
190
+ validate Validate every \`.meta/changelog-overrides.json\` file across all scopes
191
+
192
+ Options:
193
+ --help, -h Show this help message
194
+ `);
195
+ }
196
+ function showOverridesValidateHelp() {
197
+ console.info(`
198
+ Usage: release-kit overrides validate
199
+
200
+ Validate every \`.meta/changelog-overrides.json\` file across the project and per-workspace
201
+ scopes. Reports schema/parse errors, ambiguous-prefix errors, and stale-key warnings.
202
+
203
+ Exit codes:
204
+ 0 Clean \u2014 no errors, no stale keys
205
+ 1 Stale-key warnings only (no errors)
206
+ 2 Schema/parse or ambiguous-prefix errors (errors dominate)
207
+
177
208
  Options:
178
209
  --help, -h Show this help message
179
210
  `);
@@ -389,6 +420,34 @@ if (command === "sync-labels") {
389
420
  showSyncLabelsHelp();
390
421
  process.exit(1);
391
422
  }
423
+ if (command === "overrides") {
424
+ const subcommand = flags[0];
425
+ const subflags = flags.slice(1);
426
+ if (subcommand === "--help" || subcommand === "-h" || subcommand === void 0) {
427
+ showOverridesHelp();
428
+ process.exit(0);
429
+ }
430
+ if (subcommand === "validate") {
431
+ if (subflags.some((f) => f === "--help" || f === "-h")) {
432
+ showOverridesValidateHelp();
433
+ process.exit(0);
434
+ }
435
+ if (subflags.length > 0) {
436
+ console.error(`Error: Unknown option: ${subflags[0]}`);
437
+ process.exit(1);
438
+ }
439
+ const result = await validateOverridesCommand();
440
+ if (result.exitCode === 0) {
441
+ console.info(result.message);
442
+ } else {
443
+ console.error(result.message);
444
+ }
445
+ process.exit(result.exitCode);
446
+ }
447
+ console.error(`Error: Unknown subcommand: ${subcommand}`);
448
+ showOverridesHelp();
449
+ process.exit(1);
450
+ }
392
451
  if (command === "work-types") {
393
452
  const subcommand = flags[0];
394
453
  const subflags = flags.slice(1);
@@ -69,6 +69,9 @@ function toCliffContextCommit(value) {
69
69
  if (typeof value.group === "string") {
70
70
  commit.group = value.group;
71
71
  }
72
+ if (typeof value.id === "string") {
73
+ commit.id = value.id;
74
+ }
72
75
  return commit;
73
76
  }
74
77
  function transformReleases(releases, devOnlySections) {
@@ -98,6 +101,9 @@ function transformReleases(releases, devOnlySections) {
98
101
  if (breaking) {
99
102
  item.breaking = true;
100
103
  }
104
+ if (commit.id !== void 0 && commit.id !== "") {
105
+ item.hash = commit.id;
106
+ }
101
107
  items.push(item);
102
108
  }
103
109
  const sections = [];
@@ -0,0 +1,2 @@
1
+ import type { ChangelogEntry } from './types.ts';
2
+ export declare function buildEmptyReleaseEntry(version: string, date: string): ChangelogEntry;
@@ -0,0 +1,16 @@
1
+ function buildEmptyReleaseEntry(version, date) {
2
+ return {
3
+ version,
4
+ date,
5
+ sections: [
6
+ {
7
+ title: "Notes",
8
+ audience: "dev",
9
+ items: [{ description: "Forced version bump." }]
10
+ }
11
+ ]
12
+ };
13
+ }
14
+ export {
15
+ buildEmptyReleaseEntry
16
+ };
@@ -2,3 +2,5 @@ import type { ChangelogEntry, ReleaseConfig } from './types.ts';
2
2
  export declare function resolveChangelogJsonPath(config: Pick<ReleaseConfig, 'changelogJson'>, changelogPath: string): string;
3
3
  export declare function writeChangelogJson(filePath: string, entries: ChangelogEntry[]): string;
4
4
  export declare function upsertChangelogJson(filePath: string, entries: ChangelogEntry[]): string;
5
+ export declare function upsertChangelogJsonAndReturn(filePath: string, entries: ChangelogEntry[]): ChangelogEntry[];
6
+ export declare function mergeChangelogEntriesWithDisk(filePath: string, entries: ChangelogEntry[]): ChangelogEntry[];
@@ -14,11 +14,19 @@ function writeChangelogJson(filePath, entries) {
14
14
  return filePath;
15
15
  }
16
16
  function upsertChangelogJson(filePath, entries) {
17
+ upsertChangelogJsonAndReturn(filePath, entries);
18
+ return filePath;
19
+ }
20
+ function upsertChangelogJsonAndReturn(filePath, entries) {
17
21
  const existing = readExistingEntries(filePath);
18
22
  const merged = mergeEntries(entries, existing);
19
23
  mkdirSync(dirname(filePath), { recursive: true });
20
24
  writeFileSync(filePath, stringify(merged, { maxLength: 100 }) + "\n", "utf8");
21
- return filePath;
25
+ return merged;
26
+ }
27
+ function mergeChangelogEntriesWithDisk(filePath, entries) {
28
+ const existing = readExistingEntries(filePath);
29
+ return mergeEntries(entries, existing);
22
30
  }
23
31
  function sortNewestFirst(entries) {
24
32
  return [...entries].sort((a, b) => compareVersionsDescending(a.version, b.version));
@@ -62,7 +70,9 @@ function compareVersionsDescending(a, b) {
62
70
  return 0;
63
71
  }
64
72
  export {
73
+ mergeChangelogEntriesWithDisk,
65
74
  resolveChangelogJsonPath,
66
75
  upsertChangelogJson,
76
+ upsertChangelogJsonAndReturn,
67
77
  writeChangelogJson
68
78
  };
@@ -1,4 +1,5 @@
1
- import type { ChangelogEntry } from './types.ts';
1
+ import type { ChangelogEntry, ChangelogItem } from './types.ts';
2
+ export declare function isChangelogItem(value: unknown): value is ChangelogItem;
2
3
  export declare function isChangelogEntry(value: unknown): value is ChangelogEntry;
3
4
  export declare function extractVersion(tag: string): string;
4
5
  export declare function readChangelogEntries(filePath: string): ChangelogEntry[] | undefined;