@williamthorsen/release-kit 8.0.0 → 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,28 @@
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
+
19
+ ## 8.0.1 — 2026-07-18
20
+
21
+ ### 🐛 Bug fixes
22
+
23
+ - Skip private packages cleanly in publish and create-github-release (#477)
24
+
25
+ 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.
26
+
5
27
  ## 8.0.0 — 2026-07-13
6
28
 
7
29
  ### 🎉 Features
package/README.md CHANGED
@@ -5,19 +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.0 (2026-07-13)
8
+ ## Release notes — v8.1.0 (2026-07-22)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - 🚨 **Breaking:** Fail a package that declares types but ships none (#467)
12
+ - Make label deletions reviewable and apply labels on merge (#493)
13
13
 
14
- `nmr attw` now fails a package that claims to ship type declarations but ships none. Such a package used to pass, leaving every TypeScript consumer of it silently typed as `any`.
15
-
16
- A package that ships working declarations found only by sitting beside its JavaScript entry point escapes the check, since it declares nothing. Add a `types` entry to bring it under the check.
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`.
17
15
  <!-- /section:release-notes -->
18
16
 
19
17
  ## Installation
20
18
 
19
+ Requires Node.js 24 or later.
20
+
21
21
  ```bash
22
22
  pnpm add -D @williamthorsen/release-kit
23
23
  ```
@@ -626,22 +626,24 @@ Publish packages that have release tags on HEAD. The publish workflow's reusable
626
626
 
627
627
  #### Publishability filter
628
628
 
629
- `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.
629
+ `publish` and `create-github-release` operate 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` and get a `CHANGELOG.md` entry, but it is skipped by both `release-kit publish` (no registry publish) and `release-kit create-github-release` (no GitHub Release). `private: true` alone routes a workspace out of publish and announce — no extra release config is required. Other commands ignore this filter and operate on private workspaces unchanged.
630
630
 
631
- The filter behaves differently depending on whether `--tags` is provided:
631
+ An unpublishable tag is always skipped cleanly, never fatal. For `release-kit publish`, whether the skip is announced depends on how the tag was resolved:
632
632
 
633
633
  - **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.
634
- - **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.
634
+ - **With `--tags`** (explicit naming): each named tag pointing at an unpublishable workspace is skipped with a warning, and any publishable tags named in the same command still publish.
635
+
636
+ `release-kit create-github-release` skips unpublishable workspaces with a warning regardless of `--tags`; an all-private tag set is a clean no-op.
635
637
 
636
- Example output when an explicit tag is unpublishable:
638
+ Example warning when an explicit tag is unpublishable:
637
639
 
638
640
  ```
639
- Error: basic-v1.0.0 (packages/basic) cannot be published: package.json#private is true.
641
+ Skipping basic-v1.0.0 (packages/basic): package.json#private is true.
640
642
  ```
641
643
 
642
644
  ### `release-kit create-github-release`
643
645
 
644
- 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.
646
+ Create GitHub Releases from `changelog.json` for tags on HEAD. Independent of `npm publish`: invoking this command creates Releases for publishable packages whether or not they were published to a registry. Private workspaces are skipped (see [Publishability filter](#publishability-filter)).
645
647
 
646
648
  | Flag | Description |
647
649
  | ---------------------- | ------------------------------------------------------------------------- |
@@ -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:
@@ -142,7 +142,8 @@ function showCreateGithubReleaseHelp() {
142
142
  console.info(`
143
143
  Usage: release-kit create-github-release [options]
144
144
 
145
- Create GitHub Releases from changelog.json for tags on HEAD.
145
+ Create GitHub Releases from changelog.json for tags on HEAD. Private
146
+ (package.json#private) workspaces are skipped with a warning and get no Release.
146
147
 
147
148
  Options:
148
149
  --dry-run Preview without creating releases
@@ -241,7 +242,8 @@ Usage: release-kit publish [options]
241
242
 
242
243
  Publish packages that have release tags on HEAD. Operates only on workspaces where
243
244
  package.json#private is absent or false. Without --tags, unpublishable workspaces are
244
- silently filtered out. With --tags, naming an unpublishable tag is an error.
245
+ silently filtered out. With --tags, an unpublishable tag is skipped with a warning while
246
+ any publishable tags still publish.
245
247
 
246
248
  Options:
247
249
  --dry-run Preview without publishing
@@ -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);
@@ -1,5 +1,6 @@
1
1
  import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
2
  import { createGithubReleases } from "./createGithubRelease.js";
3
+ import { formatPrivateSkip } from "./formatPrivateSkip.js";
3
4
  import { parseRequestedTags } from "./parseRequestedTags.js";
4
5
  import { resolveCommandTags } from "./resolveCommandTags.js";
5
6
  import { resolveReleaseNotesConfig } from "./resolveReleaseNotesConfig.js";
@@ -12,10 +13,19 @@ export async function createGithubReleaseCommand(argv) {
12
13
  const { dryRun } = parsed.flags;
13
14
  const requestedTags = parseRequestedTags(parsed.flags.tags);
14
15
  const resolvedTags = await resolveCommandTags(requestedTags);
16
+ const publishableTags = resolvedTags.filter((resolvedTag) => resolvedTag.isPublishable);
17
+ for (const resolvedTag of resolvedTags) {
18
+ if (!resolvedTag.isPublishable) {
19
+ console.warn(formatPrivateSkip(resolvedTag));
20
+ }
21
+ }
22
+ if (publishableTags.length === 0) {
23
+ return;
24
+ }
15
25
  const { changelogJsonOutputPath, sectionOrder } = await resolveReleaseNotesConfig({ strictLoad: true });
16
26
  let outcome;
17
27
  try {
18
- outcome = createGithubReleases(resolvedTags, changelogJsonOutputPath, dryRun, sectionOrder);
28
+ outcome = createGithubReleases(publishableTags, changelogJsonOutputPath, dryRun, sectionOrder);
19
29
  }
20
30
  catch (error) {
21
31
  reportError(`Failed to create GitHub Releases: ${error instanceof Error ? error.message : String(error)}`);
@@ -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} ━━━`;
@@ -0,0 +1,2 @@
1
+ import type { ResolvedTag } from './resolveReleaseTags.js';
2
+ export declare function formatPrivateSkip(resolvedTag: Pick<ResolvedTag, 'tag' | 'workspacePath'>): string;
@@ -0,0 +1,3 @@
1
+ export function formatPrivateSkip(resolvedTag) {
2
+ return `Skipping ${resolvedTag.tag} (${resolvedTag.workspacePath}): package.json#private is true.`;
3
+ }
@@ -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
  }
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
4
4
  import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
5
5
  import { detectPackageManager } from "./detectPackageManager.js";
6
+ import { formatPrivateSkip } from "./formatPrivateSkip.js";
6
7
  import { injectReleaseNotesIntoReadme, resolveReadmePath } from "./injectReleaseNotesIntoReadme.js";
7
8
  import { parseRequestedTags } from "./parseRequestedTags.js";
8
9
  import { publishPackage } from "./publish.js";
@@ -82,11 +83,10 @@ function filterPublishableTags(resolvedTags, isExplicit) {
82
83
  for (const tag of resolvedTags) {
83
84
  (tag.isPublishable ? publishable : unpublishable).push(tag);
84
85
  }
85
- if (isExplicit && unpublishable.length > 0) {
86
- for (const { tag, workspacePath } of unpublishable) {
87
- reportError(`${tag} (${workspacePath}) cannot be published: package.json#private is true.`);
86
+ if (isExplicit) {
87
+ for (const resolvedTag of unpublishable) {
88
+ console.warn(formatPrivateSkip(resolvedTag));
88
89
  }
89
- process.exit(1);
90
90
  }
91
91
  return publishable;
92
92
  }
@@ -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.0",
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",