@williamthorsen/release-kit 8.0.1 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +93 -18
  3. package/cliff.toml.template +20 -20
  4. package/dist/esm/bin/release-kit.js +12 -9
  5. package/dist/esm/buildChangelogEntries.js +5 -4
  6. package/dist/esm/changelogOverrides.js +4 -3
  7. package/dist/esm/createGithubRelease.js +2 -2
  8. package/dist/esm/defineConfig.d.ts +2 -0
  9. package/dist/esm/defineConfig.js +3 -0
  10. package/dist/esm/detectPackageManager.js +1 -1
  11. package/dist/esm/format.js +2 -2
  12. package/dist/esm/getCommitsSinceTarget.js +1 -1
  13. package/dist/esm/index.d.ts +2 -2
  14. package/dist/esm/index.js +1 -0
  15. package/dist/esm/init/templates.js +6 -10
  16. package/dist/esm/injectReleaseNotesIntoReadme.js +1 -1
  17. package/dist/esm/loadValidatedConfig.d.ts +10 -0
  18. package/dist/esm/loadValidatedConfig.js +26 -0
  19. package/dist/esm/parseCommitMessage.d.ts +1 -0
  20. package/dist/esm/parseCommitMessage.js +3 -1
  21. package/dist/esm/prepareCommand.js +7 -6
  22. package/dist/esm/previewTagPrefixes.js +2 -5
  23. package/dist/esm/releasePrepareMono.js +6 -5
  24. package/dist/esm/renderReleaseNotes.js +2 -2
  25. package/dist/esm/resolveCommandTags.js +4 -3
  26. package/dist/esm/stripScope.js +3 -2
  27. package/dist/esm/sync-labels/generateCommand.d.ts +7 -1
  28. package/dist/esm/sync-labels/generateCommand.js +45 -15
  29. package/dist/esm/sync-labels/initCommand.js +47 -7
  30. package/dist/esm/sync-labels/resolveLabels.d.ts +3 -2
  31. package/dist/esm/sync-labels/resolveLabels.js +14 -26
  32. package/dist/esm/sync-labels/retiredConfig.d.ts +2 -0
  33. package/dist/esm/sync-labels/retiredConfig.js +11 -0
  34. package/dist/esm/sync-labels/syncCommand.js +4 -0
  35. package/dist/esm/sync-labels/templates.d.ts +3 -2
  36. package/dist/esm/sync-labels/templates.js +44 -16
  37. package/dist/esm/sync-labels/types.d.ts +0 -4
  38. package/dist/esm/types.d.ts +20 -0
  39. package/dist/esm/types.js +13 -0
  40. package/dist/esm/writeReleaseNotesPreviews.js +1 -1
  41. package/package.json +3 -3
  42. package/presets/labels/common.yaml +3 -0
  43. package/dist/esm/sync-labels/loadSyncLabelsConfig.d.ts +0 -3
  44. package/dist/esm/sync-labels/loadSyncLabelsConfig.js +0 -53
@@ -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
  }
@@ -1,4 +1,5 @@
1
- import { COMMIT_PREPROCESSOR_PATTERNS } from "./parseCommitMessage.js";
1
+ import { COMMIT_PREPROCESSOR_PATTERNS, PIPE_SCOPE_SOURCE } from "./parseCommitMessage.js";
2
+ const PIPE_SCOPE_PATTERN = new RegExp(String.raw `^${PIPE_SCOPE_SOURCE}\|(.*)$`);
2
3
  export function stripScope(message) {
3
4
  let ticketPrefix = '';
4
5
  let remainder = message;
@@ -9,7 +10,7 @@ export function stripScope(message) {
9
10
  remainder = remainder.slice(match[0].length);
10
11
  }
11
12
  }
12
- const pipeMatch = remainder.match(/^[^|]+\|(.*)$/);
13
+ const pipeMatch = remainder.match(PIPE_SCOPE_PATTERN);
13
14
  if (pipeMatch) {
14
15
  return `${ticketPrefix}${pipeMatch[1]}`;
15
16
  }
