@williamthorsen/release-kit 10.9.0 → 12.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 (97) hide show
  1. package/.meta/changelog.json +119 -2
  2. package/.readyup/kits/default.js +1 -17
  3. package/.readyup/kits/npm-auto-publish.js +42 -22
  4. package/.readyup/manifest.json +7 -7
  5. package/CHANGELOG.md +88 -0
  6. package/README.md +32 -32
  7. package/dist/esm/attachChangelogDiagnostics.d.ts +4 -0
  8. package/dist/esm/attachChangelogDiagnostics.js +11 -0
  9. package/dist/esm/bin/release-kit.js +34 -25
  10. package/dist/esm/buildChangelogEntries.d.ts +59 -17
  11. package/dist/esm/buildChangelogEntries.js +195 -111
  12. package/dist/esm/buildReleaseSummary.js +2 -2
  13. package/dist/esm/changelogOverrides.d.ts +31 -13
  14. package/dist/esm/changelogOverrides.js +138 -58
  15. package/dist/esm/classifyChangelogCommit.d.ts +38 -0
  16. package/dist/esm/classifyChangelogCommit.js +33 -0
  17. package/dist/esm/configFlagSchema.d.ts +12 -0
  18. package/dist/esm/configFlagSchema.js +3 -0
  19. package/dist/esm/createGithubReleaseCommand.js +6 -2
  20. package/dist/esm/decideRelease.d.ts +11 -37
  21. package/dist/esm/decideRelease.js +5 -27
  22. package/dist/esm/defaults.d.ts +1 -1
  23. package/dist/esm/defaults.js +3 -0
  24. package/dist/esm/determineBumpType.d.ts +11 -11
  25. package/dist/esm/determineBumpType.js +6 -7
  26. package/dist/esm/discoverWorkspaces.d.ts +53 -8
  27. package/dist/esm/discoverWorkspaces.js +57 -37
  28. package/dist/esm/enumerateReleaseWindows.d.ts +50 -0
  29. package/dist/esm/enumerateReleaseWindows.js +124 -0
  30. package/dist/esm/generateChangelogs.d.ts +5 -23
  31. package/dist/esm/generateChangelogs.js +0 -14
  32. package/dist/esm/glyphs.d.ts +1 -1
  33. package/dist/esm/help/prepareHelp.d.ts +1 -1
  34. package/dist/esm/help/prepareHelp.js +5 -6
  35. package/dist/esm/index.d.ts +1 -1
  36. package/dist/esm/init/initCommand.js +1 -1
  37. package/dist/esm/init/scaffold.d.ts +0 -2
  38. package/dist/esm/init/scaffold.js +2 -30
  39. package/dist/esm/loadConfig.d.ts +8 -5
  40. package/dist/esm/loadConfig.js +7 -7
  41. package/dist/esm/loadValidatedConfig.d.ts +46 -5
  42. package/dist/esm/loadValidatedConfig.js +32 -15
  43. package/dist/esm/parseChangeRecordBlock.d.ts +37 -0
  44. package/dist/esm/parseChangeRecordBlock.js +134 -0
  45. package/dist/esm/parseCommitMessage.d.ts +31 -6
  46. package/dist/esm/parseCommitMessage.js +6 -5
  47. package/dist/esm/prepareCommand.d.ts +5 -0
  48. package/dist/esm/prepareCommand.js +30 -47
  49. package/dist/esm/previewTagPrefixes.d.ts +9 -6
  50. package/dist/esm/previewTagPrefixes.js +12 -24
  51. package/dist/esm/publishCommand.d.ts +2 -2
  52. package/dist/esm/publishCommand.js +8 -2
  53. package/dist/esm/pushCommand.d.ts +1 -1
  54. package/dist/esm/pushCommand.js +2 -2
  55. package/dist/esm/releasePrepare.d.ts +7 -8
  56. package/dist/esm/releasePrepare.js +53 -73
  57. package/dist/esm/releasePrepareMono.js +48 -85
  58. package/dist/esm/releasePrepareProject.d.ts +1 -1
  59. package/dist/esm/releasePrepareProject.js +19 -37
  60. package/dist/esm/reportPrepare.js +45 -30
  61. package/dist/esm/resolveCommandTags.d.ts +3 -2
  62. package/dist/esm/resolveCommandTags.js +10 -6
  63. package/dist/esm/resolveReleaseNotesConfig.d.ts +9 -11
  64. package/dist/esm/resolveReleaseNotesConfig.js +9 -28
  65. package/dist/esm/showTagPrefixesCommand.d.ts +4 -2
  66. package/dist/esm/showTagPrefixesCommand.js +17 -3
  67. package/dist/esm/sync-labels/generateCommand.d.ts +16 -8
  68. package/dist/esm/sync-labels/generateCommand.js +12 -11
  69. package/dist/esm/sync-labels/initCommand.d.ts +9 -4
  70. package/dist/esm/sync-labels/initCommand.js +21 -15
  71. package/dist/esm/sync-labels/retiredConfig.d.ts +4 -2
  72. package/dist/esm/sync-labels/retiredConfig.js +2 -2
  73. package/dist/esm/types.d.ts +99 -55
  74. package/dist/esm/types.js +1 -1
  75. package/dist/esm/validateConfig.js +4 -0
  76. package/dist/esm/validateOverridesCommand.d.ts +16 -9
  77. package/dist/esm/validateOverridesCommand.js +39 -54
  78. package/docs/changelogs.md +64 -25
  79. package/docs/configuration.md +14 -3
  80. package/docs/editorial-overrides.md +75 -16
  81. package/docs/labels.md +2 -0
  82. package/docs/project-releases.md +4 -4
  83. package/docs/releasing.md +13 -8
  84. package/docs/work-types.md +13 -9
  85. package/package.json +5 -7
  86. package/schemas/changelog-overrides.json +39 -0
  87. package/cliff.toml.template +0 -65
  88. package/dist/esm/collectPolicyViolations.d.ts +0 -15
  89. package/dist/esm/collectPolicyViolations.js +0 -12
  90. package/dist/esm/determineBumpFromCommits.d.ts +0 -17
  91. package/dist/esm/determineBumpFromCommits.js +0 -28
  92. package/dist/esm/getCommitsSinceTarget.d.ts +0 -21
  93. package/dist/esm/getCommitsSinceTarget.js +0 -66
  94. package/dist/esm/resolveCliffConfigPath.d.ts +0 -11
  95. package/dist/esm/resolveCliffConfigPath.js +0 -20
  96. package/dist/esm/runGitCliff.d.ts +0 -38
  97. package/dist/esm/runGitCliff.js +0 -25
