@williamthorsen/release-kit 8.0.1 → 9.0.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 (44) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +93 -18
  3. package/cliff.toml.template +20 -20
  4. package/dist/esm/bin/release-kit.js +12 -9
  5. package/dist/esm/buildChangelogEntries.js +5 -4
  6. package/dist/esm/changelogOverrides.js +4 -3
  7. package/dist/esm/createGithubRelease.js +2 -2
  8. package/dist/esm/defineConfig.d.ts +2 -0
  9. package/dist/esm/defineConfig.js +3 -0
  10. package/dist/esm/detectPackageManager.js +1 -1
  11. package/dist/esm/format.js +2 -2
  12. package/dist/esm/getCommitsSinceTarget.js +1 -1
  13. package/dist/esm/index.d.ts +2 -2
  14. package/dist/esm/index.js +1 -0
  15. package/dist/esm/init/templates.js +6 -10
  16. package/dist/esm/injectReleaseNotesIntoReadme.js +1 -1
  17. package/dist/esm/loadValidatedConfig.d.ts +10 -0
  18. package/dist/esm/loadValidatedConfig.js +26 -0
  19. package/dist/esm/parseCommitMessage.d.ts +1 -0
  20. package/dist/esm/parseCommitMessage.js +3 -1
  21. package/dist/esm/prepareCommand.js +7 -6
  22. package/dist/esm/previewTagPrefixes.js +2 -5
  23. package/dist/esm/releasePrepareMono.js +6 -5
  24. package/dist/esm/renderReleaseNotes.js +2 -2
  25. package/dist/esm/resolveCommandTags.js +4 -3
  26. package/dist/esm/stripScope.js +3 -2
  27. package/dist/esm/sync-labels/generateCommand.d.ts +7 -1
  28. package/dist/esm/sync-labels/generateCommand.js +45 -15
  29. package/dist/esm/sync-labels/initCommand.js +47 -7
  30. package/dist/esm/sync-labels/resolveLabels.d.ts +3 -2
  31. package/dist/esm/sync-labels/resolveLabels.js +14 -26
  32. package/dist/esm/sync-labels/retiredConfig.d.ts +2 -0
  33. package/dist/esm/sync-labels/retiredConfig.js +11 -0
  34. package/dist/esm/sync-labels/syncCommand.js +4 -0
  35. package/dist/esm/sync-labels/templates.d.ts +3 -2
  36. package/dist/esm/sync-labels/templates.js +44 -16
  37. package/dist/esm/sync-labels/types.d.ts +0 -4
  38. package/dist/esm/types.d.ts +20 -0
  39. package/dist/esm/types.js +13 -0
  40. package/dist/esm/writeReleaseNotesPreviews.js +1 -1
  41. package/package.json +3 -3
  42. package/presets/labels/common.yaml +3 -0
  43. package/dist/esm/sync-labels/loadSyncLabelsConfig.d.ts +0 -3
  44. package/dist/esm/sync-labels/loadSyncLabelsConfig.js +0 -53
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 9.0.0 — 2026-07-28
6
+
7
+ ### šŸŽ‰ Features
8
+
9
+ - 🚨 **Breaking:** Unify label configuration in release-kit.config.ts (#505)
10
+
11
+ Repositories now declare their label set alongside the rest of their release-kit configuration in `.config/release-kit.config.ts`, and the declaration is validated and completed in the editor as it is written. A label supplied by a bundled preset can now be recolored, reworded, or dropped. `sync-labels generate --check` now reports when the committed label file has fallen behind the declaration, writing nothing. The standalone `.config/sync-labels.config.ts` is no longer read; while it remains in place, every `sync-labels` command refuses to run, naming what to move and where.
12
+
13
+ ### šŸ› Bug fixes
14
+
15
+ - Include commits scoped to `*` in the changelog (#496)
16
+
17
+ Fixes an issue where commits scoped to `*` raised the version but were silently dropped from the generated changelog.
18
+
19
+ ## 8.1.0 — 2026-07-22
20
+
21
+ ### šŸŽ‰ Features
22
+
23
+ - Make label deletions reviewable and apply labels on merge (#493)
24
+
25
+ Pull requests that touch a repository's label file now get a check listing every label change the sync would make, including deletions of labels the file never mentions, which a diff of the file cannot show. Merging the pull request now applies those labels. The sync's deletion and rename handling now changes only through a reviewed upgrade to a verified release. Repositories that already scaffolded this workflow adopt the new triggers by re-running `release-kit sync-labels init --force`.
26
+
27
+ ### šŸ“¦ Dependencies
28
+
29
+ - Upgrade dependencies and align the Node support policy (#483)
30
+
31
+ All four published packages (`nmr`, `nmr-core`, `release-kit`, and `v11y-check`) now require Node.js 24 or later, up from Node 18.17. Separately, `nmr-compile` now rebuilds when the TypeScript version changes.
32
+
5
33
  ## 8.0.1 — 2026-07-18
6
34
 
7
35
  ### šŸ› Bug fixes
@@ -32,12 +60,24 @@ All notable changes to this project will be documented in this file.
32
60
 
33
61
  ### šŸŽ‰ Features
34
62
 
63
+ - 🚨 **Breaking:** Rewrite parseArgs on node:util with typed error and exit API (#428)
64
+
65
+ `@williamthorsen/nmr-core` gains a typed error API for CLI parsing: `parseArgsOrExit` (parse, or print a usage error and exit) and `ParseError` replace the per-command boilerplate, so every bundled CLI reports a flag mistake the same way. Breaking: the `translateParseError` export is removed and `parseArgs` now throws `ParseError`.
66
+
35
67
  - 🚨 **Breaking:** Auto-activate integration test variant from config presence (#448)
36
68
 
37
69
  A package can now separate its integration tests from its standalone suite simply by including a `vitest.integration.config.ts` (alongside a `vitest.standalone.config.ts`). The `--int-test` flag that previously enabled this is removed — that config-file pairing is now the only way to activate the separation. In such a package, `test` and `test:coverage` run only the standalone suite and skip integration tests, while a new `test:all` runs both suites together. The separation now holds even when tests run across every package at once, so a full-workspace `test` run still keeps integration tests out of the default suite. Packages that previously hand-copied these test scripts no longer need to.
38
70
 
39
71
  ### ā™»ļø Refactoring
40
72
 
73
+ - Route all CLI error reporting through a single stderr helper (#437)
74
+
75
+ Consolidates error reporting across the CLIs so every command reports errors the same way, and guards against the previous inconsistency returning. The error messages and exit codes users see are unchanged.
76
+
77
+ - Normalize CLI error wording to the canonical Error: format (#439)
78
+
79
+ Single-line error messages from the `nmr` and `release-kit` commands now print in one uniform shape, so the same class of failure reads the same way no matter which command produced it. Previously the wording varied, which made error output harder to scan and harder for scripts to match against. Richer output, such as multi-line validation reports and the stack trace from an unexpected crash, is unchanged.
80
+
41
81
  - Consolidate prepare parsing onto parseArgsOrExit (#441)
42
82
 
43
83
  Makes `release-kit`'s `prepare` command report invalid arguments the same way its other commands already do. The error message and exit code a user sees for a bad argument are unchanged.
package/README.md CHANGED
@@ -5,17 +5,25 @@ Version-bumping and changelog-generation toolkit for release workflows.
5
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 — v8.0.1 (2026-07-18)
8
+ ## Release notes — v9.0.0 (2026-07-28)
9
+
10
+ ### šŸŽ‰ Features
11
+
12
+ - 🚨 **Breaking:** Unify label configuration in release-kit.config.ts (#505)
13
+
14
+ Repositories now declare their label set alongside the rest of their release-kit configuration in `.config/release-kit.config.ts`, and the declaration is validated and completed in the editor as it is written. A label supplied by a bundled preset can now be recolored, reworded, or dropped. `sync-labels generate --check` now reports when the committed label file has fallen behind the declaration, writing nothing. The standalone `.config/sync-labels.config.ts` is no longer read; while it remains in place, every `sync-labels` command refuses to run, naming what to move and where.
9
15
 
10
16
  ### šŸ› Bug fixes
11
17
 
12
- - Skip private packages cleanly in publish and create-github-release (#477)
18
+ - Include commits scoped to `*` in the changelog (#496)
13
19
 
14
- Fixes an issue where a workspace marked private in its `package.json` could break a release: `release-kit publish` failed on it, and `release-kit create-github-release` created an unwanted public GitHub Release for it. Both commands now skip private workspaces cleanly: publish reports the skip and continues instead of failing the run, and create-github-release produces no Release for a private package. Any publishable packages released alongside it still publish and still get their Releases. Marking a workspace private is now all it takes to exclude it from both publishing and GitHub Releases.
20
+ Fixes an issue where commits scoped to `*` raised the version but were silently dropped from the generated changelog.
15
21
  <!-- /section:release-notes -->
16
22
 
17
23
  ## Installation
18
24
 
25
+ Requires Node.js 24 or later.
26
+
19
27
  ```bash
20
28
  pnpm add -D @williamthorsen/release-kit
21
29
  ```
@@ -92,9 +100,9 @@ Configuration is optional. The CLI works out of the box by auto-discovering work
92
100
  ### Config file
93
101
 
94
102
  ```typescript
95
- import type { ReleaseKitConfig } from '@williamthorsen/release-kit';
103
+ import { defineConfig } from '@williamthorsen/release-kit';
96
104
 
97
- const config: ReleaseKitConfig = {
105
+ export default defineConfig({
98
106
  // Exclude a workspace from release processing
99
107
  workspaces: [{ dir: 'internal-tools', shouldExclude: true }],
100
108
 
@@ -106,12 +114,10 @@ const config: ReleaseKitConfig = {
106
114
 
107
115
  // Add or override work types (merged with defaults by key)
108
116
  workTypes: { perf: { header: 'Performance' } },
109
- };
110
-
111
- export default config;
117
+ });
112
118
  ```
113
119
 
114
- The config file supports both `export default config` and `export const config = { ... }`.
120
+ `defineConfig` is a type-safe identity function: it gives the config object validation and completion in `.ts` files and full inference in `.js` ones. The loader also accepts a plain `export default config` or `export const config = { ... }`.
115
121
 
116
122
  ### `ReleaseKitConfig` reference
117
123
 
@@ -126,6 +132,7 @@ The config file supports both `export default config` and `export const config =
126
132
  | `breakingPolicies` | `Record<string, 'forbidden' \| 'optional' \| 'required'>` | Per-type `!`-policy lookup. Defaults to `DEFAULT_BREAKING_POLICIES`. Replaces the default entirely when provided. Set to `{}` to disable enforcement |
127
133
  | `retiredPackages` | `RetiredPackage[]` | Packages that once lived in this repo but have been extracted or removed; suppresses undeclared-tag-prefix warnings |
128
134
  | `project` | `ProjectConfig` | Opt-in project-level release block. Declaring `project: {}` (even empty) enables a project-release stage in `prepare` |
135
+ | `repoLabels` | `RepoLabelsConfig` | Declares the repository's label registry for `sync-labels`; see [Label configuration](#label-configuration) |
129
136
 
130
137
  All fields are optional.
131
138
 
@@ -161,13 +168,11 @@ interface RetiredPackage {
161
168
  Worked example — `preflight` was extracted from this monorepo and continues as the standalone `readyup` project. Its tags stay in this repo as historical anchors:
162
169
 
163
170
  ```typescript
164
- import type { ReleaseKitConfig } from '@williamthorsen/release-kit';
171
+ import { defineConfig } from '@williamthorsen/release-kit';
165
172
 
166
- const config: ReleaseKitConfig = {
173
+ export default defineConfig({
167
174
  retiredPackages: [{ name: '@scope/preflight', tagPrefix: 'preflight-v', successor: 'readyup' }],
168
- };
169
-
170
- export default config;
175
+ });
171
176
  ```
172
177
 
173
178
  Validation rules:
@@ -718,15 +723,85 @@ The token needs `contents: read` on the codeassembly repo (fine-grained PAT scop
718
723
 
719
724
  ### `release-kit sync-labels`
720
725
 
721
- Manage GitHub label definitions via config-driven YAML files.
726
+ Manage GitHub label definitions via the `repoLabels` block of `.config/release-kit.config.ts`.
722
727
 
723
728
  | Subcommand | Description | Flags |
724
729
  | ---------- | -------------------------------------------------------------- | ---------------------- |
725
- | `init` | Scaffold config, caller workflow, and generate labels | `--dry-run`, `--force` |
726
- | `generate` | Regenerate `.github/labels.yaml` from config | — |
730
+ | `init` | Scaffold the caller workflow, seed config, and generate labels | `--dry-run`, `--force` |
731
+ | `generate` | Regenerate `.github/labels.yaml` from config | `--check` |
727
732
  | `sync` | Trigger the `sync-labels` GitHub Actions workflow via `gh` CLI | — |
728
733
 
729
- `init` scaffolds `.config/sync-labels.config.ts` with auto-detected workspace scope labels and a `.github/workflows/sync-labels.yaml` caller workflow, then generates `.github/labels.yaml`. `generate` reads the config and writes `.github/labels.yaml`. `sync` triggers the workflow remotely — it requires the `gh` CLI and an existing workflow file.
734
+ `init` scaffolds the `.github/workflows/sync-labels.yaml` caller workflow and seeds a `repoLabels` block with scope labels discovered from workspaces and declared `retiredPackages`. When `.config/release-kit.config.ts` does not exist, `init` writes it; when it does, `init` prints the block for manual paste — a hand-authored config is never rewritten. `generate` resolves the block and writes `.github/labels.yaml`; with `--check` it regenerates in memory and exits non-zero if the committed file is stale or missing, writing nothing. `sync` triggers the workflow remotely — it requires the `gh` CLI and an existing workflow file.
735
+
736
+ Every `sync-labels` subcommand refuses to run while the retired `.config/sync-labels.config.ts` is present, so custom labels cannot be silently dropped mid-migration; see [Migrating from `.config/sync-labels.config.ts`](#migrating-from-configsync-labelsconfigts).
737
+
738
+ #### Label configuration
739
+
740
+ ```typescript
741
+ import { defineConfig } from '@williamthorsen/release-kit';
742
+
743
+ export default defineConfig({
744
+ repoLabels: {
745
+ extends: ['common'],
746
+ labels: {
747
+ 'scope:my-package': { color: '00ff96', description: 'my-package package' }, // add
748
+ bug: { color: 'b60205', description: 'Something broken' }, // replace the preset's `bug`
749
+ wontfix: null, // remove the preset's `wontfix`
750
+ },
751
+ },
752
+ });
753
+ ```
754
+
755
+ The block declares the repository's label registry — the set of labels defined on the GitHub repo, distinct from labels applied to PRs and issues. Resolution is an ordered fold with last-writer-wins:
756
+
757
+ 1. Presets, in `extends` order — a later preset wins on a shared name.
758
+ 2. The `labels` record — an entry adds a label, replaces one an earlier layer defined, or removes it (`null`). A replacing entry supplies the full `{ color, description }`.
759
+
760
+ Overlaps are never errors; order resolves them, and the committed `.github/labels.yaml` diff is where an unexpected change surfaces at review. The one config error is a dangling `null` — removing a name no preset defines — because that misstatement is invisible in the output diff.
761
+
762
+ #### Migrating from `.config/sync-labels.config.ts`
763
+
764
+ Label configuration formerly lived in a standalone `.config/sync-labels.config.ts` (`presets` + a `labels` array). To migrate:
765
+
766
+ 1. Move the preset list to `repoLabels.extends` in `.config/release-kit.config.ts`.
767
+ 2. Convert each `labels` array entry to a `'name': { color, description }` record entry under `repoLabels.labels`.
768
+ 3. Delete `.config/sync-labels.config.ts` and run `release-kit sync-labels generate`.
769
+
770
+ The regenerated `.github/labels.yaml` differs only in its `# Source:` header line. Name collisions with preset labels, which the old format rejected, now mean your entry replaces the preset label.
771
+
772
+ #### When labels are applied
773
+
774
+ The scaffolded workflow carries three triggers:
775
+
776
+ | Trigger | Job | Token | Effect |
777
+ | --------------------------------------------------------- | ------- | --------------- | --------------------------------- |
778
+ | Push to the default branch touching `.github/labels.yaml` | `sync` | `issues: write` | Applies the labels |
779
+ | Manual dispatch (`release-kit sync-labels sync`) | `sync` | `issues: write` | Applies the labels |
780
+ | Pull request touching `.github/labels.yaml` | `check` | `issues: read` | Logs the diff without applying it |
781
+
782
+ Applying on merge closes the window in which a regenerated `.github/labels.yaml` sits unapplied — a window in which a later manual dispatch would apply a label set nobody reviewed.
783
+
784
+ The `check` job runs the same sync in dry-run. Its **Label diff** log group lists every create, edit, rename, and deletion the sync would perform, including deletions of labels the file does not declare — the destructive edits a diff of `.github/labels.yaml` alone cannot show, because a label created by hand or by another workflow is absent from the file both before and after. Read that log; the check reports success whether or not the diff is destructive, so a green check is not evidence that nothing will be deleted.
785
+
786
+ The two jobs are split because a job's permissions are fixed when the run is created and cannot vary by trigger. The split is what keeps a write-scoped token out of pull-request runs.
787
+
788
+ The push trigger filters on the path alone; the `sync` job compares `github.ref_name` against the repository's default branch. The workflow therefore needs no per-repo edit whatever that branch is named — and a push touching `.github/labels.yaml` on any other branch produces a run whose jobs all skip.
789
+
790
+ Manual dispatch is not a preview. It matches the `sync` job, so it applies the labels, deletions included; only the pull-request path runs in dry-run.
791
+
792
+ #### Dry-run checks on fork pull requests
793
+
794
+ GitHub issues a read-only `GITHUB_TOKEN` to pull requests from forks, and by default holds runs from first-time contributors until a maintainer approves them. The dry-run reads labels and writes nothing, so the check normally runs once approved. A repo that disables workflows on fork pull requests gets no check at all; to preview such a change, push the branch to the base repo and open the pull request from there.
795
+
796
+ #### Migrating an existing repo
797
+
798
+ The caller workflow gained the `push` and `pull_request` triggers after its first release. A repo scaffolded before that keeps working on manual dispatch alone — nothing breaks — but it applies labels only when someone remembers to. To adopt the new shape:
799
+
800
+ ```sh
801
+ pnpm exec release-kit sync-labels init --force
802
+ ```
803
+
804
+ `readyup` prompts for this: its `sync-labels.yaml matches template` check fails once the scaffolded workflow no longer matches the current template.
730
805
 
731
806
  #### Published JSON Schema for `.meta/label-map.json`
732
807
 
@@ -33,26 +33,26 @@ commit_preprocessors = []
33
33
  commit_parsers = [
34
34
  { message = "^release:", skip = true },
35
35
  { message = "^Merge", skip = true },
36
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?feat(!)?:", group = "<!-- 01 -->šŸŽ‰ Features" },
37
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?feature(!)?:", group = "<!-- 01 -->šŸŽ‰ Features" },
38
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?drop(!)?:", group = "<!-- 02 -->🪦 Removed" },
39
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?deprecate(!)?:", group = "<!-- 03 -->šŸ—‘ļø Deprecated" },
40
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?fix(!)?:", group = "<!-- 04 -->šŸ› Bug fixes" },
41
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?bugfix(!)?:", group = "<!-- 04 -->šŸ› Bug fixes" },
42
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?sec(!)?:", group = "<!-- 05 -->šŸ”’ Security" },
43
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?security(!)?:", group = "<!-- 05 -->šŸ”’ Security" },
44
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?perf(!)?:", group = "<!-- 06 -->⚔ Performance" },
45
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?performance(!)?:", group = "<!-- 06 -->⚔ Performance" },
46
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?internal(!)?:", group = "<!-- 07 -->šŸ—ļø Internal features" },
47
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?utility(!)?:", group = "<!-- 07 -->šŸ—ļø Internal features" },
48
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?refactor(!)?:", group = "<!-- 08 -->ā™»ļø Refactoring" },
49
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?tests?(!)?:", group = "<!-- 09 -->🧪 Tests" },
50
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?tooling(!)?:", group = "<!-- 10 -->āš™ļø Tooling" },
51
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?ci(!)?:", group = "<!-- 11 -->šŸ‘· CI" },
52
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?deps?(!)?:", group = "<!-- 12 -->šŸ“¦ Dependencies" },
53
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?ai(!)?:", group = "<!-- 13 -->šŸ¤– Agentic support" },
54
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?docs?(!)?:", group = "<!-- 14 -->šŸ“š Documentation" },
55
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?fmt(!)?:", skip = true },
36
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?feat(!)?:", group = "<!-- 01 -->šŸŽ‰ Features" },
37
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?feature(!)?:", group = "<!-- 01 -->šŸŽ‰ Features" },
38
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?drop(!)?:", group = "<!-- 02 -->🪦 Removed" },
39
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?deprecate(!)?:", group = "<!-- 03 -->šŸ—‘ļø Deprecated" },
40
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?fix(!)?:", group = "<!-- 04 -->šŸ› Bug fixes" },
41
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?bugfix(!)?:", group = "<!-- 04 -->šŸ› Bug fixes" },
42
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?sec(!)?:", group = "<!-- 05 -->šŸ”’ Security" },
43
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?security(!)?:", group = "<!-- 05 -->šŸ”’ Security" },
44
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?perf(!)?:", group = "<!-- 06 -->⚔ Performance" },
45
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?performance(!)?:", group = "<!-- 06 -->⚔ Performance" },
46
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?internal(!)?:", group = "<!-- 07 -->šŸ—ļø Internal features" },
47
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?utility(!)?:", group = "<!-- 07 -->šŸ—ļø Internal features" },
48
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?refactor(!)?:", group = "<!-- 08 -->ā™»ļø Refactoring" },
49
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?tests?(!)?:", group = "<!-- 09 -->🧪 Tests" },
50
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?tooling(!)?:", group = "<!-- 10 -->āš™ļø Tooling" },
51
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?ci(!)?:", group = "<!-- 11 -->šŸ‘· CI" },
52
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?deps?(!)?:", group = "<!-- 12 -->šŸ“¦ Dependencies" },
53
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?ai(!)?:", group = "<!-- 13 -->šŸ¤– Agentic support" },
54
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?docs?(!)?:", group = "<!-- 14 -->šŸ“š Documentation" },
55
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([^|]+\\|)?fmt(!)?:", skip = true },
56
56
  # Skip unticketed commits (maintenance, deps, initial scaffolding, etc.)
57
57
  { message = ".*", skip = true },
58
58
  ]
@@ -57,8 +57,10 @@ function showSyncLabelsInitHelp() {
57
57
  console.info(`
58
58
  Usage: release-kit sync-labels init [options]
59
59
 
60
- Scaffold the sync-labels caller workflow and config file, auto-discover workspaces
61
- for scope labels, then generate .github/labels.yaml.
60
+ Scaffold the sync-labels caller workflow and seed the repoLabels block of
61
+ .config/release-kit.config.ts with scope labels discovered from workspaces and
62
+ retired packages, then generate .github/labels.yaml. When the config file already
63
+ exists, print the block for manual paste instead of rewriting the file.
62
64
 
63
65
  Options:
64
66
  --dry-run Preview changes without writing files
@@ -68,11 +70,12 @@ Options:
68
70
  }
69
71
  function showSyncLabelsGenerateHelp() {
70
72
  console.info(`
71
- Usage: release-kit sync-labels generate
73
+ Usage: release-kit sync-labels generate [options]
72
74
 
73
- Regenerate .github/labels.yaml from .config/sync-labels.config.ts.
75
+ Regenerate .github/labels.yaml from the repoLabels block of .config/release-kit.config.ts.
74
76
 
75
77
  Options:
78
+ --check Report whether .github/labels.yaml is stale instead of writing it
76
79
  --help, -h Show this help message
77
80
  `);
78
81
  }
@@ -369,11 +372,11 @@ if (command === 'sync-labels') {
369
372
  showSyncLabelsGenerateHelp();
370
373
  process.exit(0);
371
374
  }
372
- if (subflags.length > 0) {
373
- reportError(`Unknown option: ${subflags[0]}`);
374
- process.exit(1);
375
- }
376
- const exitCode = await generateCommand();
375
+ const generateFlagSchema = {
376
+ check: { long: '--check', type: 'boolean' },
377
+ };
378
+ const { check } = parseArgsOrExit(subflags, generateFlagSchema).flags;
379
+ const exitCode = await generateCommand({ check });
377
380
  process.exit(exitCode);
378
381
  }
379
382
  if (subcommand === 'sync') {
@@ -1,6 +1,6 @@
1
1
  import { extractVersion } from "./changelogJsonUtils.js";
2
2
  import { DEFAULT_WORK_TYPES } from "./defaults.js";
3
- import { COMMIT_PREPROCESSOR_PATTERNS } from "./parseCommitMessage.js";
3
+ import { COMMIT_PREPROCESSOR_PATTERNS, PIPE_SCOPE_SOURCE } from "./parseCommitMessage.js";
4
4
  import { resolveCliffConfigPath } from "./resolveCliffConfigPath.js";
5
5
  import { runGitCliff } from "./runGitCliff.js";
6
6
  import { stripEmojiPrefix } from "./stripEmojiPrefix.js";
@@ -9,7 +9,7 @@ const HTML_COMMENT_PREFIX_PATTERN = /^<!--[^>]*-->/;
9
9
  const CANONICAL_SECTION_ORDER = new Map(Object.values(DEFAULT_WORK_TYPES).map((config, index) => [stripGroupDecorations(config.header), index]));
10
10
  function canonicalSectionPriority(title) {
11
11
  const index = CANONICAL_SECTION_ORDER.get(stripGroupDecorations(title));
12
- return index ?? Number.POSITIVE_INFINITY;
12
+ return index ?? Infinity;
13
13
  }
14
14
  export function stripGroupDecorations(group) {
15
15
  return stripEmojiPrefix(group.replace(HTML_COMMENT_PREFIX_PATTERN, ''));
@@ -124,15 +124,16 @@ function transformReleases(releases, devOnlySections) {
124
124
  function stripCommentPrefix(group) {
125
125
  return group.replace(HTML_COMMENT_PREFIX_PATTERN, '');
126
126
  }
127
+ const SUBJECT_BREAKING_MARKER_PATTERN = new RegExp(String.raw `^(?:${PIPE_SCOPE_SOURCE}\|)?\w+(?:\([^)]+\))?!:`);
127
128
  function subjectHasBreakingMarker(message) {
128
129
  let subject = message.split('\n', 1)[0] ?? '';
129
130
  for (const pattern of COMMIT_PREPROCESSOR_PATTERNS) {
130
131
  subject = subject.replace(pattern, '');
131
132
  }
132
- return /^(?:[^|]+\|)?\w+(?:\([^)]+\))?!:/.test(subject);
133
+ return SUBJECT_BREAKING_MARKER_PATTERN.test(subject);
133
134
  }
134
135
  function extractDescription(message) {
135
- const firstLine = message.split('\n')[0] ?? message;
136
+ const firstLine = message.split('\n', 1)[0] ?? message;
136
137
  const afterColon = firstLine.split(': ').slice(1).join(': ');
137
138
  if (afterColon.length > 0) {
138
139
  return afterColon.charAt(0).toUpperCase() + afterColon.slice(1);
@@ -59,10 +59,11 @@ function validateSingleOverride(key, rawEntry, errors) {
59
59
  }
60
60
  let entryValid = true;
61
61
  for (const fieldName of Object.keys(rawEntry)) {
62
- if (!KNOWN_OVERRIDE_FIELDS.has(fieldName)) {
63
- errors.push(`overrides['${key}']: unknown field '${fieldName}'`);
64
- entryValid = false;
62
+ if (KNOWN_OVERRIDE_FIELDS.has(fieldName)) {
63
+ continue;
65
64
  }
65
+ errors.push(`overrides['${key}']: unknown field '${fieldName}'`);
66
+ entryValid = false;
66
67
  }
67
68
  const result = {};
68
69
  if (rawEntry.audience !== undefined) {
@@ -26,7 +26,7 @@ export function createGithubRelease(options) {
26
26
  const body = renderReleaseNotesSingle(entry, {
27
27
  filter: matchesAudience('all'),
28
28
  includeHeading: false,
29
- ...(sectionOrder === undefined ? {} : { sectionOrder }),
29
+ ...(sectionOrder !== undefined && { sectionOrder }),
30
30
  });
31
31
  if (body.trim() === '') {
32
32
  return { status: 'skipped', reason: 'empty-body' };
@@ -48,7 +48,7 @@ export function createGithubReleases(tags, changelogJsonOutputPath, dryRun, sect
48
48
  tag,
49
49
  changelogJsonPath,
50
50
  dryRun,
51
- ...(sectionOrder === undefined ? {} : { sectionOrder }),
51
+ ...(sectionOrder !== undefined && { sectionOrder }),
52
52
  });
53
53
  if (result.status === 'created') {
54
54
  created.push(tag);
@@ -0,0 +1,2 @@
1
+ import type { ReleaseKitConfig } from './types.js';
2
+ export declare function defineConfig(config: ReleaseKitConfig): ReleaseKitConfig;
@@ -0,0 +1,3 @@
1
+ export function defineConfig(config) {
2
+ return config;
3
+ }
@@ -7,7 +7,7 @@ export function detectPackageManager() {
7
7
  const content = readFileSync(packageJsonPath, 'utf8');
8
8
  const parsed = JSON.parse(content);
9
9
  if (isRecord(parsed) && typeof parsed.packageManager === 'string') {
10
- const [name, version] = parsed.packageManager.split('@');
10
+ const [name, version] = parsed.packageManager.split('@', 2);
11
11
  if (name === 'pnpm' || name === 'npm') {
12
12
  return name;
13
13
  }
@@ -1,8 +1,8 @@
1
1
  export function bold(text) {
2
- return `\u001B[1m${text}\u001B[0m`;
2
+ return `\u{1B}[1m${text}\u{1B}[0m`;
3
3
  }
4
4
  export function dim(text) {
5
- return `\u001B[2m${text}\u001B[0m`;
5
+ return `\u{1B}[2m${text}\u{1B}[0m`;
6
6
  }
7
7
  export function sectionHeader(name) {
8
8
  return `━━━ ${name} ━━━`;
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- const FIELD_SEPARATOR = '\u001F';
2
+ const FIELD_SEPARATOR = '\u{1F}';
3
3
  function isNoTagError(err) {
4
4
  return typeof err === 'object' && err !== null && 'status' in err && err.status === 128;
5
5
  }
@@ -1,4 +1,4 @@
1
1
  export { type ChangelogOverrideScope, validateAllChangelogOverrides, type ValidateAllChangelogOverridesInputs, type ValidateAllChangelogOverridesResult, } from './changelogOverrides.js';
2
- export type { SyncLabelsConfig } from './sync-labels/types.js';
3
- export type { ReleaseKitConfig } from './types.js';
2
+ export { defineConfig } from './defineConfig.js';
3
+ export type { LabelSpec, ReleaseKitConfig, RepoLabelsConfig } from './types.js';
4
4
  export { formatValidateOverridesResult, type ValidateOverridesCommandResult } from './validateOverridesCommand.js';
package/dist/esm/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { validateAllChangelogOverrides, } from "./changelogOverrides.js";
2
+ export { defineConfig } from "./defineConfig.js";
2
3
  export { formatValidateOverridesResult } from "./validateOverridesCommand.js";
@@ -1,8 +1,8 @@
1
1
  export function releaseConfigScript(repoType) {
2
2
  if (repoType === 'monorepo') {
3
- return `import type { ReleaseKitConfig } from '@williamthorsen/release-kit';
3
+ return `import { defineConfig } from '@williamthorsen/release-kit';
4
4
 
5
- const config: ReleaseKitConfig = {
5
+ export default defineConfig({
6
6
  releaseNotes: {
7
7
  shouldInjectIntoReadme: true,
8
8
  },
@@ -19,14 +19,12 @@ const config: ReleaseKitConfig = {
19
19
 
20
20
  // Uncomment to add custom work types (merged with defaults):
21
21
  // workTypes: { perf: { header: 'Performance' } },
22
- };
23
-
24
- export default config;
22
+ });
25
23
  `;
26
24
  }
27
- return `import type { ReleaseKitConfig } from '@williamthorsen/release-kit';
25
+ return `import { defineConfig } from '@williamthorsen/release-kit';
28
26
 
29
- const config: ReleaseKitConfig = {
27
+ export default defineConfig({
30
28
  releaseNotes: {
31
29
  shouldInjectIntoReadme: true,
32
30
  },
@@ -38,9 +36,7 @@ const config: ReleaseKitConfig = {
38
36
 
39
37
  // Uncomment to add custom work types (merged with defaults):
40
38
  // workTypes: { perf: { header: 'Performance' } },
41
- };
42
-
43
- export default config;
39
+ });
44
40
  `;
45
41
  }
46
42
  export function publishWorkflow(repoType) {
@@ -22,7 +22,7 @@ export function renderInjectedReadme(readme, changelogJsonPath, tag, sectionOrde
22
22
  const renderedSections = renderReleaseNotesSingle(entry, {
23
23
  filter: matchesAudience('all'),
24
24
  includeHeading: false,
25
- ...(sectionOrder === undefined ? {} : { sectionOrder }),
25
+ ...(sectionOrder !== undefined && { sectionOrder }),
26
26
  });
27
27
  if (renderedSections.trimEnd().length === 0) {
28
28
  console.warn(`Warning: no user-facing release notes for version ${version}; skipping README injection`);
@@ -0,0 +1,10 @@
1
+ import type { ReleaseKitConfig } from './types.js';
2
+ export type LoadValidatedConfigResult = {
3
+ status: 'invalid';
4
+ } | {
5
+ status: 'missing';
6
+ } | {
7
+ status: 'ok';
8
+ config: ReleaseKitConfig;
9
+ };
10
+ export declare function loadValidatedConfig(): Promise<LoadValidatedConfigResult>;
@@ -0,0 +1,26 @@
1
+ import { reportError } from '@williamthorsen/nmr-core';
2
+ import { loadConfig } from "./loadConfig.js";
3
+ import { validateConfig } from "./validateConfig.js";
4
+ export async function loadValidatedConfig() {
5
+ let raw;
6
+ try {
7
+ raw = await loadConfig();
8
+ }
9
+ catch (error) {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ reportError(`Failed to load config: ${message}`);
12
+ return { status: 'invalid' };
13
+ }
14
+ if (raw === undefined) {
15
+ return { status: 'missing' };
16
+ }
17
+ const { config, errors } = validateConfig(raw);
18
+ if (errors.length > 0) {
19
+ process.stderr.write('Invalid config:\n');
20
+ for (const err of errors) {
21
+ process.stderr.write(` āŒ ${err}\n`);
22
+ }
23
+ return { status: 'invalid' };
24
+ }
25
+ return { status: 'ok', config };
26
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Commit, ParsedCommit, WorkTypeConfig } from './types.js';
2
2
  export declare const COMMIT_PREPROCESSOR_PATTERNS: readonly RegExp[];
3
+ export declare const PIPE_SCOPE_SOURCE = "[^|]+";
3
4
  export type PolicyViolationSurface = 'prefix' | 'body';
4
5
  export type PolicyViolationHandler = (commit: Commit, type: string, surface: PolicyViolationSurface) => void;
5
6
  export interface ParseCommitMessageOptions {
@@ -1,8 +1,10 @@
1
1
  export const COMMIT_PREPROCESSOR_PATTERNS = [/^##\s+/, /^#\d+([.-]\d+)?\s+/, /^[A-Z]+-\d+\s+/];
2
+ export const PIPE_SCOPE_SOURCE = '[^|]+';
3
+ const COMMIT_SUBJECT_PATTERN = new RegExp(String.raw `^(?:(${PIPE_SCOPE_SOURCE})\|)?(\w+)(?:\(([^)]+)\))?(!)?:\s*(.*)$`);
2
4
  export function parseCommitMessage(message, hash, workTypes, scopeAliases, options) {
3
5
  const firstLine = message.split('\n', 1)[0] ?? '';
4
6
  const stripped = stripTicketPrefix(firstLine);
5
- const match = stripped.match(/^(?:([^|]+)\|)?(\w+)(?:\(([^)]+)\))?(!)?:\s*(.*)$/);
7
+ const match = stripped.match(COMMIT_SUBJECT_PATTERN);
6
8
  if (!match) {
7
9
  return undefined;
8
10
  }
@@ -85,9 +85,9 @@ export async function prepareCommand(argv) {
85
85
  const options = {
86
86
  dryRun,
87
87
  force,
88
- ...(bumpOverride === undefined ? {} : { bumpOverride }),
89
- ...(setVersion === undefined ? {} : { setVersion }),
90
- ...(withReleaseNotes ? { withReleaseNotes: true } : {}),
88
+ ...(bumpOverride !== undefined && { bumpOverride }),
89
+ ...(setVersion !== undefined && { setVersion }),
90
+ ...(withReleaseNotes && { withReleaseNotes: true }),
91
91
  };
92
92
  if (dryRun) {
93
93
  console.info('\nšŸ” DRY RUN — no files will be modified\n');
@@ -155,10 +155,11 @@ function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion,
155
155
  if (only !== undefined) {
156
156
  const knownNames = config.workspaces.map((w) => w.dir);
157
157
  for (const name of only) {
158
- if (!knownNames.includes(name)) {
159
- reportError(`Unknown workspace "${name}". Known workspaces: ${knownNames.join(', ')}`);
160
- process.exit(1);
158
+ if (knownNames.includes(name)) {
159
+ continue;
161
160
  }
161
+ reportError(`Unknown workspace "${name}". Known workspaces: ${knownNames.join(', ')}`);
162
+ process.exit(1);
162
163
  }
163
164
  const graph = buildDependencyGraph(config.workspaces);
164
165
  const violations = validateOnlyExcludesStrandedDependents(config.workspaces, only, graph, (workspace) => {
@@ -9,10 +9,7 @@ export async function previewTagPrefixes() {
9
9
  const workspacePaths = (await discoverWorkspaces()) ?? [];
10
10
  const userConfig = await loadUserConfig();
11
11
  const overridesByDir = buildOverrideMap(userConfig);
12
- const workspaces = [];
13
- for (const workspacePath of workspacePaths) {
14
- workspaces.push(buildPreviewRow(workspacePath, overridesByDir));
15
- }
12
+ const workspaces = Array.from(workspacePaths, (workspacePath) => buildPreviewRow(workspacePath, overridesByDir));
16
13
  const retiredPackages = buildRetiredPreviewEntries(userConfig?.retiredPackages ?? []);
17
14
  const collisions = detectCollisions(workspaces);
18
15
  const knownPrefixes = collectKnownPrefixes(workspaces, retiredPackages);
@@ -120,6 +117,6 @@ function buildRetiredPreviewEntries(retiredPackages) {
120
117
  name: retired.name,
121
118
  tagPrefix: retired.tagPrefix,
122
119
  tagCount: countTagsMatching(retired.tagPrefix),
123
- ...(retired.successor !== undefined ? { successor: retired.successor } : {}),
120
+ ...(retired.successor !== undefined && { successor: retired.successor }),
124
121
  }));
125
122
  }