@williamthorsen/release-kit 5.1.0 → 5.2.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 (50) hide show
  1. package/CHANGELOG.md +105 -65
  2. package/README.md +160 -57
  3. package/cliff.toml.template +26 -17
  4. package/dist/esm/.cache +1 -1
  5. package/dist/esm/bin/release-kit.js +96 -3
  6. package/dist/esm/buildChangelogEntries.d.ts +1 -0
  7. package/dist/esm/buildChangelogEntries.js +39 -25
  8. package/dist/esm/checkWorkTypesDrift.d.ts +11 -0
  9. package/dist/esm/checkWorkTypesDrift.js +110 -0
  10. package/dist/esm/collectPolicyViolations.d.ts +6 -0
  11. package/dist/esm/collectPolicyViolations.js +15 -0
  12. package/dist/esm/createGithubRelease.d.ts +12 -2
  13. package/dist/esm/createGithubRelease.js +12 -8
  14. package/dist/esm/createGithubReleaseCommand.js +10 -6
  15. package/dist/esm/decideRelease.d.ts +3 -0
  16. package/dist/esm/decideRelease.js +19 -3
  17. package/dist/esm/defaults.d.ts +7 -0
  18. package/dist/esm/defaults.js +41 -20
  19. package/dist/esm/deriveWorkspaceConfig.js +3 -0
  20. package/dist/esm/determineBumpFromCommits.d.ts +6 -1
  21. package/dist/esm/determineBumpFromCommits.js +9 -3
  22. package/dist/esm/generateChangelogs.js +14 -29
  23. package/dist/esm/loadConfig.js +14 -22
  24. package/dist/esm/parseCommitMessage.d.ts +8 -2
  25. package/dist/esm/parseCommitMessage.js +32 -3
  26. package/dist/esm/publishCommand.js +21 -2
  27. package/dist/esm/releasePrepare.js +39 -15
  28. package/dist/esm/releasePrepareMono.js +26 -3
  29. package/dist/esm/releasePrepareProject.js +13 -1
  30. package/dist/esm/renderReleaseNotes.js +2 -1
  31. package/dist/esm/reportPrepare.js +18 -0
  32. package/dist/esm/resolveCommandTags.js +16 -6
  33. package/dist/esm/resolveReleaseTags.d.ts +8 -1
  34. package/dist/esm/resolveReleaseTags.js +11 -7
  35. package/dist/esm/runGitCliff.d.ts +2 -0
  36. package/dist/esm/runGitCliff.js +27 -0
  37. package/dist/esm/stripEmojiPrefix.d.ts +1 -0
  38. package/dist/esm/stripEmojiPrefix.js +7 -0
  39. package/dist/esm/syncWorkTypes.d.ts +10 -0
  40. package/dist/esm/syncWorkTypes.js +90 -0
  41. package/dist/esm/types.d.ts +15 -0
  42. package/dist/esm/work-types.json +127 -0
  43. package/dist/esm/work-types.schema.json +73 -0
  44. package/dist/esm/workTypesData.d.ts +14 -0
  45. package/dist/esm/workTypesData.js +59 -0
  46. package/dist/esm/workTypesUtils.d.ts +5 -0
  47. package/dist/esm/workTypesUtils.js +16 -0
  48. package/package.json +6 -3
  49. package/dist/esm/version.d.ts +0 -1
  50. package/dist/esm/version.js +0 -4
package/README.md CHANGED
@@ -5,49 +5,39 @@ 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 with `git-cliff`.
6
6
 
7
7
  <!-- section:release-notes -->
8
- ## Release notes — v5.1.0 (2026-04-30)
8
+ ## Release notes — v5.2.0 (2026-05-04)
9
9
 
10
- ### Bug fixes
10
+ ### 🎉 Features
11
11
 