@@ -1,13 +1,11 @@
1
1
  import { join as joinPath } from 'node:path';
2
- import { buildChangelogEntries } from "./buildChangelogEntries.js";
2
+ import { attachChangelogDiagnostics } from "./attachChangelogDiagnostics.js";
3
+ import { readReleaseHistory, toChangelogEntries } from "./buildChangelogEntries.js";
3
4
  import { buildEmptyReleaseEntry } from "./buildEmptyReleaseEntry.js";
4
5
  import { mergeChangelogEntriesWithDisk, renderChangelogJson, resolveChangelogJsonPath } from "./changelogJsonFile.js";
5
6
  import { applyChangelogOverrides } from "./changelogOverrides.js";
6
- import { createPolicyViolationCollector } from "./collectPolicyViolations.js";
7
7
  import { decideRelease } from "./decideRelease.js";
8
- import { DEFAULT_BREAKING_POLICIES, DEFAULT_VERSION_PATTERNS, DEFAULT_WORK_TYPES } from "./defaults.js";
9
- import { buildTagPattern } from "./generateChangelogs.js";
10
- import { getCommitsSinceTarget } from "./getCommitsSinceTarget.js";
8
+ import { DEFAULT_WORK_TYPES } from "./defaults.js";
11
9
  import { planReleaseNotesPreviews } from "./planReleaseNotesPreviews.js";
12
10
  import { planVersionBump } from "./planVersionBump.js";
13
11
  import { renderChangelogMarkdown } from "./renderChangelogMarkdown.js";
@@ -23,54 +21,43 @@ export function releasePrepareProject(args) {
23
21
  throw new Error('releasePrepareProject called without a configured project block');
24
22
  }
25
23
  const workTypes = config.workTypes ?? { ...DEFAULT_WORK_TYPES };
26
- const versionPatterns = config.versionPatterns ?? { ...DEFAULT_VERSION_PATTERNS };
27
- const breakingPolicies = config.breakingPolicies ?? DEFAULT_BREAKING_POLICIES;
28
- const contributingPaths = project.paths;
29
- const { tag, commits } = getCommitsSinceTarget([project.tagPrefix], contributingPaths);
24
+ const history = readReleaseHistory(config, { tagPrefixes: [project.tagPrefix], paths: project.paths });
25
+ const { commits, diagnostics, parsedCommitCount, unparseableCommits } = history.unreleased;
26
+ const tag = history.previousTag;
30
27
  const since = tag === undefined ? '(no previous release found)' : `since ${tag}`;
31
- const collector = createPolicyViolationCollector();
32
28
  const decision = decideRelease({
33
- commits,
29
+ naturalBump: history.unreleased.bump,
30
+ commitCount: commits.length,
34
31
  force,
35
32
  bumpOverride,
36
- workTypes,
37
- versionPatterns,
38
- scopeAliases: config.scopeAliases,
39
- breakingPolicies,
40
- onPolicyViolation: collector.onPolicyViolation,
41
33
  skipReasons: {
42
34
  noCommits: `No commits ${since}. Pass --force to release at patch. Skipping.`,
43
35
  noBumpWorthy: `No bump-worthy commits ${since}. Pass --force to release at patch (or --force --bump=X for a different level). Skipping.`,
44
36
  },
45
37
  });