@@ -1,4 +1,10 @@
1
+ import type { RepoLabelsConfig } from '../types.js';
1
2
  import type { LabelDefinition } from './types.js';
2
3
  export declare const LABELS_OUTPUT_PATH = ".github/labels.yaml";
3
4
  export declare function formatLabelsYaml(labels: LabelDefinition[], presetHashes: Map<string, string>): string;
4
- export declare function generateCommand(): Promise<number>;
5
+ export declare function loadRepoLabelsConfig(): Promise<RepoLabelsConfig | undefined>;
6
+ interface GenerateOptions {
7
+ check?: boolean;
8
+ }
9
+ export declare function generateCommand({ check }?: GenerateOptions): Promise<number>;
10
+ export {};
@@ -1,36 +1,47 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs';
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { reportError } from '@williamthorsen/nmr-core';
4
4
  import { stringify } from 'yaml';
5
- import { loadSyncLabelsConfig, SYNC_LABELS_CONFIG_PATH } from "./loadSyncLabelsConfig.js";
5
+ import { CONFIG_FILE_PATH } from "../loadConfig.js";
6
+ import { loadValidatedConfig } from "../loadValidatedConfig.js";
6
7
  import { hashPresetFile } from "./presets.js";
7
8
  import { resolveLabels } from "./resolveLabels.js";
9
+ import { checkRetiredSyncLabelsConfig } from "./retiredConfig.js";
8
10
  export const LABELS_OUTPUT_PATH = '.github/labels.yaml';