12
- - Make publish's clean-tree safety gate reachable (#311)
12
+ - Add emojis to changelog and release-note headings (#352)
13
13
 
14
- Fixes an issue where `release-kit publish` failed with pnpm's "working tree is dirty" error on a clean tree whenever `releaseNotes.shouldInjectIntoReadme: true` was configured. release-kit injects the release notes into the package README before invoking `pnpm publish`, so pnpm's own working-tree check fired on a tree release-kit had just dirtied even though the user's tree was clean at command start.
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.
15
15
 
16
- - Make `--set-version` + `project` rejection explicit (#319)
16
+ - Surface bang violations in release prepare reports (#359)
17
17
 
18
- Improves the error users see when invoking `release-kit prepare --set-version` with a `project` block configured. The combination is still rejected — as beforebut now produces a single, project-aware message ("--set-version cannot be combined with a project release…") rather than the previous two-step chain (`--set-version requires --only`, then `--only cannot be combined with a project release` after the user added `--only`).
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.
19
19
 
20
- - Reject `--only` that would strand excluded dependents (#321)
20
+ ### 🐛 Bug fixes
21
21
 
22
- Fixes a silent footgun in `release-kit prepare --only=...`: an excluded internal dependent with its own changes would be left unreleased with no runtime signal, even though the targeted workspace it depends on was being released. The command now rejects such invocations up front, naming every excluded dependent whose changes would be stranded.
22
+ - Restrict publish to publishable workspaces (#345)
23
23
 
24
- - Order prerelease versions correctly in changelog sort (#334)
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.
25
25
 
26
- Fixes a latent issue in `@williamthorsen/release-kit` where prerelease version tags (e.g., `1.2.3-alpha`, `1.2.3-rc.1`) were sorted as if their prerelease component were absent, causing them to appear in the wrong position relative to releases sharing the same base version. Changelog entries are now ordered per SemVer §11: prerelease versions precede the corresponding release (`1.2.3-alpha < 1.2.3`), build metadata is ignored for ordering, and entries that fail SemVer validation sort deterministically to the bottom of the list rather than collapsing into mid-list positions.
26
+ - Skip tooling-only releases instead of failing (#347)
27
27
 
28
- ### Features
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.
29
29
 
30
- - Add `project` block for project-level release stage (#317)
30
+ - Establish canonical work-types SSOT and restore changelog section ordering (#358)
31
31
 
32
- Adds support for monorepos that ship a single combined deliverable to version, changelog, and release-note the project itself rather than only its constituent workspaces.
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.
33
33
 
34
- - Publish JSON Schema for `.meta/label-map.json` (#325)
34
+ Closes #355.
35
35
 
36
- Adds a JSON Schema for `.meta/label-map.json` to release-kit, packaged at `packages/release-kit/schemas/label-map.json` and shipped to npm. Consumers reference it via the stable raw URL pattern `https://github.com/williamthorsen/node-monorepo-tools/raw/release-kit-v<version>/packages/release-kit/schemas/label-map.json` — the same shape audit-deps already uses.
36
+ ### Performance
37
37
 
38
- - Label prepare errors with the failing stage (#326)
38
+ - Skip npx registry revalidation when running git-cliff (#361)
39
39
 
40
- Adds stage attribution to errors thrown during `release-kit prepare`. Errors from per-workspace bumps and changelog generation, the project release stage, and the post-release format command now begin with a stage label that identifies the failing stage and (where relevant) the affected workspace.
41
-
42
- - Make `--force` and `--bump` orthogonal (#328)
43
-
44
- Decouples `--force` and `--bump` so each flag has a single responsibility, and unifies skip semantics across the per-workspace and project pipelines.
45
-
46
- ### Documentation
47
-
48
- - Document tag prefix collisions as general rule (#320)
49
-
50
- Documents the strict-prefix tag-prefix collision rule as a general validation rule that applies to every release-kit consumer declaring more than one tag prefix: across active workspaces, declared legacy identities, retired packages, and the optional `project` block. Previously, the rule appeared only inside the `Project releases` validation list.
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.
51
41
  <!-- /section:release-notes -->
52
42
 
53
43
  ## Installation
@@ -151,16 +141,17 @@ The config file supports both `export default config` and `export const config =
151
141
 
152
142
  ### `ReleaseKitConfig` reference
153
143
 
154
- | Field | Type | Description |
155
- | ----------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
156
- | `cliffConfigPath` | `string` | Explicit path to cliff config. If omitted, resolved automatically: `.config/git-cliff.toml` → `cliff.toml` → bundled template |
157
- | `workspaces` | `WorkspaceOverride[]` | Override or exclude discovered workspaces (matched by `dir`) |
158
- | `formatCommand` | `string` | Shell command to run after changelog generation; modified file paths are appended as arguments |
159
- | `versionPatterns` | `VersionPatterns` | Rules for which commit types trigger major/minor bumps |
160
- | `scopeAliases` | `Record<string, string>` | Maps shorthand scope names to canonical names in commits |
161
- | `workTypes` | `Record<string, WorkTypeConfig>` | Work type definitions, merged with defaults by key |
162
- | `retiredPackages` | `RetiredPackage[]` | Packages that once lived in this repo but have been extracted or removed; suppresses undeclared-tag-prefix warnings |
163
- | `project` | `ProjectConfig` | Opt-in project-level release block. Declaring `project: {}` (even empty) enables a project-release stage in `prepare` |
144
+ | Field | Type | Description |
145
+ | ------------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
146
+ | `cliffConfigPath` | `string` | Explicit path to cliff config. If omitted, resolved automatically: `.config/git-cliff.toml` → `cliff.toml` → bundled template |
147
+ | `workspaces` | `WorkspaceOverride[]` | Override or exclude discovered workspaces (matched by `dir`) |
148
+ | `formatCommand` | `string` | Shell command to run after changelog generation; modified file paths are appended as arguments |
149
+ | `versionPatterns` | `VersionPatterns` | Rules for which commit types trigger major/minor bumps |
150
+ | `scopeAliases` | `Record<string, string>` | Maps shorthand scope names to canonical names in commits |
151
+ | `workTypes` | `Record<string, WorkTypeConfig>` | Work type definitions, merged with defaults by key |
152
+ | `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 |
153
+ | `retiredPackages` | `RetiredPackage[]` | Packages that once lived in this repo but have been extracted or removed; suppresses undeclared-tag-prefix warnings |
154
+ | `project` | `ProjectConfig` | Opt-in project-level release block. Declaring `project: {}` (even empty) enables a project-release stage in `prepare` |
164
155
 
165
156
  All fields are optional.
166
157
 
@@ -313,30 +304,93 @@ interface VersionPatterns {
313
304
 
314
305
  Default: `{ major: ['!'], minor: ['feat'] }`
315
306
 
316
- ### Default work types
307
+ ### Work types and tiers
308
+
309
+ The canonical taxonomy lives in `packages/release-kit/src/work-types.json` and is split into three tiers that drive section rendering and audience classification.
310
+
311
+ | Tier | Key | Header | Aliases | `!` policy |
312
+ | -------- | ----------- | ------------------------- | ------------- | ------------ |
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 |
328
+
329
+ #### Tier semantics
330
+
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.
334
+
335
+ 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
+
337
+ #### `docs` reclassification
338
+
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.
340
+
341
+ #### `utility` alias
317
342
 
318
- | Key | Header | Aliases |
319
- | ----------- | --------------- | ------------- |
320
- | `fix` | Bug fixes | `bugfix` |
321
- | `deprecate` | Deprecated | |
322
- | `feat` | Features | `feature` |
323
- | `internal` | Internal | |
324
- | `perf` | Performance | `performance` |
325
- | `refactor` | Refactoring | |
326
- | `sec` | Security | `security` |
327
- | `tests` | Tests | `test` |
328
- | `tooling` | Tooling | |
329
- | `ci` | CI | |
330
- | `deps` | Dependencies | `dep` |
331
- | `docs` | Documentation | `doc` |
332
- | `ai` | Agentic support | |
333
- | `fmt` | (skipped) | |
343
+ `utility:` is a backward-compat alias for `internal:`. Both forms parse to the same canonical type, route to the same `🏗️ Internal features` section, and are subject to the same `!` policy.
334
344
 
335
- Work types from your config are merged with these defaults by key — your entries override or extend, they don't replace the full set.
345
+ #### `!` (breaking change) policy
336
346
 
337
- `fmt:` commits are recognized for version-bump determination (they contribute to a patch bump) but are skipped by the bundled `cliff.toml.template`, so they do not appear in `CHANGELOG.md` or release notes.
347
+ Each work-type carries a `breakingPolicy` value:
338
348
 
339
- Release-notes sections are rendered in the declaration order of the merged work-types record, with any unknown titles trailing the known ones. The default `devOnlySections` (excluded from public release notes but still written to `CHANGELOG.md`) are: `Agentic support`, `CI`, `Dependencies`, `Internal`, `Refactoring`, `Tests`, `Tooling`. Override via `changelogJson.devOnlySections` in your config.
349
+ - `optional` (`feat`, `sec`) `!` is allowed; both `type:` and `type!:` parse cleanly.
350
+ - `forbidden` (most types) — `!` is a policy violation. The premise: types like `internal!`, `perf!`, `fix!` are contradictory; an internal change cannot break a consumer contract, a pure perf change preserves the contract, and a bug-fix is by definition not a contract change.
351
+ - `required` (`drop`) — bare `drop:` is a policy violation; only `drop!:` is accepted. The premise: removing a feature always breaks consumers; the `!` form makes that explicit.
352
+
353
+ ##### Two-tier policy enforcement
354
+
355
+ The `!` policy operates at two distinct levels with different semantics:
356
+
357
+ - **Write-time** (commit-msg hook) — strict rejection. Policy violations are blocked at the gate where the author can act on them immediately. _Hook-based enforcement is tracked separately and is not yet shipped._
358
+ - **Release-time** (`parseCommitMessage`) — tolerant warn-and-continue. Commits already in the log cannot be rewritten, so a policy-violating commit is parsed using its canonical type with `breaking: false` (the `!` is dropped from the parse) and a `onPolicyViolation` callback fires. Callers (`decideRelease` etc.) can collect these warnings and surface them in the release report. A single legacy `internal!` in a year-old log does not block releases.
359
+
360
+ A `BREAKING CHANGE:` body footer on a `forbidden`-policy type triggers the same warning path as the prefix `!` does — the spirit of the policy is "internal/perf/etc. cannot be breaking", which must apply to both surfaces.
361
+
362
+ The release-prepare orchestrators (`releasePrepare`, `releasePrepareMono`, `releasePrepareProject`) apply `DEFAULT_BREAKING_POLICIES` automatically. Violations encountered while parsing each workspace's or project's commit window are collected onto the corresponding result's `policyViolations` field and rendered under the section in the prepare report:
363
+
364
+ ```
365
+ arrays
366
+ Found 1 commits since arrays-v1.0.0
367
+ ⚠️ 1 policy violation:
368
+ · def5678 'internal!: refactor cache' — type 'internal' at prefix surface
369
+ Bumping versions (patch)...
370
+ 📦 1.0.0 → 1.0.1 (patch)
371
+ ```
372
+
373
+ To customize, set `breakingPolicies` in `release-kit.config.ts` — provide a partial map to override individual types, or `{}` to disable enforcement entirely (the parser falls back to `'optional'` for any missing type). Violations remain warnings, never failures.
374
+
375
+ #### `🚨 **Breaking:**` bullet marker
376
+
377
+ Items whose commit subject carries the `!` prefix (e.g. `feat!`, `drop!`, `feat(api)!`) are rendered with a `🚨 **Breaking:** ` prefix on the bullet:
378
+
379
+ ```markdown
380
+ - 🚨 **Breaking:** Drop legacy /v1 endpoint
381
+ ```
382
+
383
+ 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
+
385
+ #### `fmt`
386
+
387
+ `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.
388
+
389
+ #### Custom work types
390
+
391
+ 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
+
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.
340
394
 
341
395
  ## CLI reference
342
396
 
@@ -404,6 +458,33 @@ release-kit prepare --only arrays --set-version 1.0.0
404
458
 
405
459
  An empty changelog section is expected for a bare promotion, because the changelog is generated from commits since the last tag. To include a narrative entry, land a descriptive release commit (e.g., a `feat!` describing the stable API) before running `prepare`.
406
460
 
461
+ ### `release-kit publish`
462
+
463
+ Publish packages that have release tags on HEAD. The publish workflow's reusable workflow `publish.reusable.yaml` invokes this command in CI.
464
+
465
+ | Flag | Description |
466
+ | ---------------------- | ---------------------------------------------------------------------------- |
467
+ | `--dry-run` | Preview without publishing |
468
+ | `--no-git-checks` | Skip the clean-working-tree check |
469
+ | `--tags=tag1,tag2,...` | Only publish the named tags (comma-separated, full tag names) |
470
+ | `--provenance` | Generate provenance statement (requires OIDC, not supported by classic yarn) |
471
+ | `--help`, `-h` | Show help |
472
+
473
+ #### Publishability filter
474
+
475
+ `publish` operates only on workspaces where `package.json#private` is absent or `false`. A workspace marked `private: true` is "versioned but not published": it can still be tagged by `release-kit tag`, get a `CHANGELOG.md` entry, and get a GitHub Release via `release-kit create-github-release` — only the registry publish step is skipped. Other commands ignore this filter and operate on private workspaces unchanged.
476
+
477
+ The filter behaves differently depending on whether `--tags` is provided:
478
+
479
+ - **Without `--tags`** (implicit resolution): unpublishable tags on HEAD are silently filtered. The pre-publish listing shows only the publishable subset. If the filter empties the set, `release-kit publish` prints `Nothing to publish.` and exits 0.
480
+ - **With `--tags`** (explicit naming): if any named tag points at an unpublishable workspace, `release-kit publish` exits 1 with one error line per unpublishable tag, citing `package.json#private`. Explicit naming surfaces the contradiction rather than silently dropping the tag.
481
+
482
+ Example output when an explicit tag is unpublishable:
483
+
484
+ ```
485
+ Error: basic-v1.0.0 (packages/basic) cannot be published: package.json#private is true.
486
+ ```
487
+
407
488
  ### `release-kit create-github-release`
408
489
 
409
490
  Create GitHub Releases from `changelog.json` for tags on HEAD. Independent of `npm publish`: invoking this command creates Releases regardless of whether the matching package was published.
@@ -447,6 +528,28 @@ Scaffolded files:
447
528
  - `.config/release-kit.config.ts` — starter config with commented-out customization examples (with `--with-config`)
448
529
  - `.config/git-cliff.toml` — copied from the bundled template (with `--with-config`)
449
530
 
531
+ ### `release-kit work-types`
532
+
533
+ Manage the canonical work-types taxonomy used by changelog and release-notes generation.
534
+
535
+ | Subcommand | Description |
536
+ | ---------- | ----------------------------------------------------------------------------------- |
537
+ | `check` | Compare the local `work-types.json` against the upstream codeassembly canonical |
538
+ | `sync` | Overwrite the local `work-types.json` with the upstream contents (after validation) |
539
+
540
+ `check` exit codes:
541
+
542
+ | Code | Meaning |
543
+ | ---- | --------------------------------------------------------------------------------- |
544
+ | `0` | Match (or upstream missing — transitional warning printed) |
545
+ | `1` | Drift detected |
546
+ | `2` | Network error or non-OK HTTP response |
547
+ | `3` | Schema mismatch (upstream JSON does not parse or fails the top-level shape check) |
548
+
549
+ The check is non-blocking initially: until codeassembly publishes its `work-types.json`, the upstream URL returns 404 and `check` exits 0 with a warning. CI flip to a blocking check is tracked as a follow-up once the upstream ships.
550
+
551
+ These commands are also exposed as `nmr work-types:check` / `nmr work-types:sync` from any package directory.
552
+
450
553
  ### `release-kit sync-labels`
451
554
 
452
555
  Manage GitHub label definitions via config-driven YAML files.
@@ -10,6 +10,13 @@
10
10
  # Only ticketed commits (leading #NN, PROJ-NN, or ##) are included.
11
11
  # Unticketed maintenance commits (deps upgrades, tooling tweaks) are skipped.
12
12
  # Use ## as a synthetic ticket prefix for ad-hoc commits that belong in the changelog.
13
+ #
14
+ # Section order: each `group` value carries a hidden `<!-- NN -->` HTML-comment
15
+ # prefix encoding the canonical row position. tera's `group_by` filter sorts
16
+ # groups lexicographically by string, and the body template's `striptags`
17
+ # filter erases the comment from the rendered heading. Numbering is per
18
+ # unique group (not per parser entry) — all parsers routing to the same group
19
+ # share the same prefix.
13
20
 
14
21
  [changelog]
15
22
  header = """
@@ -61,24 +68,26 @@ commit_preprocessors = []
61
68
  commit_parsers = [
62
69
  { message = "^release:", skip = true },
63
70
  { message = "^Merge", skip = true },
64
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?ai(!)?:", group = "Agentic support" },
65
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?bugfix(!)?:", group = "Bug fixes" },
66
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?ci(!)?:", group = "CI" },
67
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?deprecate(!)?:", group = "Deprecated" },
68
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?deps?(!)?:", group = "Dependencies" },
69
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?docs?(!)?:", group = "Documentation" },
70
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?feat(!)?:", group = "Features" },
71
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?feature(!)?:", group = "Features" },
72
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?fix(!)?:", group = "Bug fixes" },
71
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?feat(!)?:", group = "<!-- 01 -->🎉 Features" },
72
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?feature(!)?:", group = "<!-- 01 -->🎉 Features" },
73
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?drop(!)?:", group = "<!-- 02 -->🪦 Removed" },
74
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?deprecate(!)?:", group = "<!-- 03 -->🗑️ Deprecated" },
75
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?fix(!)?:", group = "<!-- 04 -->🐛 Bug fixes" },
76
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?bugfix(!)?:", group = "<!-- 04 -->🐛 Bug fixes" },
77
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?sec(!)?:", group = "<!-- 05 -->🔒 Security" },
78
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?security(!)?:", group = "<!-- 05 -->🔒 Security" },
79
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?perf(!)?:", group = "<!-- 06 -->⚡ Performance" },
80
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?performance(!)?:", group = "<!-- 06 -->⚡ Performance" },
81
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?internal(!)?:", group = "<!-- 07 -->🏗️ Internal features" },
82
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?utility(!)?:", group = "<!-- 07 -->🏗️ Internal features" },
83
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?refactor(!)?:", group = "<!-- 08 -->♻️ Refactoring" },
84
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?tests?(!)?:", group = "<!-- 09 -->🧪 Tests" },
85
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?tooling(!)?:", group = "<!-- 10 -->⚙️ Tooling" },
86
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?ci(!)?:", group = "<!-- 11 -->👷 CI" },
87
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?deps?(!)?:", group = "<!-- 12 -->📦 Dependencies" },
88
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?ai(!)?:", group = "<!-- 13 -->🤖 Agentic support" },
89
+ { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?docs?(!)?:", group = "<!-- 14 -->📚 Documentation" },
73
90
  { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?fmt(!)?:", skip = true },
74
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?internal(!)?:", group = "Internal" },
75
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?perf(!)?:", group = "Performance" },
76
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?performance(!)?:", group = "Performance" },
77
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?refactor(!)?:", group = "Refactoring" },
78
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?sec(!)?:", group = "Security" },
79
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?security(!)?:", group = "Security" },
80
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?tests?(!)?:", group = "Tests" },
81
- { message = "^(\\#\\d+([.\\-]\\d+)?|\\#\\#|[A-Z]+-\\d+)\\s+([\\w-]+\\|)?tooling(!)?:", group = "Tooling" },
82
91
  # Skip unticketed commits (maintenance, deps, initial scaffolding, etc.)
83
92
  { message = ".*", skip = true },
84
93
  ]
package/dist/esm/.cache CHANGED
@@ -1 +1 @@
1
- fca70d83414ef35352e152c9e916f24705c93c5556b14c16388a5b791752f075
1
+ 64d26b06f4a6e205faa07a507fd64938899e4d635dd02b333016caf1d937a14e
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { parseArgs, translateParseError } from "@williamthorsen/nmr-core";
2
+ import { parseArgs, readPackageVersion, translateParseError } from "@williamthorsen/nmr-core";
3
+ import { checkWorkTypesDrift } from "../checkWorkTypesDrift.js";
3
4
  import { commitCommand } from "../commitCommand.js";
4
5
  import { createGithubReleaseCommand } from "../createGithubReleaseCommand.js";
5
6
  import { initCommand } from "../init/initCommand.js";
@@ -10,8 +11,9 @@ import { showTagPrefixesCommand } from "../showTagPrefixesCommand.js";
10
11
  import { generateCommand } from "../sync-labels/generateCommand.js";
11
12
  import { syncLabelsInitCommand } from "../sync-labels/initCommand.js";
12
13
  import { syncLabelsCommand } from "../sync-labels/syncCommand.js";
14
+ import { syncWorkTypes } from "../syncWorkTypes.js";
13
15
  import { tagCommand } from "../tagCommand.js";
14
- import { VERSION } from "../version.js";
16
+ const VERSION = readPackageVersion(import.meta.url);
15
17
  function showUsage() {
16
18
  console.info(`
17
19
  Usage: release-kit <command> [options]
@@ -26,6 +28,7 @@ Commands:
26
28
  show-tag-prefixes Show derived and declared legacy tag prefixes per workspace
27
29
  init Initialize release-kit in the current repository
28
30
  sync-labels Manage GitHub label synchronization
31
+ work-types Check for or sync work-type taxonomy drift against the upstream canonical
29
32
 
30
33
  Options:
31
34
  --dry-run Preview changes without writing files
@@ -171,6 +174,49 @@ legacy tag prefixes. Surfaces any release-shaped tags whose prefix is neither a
171
174
  derived prefix nor declared in \`legacyIdentities\`, with a copy-pasteable
172
175
  config snippet.
173
176
 
177
+ Options:
178
+ --help, -h Show this help message
179
+ `);
180
+ }
181
+ function showWorkTypesHelp() {
182
+ console.info(`
183
+ Usage: release-kit work-types <subcommand>
184
+
185
+ Manage the canonical work-types taxonomy used by changelog and release-notes generation.
186
+
187
+ Subcommands:
188
+ check Compare the local work-types.json against the upstream codeassembly canonical
189
+ sync Overwrite the local work-types.json with the upstream contents
190
+
191
+ Exit codes (check):
192
+ 0 Match (or upstream missing \u2014 transitional warning printed)
193
+ 1 Drift detected
194
+ 2 Network error
195
+ 3 Schema mismatch
196
+
197
+ Options:
198
+ --help, -h Show this help message
199
+ `);
200
+ }
201
+ function showWorkTypesCheckHelp() {
202
+ console.info(`
203
+ Usage: release-kit work-types check
204
+
205
+ Compare the local work-types.json against the upstream codeassembly canonical and report
206
+ drift. Exit 0 on match, 1 on drift, 0 + warning when upstream is missing (transitional),
207
+ 2 on network error, 3 on schema mismatch.
208
+
209
+ Options:
210
+ --help, -h Show this help message
211
+ `);
212
+ }
213
+ function showWorkTypesSyncHelp() {
214
+ console.info(`
215
+ Usage: release-kit work-types sync
216
+
217
+ Fetch the upstream work-types.json, validate its top-level shape, and overwrite the local
218
+ copy with the upstream content (formatted with 2-space indent + trailing newline).
219
+
174
220
  Options:
175
221
  --help, -h Show this help message
176
222
  `);
@@ -179,7 +225,9 @@ function showPublishHelp() {
179
225
  console.info(`
180
226
  Usage: release-kit publish [options]
181
227
 
182
- Publish packages that have release tags on HEAD.
228
+ Publish packages that have release tags on HEAD. Operates only on workspaces where
229
+ package.json#private is absent or false. Without --tags, unpublishable workspaces are
230
+ silently filtered out. With --tags, naming an unpublishable tag is an error.
183
231
 
184
232
  Options:
185
233
  --dry-run Preview without publishing
@@ -341,6 +389,51 @@ if (command === "sync-labels") {
341
389
  showSyncLabelsHelp();
342
390
  process.exit(1);
343
391
  }
392
+ if (command === "work-types") {
393
+ const subcommand = flags[0];
394
+ const subflags = flags.slice(1);
395
+ if (subcommand === "--help" || subcommand === "-h" || subcommand === void 0) {
396
+ showWorkTypesHelp();
397
+ process.exit(0);
398
+ }
399
+ if (subcommand === "check") {
400
+ if (subflags.some((f) => f === "--help" || f === "-h")) {
401
+ showWorkTypesCheckHelp();
402
+ process.exit(0);
403
+ }
404
+ if (subflags.length > 0) {
405
+ console.error(`Error: Unknown option: ${subflags[0]}`);
406
+ process.exit(1);
407
+ }
408
+ const result = await checkWorkTypesDrift();
409
+ if (result.exitCode === 0) {
410
+ console.info(result.message);
411
+ } else {
412
+ console.error(result.message);
413
+ }
414
+ process.exit(result.exitCode);
415
+ }
416
+ if (subcommand === "sync") {
417
+ if (subflags.some((f) => f === "--help" || f === "-h")) {
418
+ showWorkTypesSyncHelp();
419
+ process.exit(0);
420
+ }
421
+ if (subflags.length > 0) {
422
+ console.error(`Error: Unknown option: ${subflags[0]}`);
423
+ process.exit(1);
424
+ }
425
+ const result = await syncWorkTypes();
426
+ if (result.exitCode === 0) {
427
+ console.info(result.message);
428
+ } else {
429
+ console.error(result.message);
430
+ }
431
+ process.exit(result.exitCode);
432
+ }
433
+ console.error(`Error: Unknown subcommand: ${subcommand}`);
434
+ showWorkTypesHelp();
435
+ process.exit(1);
436
+ }
344
437
  console.error(`Error: Unknown command: ${command}`);
345
438
  showUsage();
346
439
  process.exit(1);
@@ -1,3 +1,4 @@
1
1
  import type { GenerateChangelogOptions } from './generateChangelogs.ts';
2
2
  import type { ChangelogEntry, ReleaseConfig } from './types.ts';
3
+ export declare function stripGroupDecorations(group: string): string;
3
4
  export declare function buildChangelogEntries(config: Pick<ReleaseConfig, 'cliffConfigPath' | 'changelogJson'>, tag: string, options?: GenerateChangelogOptions): ChangelogEntry[];
@@ -1,31 +1,32 @@
1
- import { execFileSync } from "node:child_process";
2
- import { copyFileSync, mkdtempSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
1
  import { extractVersion } from "./changelogJsonUtils.js";
2
+ import { DEFAULT_WORK_TYPES } from "./defaults.js";
3
+ import { COMMIT_PREPROCESSOR_PATTERNS } from "./parseCommitMessage.js";
6
4
  import { resolveCliffConfigPath } from "./resolveCliffConfigPath.js";
5
+ import { runGitCliff } from "./runGitCliff.js";
6
+ import { stripEmojiPrefix } from "./stripEmojiPrefix.js";
7
7
  import { isRecord, isUnknownArray } from "./typeGuards.js";
8
+ const HTML_COMMENT_PREFIX_PATTERN = /^<!--[^>]*-->/;
9
+ const CANONICAL_SECTION_ORDER = new Map(
10
+ Object.values(DEFAULT_WORK_TYPES).map((config, index) => [stripGroupDecorations(config.header), index])
11
+ );
12
+ function canonicalSectionPriority(title) {
13
+ const index = CANONICAL_SECTION_ORDER.get(stripGroupDecorations(title));
14
+ return index ?? Number.POSITIVE_INFINITY;
15
+ }
16
+ function stripGroupDecorations(group) {
17
+ return stripEmojiPrefix(group.replace(HTML_COMMENT_PREFIX_PATTERN, ""));
18
+ }
8
19
  function buildChangelogEntries(config, tag, options) {
9
20
  const resolvedConfigPath = resolveCliffConfigPath(config.cliffConfigPath, import.meta.url);
10
- let cliffConfigPath = resolvedConfigPath;
11
- let tempDir;
12
- if (resolvedConfigPath.endsWith(".template")) {
13
- tempDir = mkdtempSync(join(tmpdir(), "cliff-"));
14
- cliffConfigPath = join(tempDir, "cliff.toml");
15
- copyFileSync(resolvedConfigPath, cliffConfigPath);
16
- }
17
- const args = ["--config", cliffConfigPath, "--context", "--tag", tag];
21
+ const cliffArgs = ["--context", "--tag", tag];
18
22
  if (options?.tagPattern !== void 0) {
19
- args.push("--tag-pattern", options.tagPattern);
23
+ cliffArgs.push("--tag-pattern", options.tagPattern);
20
24
  }
21
25
  for (const includePath of options?.includePaths ?? []) {
22
- args.push("--include-path", includePath);
26
+ cliffArgs.push("--include-path", includePath);
23
27
  }
24
28
  try {
25
- const contextJson = execFileSync("npx", ["--yes", "git-cliff", ...args], {
26
- encoding: "utf8",
27
- stdio: ["pipe", "pipe", "inherit"]
28
- });
29
+ const contextJson = runGitCliff(resolvedConfigPath, cliffArgs, ["pipe", "pipe", "inherit"]);
29
30
  const releases = parseCliffContext(contextJson);
30
31
  const devOnlySections = new Set(config.changelogJson.devOnlySections);
31
32
  return transformReleases(releases, devOnlySections);
@@ -33,10 +34,6 @@ function buildChangelogEntries(config, tag, options) {
33
34
  throw new Error(
34
35
  `Failed to build changelog entries for tag ${tag}: ${error instanceof Error ? error.message : String(error)}`
35
36
  );
36
- } finally {
37
- if (tempDir !== void 0) {
38
- rmSync(tempDir, { recursive: true, force: true });
39
- }
40
37
  }
41
38
  }
42
39
  function parseCliffContext(json) {
@@ -76,6 +73,7 @@ function toCliffContextCommit(value) {
76
73
  }
77
74
  function transformReleases(releases, devOnlySections) {
78
75
  const entries = [];
76
+ const devOnlyNormalised = new Set([...devOnlySections].map(stripGroupDecorations));
79
77
  for (const release of releases) {
80
78
  if (release.version === void 0) {
81
79
  continue;
@@ -84,9 +82,10 @@ function transformReleases(releases, devOnlySections) {
84
82
  const date = release.timestamp !== void 0 ? new Date(release.timestamp * 1e3).toISOString().slice(0, 10) : "unreleased";
85
83
  const sectionMap = /* @__PURE__ */ new Map();
86
84
  for (const commit of release.commits ?? []) {
87
- const group = commit.group ?? "Other";
85
+ const group = stripCommentPrefix(commit.group ?? "Other");
88
86
  const description = extractDescription(commit.message);
89
87
  const body = extractBody(commit.message);
88
+ const breaking = subjectHasBreakingMarker(commit.message);
90
89
  let items = sectionMap.get(group);
91
90
  if (items === void 0) {
92
91
  items = [];
@@ -96,6 +95,9 @@ function transformReleases(releases, devOnlySections) {
96
95
  if (body !== void 0) {
97
96
  item.body = body;
98
97
  }
98
+ if (breaking) {
99
+ item.breaking = true;
100
+ }
99
101
  items.push(item);
100
102
  }
101
103
  const sections = [];
@@ -105,16 +107,27 @@ function transformReleases(releases, devOnlySections) {
105
107
  }
106
108
  sections.push({
107
109
  title,
108
- audience: devOnlySections.has(title) ? "dev" : "all",
110
+ audience: devOnlyNormalised.has(stripGroupDecorations(title)) ? "dev" : "all",
109
111
  items
110
112
  });
111
113
  }
114
+ sections.sort((a, b) => canonicalSectionPriority(a.title) - canonicalSectionPriority(b.title));
112
115
  if (sections.length > 0) {
113
116
  entries.push({ version, date, sections });
114
117
  }
115
118
  }
116
119
  return entries;
117
120
  }
121
+ function stripCommentPrefix(group) {
122
+ return group.replace(HTML_COMMENT_PREFIX_PATTERN, "");
123
+ }
124
+ function subjectHasBreakingMarker(message) {
125
+ let subject = message.split("\n", 1)[0] ?? "";
126
+ for (const pattern of COMMIT_PREPROCESSOR_PATTERNS) {
127
+ subject = subject.replace(pattern, "");
128
+ }
129
+ return /^(?:[^|]+\|)?\w+(?:\([^)]+\))?!:/.test(subject);
130
+ }
118
131
  function extractDescription(message) {
119
132
  const firstLine = message.split("\n")[0] ?? message;
120
133
  const afterColon = firstLine.split(": ").slice(1).join(": ");
@@ -155,5 +168,6 @@ function extractBody(message) {
155
168
  return lines.slice(start, end).join("\n").trim();
156
169
  }
157
170
  export {
158
- buildChangelogEntries
171
+ buildChangelogEntries,
172
+ stripGroupDecorations
159
173
  };