@williamthorsen/release-kit 8.1.0 → 9.0.1

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 9.0.1 — 2026-07-30
6
+
7
+ ### ⚙️ Tooling
8
+
9
+ - Adopt Vitest projects in this repo (#530)
10
+
11
+ Simplifies test configurations so that every package exposes the same test commands, which select suites by test category: `nmr test` runs everything except integration tests, `nmr test:integration` runs only those, and `nmr test:all` runs both. `nmr test:integration` now succeeds in a package that has no integration tests instead of failing. release-kit's drift checks can now be run on their own, apart from unit and integration tests. A fresh clone now has to run `pnpm run bootstrap` before any test run, not just before `nmr` commands.
12
+
13
+ ## 9.0.0 — 2026-07-28
14
+
15
+ ### 🎉 Features
16
+
17
+ - 🚨 **Breaking:** Unify label configuration in release-kit.config.ts (#505)
18
+
19
+ 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.
20
+
21
+ ### 🐛 Bug fixes
22
+
23
+ - Include commits scoped to `*` in the changelog (#496)
24
+
25
+ Fixes an issue where commits scoped to `*` raised the version but were silently dropped from the generated changelog.
26
+
5
27
  ## 8.1.0 — 2026-07-22
6
28
 
7
29
  ### 🎉 Features
@@ -46,12 +68,24 @@ All notable changes to this project will be documented in this file.
46
68
 
47
69
  ### 🎉 Features
48
70
 
71
+ - 🚨 **Breaking:** Rewrite parseArgs on node:util with typed error and exit API (#428)
72
+
73
+ `@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`.
74
+
49
75
  - 🚨 **Breaking:** Auto-activate integration test variant from config presence (#448)
50
76
 
51
77
  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.
52
78
 
53
79
  ### ♻️ Refactoring
54
80
 
81
+ - Route all CLI error reporting through a single stderr helper (#437)
82
+
83
+ 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.
84
+
85
+ - Normalize CLI error wording to the canonical Error: format (#439)
86
+
87
+ 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.
88
+
55
89
  - Consolidate prepare parsing onto parseArgsOrExit (#441)
56
90
 
57
91
  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
@@ -4,15 +4,7 @@ Version-bumping and changelog-generation toolkit for release workflows.
4
4
 
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
- <!-- section:release-notes -->
8
- ## Release notes — v8.1.0 (2026-07-22)
9
-
10
- ### 🎉 Features
11
-
12
- - Make label deletions reviewable and apply labels on merge (#493)
13
-
14
- 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`.
15
- <!-- /section:release-notes -->
7
+ <!-- section:release-notes --><!-- /section:release-notes -->
16
8
 
17
9
  ## Installation
18
10
 
@@ -94,9 +86,9 @@ Configuration is optional. The CLI works out of the box by auto-discovering work
94
86
  ### Config file
95
87
 
96
88
  ```typescript
97
- import type { ReleaseKitConfig } from '@williamthorsen/release-kit';
89
+ import { defineConfig } from '@williamthorsen/release-kit';
98
90
 
99
- const config: ReleaseKitConfig = {
91
+ export default defineConfig({
100
92
  // Exclude a workspace from release processing
101
93
  workspaces: [{ dir: 'internal-tools', shouldExclude: true }],
102
94
 
@@ -108,12 +100,10 @@ const config: ReleaseKitConfig = {
108
100
 
109
101
  // Add or override work types (merged with defaults by key)
110
102
  workTypes: { perf: { header: 'Performance' } },
111
- };
112
-
113
- export default config;
103
+ });
114
104
  ```
115
105
 
116
- The config file supports both `export default config` and `export const config = { ... }`.
106
+ `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 = { ... }`.
117
107
 
118
108
  ### `ReleaseKitConfig` reference
119
109
 
@@ -128,6 +118,7 @@ The config file supports both `export default config` and `export const config =
128
118
  | `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 |
129
119
  | `retiredPackages` | `RetiredPackage[]` | Packages that once lived in this repo but have been extracted or removed; suppresses undeclared-tag-prefix warnings |
130
120
  | `project` | `ProjectConfig` | Opt-in project-level release block. Declaring `project: {}` (even empty) enables a project-release stage in `prepare` |
121
+ | `repoLabels` | `RepoLabelsConfig` | Declares the repository's label registry for `sync-labels`; see [Label configuration](#label-configuration) |
131
122
 
132
123
  All fields are optional.
133
124
 
@@ -163,13 +154,11 @@ interface RetiredPackage {
163
154
  Worked example — `preflight` was extracted from this monorepo and continues as the standalone `readyup` project. Its tags stay in this repo as historical anchors:
164
155
 
165
156
  ```typescript
166
- import type { ReleaseKitConfig } from '@williamthorsen/release-kit';
157
+ import { defineConfig } from '@williamthorsen/release-kit';
167
158
 
168
- const config: ReleaseKitConfig = {
159
+ export default defineConfig({
169
160
  retiredPackages: [{ name: '@scope/preflight', tagPrefix: 'preflight-v', successor: 'readyup' }],
170
- };
171
-
172
- export default config;
161
+ });
173
162
  ```
174
163
 
175
164
  Validation rules:
@@ -720,15 +709,51 @@ The token needs `contents: read` on the codeassembly repo (fine-grained PAT scop
720
709
 
721
710
  ### `release-kit sync-labels`
722
711
 
723
- Manage GitHub label definitions via config-driven YAML files.
712
+ Manage GitHub label definitions via the `repoLabels` block of `.config/release-kit.config.ts`.
724
713
 
725
714
  | Subcommand | Description | Flags |
726
715
  | ---------- | -------------------------------------------------------------- | ---------------------- |
727
- | `init` | Scaffold config, caller workflow, and generate labels | `--dry-run`, `--force` |
728
- | `generate` | Regenerate `.github/labels.yaml` from config | — |
716
+ | `init` | Scaffold the caller workflow, seed config, and generate labels | `--dry-run`, `--force` |
717
+ | `generate` | Regenerate `.github/labels.yaml` from config | `--check` |
729
718
  | `sync` | Trigger the `sync-labels` GitHub Actions workflow via `gh` CLI | — |
730
719
 
731
- `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.
720
+ `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.
721
+
722
+ 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).
723
+
724
+ #### Label configuration
725
+
726
+ ```typescript
727
+ import { defineConfig } from '@williamthorsen/release-kit';
728
+
729
+ export default defineConfig({
730
+ repoLabels: {
731
+ extends: ['common'],
732
+ labels: {
733
+ 'scope:my-package': { color: '00ff96', description: 'my-package package' }, // add
734
+ bug: { color: 'b60205', description: 'Something broken' }, // replace the preset's `bug`
735
+ wontfix: null, // remove the preset's `wontfix`
736
+ },
737
+ },
738
+ });
739
+ ```
740
+
741
+ 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:
742
+
743
+ 1. Presets, in `extends` order — a later preset wins on a shared name.
744
+ 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 }`.
745
+
746
+ 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.
747
+
748
+ #### Migrating from `.config/sync-labels.config.ts`
749
+
750
+ Label configuration formerly lived in a standalone `.config/sync-labels.config.ts` (`presets` + a `labels` array). To migrate:
751
+
752
+ 1. Move the preset list to `repoLabels.extends` in `.config/release-kit.config.ts`.
753
+ 2. Convert each `labels` array entry to a `'name': { color, description }` record entry under `repoLabels.labels`.
754
+ 3. Delete `.config/sync-labels.config.ts` and run `release-kit sync-labels generate`.
755
+
756
+ 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.
732
757
 
733
758
  #### When labels are applied
734
759
 
@@ -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";
@@ -124,12 +124,13 @@ 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
136
  const firstLine = message.split('\n', 1)[0] ?? message;
@@ -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
+ }
@@ -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) {
@@ -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
  }
@@ -1,4 +1,5 @@
1
- import { COMMIT_PREPROCESSOR_PATTERNS } from "./parseCommitMessage.js";
1
+ import { COMMIT_PREPROCESSOR_PATTERNS, PIPE_SCOPE_SOURCE } from "./parseCommitMessage.js";
2
+ const PIPE_SCOPE_PATTERN = new RegExp(String.raw `^${PIPE_SCOPE_SOURCE}\|(.*)$`);
2
3
  export function stripScope(message) {
3
4
  let ticketPrefix = '';
4
5
  let remainder = message;
@@ -9,7 +10,7 @@ export function stripScope(message) {
9
10
  remainder = remainder.slice(match[0].length);
10
11
  }
11
12
  }
12
- const pipeMatch = remainder.match(/^[^|]+\|(.*)$/);
13
+ const pipeMatch = remainder.match(PIPE_SCOPE_PATTERN);
13
14
  if (pipeMatch) {
14
15
  return `${ticketPrefix}${pipeMatch[1]}`;
15
16
  }
@@ -1,4 +1,10 @@
1
+ import type { RepoLabelsConfig } from '../types.js';
1
2
  import type { LabelDefinition } from './types.js';
2
3
  export declare const LABELS_OUTPUT_PATH = ".github/labels.yaml";
3
4
  export declare function formatLabelsYaml(labels: LabelDefinition[], presetHashes: Map<string, string>): string;
4
- export declare function generateCommand(): Promise<number>;
5
+ export declare function loadRepoLabelsConfig(): Promise<RepoLabelsConfig | undefined>;
6
+ interface GenerateOptions {
7
+ check?: boolean;
8
+ }
9
+ export declare function generateCommand({ check }?: GenerateOptions): Promise<number>;
10
+ export {};
@@ -1,36 +1,47 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs';
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { reportError } from '@williamthorsen/nmr-core';
4
4
  import { stringify } from 'yaml';
5
- import { loadSyncLabelsConfig, SYNC_LABELS_CONFIG_PATH } from "./loadSyncLabelsConfig.js";
5
+ import { CONFIG_FILE_PATH } from "../loadConfig.js";
6
+ import { loadValidatedConfig } from "../loadValidatedConfig.js";
6
7
  import { hashPresetFile } from "./presets.js";
7
8
  import { resolveLabels } from "./resolveLabels.js";
9
+ import { checkRetiredSyncLabelsConfig } from "./retiredConfig.js";
8
10
  export const LABELS_OUTPUT_PATH = '.github/labels.yaml';
9
11
  export function formatLabelsYaml(labels, presetHashes) {
10
- const headerLines = ['# Generated by release-kit sync-labels — do not edit.', `# Source: ${SYNC_LABELS_CONFIG_PATH}`];
12
+ const headerLines = ['# Generated by release-kit sync-labels — do not edit.', `# Source: ${CONFIG_FILE_PATH}`];
11
13
  for (const [name, hash] of [...presetHashes].sort(([a], [b]) => a.localeCompare(b))) {
12
14
  headerLines.push(`# ${name} preset hash: ${hash}`);
13
15
  }
14
16
  const yamlBody = stringify(labels, { singleQuote: true, lineWidth: 0 });
15
17
  return headerLines.join('\n') + '\n' + yamlBody;
16
18
  }
17
- export async function generateCommand() {
18
- let config;
19
- try {
20
- config = await loadSyncLabelsConfig();
19
+ export async function loadRepoLabelsConfig() {
20
+ const result = await loadValidatedConfig();
21
+ if (result.status === 'missing') {
22
+ reportError(`No config file found at ${CONFIG_FILE_PATH}. Run \`release-kit sync-labels init\` first.`);
23
+ return undefined;
21
24
  }
22
- catch (error) {
23
- const message = error instanceof Error ? error.message : String(error);
24
- reportError(`Failed to load config: ${message}`);
25
+ if (result.status === 'invalid') {
26
+ return undefined;
27
+ }
28
+ if (result.config.repoLabels === undefined) {
29
+ reportError(`No \`repoLabels\` block found in ${CONFIG_FILE_PATH}. Run \`release-kit sync-labels init\` to seed one.`);
30
+ return undefined;
31
+ }
32
+ return result.config.repoLabels;
33
+ }
34
+ export async function generateCommand({ check = false } = {}) {
35
+ if (checkRetiredSyncLabelsConfig()) {
25
36
  return 1;
26
37
  }
27
- if (config === undefined) {
28
- reportError(`No config file found at ${SYNC_LABELS_CONFIG_PATH}. Run \`release-kit sync-labels init\` first.`);
38
+ const repoLabels = await loadRepoLabelsConfig();
39
+ if (repoLabels === undefined) {
29
40
  return 1;
30
41
  }
31
42
  let labels;
32
43
  try {
33
- labels = resolveLabels(config);
44
+ labels = resolveLabels(repoLabels);
34
45
  }
35
46
  catch (error) {
36
47
  const message = error instanceof Error ? error.message : String(error);
@@ -38,10 +49,13 @@ export async function generateCommand() {
38
49
  return 1;
39
50
  }
40
51
  const presetHashes = new Map();
41
- for (const presetName of config.presets ?? []) {
52
+ for (const presetName of repoLabels.extends ?? []) {
42
53
  presetHashes.set(presetName, hashPresetFile(presetName));
43
54
  }
44
55
  const content = formatLabelsYaml(labels, presetHashes);
56
+ if (check) {
57
+ return compareAgainstCommittedFile(content);
58
+ }
45
59
  try {
46
60
  mkdirSync(dirname(LABELS_OUTPUT_PATH), { recursive: true });
47
61
  writeFileSync(LABELS_OUTPUT_PATH, content, 'utf8');
@@ -54,3 +68,19 @@ export async function generateCommand() {
54
68
  console.info(`Generated ${LABELS_OUTPUT_PATH} with ${String(labels.length)} labels.`);
55
69
  return 0;
56
70
  }
71
+ function compareAgainstCommittedFile(expected) {
72
+ let existing;
73
+ try {
74
+ existing = readFileSync(LABELS_OUTPUT_PATH, 'utf8');
75
+ }
76
+ catch {
77
+ reportError(`${LABELS_OUTPUT_PATH} is missing. Run \`release-kit sync-labels generate\` and commit the result.`);
78
+ return 1;
79
+ }
80
+ if (existing !== expected) {
81
+ reportError(`${LABELS_OUTPUT_PATH} is stale. Run \`release-kit sync-labels generate\` and commit the result.`);
82
+ return 1;
83
+ }
84
+ console.info(`${LABELS_OUTPUT_PATH} is up to date.`);
85
+ return 0;
86
+ }
@@ -1,10 +1,16 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { reportWriteResult, writeFileWithCheck } from '@williamthorsen/nmr-core';
2
3
  import { discoverWorkspaces } from "../discoverWorkspaces.js";
4
+ import { CONFIG_FILE_PATH } from "../loadConfig.js";
5
+ import { loadValidatedConfig } from "../loadValidatedConfig.js";
3
6
  import { generateCommand, LABELS_OUTPUT_PATH } from "./generateCommand.js";
4
- import { SYNC_LABELS_CONFIG_PATH } from "./loadSyncLabelsConfig.js";
5
- import { buildScopeLabels, syncLabelsConfigScript, syncLabelsWorkflow } from "./templates.js";
7
+ import { checkRetiredSyncLabelsConfig } from "./retiredConfig.js";
8
+ import { buildScopeLabels, renderRepoLabelsBlock, repoLabelsConfigScript, syncLabelsWorkflow } from "./templates.js";
6
9
  const WORKFLOW_PATH = '.github/workflows/sync-labels.yaml';
7
10
  export async function syncLabelsInitCommand({ dryRun, force }) {
11
+ if (checkRetiredSyncLabelsConfig()) {
12
+ return 1;
13
+ }
8
14
  if (dryRun) {
9
15
  console.info('[dry-run mode]');
10
16
  }
@@ -18,20 +24,41 @@ export async function syncLabelsInitCommand({ dryRun, force }) {
18
24
  process.stderr.write(` Failed to discover workspaces: ${message}\n`);
19
25
  return 1;
20
26
  }
21
- const scopeLabels = workspacePaths === undefined ? [] : buildScopeLabels(workspacePaths);
22
27
  if (workspacePaths === undefined) {
23
28
  console.info(' No pnpm workspaces found (single-package repo)');
24
29
  }
25
30
  else {
26
31
  console.info(` Found ${String(workspacePaths.length)} workspaces`);
27
32
  }
33
+ const configExists = existsSync(CONFIG_FILE_PATH);
34
+ const retiredNames = configExists ? await loadRetiredPackageNames() : [];
35
+ if (retiredNames === undefined) {
36
+ return 1;
37
+ }
38
+ const scopeLabels = workspacePaths === undefined ? [] : buildScopeLabels(workspacePaths, retiredNames);
28
39
  console.info('\n> Scaffolding files');
29
40
  const workflowResult = writeFileWithCheck(WORKFLOW_PATH, syncLabelsWorkflow(), { dryRun, overwrite: force });
30
- const configResult = writeFileWithCheck(SYNC_LABELS_CONFIG_PATH, syncLabelsConfigScript(scopeLabels), {
41
+ reportWriteResult(workflowResult, dryRun);
42
+ if (configExists) {
43
+ console.info(`\n> ${CONFIG_FILE_PATH} already exists; add this block to the object passed to defineConfig:\n`);
44
+ console.info(renderRepoLabelsBlock(scopeLabels));
45
+ if (workflowResult.outcome === 'failed') {
46
+ process.stderr.write('Failed to scaffold one or more files.\n');
47
+ return 1;
48
+ }
49
+ console.info(`
50
+ > Next steps
51
+ 1. Paste the block above into ${CONFIG_FILE_PATH}.
52
+ 2. Run \`release-kit sync-labels generate\` to produce ${LABELS_OUTPUT_PATH}.
53
+ 3. Commit the changes.
54
+ 4. Run \`release-kit sync-labels sync\` to apply labels to your GitHub repo.
55
+ `);
56
+ return 0;
57
+ }
58
+ const configResult = writeFileWithCheck(CONFIG_FILE_PATH, repoLabelsConfigScript(scopeLabels), {
31
59
  dryRun,
32
60
  overwrite: force,
33
61
  });
34
- reportWriteResult(workflowResult, dryRun);
35
62
  reportWriteResult(configResult, dryRun);
36
63
  if (workflowResult.outcome === 'failed' || configResult.outcome === 'failed') {
37
64
  process.stderr.write('Failed to scaffold one or more files.\n');
@@ -51,11 +78,24 @@ export async function syncLabelsInitCommand({ dryRun, force }) {
51
78
  > Next steps
52
79
  1. Review the generated files:
53
80
  - ${WORKFLOW_PATH}
54
- - ${SYNC_LABELS_CONFIG_PATH}
81
+ - ${CONFIG_FILE_PATH}
55
82
  - ${LABELS_OUTPUT_PATH}
56
- 2. Customize ${SYNC_LABELS_CONFIG_PATH} as needed, then re-run \`release-kit sync-labels generate\`.
83
+ 2. Customize the \`repoLabels\` block in ${CONFIG_FILE_PATH} as needed, then re-run \`release-kit sync-labels generate\`.
57
84
  3. Commit the generated files.
58
85
  4. Run \`release-kit sync-labels sync\` to apply labels to your GitHub repo.
59
86
  `);
60
87
  return 0;
61
88
  }
89
+ async function loadRetiredPackageNames() {
90
+ const result = await loadValidatedConfig();
91
+ if (result.status === 'invalid') {
92
+ return undefined;
93
+ }
94
+ if (result.status === 'missing') {
95
+ return [];
96
+ }
97
+ return (result.config.retiredPackages ?? []).map((retired) => toUnscopedName(retired.name));
98
+ }
99
+ function toUnscopedName(name) {
100
+ return name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name;
101
+ }
@@ -1,2 +1,3 @@
1
- import type { LabelDefinition, SyncLabelsConfig } from './types.js';
2
- export declare function resolveLabels(config: SyncLabelsConfig): LabelDefinition[];
1
+ import type { RepoLabelsConfig } from '../types.js';
2
+ import type { LabelDefinition } from './types.js';
3
+ export declare function resolveLabels(config: RepoLabelsConfig): LabelDefinition[];
@@ -1,35 +1,23 @@
1
1
  import { loadPreset } from "./presets.js";
2
2
  export function resolveLabels(config) {
3
- const presetLabels = [];
4
- for (const presetName of config.presets ?? []) {
5
- const loaded = loadPreset(presetName);
6
- presetLabels.push(...loaded);
7
- }
8
- const customLabels = config.labels ?? [];
9
- detectCollisions(presetLabels, customLabels);
10
- return sortLabels([...presetLabels, ...customLabels]);
11
- }
12
- function detectCollisions(presetLabels, customLabels) {
13
- const seenPresetNames = new Set();
14
- const withinPresetDuplicates = [];
15
- for (const label of presetLabels) {
16
- if (seenPresetNames.has(label.name)) {
17
- withinPresetDuplicates.push(label.name);
3
+ const resolved = new Map();
4
+ for (const presetName of config.extends ?? []) {
5
+ for (const label of loadPreset(presetName)) {
6
+ resolved.set(label.name, label);
18
7
  }
19
- seenPresetNames.add(label.name);
20
- }
21
- if (withinPresetDuplicates.length > 0) {
22
- throw new Error(`Label name collision within presets: the following labels are defined by multiple presets: ${withinPresetDuplicates.join(', ')}. Remove the duplicates or use different presets.`);
23
8
  }
24
- const conflicts = [];
25
- for (const label of customLabels) {
26
- if (seenPresetNames.has(label.name)) {
27
- conflicts.push(label.name);
9
+ for (const [name, spec] of Object.entries(config.labels ?? {})) {
10
+ if (spec === null) {
11
+ if (!resolved.has(name)) {
12
+ throw new Error(`Label '${name}' is set to null (remove), but no preset in 'extends' defines it. Fix the name or delete the entry.`);
13
+ }
14
+ resolved.delete(name);
15
+ }
16
+ else {
17
+ resolved.set(name, { name, color: spec.color, description: spec.description });
28
18
  }
29
19
  }
30
- if (conflicts.length > 0) {
31
- throw new Error(`Label name collision: the following labels appear in both presets and custom labels: ${conflicts.join(', ')}. Remove duplicates from your custom labels or use a different name.`);
32
- }
20
+ return sortLabels([...resolved.values()]);
33
21
  }
34
22
  function sortLabels(labels) {
35
23
  return [...labels].sort((a, b) => a.name.localeCompare(b.name));
@@ -0,0 +1,2 @@
1
+ export declare const RETIRED_SYNC_LABELS_CONFIG_PATH = ".config/sync-labels.config.ts";
2
+ export declare function checkRetiredSyncLabelsConfig(): boolean;
@@ -0,0 +1,11 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { reportError } from '@williamthorsen/nmr-core';
3
+ import { CONFIG_FILE_PATH } from "../loadConfig.js";
4
+ export const RETIRED_SYNC_LABELS_CONFIG_PATH = '.config/sync-labels.config.ts';
5
+ export function checkRetiredSyncLabelsConfig() {
6
+ if (!existsSync(RETIRED_SYNC_LABELS_CONFIG_PATH)) {
7
+ return false;
8
+ }
9
+ reportError(`${RETIRED_SYNC_LABELS_CONFIG_PATH} is no longer read. Move its labels into the \`repoLabels\` block of ${CONFIG_FILE_PATH} (presets move to \`repoLabels.extends\`; each label becomes a \`'name': { color, description }\` entry under \`repoLabels.labels\`), then delete the file.`);
10
+ return true;
11
+ }
@@ -1,6 +1,7 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { reportError } from '@williamthorsen/nmr-core';
4
+ import { checkRetiredSyncLabelsConfig } from "./retiredConfig.js";
4
5
  const WORKFLOW_FILE = '.github/workflows/sync-labels.yaml';
5
6
  function checkGhAvailable() {
6
7
  try {
@@ -12,6 +13,9 @@ function checkGhAvailable() {
12
13
  }
13
14
  }
14
15
  export function syncLabelsCommand() {
16
+ if (checkRetiredSyncLabelsConfig()) {
17
+ return 1;
18
+ }
15
19
  if (!checkGhAvailable()) {
16
20
  reportError('The `gh` CLI is not installed or not in PATH. Install it from https://cli.github.com/');
17
21
  return 1;
@@ -1,4 +1,5 @@
1
1
  import type { LabelDefinition } from './types.js';
2
2
  export declare function syncLabelsWorkflow(): string;
3
- export declare function buildScopeLabels(workspacePaths: string[]): LabelDefinition[];
4
- export declare function syncLabelsConfigScript(scopeLabels: LabelDefinition[]): string;
3
+ export declare function buildScopeLabels(workspacePaths: string[], retiredNames?: string[]): LabelDefinition[];
4
+ export declare function renderRepoLabelsBlock(scopeLabels: LabelDefinition[]): string;
5
+ export declare function repoLabelsConfigScript(scopeLabels: LabelDefinition[]): string;
@@ -6,22 +6,21 @@ name: Sync labels
6
6
  on:
7
7
  workflow_dispatch:
8
8
 
9
- # Apply on merge, so a regenerated labels file cannot sit unapplied.
9
+ # Apply on merge, so that a regenerated labels file cannot sit unapplied.
10
10
  push:
11
11
  paths:
12
12
  - .github/labels.yaml
13
13
 
14
- # Preview on review: the check's log carries the full computed diff,
15
- # including deletions of labels the file does not declare.
14
+ # Preview on review: The check's log has a full computed diff, including deletions of labels not declared by the file.
16
15
  pull_request:
17
16
  paths:
18
17
  - .github/labels.yaml
19
18
 
20
19
  # Permissions are fixed when a run is created and cannot vary by trigger within a job,
21
- # so applying and previewing are separate jobs and the write token stays out of PR runs.
20
+ # so applying and previewing are separate jobs.
22
21
  jobs:
23
- # The push arm applies on the default branch alone. A \`branches:\` filter cannot name the
24
- # default branch, so the gate lives on the job and this file stays identical across repos.
22
+ # The push arm applies on the default branch alone. A \`branches:\` filter cannot name the default branch,
23
+ # so that the gate lives on the job and this file stays identical across repos.
25
24
  sync:
26
25
  if: github.event_name != 'pull_request' && (github.event_name != 'push' || github.ref_name == github.event.repository.default_branch)
27
26
  permissions:
@@ -39,7 +38,7 @@ jobs:
39
38
  dry-run: true
40
39
  `;
41
40
  }
42
- export function buildScopeLabels(workspacePaths) {
41
+ export function buildScopeLabels(workspacePaths, retiredNames = []) {
43
42
  const labels = [
44
43
  { name: 'scope:root', color: '00ff96', description: 'Monorepo root configuration' },
45
44
  ];
@@ -47,29 +46,34 @@ export function buildScopeLabels(workspacePaths) {
47
46
  const name = basename(workspacePath);
48
47
  labels.push({ name: `scope:${name}`, color: '00ff96', description: `${name} package` });
49
48
  }
49
+ for (const retiredName of retiredNames) {
50
+ labels.push({ name: `scope:${retiredName}`, color: '00ff96', description: `${retiredName} package (retired)` });
51
+ }
50
52
  return labels;
51
53
  }
52
54
  function escapeForSingleQuotedString(value) {
53
55
  return value.replace(/\\/g, '\\\\').replace(/'/g, String.raw `\'`);
54
56
  }
55
- export function syncLabelsConfigScript(scopeLabels) {
56
- const labelsArray = scopeLabels
57
+ export function renderRepoLabelsBlock(scopeLabels) {
58
+ const entries = scopeLabels
57
59
  .map((label) => {
58
60
  const name = escapeForSingleQuotedString(label.name);
59
61
  const color = escapeForSingleQuotedString(label.color);
60
62
  const description = escapeForSingleQuotedString(label.description);
61
- return ` { name: '${name}', color: '${color}', description: '${description}' },`;
63
+ return ` '${name}': { color: '${color}', description: '${description}' },`;
62
64
  })
63
65
  .join('\n');
64
- return `import type { SyncLabelsConfig } from '@williamthorsen/release-kit';
65
-
66
- const config: SyncLabelsConfig = {
67
- presets: ['common'],
68
- labels: [
69
- ${labelsArray}
70
- ],
71
- };
66
+ const labelsBlock = entries === '' ? ' labels: {},' : ` labels: {\n${entries}\n },`;
67
+ return ` repoLabels: {
68
+ extends: ['common'],
69
+ ${labelsBlock}
70
+ },`;
71
+ }
72
+ export function repoLabelsConfigScript(scopeLabels) {
73
+ return `import { defineConfig } from '@williamthorsen/release-kit';
72
74
 
73
- export default config;
75
+ export default defineConfig({
76
+ ${renderRepoLabelsBlock(scopeLabels)}
77
+ });
74
78
  `;
75
79
  }
@@ -3,7 +3,3 @@ export interface LabelDefinition {
3
3
  color: string;
4
4
  description: string;
5
5
  }
6
- export interface SyncLabelsConfig {
7
- presets?: string[];
8
- labels?: LabelDefinition[];
9
- }
@@ -171,6 +171,19 @@ export declare const breakingPolicyValueSchema: z.ZodEnum<{
171
171
  forbidden: "forbidden";
172
172
  required: "required";
173
173
  }>;
174
+ export declare const labelSpecSchema: z.ZodObject<{
175
+ color: z.ZodString;
176
+ description: z.ZodString;
177
+ }, z.core.$strict>;
178
+ export type LabelSpec = z.infer<typeof labelSpecSchema>;
179
+ export declare const repoLabelsSchema: z.ZodObject<{
180
+ extends: z.ZodOptional<z.ZodArray<z.ZodString>>;
181
+ labels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodObject<{
182
+ color: z.ZodString;
183
+ description: z.ZodString;
184
+ }, z.core.$strict>>>>;
185
+ }, z.core.$strict>;
186
+ export type RepoLabelsConfig = z.infer<typeof repoLabelsSchema>;
174
187
  export declare const releaseKitConfigSchema: z.ZodObject<{
175
188
  breakingPolicies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
176
189
  optional: "optional";
@@ -190,6 +203,13 @@ export declare const releaseKitConfigSchema: z.ZodObject<{
190
203
  releaseNotes: z.ZodOptional<z.ZodObject<{
191
204
  shouldInjectIntoReadme: z.ZodOptional<z.ZodBoolean>;
192
205
  }, z.core.$strict>>;
206
+ repoLabels: z.ZodOptional<z.ZodObject<{
207
+ extends: z.ZodOptional<z.ZodArray<z.ZodString>>;
208
+ labels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodObject<{
209
+ color: z.ZodString;
210
+ description: z.ZodString;
211
+ }, z.core.$strict>>>>;
212
+ }, z.core.$strict>>;
193
213
  retiredPackages: z.ZodOptional<z.ZodArray<z.ZodObject<{
194
214
  name: z.ZodString;
195
215
  tagPrefix: z.ZodString;
package/dist/esm/types.js CHANGED
@@ -49,6 +49,18 @@ export const versionPatternsSchema = z
49
49
  })
50
50
  .strict();
51
51
  export const breakingPolicyValueSchema = z.enum(['forbidden', 'optional', 'required']);
52
+ export const labelSpecSchema = z
53
+ .object({
54
+ color: z.string().min(1),
55
+ description: z.string(),
56
+ })
57
+ .strict();
58
+ export const repoLabelsSchema = z
59
+ .object({
60
+ extends: z.array(z.string().min(1)).optional(),
61
+ labels: z.record(z.string().min(1), labelSpecSchema.nullable()).optional(),
62
+ })
63
+ .strict();
52
64
  export const releaseKitConfigSchema = z
53
65
  .object({
54
66
  breakingPolicies: z.record(z.string(), breakingPolicyValueSchema).optional(),
@@ -57,6 +69,7 @@ export const releaseKitConfigSchema = z
57
69
  formatCommand: z.string().min(1).optional(),
58
70
  project: projectConfigSchema.optional(),
59
71
  releaseNotes: releaseNotesInputSchema.optional(),
72
+ repoLabels: repoLabelsSchema.optional(),
60
73
  retiredPackages: z.array(retiredPackageSchema).optional(),
61
74
  scopeAliases: z.record(z.string(), z.string()).optional(),
62
75
  versionPatterns: versionPatternsSchema.optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/release-kit",
3
- "version": "8.1.0",
3
+ "version": "9.0.1",
4
4
  "description": "Version-bumping and changelog-generation toolkit for release workflows",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/williamthorsen/node-monorepo-tools/tree/main/packages/release-kit#readme",
@@ -39,7 +39,7 @@
39
39
  "semver": "7.8.5",
40
40
  "yaml": "2.9.0",
41
41
  "zod": "4.4.3",
42
- "@williamthorsen/nmr-core": "0.7.1"
42
+ "@williamthorsen/nmr-core": "0.7.3"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/semver": "7.7.1",
@@ -2,6 +2,9 @@
2
2
  # Compatible with EndBug/label-sync format.
3
3
 
4
4
  # Main categories
5
+ - name: ai
6
+ color: a2eeef
7
+ description: Documentation and configs used by agents
5
8
  - name: breaking
6
9
  color: b60205
7
10
  description: Breaking API change or removal of functionality
@@ -1,3 +0,0 @@
1
- import type { SyncLabelsConfig } from './types.js';
2
- export declare const SYNC_LABELS_CONFIG_PATH = ".config/sync-labels.config.ts";
3
- export declare function loadSyncLabelsConfig(): Promise<SyncLabelsConfig | undefined>;
@@ -1,53 +0,0 @@
1
- import { existsSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { isRecord } from "../typeGuards.js";
4
- export const SYNC_LABELS_CONFIG_PATH = '.config/sync-labels.config.ts';
5
- export async function loadSyncLabelsConfig() {
6
- const absoluteConfigPath = path.resolve(process.cwd(), SYNC_LABELS_CONFIG_PATH);
7
- if (!existsSync(absoluteConfigPath)) {
8
- return undefined;
9
- }
10
- const { createJiti } = await import('jiti');
11
- const jiti = createJiti(import.meta.url);
12
- const imported = await jiti.import(absoluteConfigPath);
13
- if (!isRecord(imported)) {
14
- throw new Error(`Config file must export an object, got ${Array.isArray(imported) ? 'array' : typeof imported}`);
15
- }
16
- const resolved = imported.default ?? imported.config;
17
- if (resolved === undefined) {
18
- throw new Error('Config file must have a default export or a named `config` export (e.g., `export default { ... }` or `export const config = { ... }`)');
19
- }
20
- return validateSyncLabelsConfig(resolved);
21
- }
22
- function validateSyncLabelsConfig(value) {
23
- if (!isRecord(value)) {
24
- throw new Error(`Config must be an object, got ${typeof value}`);
25
- }
26
- let presets;
27
- if (value.presets !== undefined) {
28
- if (!Array.isArray(value.presets) || !value.presets.every((p) => typeof p === 'string')) {
29
- throw new Error('Config `presets` must be an array of strings');
30
- }
31
- presets = value.presets;
32
- }
33
- let labels;
34
- if (value.labels !== undefined) {
35
- if (!Array.isArray(value.labels)) {
36
- throw new TypeError('Config `labels` must be an array');
37
- }
38
- labels = [];
39
- for (const label of value.labels) {
40
- if (!isRecord(label)) {
41
- throw new Error('Each label must be an object with `name`, `color`, and `description`');
42
- }
43
- if (typeof label.name !== 'string' || typeof label.color !== 'string' || typeof label.description !== 'string') {
44
- throw new TypeError('Each label must have string `name`, `color`, and `description` fields');
45
- }
46
- labels.push({ name: label.name, color: label.color, description: label.description });
47
- }
48
- }
49
- return {
50
- ...(presets !== undefined && { presets }),
51
- ...(labels !== undefined && { labels }),
52
- };
53
- }