46
- const policyViolations = collector.violations.length > 0 ? collector.violations : undefined;
47
38
  if (decision.outcome === 'skip') {
48
39
  const skipped = {
49
40
  status: 'skipped',
50
41
  commitCount: commits.length,
51
- parsedCommitCount: decision.parsedCommitCount,
42
+ parsedCommitCount,
52
43
  skipReason: decision.skipReason,
53
44
  };
54
45
  if (tag !== undefined) {
55
46
  skipped.previousTag = tag;
56
47
  }
57
- if (decision.unparseableCommits !== undefined) {
58
- skipped.unparseableCommits = decision.unparseableCommits;
59
- }
60
- if (policyViolations !== undefined) {
61
- skipped.policyViolations = policyViolations;
48
+ if (unparseableCommits !== undefined) {
49
+ skipped.unparseableCommits = unparseableCommits;
62
50
  }
51
+ attachChangelogDiagnostics(skipped, diagnostics);
63
52
  return skipped;
64
53
  }
65
- const { releaseType, parsedCommitCount, unparseableCommits } = decision;
54
+ const { releaseType } = decision;
66
55
  const bump = planVersionBump([ROOT_PACKAGE_FILE], releaseType);
67
56
  writes.push(...bump.writes);
68
57
  const newTag = `${project.tagPrefix}${bump.newVersion}`;
69
58
  const changelogs = planProjectChangelogs({
70
59
  config,
71
- project,
72
- commits,
73
- contributingPaths,
60
+ history,
74
61
  newTag,
75
62
  newVersion: bump.newVersion,
76
63
  rootOverrides,
@@ -114,12 +101,10 @@ export function releasePrepareProject(args) {
114
101
  if (unparseableCommits !== undefined) {
115
102
  result.unparseableCommits = unparseableCommits;
116
103
  }
117
- if (policyViolations !== undefined) {
118
- result.policyViolations = policyViolations;
119
- }
120
104
  if (bumpOverride !== undefined) {
121
105
  result.bumpOverride = bumpOverride;
122
106
  }
107
+ attachChangelogDiagnostics(result, diagnostics);
123
108
  return result;
124
109
  }
125
110
  function resolveOptionalOverrideArgs(args) {
@@ -131,14 +116,11 @@ function resolveOptionalOverrideArgs(args) {
131
116
  };
132
117
  }
133
118
  function planProjectChangelogs(args) {
134
- const { config, project, commits, contributingPaths, newTag, newVersion, rootOverrides, overrideWarnings, globalMatchedRootKeys, } = args;
135
- const isEmptyRange = commits.length === 0;
119
+ const { config, history, newTag, newVersion, rootOverrides, overrideWarnings, globalMatchedRootKeys } = args;
120
+ const isEmptyRange = history.unreleased.commits.length === 0;
136
121
  const today = new Date().toISOString().slice(0, 10);
137
- const tagPattern = buildTagPattern([project.tagPrefix]);
138
- const newEntries = isEmptyRange
139
- ? [buildEmptyReleaseEntry(newVersion, today)]
140
- : buildChangelogEntries(config, newTag, { tagPattern, includePaths: contributingPaths });
141
- const applied = applyChangelogOverrides(newEntries, rootOverrides);
122
+ const builtEntries = isEmptyRange ? [buildEmptyReleaseEntry(newVersion, today)] : toChangelogEntries(history, newTag);
123
+ const applied = applyChangelogOverrides(builtEntries, rootOverrides);
142
124
  if (applied.errors.length > 0) {
143
125
  throw new Error(`Changelog override application failed:\n - ${applied.errors.join('\n - ')}`);
144
126
  }
@@ -1,8 +1,7 @@
1
- import { formatGlyphLine, formatStatusLine, measureGlyphColumn, STATUS_GLYPHS, } from '@williamthorsen/nmr-core';
1
+ import { formatGlyphLine, formatStatusLine, measureGlyphColumn, STATUS_GLYPHS, truncateToWidth, } from '@williamthorsen/nmr-core';
2
2
  import { bold, dim, sectionHeader } from "./format.js";
3
3
  import { RELEASE_GLYPHS } from "./glyphs.js";
4
- import { GIT_CLIFF_NPX_ARGS } from "./runGitCliff.js";
5
- const CLIFF_DRY_RUN_COMMAND = `npx ${GIT_CLIFF_NPX_ARGS.join(' ')} ...`;
4
+ const SUBJECT_COLUMN_BUDGET = 72;
6
5
  export function reportPrepare(plan, options) {
7
6
  const isMultiWorkspace = plan.workspaces.some((w) => w.name !== undefined) || plan.project !== undefined;
8
7
  if (isMultiWorkspace) {
@@ -26,6 +25,7 @@ function formatSingleWorkspace(result, options) {
26
25
  formatUnparseableWarning(lines, workspace, style);
27
26
  formatPolicyViolations(lines, workspace.policyViolations, style);
28
27
  if (workspace.status === 'skipped') {
28
+ formatChangeRecordWarnings(lines, workspace, style);
29
29
  lines.push(formatStatusLine(style, 'skipped', workspace.skipReason));
30
30
  formatWarnings(lines, result, style);
31
31
  return lines.join('\n');
@@ -48,6 +48,7 @@ function formatSingleWorkspace(result, options) {
48
48
  else if (workspace.releaseType !== undefined) {
49
49
  lines.push(formatGlyphLine(RELEASE_GLYPHS, style, 'bump', `${workspace.currentVersion} → ${bold(workspace.newVersion)} (${workspace.releaseType})`));
50
50
  }
51
+ formatChangeRecordWarnings(lines, workspace, style);
51
52
  formatBumpFiles(lines, workspace, dryRun);
52
53
  lines.push(dim('Generating changelogs...'));
53
54
  formatChangelogFiles(lines, workspace, dryRun);
@@ -86,6 +87,8 @@ function formatProjectSection(lines, project, dryRun, style) {
86
87
  lines.push(dim(` Found ${project.commitCount} commits ${since}`));
87
88
  formatPolicyViolations(lines, project.policyViolations, style, ' ');
88
89
  if (project.status === 'skipped') {
90
+ formatUnparseableWarning(lines, project, style, ' ');
91
+ formatChangeRecordWarnings(lines, project, style, ' ');
89
92
  lines.push(` ${formatStatusLine(style, 'skipped', project.skipReason)}`);
90
93
  return;
91
94
  }
@@ -96,7 +99,8 @@ function formatProjectSection(lines, project, dryRun, style) {
96
99
  if (project.bumpOverride !== undefined) {
97
100
  lines.push(` Using bump override: ${project.bumpOverride}`);
98
101
  }
99
- formatProjectUnparseable(lines, project, style);
102
+ formatUnparseableWarning(lines, project, style, ' ');
103
+ formatChangeRecordWarnings(lines, project, style, ' ');
100
104
  lines.push(dim(` Bumping versions (${releaseType})...`), ` ${formatGlyphLine(RELEASE_GLYPHS, style, 'bump', `${currentVersion} → ${bold(newVersion)} (${releaseType})`)}`);
101
105
  for (const file of project.bumpedFiles) {
102
106
  if (dryRun) {
@@ -109,7 +113,7 @@ function formatProjectSection(lines, project, dryRun, style) {
109
113
  lines.push(dim(' Generating changelogs...'));
110
114
  for (const file of project.changelogFiles) {
111
115
  if (dryRun) {
112
- lines.push(dim(` [dry-run] Would run: ${CLIFF_DRY_RUN_COMMAND} --output ${file}`));
116
+ lines.push(dim(` [dry-run] Would generate changelog: ${file}`));
113
117
  }
114
118
  else {
115
119
  lines.push(dim(` Generating changelog: ${file}`));
@@ -118,21 +122,6 @@ function formatProjectSection(lines, project, dryRun, style) {
118
122
  formatPreviewFiles(lines, project, dryRun, ' ');
119
123
  lines.push(` ${formatGlyphLine(RELEASE_GLYPHS, style, 'tag', bold(tag))}`);
120
124
  }
121
- function formatProjectUnparseable(lines, project, style) {
122
- const unparseable = project.unparseableCommits;
123
- if (unparseable === undefined || unparseable.length === 0) {
124
- return;
125
- }
126
- const count = unparseable.length;
127
- const isPatchFloor = project.parsedCommitCount === 0;
128
- const suffix = isPatchFloor ? ' (defaulting to patch bump)' : '';
129
- lines.push(` ${formatStatusLine(style, 'warning', `${count} commit${count === 1 ? '' : 's'} could not be parsed${suffix}`)}`);
130
- for (const commit of unparseable) {
131
- const shortHash = commit.hash.slice(0, 7);
132
- const truncatedMessage = commit.message.length > 72 ? `${commit.message.slice(0, 69)}...` : commit.message;
133
- lines.push(` · ${shortHash} ${truncatedMessage}`);
134
- }
135
- }
136
125
  function formatWorkspaceSection(lines, workspace, dryRun, style) {
137
126
  if (workspace.name !== undefined) {
138
127
  lines.push(`\n${sectionHeader(workspace.name)}`);
@@ -140,6 +129,9 @@ function formatWorkspaceSection(lines, workspace, dryRun, style) {
140
129
  const since = workspace.previousTag === undefined ? '(no previous release found)' : `since ${workspace.previousTag}`;
141
130
  lines.push(dim(` Found ${workspace.commitCount} commits ${since}`));
142
131
  if (workspace.status === 'skipped') {
132
+ formatUnparseableWarning(lines, workspace, style, ' ');
133
+ formatPolicyViolations(lines, workspace.policyViolations, style, ' ');
134
+ formatChangeRecordWarnings(lines, workspace, style, ' ');
143
135
  lines.push(` ${formatStatusLine(style, 'skipped', workspace.skipReason)}`);
144
136
  return;
145
137
  }
@@ -148,6 +140,7 @@ function formatWorkspaceSection(lines, workspace, dryRun, style) {
148
140
  formatCommitSummary(lines, workspace, propagatedFrom, isPropagatedOnly);
149
141
  formatUnparseableWarning(lines, workspace, style, ' ');
150
142
  formatPolicyViolations(lines, workspace.policyViolations, style, ' ');
143
+ formatChangeRecordWarnings(lines, workspace, style, ' ');
151
144
  formatBumpLabels(lines, workspace, isPropagatedOnly);
152
145
  formatVersionLine(lines, workspace, propagatedFrom, isPropagatedOnly, style);
153
146
  formatBumpFiles(lines, workspace, dryRun, ' ');
@@ -202,7 +195,7 @@ function formatBumpFiles(lines, workspace, dryRun, indent = '') {
202
195
  function formatChangelogFiles(lines, workspace, dryRun, indent = '') {
203
196
  for (const file of workspace.changelogFiles) {
204
197
  if (dryRun) {
205
- lines.push(dim(`${indent} [dry-run] Would run: ${CLIFF_DRY_RUN_COMMAND} --output ${file}`));
198
+ lines.push(dim(`${indent} [dry-run] Would generate changelog: ${file}`));
206
199
  }
207
200
  else {
208
201
  lines.push(dim(`${indent} Generating changelog: ${file}`));
@@ -220,19 +213,17 @@ function formatPreviewFiles(lines, release, dryRun, indent = '') {
220
213
  }
221
214
  }
222
215
  }
223
- function formatUnparseableWarning(lines, workspace, style, indent = '') {
224
- const unparseable = workspace.unparseableCommits;
216
+ function formatUnparseableWarning(lines, release, style, indent = '') {
217
+ const unparseable = release.unparseableCommits;
225
218
  if (unparseable === undefined || unparseable.length === 0) {
226
219
  return;
227
220
  }
228
221
  const count = unparseable.length;
229
- const isPatchFloor = workspace.parsedCommitCount === 0;
230
- const suffix = isPatchFloor ? ' (defaulting to patch bump)' : '';
231
- lines.push(`${indent} ${formatStatusLine(style, 'warning', `${count} commit${count === 1 ? '' : 's'} could not be parsed${suffix}`)}`);
222
+ lines.push(`${indent} ${formatStatusLine(style, 'warning', `${count} commit${count === 1 ? '' : 's'} could not be parsed`)}`);
232
223
  for (const commit of unparseable) {
233
224
  const shortHash = commit.hash.slice(0, 7);
234
- const truncatedMessage = commit.message.length > 72 ? `${commit.message.slice(0, 69)}...` : commit.message;
235
- lines.push(`${indent} · ${shortHash} ${truncatedMessage}`);
225
+ const truncatedSubject = truncateSubject(commit.subject);
226
+ lines.push(`${indent} · ${shortHash} ${truncatedSubject}`);
236
227
  }
237
228
  }
238
229
  function formatPolicyViolations(lines, violations, style, indent = '') {
@@ -244,8 +235,29 @@ function formatPolicyViolations(lines, violations, style, indent = '') {
244
235
  for (const violation of violations) {
245
236
  const shortHash = violation.commitHash.slice(0, 7);
246
237
  const subject = violation.commitSubject;
247
- const truncatedSubject = subject.length > 72 ? `${subject.slice(0, 69)}...` : subject;
248
- lines.push(`${indent} · ${shortHash} '${truncatedSubject}' — type '${violation.type}' at ${violation.surface} surface`);
238
+ const truncatedSubject = truncateSubject(subject);
239
+ const location = violation.surface === 'entry' ? `entry ${violation.entryPosition ?? '?'}` : `${violation.surface} surface`;
240
+ lines.push(`${indent} · ${shortHash} '${truncatedSubject}' — type '${violation.type}' at ${location}`);
241
+ }
242
+ }
243
+ function formatChangeRecordWarnings(lines, release, style, indent = '') {
244
+ const { malformedBlocks = [], undeclaredEntryTypes = [] } = release;
245
+ if (malformedBlocks.length > 0) {
246
+ const count = malformedBlocks.length;
247
+ const header = `${count} change-record block${count === 1 ? '' : 's'} could not be read (item taken from the title)`;
248
+ lines.push(`${indent} ${formatStatusLine(style, 'warning', `${header}:`)}`);
249
+ for (const block of malformedBlocks) {
250
+ lines.push(`${indent} · ${block.commitHash.slice(0, 7)} '${truncateSubject(block.commitSubject)}' — ${block.reason}`);
251
+ }
252
+ }
253
+ if (undeclaredEntryTypes.length > 0) {
254
+ const count = undeclaredEntryTypes.length;
255
+ const header = `${count} change-record entr${count === 1 ? 'y has an' : 'ies have'} undeclared type${count === 1 ? '' : 's'} (no item)`;
256
+ lines.push(`${indent} ${formatStatusLine(style, 'warning', `${header}:`)}`);
257
+ for (const entry of undeclaredEntryTypes) {
258
+ const shortHash = entry.commitHash.slice(0, 7);
259
+ lines.push(`${indent} · ${shortHash} '${truncateSubject(entry.commitSubject)}' — type '${entry.type}' at entry ${entry.entryPosition}`);
260
+ }
249
261
  }
250
262
  }
251
263
  function formatPropagationSuffix(propagatedFrom) {
@@ -280,3 +292,6 @@ function formatFormatCommand(lines, result, options) {
280
292
  const messageIndent = ' '.repeat(2 + measureGlyphColumn(STATUS_GLYPHS[options.style]) + 1);
281
293
  lines.push(`\n ${formatStatusLine(options.style, 'warning', `Format command failed: ${result.formatCommand.command}`)}`, `${messageIndent}${options.formatError}`);
282
294
  }
295
+ function truncateSubject(subject) {
296
+ return truncateToWidth(subject, { width: SUBJECT_COLUMN_BUDGET });
297
+ }
@@ -3,7 +3,8 @@ import { type ResolvedTag } from './resolveReleaseTags.js';
3
3
  * Discover workspaces, resolve release tags from HEAD, validate `--tags` names against the
4
4
  * full resolved tag names (e.g., `nmr-core-v1.3.0`), and return the filtered tag list.
5
5
  * Works in both single-package and monorepo modes. Exits with an error message on any validation
6
- * failure — including `deriveWorkspaceConfig()` throws for workspaces missing a `package.json` `name` field.
6
+ * failure — including `deriveWorkspaceConfig()` throws for workspaces missing a `package.json` `name` field,
7
+ * and a workspace declaring patterns that resolve to no package, which is not single-package mode.
7
8
  *
8
9
  * In both modes `deriveWorkspaceConfig` is called so `WorkspaceConfig.isPublishable` (read
9
10
  * from `package.json#private`) propagates onto each `ResolvedTag`. Single-package mode now
@@ -11,4 +12,4 @@ import { type ResolvedTag } from './resolveReleaseTags.js';
11
12
  * single-package mode skipping the derive step need a `name` field on `./package.json`,
12
13
  * but in practice every npm/pnpm package already has one.
13
14
  */
14
- export declare function resolveCommandTags(tags: string[] | undefined): Promise<ResolvedTag[]>;
15
+ export declare function resolveCommandTags(tags: string[] | undefined): ResolvedTag[];
@@ -1,25 +1,29 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
3
  import { deriveWorkspaceConfig } from "./deriveWorkspaceConfig.js";
4
- import { discoverWorkspaces } from "./discoverWorkspaces.js";
4
+ import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
5
5
  import { resolveReleaseTags } from "./resolveReleaseTags.js";
6
- export async function resolveCommandTags(tags) {
7
- let discoveredPaths;
6
+ export function resolveCommandTags(tags) {
7
+ let workspace;
8
8
  try {
9
- discoveredPaths = await discoverWorkspaces();
9
+ workspace = discoverWorkspaces();
10
10
  }
11
11
  catch (error) {
12
12
  reportError(`Failed to discover workspaces: ${describeError(error)}`);
13
13
  process.exit(1);
14
14
  }
15
+ if (workspace.kind === 'empty') {
16
+ reportError(`No workspace package to tag. ${describeEmptyWorkspace(workspace)}`);
17
+ process.exit(1);
18
+ }
15
19
  let workspaces;
16
20
  let singleWorkspace;
17
21
  try {
18
- if (discoveredPaths === undefined) {
22
+ if (workspace.kind === 'single-package') {
19
23
  singleWorkspace = deriveWorkspaceConfig('.');
20
24
  }
21
25
  else {
22
- workspaces = discoveredPaths.map((workspacePath) => deriveWorkspaceConfig(workspacePath));
26
+ workspaces = workspace.packageDirs.map((workspacePath) => deriveWorkspaceConfig(workspacePath));
23
27
  }
24
28
  }
25
29
  catch (error) {
@@ -1,4 +1,4 @@
1
- import { type OutputStyle } from '@williamthorsen/nmr-core';
1
+ import type { OutputStyle } from '@williamthorsen/nmr-core';
2
2
  import type { ReleaseNotesConfig } from './types.js';
3
3
  export interface ResolvedReleaseNotesConfig {
4
4
  releaseNotes: ReleaseNotesConfig;
@@ -7,19 +7,17 @@ export interface ResolvedReleaseNotesConfig {
7
7
  sectionOrder: string[];
8
8
  }
9
9
  export interface ResolveReleaseNotesConfigOptions {
10
- /**
11
- * When `true`, a `loadConfig()` rejection causes `process.exit(1)` rather than a fallback to
12
- * defaults. Use from CLI commands whose entire behavior depends on the resolved config (e.g.
13
- * `create-github-release`), so a corrupt or unreadable config cannot silently send the command
14
- * to the wrong changelog path.
15
- */
16
- strictLoad?: boolean;
10
+ /** Config file to read, relative to the working directory. Defaults to `CONFIG_FILE_PATH`. */
11
+ configPath?: string;
17
12
  }
18
13
  /**
19
- * Load and validate the release-kit config.
14
+ * Loads and validates the release-kit config, resolving the release-notes settings it carries.
20
15
  *
21
- * By default, falls back to defaults when `loadConfig()` rejects (legacy publish behavior).
22
- * When `strictLoad` is `true`, a load failure prints an error and calls `process.exit(1)`.
16
+ * An unusable config reports to stderr and calls `process.exit(1)`, whether the file failed to load or failed
17
+ * validation, and whether it was named by the caller or the default one. Both callers are CLI commands whose
18
+ * whole behavior depends on the resolved config, so neither could proceed on derived defaults without silently
19
+ * dropping a configured `releaseNotes.shouldInjectIntoReadme`, `changelogJson.outputPath`, or `workTypes`.
20
+ * An absent default config is a supported state and resolves to the defaults.
23
21
  */
24
22
  export declare function resolveReleaseNotesConfig(stderrStyle: OutputStyle, options?: ResolveReleaseNotesConfigOptions): Promise<ResolvedReleaseNotesConfig>;
25
23
  /** Extract section headers in declaration order from a merged workTypes record. */
@@ -1,40 +1,21 @@
1
- import { formatStatusLine, printError, reportError } from '@williamthorsen/nmr-core';
2
- import { describeError } from '@williamthorsen/toolbelt.errors';
3
1
  import { DEFAULT_CHANGELOG_JSON_CONFIG, DEFAULT_RELEASE_NOTES_CONFIG } from "./defaults.js";
4
- import { loadConfig, resolveWorkTypes } from "./loadConfig.js";
5
- import { validateConfig } from "./validateConfig.js";
2
+ import { resolveWorkTypes } from "./loadConfig.js";
3
+ import { loadValidatedConfig, reportConfigProblem, reportConfigWarnings } from "./loadValidatedConfig.js";
6
4
  export async function resolveReleaseNotesConfig(stderrStyle, options = {}) {
7
- const { strictLoad = false } = options;
8
- let rawConfig;
9
- try {
10
- rawConfig = await loadConfig();
11
- }
12
- catch (error) {
13
- const message = describeError(error);
14
- if (strictLoad) {
15
- reportError(`Failed to load config: ${message}`);
16
- process.exit(1);
17
- }
18
- console.warn(`Warning: failed to load config; using defaults: ${message}`);
5
+ const result = await loadValidatedConfig(options.configPath);
6
+ if (result.status === 'invalid') {
7
+ reportConfigProblem(result.problem, stderrStyle);
8
+ process.exit(1);
19
9
  }
20
- if (rawConfig === undefined) {
10
+ if (result.status === 'missing') {
21
11
  return {
22
12
  releaseNotes: { ...DEFAULT_RELEASE_NOTES_CONFIG },
23
13
  changelogJsonOutputPath: DEFAULT_CHANGELOG_JSON_CONFIG.outputPath,
24
14
  sectionOrder: deriveSectionOrder(resolveWorkTypes()),
25
15
  };
26
16
  }
27
- const { config, errors, warnings } = validateConfig(rawConfig);
28
- if (errors.length > 0) {
29
- process.stderr.write('Invalid config:\n');
30
- for (const err of errors) {
31
- printError(err, stderrStyle);
32
- }
33
- process.exit(1);
34
- }
35
- for (const warning of warnings) {
36
- console.warn(` ${formatStatusLine(stderrStyle, 'warning', warning)}`);
37
- }
17
+ reportConfigWarnings(result.warnings, stderrStyle);
18
+ const { config } = result;
38
19
  return {
39
20
  releaseNotes: {
40
21
  shouldInjectIntoReadme: config.releaseNotes?.shouldInjectIntoReadme ?? DEFAULT_RELEASE_NOTES_CONFIG.shouldInjectIntoReadme,
@@ -6,8 +6,10 @@ import { type StreamStyles } from '@williamthorsen/nmr-core';
6
6
  * entries, followed by an "Undeclared tag prefixes" section when candidate-shaped tags
7
7
  * exist outside the known set. Exits `0` on full derivation success and no collisions;
8
8
  * `1` on any derivation failure or collision. Undeclared candidates do not affect the
9
- * exit code.
9
+ * exit code. A config that exists and either fails to load or fails validation reports to stderr and
10
+ * exits `1` in both repo modes, rather than falling back to a default-config preview that would
11
+ * understate the declared legacy prefixes. An absent default config previews against derived defaults.
10
12
  *
11
13
  * @returns The exit code the caller should use.
12
14
  */
13
- export declare function showTagPrefixesCommand(styles: StreamStyles): Promise<number>;
15
+ export declare function showTagPrefixesCommand(styles: StreamStyles, configPath?: string): Promise<number>;
@@ -1,12 +1,26 @@
1
- import { formatStatusLine } from '@williamthorsen/nmr-core';
1
+ import { formatStatusLine, reportError } from '@williamthorsen/nmr-core';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
2
3
  import { detectRepoType } from "./init/detectRepoType.js";
4
+ import { loadValidatedConfig, reportConfigProblem } from "./loadValidatedConfig.js";
3
5
  import { previewTagPrefixes } from "./previewTagPrefixes.js";
4
- export async function showTagPrefixesCommand(styles) {
6
+ export async function showTagPrefixesCommand(styles, configPath) {
7
+ const result = await loadValidatedConfig(configPath);
8
+ if (result.status === 'invalid') {
9
+ reportConfigProblem(result.problem, styles.stderr);
10
+ return 1;
11
+ }
5
12
  if (detectRepoType() === 'single-package') {
6
13
  process.stdout.write(renderSinglePackage());
7
14
  return 0;
8
15
  }
9
- const preview = await previewTagPrefixes();
16
+ let preview;
17
+ try {
18
+ preview = previewTagPrefixes(result.status === 'ok' ? result.config : undefined);
19
+ }
20
+ catch (error) {
21
+ reportError(describeError(error));
22
+ return 1;
23
+ }
10
24
  process.stdout.write(renderMonorepo(preview, styles.stdout));
11
25
  return computeExitCode(preview);
12
26
  }
@@ -3,27 +3,35 @@ import type { RepoLabelsConfig } from '../types.js';
3
3
  import type { LabelDefinition } from './types.js';
4
4
  /** Output path for the generated labels file. */
5
5
  export declare const LABELS_OUTPUT_PATH = ".github/labels.yaml";
6
- /** Formats resolved labels as YAML with generated header including preset hashes. */
7
- export declare function formatLabelsYaml(labels: LabelDefinition[], presetHashes: Map<string, string>): string;
8
6
  /**
9
- * Loads `.config/release-kit.config.ts` and returns its `repoLabels` block.
7
+ * Formats resolved labels as YAML with generated header including preset hashes.
8
+ *
9
+ * `configFilePath` is recorded in the header so that a reader of the generated file can find the config it came
10
+ * from; it defaults to `CONFIG_FILE_PATH`.
11
+ */
12
+ export declare function formatLabelsYaml(labels: LabelDefinition[], presetHashes: Map<string, string>, configFilePath?: string): string;
13
+ /**
14
+ * Loads the config file and returns its `repoLabels` block.
10
15
  *
11
16
  * Reports an actionable error and returns `undefined` when the config file is missing,
12
- * fails to load or validate, or carries no `repoLabels` block.
17
+ * fails to load or validate, or carries no `repoLabels` block. Every message names the file
18
+ * this run read, so that `--config` never points the reader at the default path.
13
19
  */
14
- export declare function loadRepoLabelsConfig(stderrStyle: OutputStyle): Promise<RepoLabelsConfig | undefined>;
20
+ export declare function loadRepoLabelsConfig(stderrStyle: OutputStyle, configPath?: string): Promise<RepoLabelsConfig | undefined>;
15
21
  /** Options for the `sync-labels generate` subcommand. */
16
22
  interface GenerateOptions {
17
23
  /** Compare the regenerated content against the committed file instead of writing. */
18
24
  check?: boolean;
25
+ /** Config file to read, relative to the working directory. Defaults to `CONFIG_FILE_PATH`. */
26
+ configPath?: string;
19
27
  styles: StreamStyles;
20
28
  }
21
29
  /**
22
30
  * Runs the `sync-labels generate` command.
23
31
  *
24
- * Loads the `repoLabels` block from `.config/release-kit.config.ts`, resolves presets and label adjustments,
25
- * and writes `.github/labels.yaml` -- or, with `check`, regenerates in memory and reports whether the committed file
32
+ * Loads the `repoLabels` block from the config file, resolves presets and label adjustments, and writes
33
+ * `.github/labels.yaml` -- or, with `check`, regenerates in memory and reports whether the committed file
26
34
  * is stale, writing nothing. Returns 0 on success, 1 on failure.
27
35
  */
28
- export declare function generateCommand({ check, styles }: GenerateOptions): Promise<number>;
36
+ export declare function generateCommand({ check, configPath, styles }: GenerateOptions): Promise<number>;
29
37
  export {};
@@ -4,13 +4,13 @@ import { reportError } from '@williamthorsen/nmr-core';
4
4
  import { describeError } from '@williamthorsen/toolbelt.errors';
5
5
  import { stringify } from 'yaml';
6
6
  import { CONFIG_FILE_PATH } from "../loadConfig.js";
7
- import { loadValidatedConfig } from "../loadValidatedConfig.js";
7
+ import { loadValidatedConfig, reportConfigProblem } from "../loadValidatedConfig.js";
8
8
  import { hashPresetFile } from "./presets.js";
9
9
  import { resolveLabels } from "./resolveLabels.js";
10
10
  import { checkRetiredSyncLabelsConfig } from "./retiredConfig.js";
11
11
  export const LABELS_OUTPUT_PATH = '.github/labels.yaml';
12
- export function formatLabelsYaml(labels, presetHashes) {
13
- const headerLines = ['# Generated by release-kit sync-labels — do not edit.', `# Source: ${CONFIG_FILE_PATH}`];
12
+ export function formatLabelsYaml(labels, presetHashes, configFilePath = CONFIG_FILE_PATH) {
13
+ const headerLines = ['# Generated by release-kit sync-labels — do not edit.', `# Source: ${configFilePath}`];
14
14
  const sortedPresetHashes = [...presetHashes].sort(([a], [b]) => a.localeCompare(b));
15
15
  for (const [name, hash] of sortedPresetHashes) {
16
16
  headerLines.push(`# ${name} preset hash: ${hash}`);
@@ -19,26 +19,27 @@ export function formatLabelsYaml(labels, presetHashes) {
19
19
  const yamlBody = stringify(declared, { singleQuote: true, lineWidth: 0 });
20
20
  return headerLines.join('\n') + '\n' + yamlBody;
21
21
  }
22
- export async function loadRepoLabelsConfig(stderrStyle) {
23
- const result = await loadValidatedConfig(stderrStyle);
22
+ export async function loadRepoLabelsConfig(stderrStyle, configPath) {
23
+ const result = await loadValidatedConfig(configPath);
24
24
  if (result.status === 'missing') {
25
- reportError(`No config file found at ${CONFIG_FILE_PATH}. Run \`release-kit sync-labels init\` first.`);
25
+ reportError(`No config file found at ${result.configFilePath}. Run \`release-kit sync-labels init\` first.`);
26
26
  return undefined;
27
27
  }
28
28
  if (result.status === 'invalid') {
29
+ reportConfigProblem(result.problem, stderrStyle);
29
30
  return undefined;
30
31
  }
31
32
  if (result.config.repoLabels === undefined) {
32
- reportError(`No \`repoLabels\` block found in ${CONFIG_FILE_PATH}. Run \`release-kit sync-labels init\` to seed one.`);
33
+ reportError(`No \`repoLabels\` block found in ${result.configFilePath}. Run \`release-kit sync-labels init\` to seed one.`);
33
34
  return undefined;
34
35
  }
35
36
  return result.config.repoLabels;
36
37
  }
37
- export async function generateCommand({ check = false, styles }) {
38
- if (checkRetiredSyncLabelsConfig()) {
38
+ export async function generateCommand({ check = false, configPath, styles }) {
39
+ if (checkRetiredSyncLabelsConfig(configPath)) {
39
40
  return 1;
40
41
  }
41
- const repoLabels = await loadRepoLabelsConfig(styles.stderr);
42
+ const repoLabels = await loadRepoLabelsConfig(styles.stderr, configPath);
42
43
  if (repoLabels === undefined) {
43
44
  return 1;
44
45
  }
@@ -56,7 +57,7 @@ export async function generateCommand({ check = false, styles }) {
56
57
  for (const presetName of presetNames) {
57
58
  presetHashes.set(presetName, hashPresetFile(presetName));
58
59
  }
59
- const content = formatLabelsYaml(labels, presetHashes);
60
+ const content = formatLabelsYaml(labels, presetHashes, configPath);
60
61
  if (check) {
61
62
  return compareAgainstCommittedFile(content);
62
63
  }
@@ -1,6 +1,8 @@
1
1
  import { type StreamStyles } from '@williamthorsen/nmr-core';
2
2
  /** Options for the `sync-labels init` subcommand. */
3
3
  interface InitOptions {
4
+ /** Config file to read, relative to the working directory. Defaults to `CONFIG_FILE_PATH`. */
5
+ configPath?: string;
4
6
  dryRun: boolean;
5
7
  force: boolean;
6
8
  styles: StreamStyles;
@@ -9,9 +11,12 @@ interface InitOptions {
9
11
  * Run the `sync-labels init` subcommand.
10
12
  *
11
13
  * Discovers workspaces and retired packages, scaffolds the caller workflow, then seeds the
12
- * `repoLabels` block: into a new `.config/release-kit.config.ts` when none exists, or
13
- * printed to stdout for manual paste when the file is already there — an existing,
14
- * hand-authored config is never rewritten. Returns 0 on success, 1 on failure.
14
+ * `repoLabels` block: into a new config file when none exists, or printed to stdout for manual
15
+ * paste when the file is already there — an existing, hand-authored config is never rewritten.
16
+ * Returns 0 on success, 1 on failure.
17
+ *
18
+ * `configPath` names the config to read. Naming one that does not exist fails the command, so the
19
+ * scaffolding branch is reached only under the default path and always writes to `CONFIG_FILE_PATH`.
15
20
  */
16
- export declare function syncLabelsInitCommand({ dryRun, force, styles }: InitOptions): Promise<number>;
21
+ export declare function syncLabelsInitCommand({ configPath, dryRun, force, styles }: InitOptions): Promise<number>;
17
22
  export {};