9
11
  export function formatLabelsYaml(labels, presetHashes) {
10
- const headerLines = ['# Generated by release-kit sync-labels — do not edit.', `# Source: ${SYNC_LABELS_CONFIG_PATH}`];
11
- for (const [name, hash] of [...presetHashes.entries()].sort(([a], [b]) => a.localeCompare(b))) {
12
+ const headerLines = ['# Generated by release-kit sync-labels — do not edit.', `# Source: ${CONFIG_FILE_PATH}`];
13
+ for (const [name, hash] of [...presetHashes].sort(([a], [b]) => a.localeCompare(b))) {
12
14
  headerLines.push(`# ${name} preset hash: ${hash}`);
13
15
  }
14
16
  const yamlBody = stringify(labels, { singleQuote: true, lineWidth: 0 });
15
17
  return headerLines.join('\n') + '\n' + yamlBody;
16
18
  }
17
- export async function generateCommand() {
18
- let config;
19
- try {
20
- config = await loadSyncLabelsConfig();
19
+ export async function loadRepoLabelsConfig() {
20
+ const result = await loadValidatedConfig();
21
+ if (result.status === 'missing') {
22
+ reportError(`No config file found at ${CONFIG_FILE_PATH}. Run \`release-kit sync-labels init\` first.`);
23
+ return undefined;
21
24
  }
22
- catch (error) {
23
- const message = error instanceof Error ? error.message : String(error);
24
- reportError(`Failed to load config: ${message}`);
25
+ if (result.status === 'invalid') {
26
+ return undefined;
27
+ }
28
+ if (result.config.repoLabels === undefined) {
29
+ reportError(`No \`repoLabels\` block found in ${CONFIG_FILE_PATH}. Run \`release-kit sync-labels init\` to seed one.`);
30
+ return undefined;
31
+ }
32
+ return result.config.repoLabels;
33
+ }
34
+ export async function generateCommand({ check = false } = {}) {
35
+ if (checkRetiredSyncLabelsConfig()) {
25
36
  return 1;
26
37
  }
27
- if (config === undefined) {
28
- reportError(`No config file found at ${SYNC_LABELS_CONFIG_PATH}. Run \`release-kit sync-labels init\` first.`);
38
+ const repoLabels = await loadRepoLabelsConfig();
39
+ if (repoLabels === undefined) {
29
40
  return 1;
30
41
  }
31
42
  let labels;
32
43
  try {
33
- labels = resolveLabels(config);
44
+ labels = resolveLabels(repoLabels);
34
45
  }
35
46
  catch (error) {
36
47
  const message = error instanceof Error ? error.message : String(error);
@@ -38,10 +49,13 @@ export async function generateCommand() {
38
49
  return 1;
39
50
  }
40
51
  const presetHashes = new Map();
41
- for (const presetName of config.presets ?? []) {
52
+ for (const presetName of repoLabels.extends ?? []) {
42
53
  presetHashes.set(presetName, hashPresetFile(presetName));
43
54
  }
44
55
  const content = formatLabelsYaml(labels, presetHashes);
56
+ if (check) {
57
+ return compareAgainstCommittedFile(content);
58
+ }
45
59
  try {
46
60
  mkdirSync(dirname(LABELS_OUTPUT_PATH), { recursive: true });
47
61
  writeFileSync(LABELS_OUTPUT_PATH, content, 'utf8');
@@ -54,3 +68,19 @@ export async function generateCommand() {
54
68
  console.info(`Generated ${LABELS_OUTPUT_PATH} with ${String(labels.length)} labels.`);
55
69
  return 0;
56
70
  }
71
+ function compareAgainstCommittedFile(expected) {
72
+ let existing;
73
+ try {
74
+ existing = readFileSync(LABELS_OUTPUT_PATH, 'utf8');
75
+ }
76
+ catch {
77
+ reportError(`${LABELS_OUTPUT_PATH} is missing. Run \`release-kit sync-labels generate\` and commit the result.`);
78
+ return 1;
79
+ }
80
+ if (existing !== expected) {
81
+ reportError(`${LABELS_OUTPUT_PATH} is stale. Run \`release-kit sync-labels generate\` and commit the result.`);
82
+ return 1;
83
+ }
84
+ console.info(`${LABELS_OUTPUT_PATH} is up to date.`);
85
+ return 0;
86
+ }
@@ -1,10 +1,16 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { reportWriteResult, writeFileWithCheck } from '@williamthorsen/nmr-core';
2
3
  import { discoverWorkspaces } from "../discoverWorkspaces.js";
4
+ import { CONFIG_FILE_PATH } from "../loadConfig.js";
5
+ import { loadValidatedConfig } from "../loadValidatedConfig.js";
3
6
  import { generateCommand, LABELS_OUTPUT_PATH } from "./generateCommand.js";
4
- import { SYNC_LABELS_CONFIG_PATH } from "./loadSyncLabelsConfig.js";
5
- import { buildScopeLabels, syncLabelsConfigScript, syncLabelsWorkflow } from "./templates.js";
7
+ import { checkRetiredSyncLabelsConfig } from "./retiredConfig.js";
8
+ import { buildScopeLabels, renderRepoLabelsBlock, repoLabelsConfigScript, syncLabelsWorkflow } from "./templates.js";
6
9
  const WORKFLOW_PATH = '.github/workflows/sync-labels.yaml';
7
10
  export async function syncLabelsInitCommand({ dryRun, force }) {
11
+ if (checkRetiredSyncLabelsConfig()) {
12
+ return 1;
13
+ }
8
14
  if (dryRun) {
9
15
  console.info('[dry-run mode]');
10
16
  }
@@ -18,20 +24,41 @@ export async function syncLabelsInitCommand({ dryRun, force }) {
18
24
  process.stderr.write(` Failed to discover workspaces: ${message}\n`);
19
25
  return 1;
20
26
  }
21
- const scopeLabels = workspacePaths === undefined ? [] : buildScopeLabels(workspacePaths);
22
27
  if (workspacePaths === undefined) {
23
28
  console.info(' No pnpm workspaces found (single-package repo)');
24
29
  }
25
30
  else {
26
31
  console.info(` Found ${String(workspacePaths.length)} workspaces`);
27
32
  }
33
+ const configExists = existsSync(CONFIG_FILE_PATH);
34
+ const retiredNames = configExists ? await loadRetiredPackageNames() : [];
35
+ if (retiredNames === undefined) {
36
+ return 1;
37
+ }
38
+ const scopeLabels = workspacePaths === undefined ? [] : buildScopeLabels(workspacePaths, retiredNames);
28
39
  console.info('\n> Scaffolding files');
29
40
  const workflowResult = writeFileWithCheck(WORKFLOW_PATH, syncLabelsWorkflow(), { dryRun, overwrite: force });
30
- const configResult = writeFileWithCheck(SYNC_LABELS_CONFIG_PATH, syncLabelsConfigScript(scopeLabels), {
41
+ reportWriteResult(workflowResult, dryRun);
42
+ if (configExists) {
43
+ console.info(`\n> ${CONFIG_FILE_PATH} already exists; add this block to the object passed to defineConfig:\n`);
44
+ console.info(renderRepoLabelsBlock(scopeLabels));
45
+ if (workflowResult.outcome === 'failed') {
46
+ process.stderr.write('Failed to scaffold one or more files.\n');
47
+ return 1;
48
+ }
49
+ console.info(`
50
+ > Next steps
51
+ 1. Paste the block above into ${CONFIG_FILE_PATH}.
52
+ 2. Run \`release-kit sync-labels generate\` to produce ${LABELS_OUTPUT_PATH}.
53
+ 3. Commit the changes.
54
+ 4. Run \`release-kit sync-labels sync\` to apply labels to your GitHub repo.
55
+ `);
56
+ return 0;
57
+ }
58
+ const configResult = writeFileWithCheck(CONFIG_FILE_PATH, repoLabelsConfigScript(scopeLabels), {
31
59
  dryRun,
32
60
  overwrite: force,
33
61
  });
34
- reportWriteResult(workflowResult, dryRun);
35
62
  reportWriteResult(configResult, dryRun);
36
63
  if (workflowResult.outcome === 'failed' || configResult.outcome === 'failed') {
37
64
  process.stderr.write('Failed to scaffold one or more files.\n');
@@ -51,11 +78,24 @@ export async function syncLabelsInitCommand({ dryRun, force }) {
51
78
  > Next steps
52
79
  1. Review the generated files:
53
80
  - ${WORKFLOW_PATH}
54
- - ${SYNC_LABELS_CONFIG_PATH}
81
+ - ${CONFIG_FILE_PATH}
55
82
  - ${LABELS_OUTPUT_PATH}
56
- 2. Customize ${SYNC_LABELS_CONFIG_PATH} as needed, then re-run \`release-kit sync-labels generate\`.
83
+ 2. Customize the \`repoLabels\` block in ${CONFIG_FILE_PATH} as needed, then re-run \`release-kit sync-labels generate\`.
57
84
  3. Commit the generated files.
58
85
  4. Run \`release-kit sync-labels sync\` to apply labels to your GitHub repo.
59
86
  `);
60
87
  return 0;
61
88
  }
89
+ async function loadRetiredPackageNames() {
90
+ const result = await loadValidatedConfig();
91
+ if (result.status === 'invalid') {
92
+ return undefined;
93
+ }
94
+ if (result.status === 'missing') {
95
+ return [];
96
+ }
97
+ return (result.config.retiredPackages ?? []).map((retired) => toUnscopedName(retired.name));
98
+ }
99
+ function toUnscopedName(name) {
100
+ return name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name;
101
+ }
@@ -1,2 +1,3 @@
1
- import type { LabelDefinition, SyncLabelsConfig } from './types.js';
2
- export declare function resolveLabels(config: SyncLabelsConfig): LabelDefinition[];
1
+ import type { RepoLabelsConfig } from '../types.js';
2
+ import type { LabelDefinition } from './types.js';
3
+ export declare function resolveLabels(config: RepoLabelsConfig): LabelDefinition[];
@@ -1,35 +1,23 @@
1
1
  import { loadPreset } from "./presets.js";
2
2
  export function resolveLabels(config) {
3
- const presetLabels = [];
4
- for (const presetName of config.presets ?? []) {
5
- const loaded = loadPreset(presetName);
6
- presetLabels.push(...loaded);
7
- }
8
- const customLabels = config.labels ?? [];
9
- detectCollisions(presetLabels, customLabels);
10
- return sortLabels([...presetLabels, ...customLabels]);
11
- }
12
- function detectCollisions(presetLabels, customLabels) {
13
- const seenPresetNames = new Set();
14
- const withinPresetDuplicates = [];
15
- for (const label of presetLabels) {
16
- if (seenPresetNames.has(label.name)) {
17
- withinPresetDuplicates.push(label.name);
3
+ const resolved = new Map();
4
+ for (const presetName of config.extends ?? []) {
5
+ for (const label of loadPreset(presetName)) {
6
+ resolved.set(label.name, label);
18
7
  }
19
- seenPresetNames.add(label.name);
20
- }
21
- if (withinPresetDuplicates.length > 0) {
22
- throw new Error(`Label name collision within presets: the following labels are defined by multiple presets: ${withinPresetDuplicates.join(', ')}. Remove the duplicates or use different presets.`);
23
8
  }
24
- const conflicts = [];
25
- for (const label of customLabels) {
26
- if (seenPresetNames.has(label.name)) {
27
- conflicts.push(label.name);
9
+ for (const [name, spec] of Object.entries(config.labels ?? {})) {
10
+ if (spec === null) {
11
+ if (!resolved.has(name)) {
12
+ throw new Error(`Label '${name}' is set to null (remove), but no preset in 'extends' defines it. Fix the name or delete the entry.`);
13
+ }
14
+ resolved.delete(name);
15
+ }
16
+ else {
17
+ resolved.set(name, { name, color: spec.color, description: spec.description });
28
18
  }
29
19
  }
30
- if (conflicts.length > 0) {
31
- throw new Error(`Label name collision: the following labels appear in both presets and custom labels: ${conflicts.join(', ')}. Remove duplicates from your custom labels or use a different name.`);
32
- }
20
+ return sortLabels([...resolved.values()]);
33
21
  }
34
22
  function sortLabels(labels) {
35
23
  return [...labels].sort((a, b) => a.name.localeCompare(b.name));
@@ -0,0 +1,2 @@
1
+ export declare const RETIRED_SYNC_LABELS_CONFIG_PATH = ".config/sync-labels.config.ts";
2
+ export declare function checkRetiredSyncLabelsConfig(): boolean;
@@ -0,0 +1,11 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { reportError } from '@williamthorsen/nmr-core';
3
+ import { CONFIG_FILE_PATH } from "../loadConfig.js";
4
+ export const RETIRED_SYNC_LABELS_CONFIG_PATH = '.config/sync-labels.config.ts';
5
+ export function checkRetiredSyncLabelsConfig() {
6
+ if (!existsSync(RETIRED_SYNC_LABELS_CONFIG_PATH)) {
7
+ return false;
8
+ }
9
+ reportError(`${RETIRED_SYNC_LABELS_CONFIG_PATH} is no longer read. Move its labels into the \`repoLabels\` block of ${CONFIG_FILE_PATH} (presets move to \`repoLabels.extends\`; each label becomes a \`'name': { color, description }\` entry under \`repoLabels.labels\`), then delete the file.`);
10
+ return true;
11
+ }
@@ -1,6 +1,7 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { reportError } from '@williamthorsen/nmr-core';
4
+ import { checkRetiredSyncLabelsConfig } from "./retiredConfig.js";
4
5
  const WORKFLOW_FILE = '.github/workflows/sync-labels.yaml';
5
6
  function checkGhAvailable() {
6
7
  try {
@@ -12,6 +13,9 @@ function checkGhAvailable() {
12
13
  }
13
14
  }
14
15
  export function syncLabelsCommand() {
16
+ if (checkRetiredSyncLabelsConfig()) {
17
+ return 1;
18
+ }
15
19
  if (!checkGhAvailable()) {
16
20
  reportError('The `gh` CLI is not installed or not in PATH. Install it from https://cli.github.com/');
17
21
  return 1;
@@ -1,4 +1,5 @@
1
1
  import type { LabelDefinition } from './types.js';
2
2
  export declare function syncLabelsWorkflow(): string;
3
- export declare function buildScopeLabels(workspacePaths: string[]): LabelDefinition[];
4
- export declare function syncLabelsConfigScript(scopeLabels: LabelDefinition[]): string;
3
+ export declare function buildScopeLabels(workspacePaths: string[], retiredNames?: string[]): LabelDefinition[];
4
+ export declare function renderRepoLabelsBlock(scopeLabels: LabelDefinition[]): string;
5
+ export declare function repoLabelsConfigScript(scopeLabels: LabelDefinition[]): string;
@@ -6,16 +6,39 @@ 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 that 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 has a full computed diff, including deletions of labels not declared by the file.
15
+ pull_request:
16
+ paths:
17
+ - .github/labels.yaml
18
+
19
+ # Permissions are fixed when a run is created and cannot vary by trigger within a job,
20
+ # so applying and previewing are separate jobs.
13
21
  jobs:
22
+ # The push arm applies on the default branch alone. A \`branches:\` filter cannot name the default branch,
23
+ # so that the gate lives on the job and this file stays identical across repos.
14
24
  sync:
25
+ if: github.event_name != 'pull_request' && (github.event_name != 'push' || github.ref_name == github.event.repository.default_branch)
26
+ permissions:
27
+ contents: read
28
+ issues: write
29
+ uses: williamthorsen/node-monorepo-tools/.github/workflows/sync-labels.reusable.yaml@workflow/sync-labels-v1
30
+
31
+ check:
32
+ if: github.event_name == 'pull_request'
33
+ permissions:
34
+ contents: read
35
+ issues: read
15
36
  uses: williamthorsen/node-monorepo-tools/.github/workflows/sync-labels.reusable.yaml@workflow/sync-labels-v1
37
+ with:
38
+ dry-run: true
16
39
  `;
17
40
  }
18
- export function buildScopeLabels(workspacePaths) {
41
+ export function buildScopeLabels(workspacePaths, retiredNames = []) {
19
42
  const labels = [
20
43
  { name: 'scope:root', color: '00ff96', description: 'Monorepo root configuration' },
21
44
  ];
@@ -23,29 +46,34 @@ export function buildScopeLabels(workspacePaths) {
23
46
  const name = basename(workspacePath);
24
47
  labels.push({ name: `scope:${name}`, color: '00ff96', description: `${name} package` });
25
48
  }
49
+ for (const retiredName of retiredNames) {
50
+ labels.push({ name: `scope:${retiredName}`, color: '00ff96', description: `${retiredName} package (retired)` });
51
+ }
26
52
  return labels;
27
53
  }
28
54
  function escapeForSingleQuotedString(value) {
29
55
  return value.replace(/\\/g, '\\\\').replace(/'/g, String.raw `\'`);
30
56
  }
31
- export function syncLabelsConfigScript(scopeLabels) {
32
- const labelsArray = scopeLabels
57
+ export function renderRepoLabelsBlock(scopeLabels) {
58
+ const entries = scopeLabels
33
59
  .map((label) => {
34
60
  const name = escapeForSingleQuotedString(label.name);
35
61
  const color = escapeForSingleQuotedString(label.color);
36
62
  const description = escapeForSingleQuotedString(label.description);
37
- return ` { name: '${name}', color: '${color}', description: '${description}' },`;
63
+ return ` '${name}': { color: '${color}', description: '${description}' },`;
38
64
  })
39
65
  .join('\n');
40
- return `import type { SyncLabelsConfig } from '@williamthorsen/release-kit';
41
-
42
- const config: SyncLabelsConfig = {
43
- presets: ['common'],
44
- labels: [
45
- ${labelsArray}
46
- ],
47
- };
66
+ const labelsBlock = entries === '' ? ' labels: {},' : ` labels: {\n${entries}\n },`;
67
+ return ` repoLabels: {
68
+ extends: ['common'],
69
+ ${labelsBlock}
70
+ },`;
71
+ }
72
+ export function repoLabelsConfigScript(scopeLabels) {
73
+ return `import { defineConfig } from '@williamthorsen/release-kit';
48
74
 
49
- export default config;
75
+ export default defineConfig({
76
+ ${renderRepoLabelsBlock(scopeLabels)}
77
+ });
50
78
  `;
51
79
  }
@@ -3,7 +3,3 @@ export interface LabelDefinition {
3
3
  color: string;
4
4
  description: string;
5
5
  }
6
- export interface SyncLabelsConfig {
7
- presets?: string[];
8
- labels?: LabelDefinition[];
9
- }
@@ -171,6 +171,19 @@ export declare const breakingPolicyValueSchema: z.ZodEnum<{
171
171
  forbidden: "forbidden";
172
172
  required: "required";
173
173
  }>;
174
+ export declare const labelSpecSchema: z.ZodObject<{
175
+ color: z.ZodString;
176
+ description: z.ZodString;
177
+ }, z.core.$strict>;
178
+ export type LabelSpec = z.infer<typeof labelSpecSchema>;
179
+ export declare const repoLabelsSchema: z.ZodObject<{
180
+ extends: z.ZodOptional<z.ZodArray<z.ZodString>>;
181
+ labels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodObject<{
182
+ color: z.ZodString;
183
+ description: z.ZodString;
184
+ }, z.core.$strict>>>>;
185
+ }, z.core.$strict>;
186
+ export type RepoLabelsConfig = z.infer<typeof repoLabelsSchema>;
174
187
  export declare const releaseKitConfigSchema: z.ZodObject<{
175
188
  breakingPolicies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
176
189
  optional: "optional";
@@ -190,6 +203,13 @@ export declare const releaseKitConfigSchema: z.ZodObject<{
190
203
  releaseNotes: z.ZodOptional<z.ZodObject<{
191
204
  shouldInjectIntoReadme: z.ZodOptional<z.ZodBoolean>;
192
205
  }, z.core.$strict>>;
206
+ repoLabels: z.ZodOptional<z.ZodObject<{
207
+ extends: z.ZodOptional<z.ZodArray<z.ZodString>>;
208
+ labels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodObject<{
209
+ color: z.ZodString;
210
+ description: z.ZodString;
211
+ }, z.core.$strict>>>>;
212
+ }, z.core.$strict>>;
193
213
  retiredPackages: z.ZodOptional<z.ZodArray<z.ZodObject<{
194
214
  name: z.ZodString;
195
215
  tagPrefix: z.ZodString;
package/dist/esm/types.js CHANGED
@@ -49,6 +49,18 @@ export const versionPatternsSchema = z
49
49
  })
50
50
  .strict();
51
51
  export const breakingPolicyValueSchema = z.enum(['forbidden', 'optional', 'required']);
52
+ export const labelSpecSchema = z
53
+ .object({
54
+ color: z.string().min(1),
55
+ description: z.string(),
56
+ })
57
+ .strict();
58
+ export const repoLabelsSchema = z
59
+ .object({
60
+ extends: z.array(z.string().min(1)).optional(),
61
+ labels: z.record(z.string().min(1), labelSpecSchema.nullable()).optional(),
62
+ })
63
+ .strict();
52
64
  export const releaseKitConfigSchema = z
53
65
  .object({
54
66
  breakingPolicies: z.record(z.string(), breakingPolicyValueSchema).optional(),
@@ -57,6 +69,7 @@ export const releaseKitConfigSchema = z
57
69
  formatCommand: z.string().min(1).optional(),
58
70
  project: projectConfigSchema.optional(),
59
71
  releaseNotes: releaseNotesInputSchema.optional(),
72
+ repoLabels: repoLabelsSchema.optional(),
60
73
  retiredPackages: z.array(retiredPackageSchema).optional(),
61
74
  scopeAliases: z.record(z.string(), z.string()).optional(),
62
75
  versionPatterns: versionPatternsSchema.optional(),
@@ -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": "9.0.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.2"
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",
@@ -2,6 +2,9 @@
2
2
  # Compatible with EndBug/label-sync format.
3
3
 
4
4
  # Main categories
5
+ - name: ai
6
+ color: a2eeef
7
+ description: Documentation and configs used by agents
5
8
  - name: breaking
6
9
  color: b60205
7
10
  description: Breaking API change or removal of functionality
@@ -1,3 +0,0 @@
1
- import type { SyncLabelsConfig } from './types.js';
2
- export declare const SYNC_LABELS_CONFIG_PATH = ".config/sync-labels.config.ts";
3
- export declare function loadSyncLabelsConfig(): Promise<SyncLabelsConfig | undefined>;
@@ -1,53 +0,0 @@
1
- import { existsSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { isRecord } from "../typeGuards.js";
4
- export const SYNC_LABELS_CONFIG_PATH = '.config/sync-labels.config.ts';
5
- export async function loadSyncLabelsConfig() {
6
- const absoluteConfigPath = path.resolve(process.cwd(), SYNC_LABELS_CONFIG_PATH);
7
- if (!existsSync(absoluteConfigPath)) {
8
- return undefined;
9
- }
10
- const { createJiti } = await import('jiti');
11
- const jiti = createJiti(import.meta.url);
12
- const imported = await jiti.import(absoluteConfigPath);
13
- if (!isRecord(imported)) {
14
- throw new Error(`Config file must export an object, got ${Array.isArray(imported) ? 'array' : typeof imported}`);
15
- }
16
- const resolved = imported.default ?? imported.config;
17
- if (resolved === undefined) {
18
- throw new Error('Config file must have a default export or a named `config` export (e.g., `export default { ... }` or `export const config = { ... }`)');
19
- }
20
- return validateSyncLabelsConfig(resolved);
21
- }
22
- function validateSyncLabelsConfig(value) {
23
- if (!isRecord(value)) {
24
- throw new Error(`Config must be an object, got ${typeof value}`);
25
- }
26
- let presets;
27
- if (value.presets !== undefined) {
28
- if (!Array.isArray(value.presets) || !value.presets.every((p) => typeof p === 'string')) {
29
- throw new Error('Config `presets` must be an array of strings');
30
- }
31
- presets = value.presets;
32
- }
33
- let labels;
34
- if (value.labels !== undefined) {
35
- if (!Array.isArray(value.labels)) {
36
- throw new TypeError('Config `labels` must be an array');
37
- }
38
- labels = [];
39
- for (const label of value.labels) {
40
- if (!isRecord(label)) {
41
- throw new Error('Each label must be an object with `name`, `color`, and `description`');
42
- }
43
- if (typeof label.name !== 'string' || typeof label.color !== 'string' || typeof label.description !== 'string') {
44
- throw new TypeError('Each label must have string `name`, `color`, and `description` fields');
45
- }
46
- labels.push({ name: label.name, color: label.color, description: label.description });
47
- }
48
- }
49
- return {
50
- ...(presets !== undefined && { presets }),
51
- ...(labels !== undefined && { labels }),
52
- };
53
- }