@williamthorsen/release-kit 8.0.1 → 8.1.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 8.1.0 — 2026-07-22
6
+
7
+ ### 🎉 Features
8
+
9
+ - Make label deletions reviewable and apply labels on merge (#493)
10
+
11
+ 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`.
12
+
13
+ ### 📦 Dependencies
14
+
15
+ - Upgrade dependencies and align the Node support policy (#483)
16
+
17
+ All four published packages (`nmr`, `nmr-core`, `release-kit`, and `v11y-check`) now require Node.js 24 or later, up from Node 18.17. Separately, `nmr-compile` now rebuilds when the TypeScript version changes.
18
+
5
19
  ## 8.0.1 — 2026-07-18
6
20
 
7
21
  ### 🐛 Bug fixes
package/README.md CHANGED
@@ -5,17 +5,19 @@ Version-bumping and changelog-generation toolkit for release workflows.
5
5
  Provides a self-contained CLI that auto-discovers workspaces from `pnpm-workspace.yaml`, parses conventional commits, determines version bumps, updates `package.json` files, and generates changelogs from `git-cliff --context` output rendered in-process (with optional [editorial overrides](#editorial-overrides)).
6
6
 
7
7
  <!-- section:release-notes -->
8
- ## Release notes — v8.0.1 (2026-07-18)
8
+ ## Release notes — v8.1.0 (2026-07-22)
9
9
 
10
- ### 🐛 Bug fixes
10
+ ### 🎉 Features
11
11
 
12
- - Skip private packages cleanly in publish and create-github-release (#477)
12
+ - Make label deletions reviewable and apply labels on merge (#493)
13
13
 
14
- Fixes an issue where a workspace marked private in its `package.json` could break a release: `release-kit publish` failed on it, and `release-kit create-github-release` created an unwanted public GitHub Release for it. Both commands now skip private workspaces cleanly: publish reports the skip and continues instead of failing the run, and create-github-release produces no Release for a private package. Any publishable packages released alongside it still publish and still get their Releases. Marking a workspace private is now all it takes to exclude it from both publishing and GitHub Releases.
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
15
  <!-- /section:release-notes -->
16
16
 
17
17
  ## Installation
18
18
 
19
+ Requires Node.js 24 or later.
20
+
19
21
  ```bash
20
22
  pnpm add -D @williamthorsen/release-kit
21
23
  ```
@@ -728,6 +730,40 @@ Manage GitHub label definitions via config-driven YAML files.
728
730
 
729
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.
730
732
 
733
+ #### When labels are applied
734
+
735
+ The scaffolded workflow carries three triggers:
736
+
737
+ | Trigger | Job | Token | Effect |
738
+ | --------------------------------------------------------- | ------- | --------------- | --------------------------------- |
739
+ | Push to the default branch touching `.github/labels.yaml` | `sync` | `issues: write` | Applies the labels |
740
+ | Manual dispatch (`release-kit sync-labels sync`) | `sync` | `issues: write` | Applies the labels |
741
+ | Pull request touching `.github/labels.yaml` | `check` | `issues: read` | Logs the diff without applying it |
742
+
743
+ Applying on merge closes the window in which a regenerated `.github/labels.yaml` sits unapplied — a window in which a later manual dispatch would apply a label set nobody reviewed.
744
+
745
+ The `check` job runs the same sync in dry-run. Its **Label diff** log group lists every create, edit, rename, and deletion the sync would perform, including deletions of labels the file does not declare — the destructive edits a diff of `.github/labels.yaml` alone cannot show, because a label created by hand or by another workflow is absent from the file both before and after. Read that log; the check reports success whether or not the diff is destructive, so a green check is not evidence that nothing will be deleted.
746
+
747
+ The two jobs are split because a job's permissions are fixed when the run is created and cannot vary by trigger. The split is what keeps a write-scoped token out of pull-request runs.
748
+
749
+ The push trigger filters on the path alone; the `sync` job compares `github.ref_name` against the repository's default branch. The workflow therefore needs no per-repo edit whatever that branch is named — and a push touching `.github/labels.yaml` on any other branch produces a run whose jobs all skip.
750
+
751
+ Manual dispatch is not a preview. It matches the `sync` job, so it applies the labels, deletions included; only the pull-request path runs in dry-run.
752
+
753
+ #### Dry-run checks on fork pull requests
754
+
755
+ GitHub issues a read-only `GITHUB_TOKEN` to pull requests from forks, and by default holds runs from first-time contributors until a maintainer approves them. The dry-run reads labels and writes nothing, so the check normally runs once approved. A repo that disables workflows on fork pull requests gets no check at all; to preview such a change, push the branch to the base repo and open the pull request from there.
756
+
757
+ #### Migrating an existing repo
758
+
759
+ The caller workflow gained the `push` and `pull_request` triggers after its first release. A repo scaffolded before that keeps working on manual dispatch alone — nothing breaks — but it applies labels only when someone remembers to. To adopt the new shape:
760
+
761
+ ```sh
762
+ pnpm exec release-kit sync-labels init --force
763
+ ```
764
+
765
+ `readyup` prompts for this: its `sync-labels.yaml matches template` check fails once the scaffolded workflow no longer matches the current template.
766
+
731
767
  #### Published JSON Schema for `.meta/label-map.json`
732
768
 
733
769
  release-kit publishes a JSON Schema for `.meta/label-map.json` — a separate, generic data file that maps commit-prefix scopes and types to GitHub label names. The schema lives at `packages/release-kit/schemas/label-map.json` in this repo and is reachable via the stable raw URL:
@@ -9,7 +9,7 @@ const HTML_COMMENT_PREFIX_PATTERN = /^<!--[^>]*-->/;
9
9
  const CANONICAL_SECTION_ORDER = new Map(Object.values(DEFAULT_WORK_TYPES).map((config, index) => [stripGroupDecorations(config.header), index]));
10
10
  function canonicalSectionPriority(title) {
11
11
  const index = CANONICAL_SECTION_ORDER.get(stripGroupDecorations(title));
12
- return index ?? Number.POSITIVE_INFINITY;
12
+ return index ?? Infinity;
13
13
  }
14
14
  export function stripGroupDecorations(group) {
15
15
  return stripEmojiPrefix(group.replace(HTML_COMMENT_PREFIX_PATTERN, ''));
@@ -132,7 +132,7 @@ function subjectHasBreakingMarker(message) {
132
132
  return /^(?:[^|]+\|)?\w+(?:\([^)]+\))?!:/.test(subject);
133
133
  }
134
134
  function extractDescription(message) {
135
- const firstLine = message.split('\n')[0] ?? message;
135
+ const firstLine = message.split('\n', 1)[0] ?? message;
136
136
  const afterColon = firstLine.split(': ').slice(1).join(': ');
137
137
  if (afterColon.length > 0) {
138
138
  return afterColon.charAt(0).toUpperCase() + afterColon.slice(1);
@@ -59,10 +59,11 @@ function validateSingleOverride(key, rawEntry, errors) {
59
59
  }
60
60
  let entryValid = true;
61
61
  for (const fieldName of Object.keys(rawEntry)) {
62
- if (!KNOWN_OVERRIDE_FIELDS.has(fieldName)) {
63
- errors.push(`overrides['${key}']: unknown field '${fieldName}'`);
64
- entryValid = false;
62
+ if (KNOWN_OVERRIDE_FIELDS.has(fieldName)) {
63
+ continue;
65
64
  }
65
+ errors.push(`overrides['${key}']: unknown field '${fieldName}'`);
66
+ entryValid = false;
66
67
  }
67
68
  const result = {};
68
69
  if (rawEntry.audience !== undefined) {
@@ -26,7 +26,7 @@ export function createGithubRelease(options) {
26
26
  const body = renderReleaseNotesSingle(entry, {
27
27
  filter: matchesAudience('all'),
28
28
  includeHeading: false,
29
- ...(sectionOrder === undefined ? {} : { sectionOrder }),
29
+ ...(sectionOrder !== undefined && { sectionOrder }),
30
30
  });
31
31
  if (body.trim() === '') {
32
32
  return { status: 'skipped', reason: 'empty-body' };
@@ -48,7 +48,7 @@ export function createGithubReleases(tags, changelogJsonOutputPath, dryRun, sect
48
48
  tag,
49
49
  changelogJsonPath,
50
50
  dryRun,
51
- ...(sectionOrder === undefined ? {} : { sectionOrder }),
51
+ ...(sectionOrder !== undefined && { sectionOrder }),
52
52
  });
53
53
  if (result.status === 'created') {
54
54
  created.push(tag);
@@ -7,7 +7,7 @@ export function detectPackageManager() {
7
7
  const content = readFileSync(packageJsonPath, 'utf8');
8
8
  const parsed = JSON.parse(content);
9
9
  if (isRecord(parsed) && typeof parsed.packageManager === 'string') {
10
- const [name, version] = parsed.packageManager.split('@');
10
+ const [name, version] = parsed.packageManager.split('@', 2);
11
11
  if (name === 'pnpm' || name === 'npm') {
12
12
  return name;
13
13
  }
@@ -1,8 +1,8 @@
1
1
  export function bold(text) {
2
- return `\u001B[1m${text}\u001B[0m`;
2
+ return `\u{1B}[1m${text}\u{1B}[0m`;
3
3
  }
4
4
  export function dim(text) {
5
- return `\u001B[2m${text}\u001B[0m`;
5
+ return `\u{1B}[2m${text}\u{1B}[0m`;
6
6
  }
7
7
  export function sectionHeader(name) {
8
8
  return `━━━ ${name} ━━━`;
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- const FIELD_SEPARATOR = '\u001F';
2
+ const FIELD_SEPARATOR = '\u{1F}';
3
3
  function isNoTagError(err) {
4
4
  return typeof err === 'object' && err !== null && 'status' in err && err.status === 128;
5
5
  }
@@ -22,7 +22,7 @@ export function renderInjectedReadme(readme, changelogJsonPath, tag, sectionOrde
22
22
  const renderedSections = renderReleaseNotesSingle(entry, {
23
23
  filter: matchesAudience('all'),
24
24
  includeHeading: false,
25
- ...(sectionOrder === undefined ? {} : { sectionOrder }),
25
+ ...(sectionOrder !== undefined && { sectionOrder }),
26
26
  });
27
27
  if (renderedSections.trimEnd().length === 0) {
28
28
  console.warn(`Warning: no user-facing release notes for version ${version}; skipping README injection`);
@@ -85,9 +85,9 @@ export async function prepareCommand(argv) {
85
85
  const options = {
86
86
  dryRun,
87
87
  force,
88
- ...(bumpOverride === undefined ? {} : { bumpOverride }),
89
- ...(setVersion === undefined ? {} : { setVersion }),
90
- ...(withReleaseNotes ? { withReleaseNotes: true } : {}),
88
+ ...(bumpOverride !== undefined && { bumpOverride }),
89
+ ...(setVersion !== undefined && { setVersion }),
90
+ ...(withReleaseNotes && { withReleaseNotes: true }),
91
91
  };
92
92
  if (dryRun) {
93
93
  console.info('\n🔍 DRY RUN — no files will be modified\n');
@@ -155,10 +155,11 @@ function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion,
155
155
  if (only !== undefined) {
156
156
  const knownNames = config.workspaces.map((w) => w.dir);
157
157
  for (const name of only) {
158
- if (!knownNames.includes(name)) {
159
- reportError(`Unknown workspace "${name}". Known workspaces: ${knownNames.join(', ')}`);
160
- process.exit(1);
158
+ if (knownNames.includes(name)) {
159
+ continue;
161
160
  }
161
+ reportError(`Unknown workspace "${name}". Known workspaces: ${knownNames.join(', ')}`);
162
+ process.exit(1);
162
163
  }
163
164
  const graph = buildDependencyGraph(config.workspaces);
164
165
  const violations = validateOnlyExcludesStrandedDependents(config.workspaces, only, graph, (workspace) => {
@@ -9,10 +9,7 @@ export async function previewTagPrefixes() {
9
9
  const workspacePaths = (await discoverWorkspaces()) ?? [];
10
10
  const userConfig = await loadUserConfig();
11
11
  const overridesByDir = buildOverrideMap(userConfig);
12
- const workspaces = [];
13
- for (const workspacePath of workspacePaths) {
14
- workspaces.push(buildPreviewRow(workspacePath, overridesByDir));
15
- }
12
+ const workspaces = Array.from(workspacePaths, (workspacePath) => buildPreviewRow(workspacePath, overridesByDir));
16
13
  const retiredPackages = buildRetiredPreviewEntries(userConfig?.retiredPackages ?? []);
17
14
  const collisions = detectCollisions(workspaces);
18
15
  const knownPrefixes = collectKnownPrefixes(workspaces, retiredPackages);
@@ -120,6 +117,6 @@ function buildRetiredPreviewEntries(retiredPackages) {
120
117
  name: retired.name,
121
118
  tagPrefix: retired.tagPrefix,
122
119
  tagCount: countTagsMatching(retired.tagPrefix),
123
- ...(retired.successor !== undefined ? { successor: retired.successor } : {}),
120
+ ...(retired.successor !== undefined && { successor: retired.successor }),
124
121
  }));
125
122
  }
@@ -95,8 +95,8 @@ export function releasePrepareMono(config, options) {
95
95
  tags,
96
96
  formatCommand,
97
97
  dryRun,
98
- ...(allWarnings.length > 0 ? { warnings: allWarnings } : {}),
99
- ...(project === undefined ? {} : { project }),
98
+ ...(allWarnings.length > 0 && { warnings: allWarnings }),
99
+ ...(project !== undefined && { project }),
100
100
  };
101
101
  }
102
102
  function determineDirectBumps(config, options) {
@@ -487,10 +487,11 @@ function topologicalSort(releaseSet, graph) {
487
487
  const sortedSet = new Set(sorted);
488
488
  const cyclicDirs = [];
489
489
  for (const dir of releaseDirs) {
490
- if (!sortedSet.has(dir)) {
491
- sorted.push(dir);
492
- cyclicDirs.push(dir);
490
+ if (sortedSet.has(dir)) {
491
+ continue;
493
492
  }
493
+ sorted.push(dir);
494
+ cyclicDirs.push(dir);
494
495
  }
495
496
  return { sorted, cyclicDirs };
496
497
  }
@@ -50,8 +50,8 @@ function sortSectionsByOrder(sections, order) {
50
50
  }
51
51
  const indexed = sections.map((section, index) => ({ section, index }));
52
52
  indexed.sort((a, b) => {
53
- const priorityA = priority.get(a.section.title) ?? Number.POSITIVE_INFINITY;
54
- const priorityB = priority.get(b.section.title) ?? Number.POSITIVE_INFINITY;
53
+ const priorityA = priority.get(a.section.title) ?? Infinity;
54
+ const priorityB = priority.get(b.section.title) ?? Infinity;
55
55
  if (priorityA !== priorityB) {
56
56
  return priorityA - priorityB;
57
57
  }
@@ -42,10 +42,11 @@ export async function resolveCommandTags(tags) {
42
42
  if (tags !== undefined) {
43
43
  const availableTagNames = resolvedTags.map((t) => t.tag);
44
44
  for (const name of tags) {
45
- if (!availableTagNames.includes(name)) {
46
- reportError(`Unknown tag "${name}" in --tags. Available: ${availableTagNames.join(', ')}`);
47
- process.exit(1);
45
+ if (availableTagNames.includes(name)) {
46
+ continue;
48
47
  }
48
+ reportError(`Unknown tag "${name}" in --tags. Available: ${availableTagNames.join(', ')}`);
49
+ process.exit(1);
49
50
  }
50
51
  resolvedTags = resolvedTags.filter((t) => tags.includes(t.tag));
51
52
  }
@@ -8,7 +8,7 @@ import { resolveLabels } from "./resolveLabels.js";
8
8
  export const LABELS_OUTPUT_PATH = '.github/labels.yaml';
9
9
  export function formatLabelsYaml(labels, presetHashes) {
10
10
  const headerLines = ['# Generated by release-kit sync-labels — do not edit.', `# Source: ${SYNC_LABELS_CONFIG_PATH}`];
11
- for (const [name, hash] of [...presetHashes.entries()].sort(([a], [b]) => a.localeCompare(b))) {
11
+ for (const [name, hash] of [...presetHashes].sort(([a], [b]) => a.localeCompare(b))) {
12
12
  headerLines.push(`# ${name} preset hash: ${hash}`);
13
13
  }
14
14
  const yamlBody = stringify(labels, { singleQuote: true, lineWidth: 0 });
@@ -6,13 +6,37 @@ name: Sync labels
6
6
  on:
7
7
  workflow_dispatch:
8
8
 
9
- permissions:
10
- contents: read
11
- issues: write
9
+ # Apply on merge, so a regenerated labels file cannot sit unapplied.
10
+ push:
11
+ paths:
12
+ - .github/labels.yaml
12
13
 
14
+ # Preview on review: the check's log carries the full computed diff,
15
+ # including deletions of labels the file does not declare.
16
+ pull_request:
17
+ paths:
18
+ - .github/labels.yaml
19
+
20
+ # 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.
13
22
  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.
14
25
  sync:
26
+ if: github.event_name != 'pull_request' && (github.event_name != 'push' || github.ref_name == github.event.repository.default_branch)
27
+ permissions:
28
+ contents: read
29
+ issues: write
30
+ uses: williamthorsen/node-monorepo-tools/.github/workflows/sync-labels.reusable.yaml@workflow/sync-labels-v1
31
+
32
+ check:
33
+ if: github.event_name == 'pull_request'
34
+ permissions:
35
+ contents: read
36
+ issues: read
15
37
  uses: williamthorsen/node-monorepo-tools/.github/workflows/sync-labels.reusable.yaml@workflow/sync-labels-v1
38
+ with:
39
+ dry-run: true
16
40
  `;
17
41
  }
18
42
  export function buildScopeLabels(workspacePaths) {
@@ -53,7 +53,7 @@ function writePreviewFile(filePath, content, dryRun) {
53
53
  const result = writeFileWithCheck(filePath, content, { dryRun: false, overwrite: true });
54
54
  if (result.outcome === 'failed') {
55
55
  reportError(`Failed to write ${filePath}: ${result.error ?? 'unknown error'}`);
56
- return { filePath, outcome: 'failed', ...(result.error === undefined ? {} : { error: result.error }) };
56
+ return { filePath, outcome: 'failed', ...(result.error !== undefined && { error: result.error }) };
57
57
  }
58
58
  if (result.outcome === 'created' || result.outcome === 'overwritten') {
59
59
  console.info(dim(` Wrote ${filePath}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/release-kit",
3
- "version": "8.0.1",
3
+ "version": "8.1.0",
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,14 +39,14 @@
39
39
  "semver": "7.8.5",
40
40
  "yaml": "2.9.0",
41
41
  "zod": "4.4.3",
42
- "@williamthorsen/nmr-core": "0.7.0"
42
+ "@williamthorsen/nmr-core": "0.7.1"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/semver": "7.7.1",
46
46
  "smol-toml": "1.7.0"
47
47
  },
48
48
  "engines": {
49
- "node": ">=18.17.0"
49
+ "node": ">=24"
50
50
  },
51
51
  "publishConfig": {
52
52
  "access": "public",