@williamthorsen/release-kit 14.0.0 → 14.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.meta/changelog.json +173 -0
  2. package/.readyup/kits/default.js +64 -7
  3. package/.readyup/kits/npm-auto-publish.js +127 -75
  4. package/.readyup/manifest.json +22 -7
  5. package/CHANGELOG.md +47 -0
  6. package/README.md +18 -4
  7. package/dist/esm/assertReleasesOnNpm.d.ts +7 -0
  8. package/dist/esm/assertReleasesOnNpm.js +56 -0
  9. package/dist/esm/assertTaggedBaseline.d.ts +2 -2
  10. package/dist/esm/attachChangelogDiagnostics.d.ts +2 -2
  11. package/dist/esm/bin/release-kit.js +10 -6
  12. package/dist/esm/buildChangelogEntries.d.ts +3 -3
  13. package/dist/esm/buildChangelogEntries.js +3 -3
  14. package/dist/esm/buildDependencyGraph.d.ts +11 -11
  15. package/dist/esm/buildDependencyGraph.js +13 -13
  16. package/dist/esm/buildReleaseSummary.d.ts +3 -3
  17. package/dist/esm/buildReleaseSummary.js +4 -12
  18. package/dist/esm/changelogArchive.d.ts +14 -0
  19. package/dist/esm/changelogArchive.js +21 -0
  20. package/dist/esm/changelogJsonFile.d.ts +2 -2
  21. package/dist/esm/changelogOverrides.d.ts +9 -8
  22. package/dist/esm/changelogOverrides.js +3 -6
  23. package/dist/esm/createGithubReleaseCommand.d.ts +2 -1
  24. package/dist/esm/createGithubReleaseCommand.js +7 -4
  25. package/dist/esm/deriveDirectoryConfig.d.ts +13 -0
  26. package/dist/esm/deriveDirectoryConfig.js +59 -0
  27. package/dist/esm/deriveWorkspaceConfig.js +2 -3
  28. package/dist/esm/discoverWorkspaces.d.ts +3 -3
  29. package/dist/esm/enumerateReleaseWindows.d.ts +1 -1
  30. package/dist/esm/enumerateReleaseWindows.js +1 -3
  31. package/dist/esm/findTransitiveDependents.js +3 -3
  32. package/dist/esm/findUnroutedEntryScopes.d.ts +2 -2
  33. package/dist/esm/formatDirectoryPublishSkip.d.ts +3 -0
  34. package/dist/esm/formatDirectoryPublishSkip.js +3 -0
  35. package/dist/esm/formatExcludedSkip.d.ts +5 -2
  36. package/dist/esm/formatExcludedSkip.js +2 -2
  37. package/dist/esm/formatProjectPublishSkip.d.ts +3 -0
  38. package/dist/esm/formatProjectPublishSkip.js +3 -0
  39. package/dist/esm/generateChangelogs.d.ts +7 -7
  40. package/dist/esm/generateChangelogs.js +2 -2
  41. package/dist/esm/help/prepareHelp.d.ts +1 -1
  42. package/dist/esm/help/prepareHelp.js +7 -5
  43. package/dist/esm/init/hasPublishablePackage.d.ts +10 -0
  44. package/dist/esm/init/hasPublishablePackage.js +28 -0
  45. package/dist/esm/init/initCommand.js +23 -13
  46. package/dist/esm/init/scaffold.d.ts +6 -1
  47. package/dist/esm/init/scaffold.js +10 -7
  48. package/dist/esm/init/templates.js +1 -1
  49. package/dist/esm/init/updateTemplatesCommand.d.ts +2 -1
  50. package/dist/esm/init/updateTemplatesCommand.js +12 -6
  51. package/dist/esm/isPublishableManifest.d.ts +2 -0
  52. package/dist/esm/isPublishableManifest.js +5 -0
  53. package/dist/esm/loadConfig.d.ts +12 -12
  54. package/dist/esm/loadConfig.js +79 -30
  55. package/dist/esm/matchesTagPrefix.d.ts +2 -0
  56. package/dist/esm/matchesTagPrefix.js +4 -0
  57. package/dist/esm/npmRegistry.d.ts +35 -0
  58. package/dist/esm/npmRegistry.js +58 -0
  59. package/dist/esm/prepareCommand.d.ts +1 -1
  60. package/dist/esm/prepareCommand.js +50 -36
  61. package/dist/esm/prepareTarget.d.ts +103 -0
  62. package/dist/esm/prepareTarget.js +274 -0
  63. package/dist/esm/previewTagPrefixes.d.ts +5 -3
  64. package/dist/esm/previewTagPrefixes.js +60 -14
  65. package/dist/esm/propagateBumps.js +1 -1
  66. package/dist/esm/publishCommand.js +13 -1
  67. package/dist/esm/readChangelogSections.d.ts +6 -4
  68. package/dist/esm/readChangelogSections.js +18 -4
  69. package/dist/esm/releasePlan.d.ts +4 -2
  70. package/dist/esm/releasePlan.js +34 -12
  71. package/dist/esm/releasePrepareTargets.d.ts +47 -0
  72. package/dist/esm/releasePrepareTargets.js +335 -0
  73. package/dist/esm/renderChangelogMarkdown.d.ts +23 -0
  74. package/dist/esm/renderChangelogMarkdown.js +47 -11
  75. package/dist/esm/reportPrepare.d.ts +2 -3
  76. package/dist/esm/reportPrepare.js +24 -12
  77. package/dist/esm/resolveCommandTags.d.ts +3 -1
  78. package/dist/esm/resolveCommandTags.js +15 -22
  79. package/dist/esm/resolveReleaseDeclaration.d.ts +45 -0
  80. package/dist/esm/resolveReleaseDeclaration.js +153 -0
  81. package/dist/esm/resolveReleaseTags.d.ts +14 -3
  82. package/dist/esm/resolveReleaseTags.js +36 -15
  83. package/dist/esm/resolveReleaseTargets.d.ts +29 -0
  84. package/dist/esm/resolveReleaseTargets.js +131 -0
  85. package/dist/esm/sync-labels/generateCommand.js +2 -1
  86. package/dist/esm/sync-labels/resolveLabels.d.ts +4 -3
  87. package/dist/esm/sync-labels/resolveLabels.js +30 -1
  88. package/dist/esm/sync-labels/templates.js +1 -1
  89. package/dist/esm/sync-labels/types.d.ts +2 -0
  90. package/dist/esm/types.d.ts +251 -167
  91. package/dist/esm/types.js +22 -0
  92. package/dist/esm/validateConfig.js +80 -21
  93. package/dist/esm/validateOverridesCommand.d.ts +1 -1
  94. package/dist/esm/validateOverridesCommand.js +17 -28
  95. package/docs/api.md +25 -58
  96. package/docs/changelogs.md +26 -6
  97. package/docs/configuration.md +80 -17
  98. package/docs/labels.md +30 -5
  99. package/docs/project-releases.md +42 -2
  100. package/docs/publishing.md +3 -3
  101. package/docs/releasing.md +12 -4
  102. package/docs/work-types.md +1 -1
  103. package/package.json +6 -6
  104. package/presets/labels/common.yaml +11 -0
  105. package/dist/esm/releasePrepare.d.ts +0 -36
  106. package/dist/esm/releasePrepare.js +0 -230
  107. package/dist/esm/releasePrepareMono.d.ts +0 -29
  108. package/dist/esm/releasePrepareMono.js +0 -503
  109. package/dist/esm/releasePrepareProject.d.ts +0 -47
  110. package/dist/esm/releasePrepareProject.js +0 -164
@@ -0,0 +1,335 @@
1
+ import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
2
+ import { assertTaggedBaseline } from "./assertTaggedBaseline.js";
3
+ import { buildDependencyGraph } from "./buildDependencyGraph.js";
4
+ import { buildReleaseSummary } from "./buildReleaseSummary.js";
5
+ import { createOverrideContext, formatStaleOverrideKeyWarning } from "./changelogOverrides.js";
6
+ import { deriveDependenciesHeader, deriveSectionOrder } from "./deriveReleaseNotesConfig.js";
7
+ import { detectUndeclaredTagPrefixes } from "./detectUndeclaredTagPrefixes.js";
8
+ import { findTransitiveDependents } from "./findTransitiveDependents.js";
9
+ import { findUnroutedEntryScopes } from "./findUnroutedEntryScopes.js";
10
+ import { getAllTagPrefixes } from "./generateChangelogs.js";
11
+ import { hasPrettierConfig } from "./hasPrettierConfig.js";
12
+ import { resolveWorkTypes } from "./loadConfig.js";
13
+ import { buildSkippedTargetResult, decideTargetRelease, isRepoRoot, planTargetRelease, readTargetHistory, } from "./prepareTarget.js";
14
+ import { propagateBumps } from "./propagateBumps.js";
15
+ import { isSinglePackageTargets, PROJECT_TARGET_ID } from "./resolveReleaseTargets.js";
16
+ const PREVIEWS_REQUIRE_JSON_WARNING = '--with-release-notes requires changelogJson.enabled; skipping preview generation';
17
+ export function releasePrepareTargets(config, options) {
18
+ const { settings } = config;
19
+ const { only, withReleaseNotes } = options;
20
+ const isSinglePackage = isSinglePackageTargets(config.targets);
21
+ const memberTargets = config.targets.filter((target) => target.kind !== 'composite');
22
+ const compositeTargets = config.targets.filter((target) => target.kind === 'composite');
23
+ const warnings = [];
24
+ const graph = buildDependencyGraph(memberTargets.filter((target) => target.kind === 'package').filter((target) => target.isWorkspace));
25
+ const startIds = selectStartIds(memberTargets, options);
26
+ const plannedIds = findTransitiveDependents(graph, startIds);
27
+ const planned = memberTargets.filter((target) => plannedIds.has(target.id));
28
+ const arePreviewsBlocked = withReleaseNotes === true && !settings.changelogJson.enabled;
29
+ if (arePreviewsBlocked && !isSinglePackage) {
30
+ warnings.push(PREVIEWS_REQUIRE_JSON_WARNING);
31
+ }
32
+ const workTypes = resolveWorkTypes(settings.workTypes);
33
+ const context = {
34
+ settings,
35
+ overrideContext: createOverrideContext(planned.flatMap(listOverrideDirectory)),
36
+ sectionOrder: deriveSectionOrder(workTypes),
37
+ dependenciesHeader: deriveDependenciesHeader(workTypes),
38
+ shouldPlanPreviews: withReleaseNotes === true && settings.changelogJson.enabled,
39
+ today: new Date().toISOString().slice(0, 10),
40
+ };
41
+ const direct = decideDirectReleases(planned, settings, options, startIds);
42
+ if (!isSinglePackage) {
43
+ reportUnroutedEntryScopes(planned, direct, settings, memberTargets);
44
+ }
45
+ const releaseSet = propagateBumps(direct.bumps, graph);
46
+ assertTaggedBaseline([...direct.untaggedBaselines]
47
+ .filter(([id]) => direct.bumps.has(id) || !releaseSet.has(id))
48
+ .map(([, untagged]) => untagged));
49
+ const { sorted: sortedIds, cyclicIds } = topologicalSort(releaseSet, graph);
50
+ if (cyclicIds.length > 0) {
51
+ warnings.push(`Circular workspace dependencies detected among: ${cyclicIds.join(', ')}. ` +
52
+ 'Propagation metadata may be incomplete for these workspaces.');
53
+ }
54
+ const resultsById = new Map();
55
+ for (const target of planned) {
56
+ const decision = direct.decisions.get(target.id);
57
+ if (decision?.outcome === 'skip' && !releaseSet.has(target.id)) {
58
+ resultsById.set(target.id, buildSkippedTargetResult(target, readHistory(direct.readings, target), decision.skipReason));
59
+ }
60
+ }
61
+ const collected = { tags: [], writes: [], removals: [], modifiedFiles: [] };
62
+ for (const id of sortedIds) {
63
+ const target = planned.find((candidate) => candidate.id === id);
64
+ const release = releaseSet.get(id);
65
+ if (target === undefined || release === undefined) {
66
+ continue;
67
+ }
68
+ const decision = direct.decisions.get(id);
69
+ const plannedRelease = runStage(describeStage(target), () => planTargetRelease({
70
+ ...context,
71
+ target,
72
+ release,
73
+ decision: decision?.outcome === 'release' ? decision : undefined,
74
+ history: readHistory(direct.readings, target),
75
+ }));
76
+ collect(collected, plannedRelease);
77
+ warnings.push(...plannedRelease.warnings);
78
+ resultsById.set(id, plannedRelease.result);
79
+ }
80
+ const results = planned.flatMap((target) => resultsById.get(target.id) ?? []);
81
+ for (const composite of compositeTargets) {
82
+ const skipWarning = describeCompositeSkip(composite, only);
83
+ if (skipWarning !== undefined) {
84
+ warnings.push(skipWarning);
85
+ continue;
86
+ }
87
+ const result = runStage(describeStage(composite), () => prepareComposite(composite, context, options, collected, warnings));
88
+ results.push(result);
89
+ }
90
+ const formatCommand = planFormatCommand(settings.formatCommand, collected);
91
+ const { overrideContext } = context;
92
+ for (const overrideKey of overrideContext.project.keys()) {
93
+ if (!overrideContext.globalMatchedRootKeys.has(overrideKey)) {
94
+ overrideContext.overrideWarnings.push(formatStaleOverrideKeyWarning(overrideKey));
95
+ }
96
+ }
97
+ const allWarnings = isSinglePackage
98
+ ? composeSinglePackageWarnings(collected, overrideContext, arePreviewsBlocked, warnings)
99
+ : [...warnings, ...overrideContext.overrideWarnings];
100
+ return {
101
+ targets: results,
102
+ tags: collected.tags,
103
+ writes: collected.writes,
104
+ removals: collected.removals,
105
+ summary: buildReleaseSummary({ targets: results }),
106
+ formatCommand,
107
+ ...(allWarnings.length > 0 && { warnings: allWarnings }),
108
+ };
109
+ }
110
+ function collect(collected, planned) {
111
+ collected.tags.push(planned.tag);
112
+ collected.writes.push(...planned.writes);
113
+ collected.removals.push(...planned.removals);
114
+ collected.modifiedFiles.push(...planned.modifiedFiles);
115
+ }
116
+ function composeSinglePackageWarnings(collected, overrideContext, arePreviewsBlocked, previewWarnings) {
117
+ if (collected.tags.length === 0) {
118
+ return [];
119
+ }
120
+ return [
121
+ ...overrideContext.overrideWarnings,
122
+ ...(arePreviewsBlocked ? [PREVIEWS_REQUIRE_JSON_WARNING] : []),
123
+ ...previewWarnings,
124
+ ];
125
+ }
126
+ function decideDirectReleases(planned, settings, options, startIds) {
127
+ const { force, bumpOverride, setVersions } = options;
128
+ const direct = {
129
+ readings: new Map(),
130
+ decisions: new Map(),
131
+ bumps: new Map(),
132
+ untaggedBaselines: new Map(),
133
+ };
134
+ const hintState = { emitted: false };
135
+ const knownPrefixes = planned.flatMap(getAllTagPrefixes);
136
+ for (const target of planned) {
137
+ const { history, untagged } = runStage(describeStage(target), () => readTargetHistory(target, settings));
138
+ direct.readings.set(target.id, history);
139
+ if (untagged !== undefined) {
140
+ direct.untaggedBaselines.set(target.id, untagged);
141
+ }
142
+ if (history.previousTag === undefined && target.kind === 'package' && target.isWorkspace) {
143
+ maybeEmitBaselineHint(target, knownPrefixes, hintState);
144
+ }
145
+ const setVersion = setVersions?.get(target.id);
146
+ const decision = decideTargetRelease(target, history, {
147
+ force: force === true && startIds.has(target.id),
148
+ bumpOverride,
149
+ setVersion,
150
+ });
151
+ direct.decisions.set(target.id, decision);
152
+ if (decision.outcome === 'skip') {
153
+ continue;
154
+ }
155
+ direct.bumps.set(target.id, decision.setVersion === undefined
156
+ ? { releaseType: decision.releaseType }
157
+ : { releaseType: 'patch', newVersionOverride: decision.setVersion });
158
+ }
159
+ return direct;
160
+ }
161
+ function describeCompositeSkip(composite, only) {
162
+ if (only === undefined || only.includes(composite.id)) {
163
+ return undefined;
164
+ }
165
+ if (composite.id === PROJECT_TARGET_ID) {
166
+ return (`Project release skipped: --only narrows this run to ${only.join(', ')}. ` +
167
+ 'Run `release-kit prepare` without --only to include the project release.');
168
+ }
169
+ return `Release '${composite.id}' skipped: --only does not name it. Add ${composite.id} to --only to include it.`;
170
+ }
171
+ function describeStage(target) {
172
+ switch (target.kind) {
173
+ case 'composite':
174
+ return 'project release stage';
175
+ case 'directory':
176
+ return `directory '${target.id}' release stage`;
177
+ case 'package':
178
+ return target.isWorkspace ? `workspace '${target.id}' release stage` : undefined;
179
+ }
180
+ }
181
+ function maybeEmitBaselineHint(target, knownPrefixes, state) {
182
+ if (state.emitted)
183
+ return;
184
+ if ((target.legacyIdentities?.length ?? 0) > 0)
185
+ return;
186
+ const candidates = detectUndeclaredTagPrefixes(knownPrefixes);
187
+ if (candidates.length === 0)
188
+ return;
189
+ const totalTags = candidates.reduce((sum, candidate) => sum + candidate.tagCount, 0);
190
+ const example = candidates[0]?.exampleTags[0] ?? `${candidates[0]?.prefix ?? ''}?`;
191
+ process.stderr.write(`Hint: no baseline tag found for ${target.id} under '${target.tagPrefix}', but ` +
192
+ `${totalTags} candidate-shaped tags exist (e.g., ${example}). ` +
193
+ "Run 'release-kit show-tag-prefixes' to check for undeclared legacy prefixes.\n");
194
+ state.emitted = true;
195
+ }
196
+ function planFormatCommand(configuredCommand, collected) {
197
+ const formatCommandStr = configuredCommand ?? (hasPrettierConfig() ? 'npx prettier --write' : undefined);
198
+ if (collected.tags.length === 0 || formatCommandStr === undefined) {
199
+ return undefined;
200
+ }
201
+ const files = collected.modifiedFiles;
202
+ return { command: `${formatCommandStr} ${files.join(' ')}`, files };
203
+ }
204
+ function prepareComposite(composite, context, options, collected, warnings) {
205
+ const { history, untagged } = readTargetHistory(composite, context.settings);
206
+ assertTaggedBaseline([untagged]);
207
+ const decision = decideTargetRelease(composite, history, {
208
+ force: options.force === true,
209
+ bumpOverride: options.bumpOverride,
210
+ setVersion: undefined,
211
+ });
212
+ if (decision.outcome === 'skip') {
213
+ return buildSkippedTargetResult(composite, history, decision.skipReason);
214
+ }
215
+ if (decision.setVersion !== undefined) {
216
+ throw new Error('A composite target cannot take --set-version');
217
+ }
218
+ const planned = planTargetRelease({
219
+ ...context,
220
+ target: composite,
221
+ release: { releaseType: decision.releaseType },
222
+ decision,
223
+ history,
224
+ });
225
+ collect(collected, planned);
226
+ warnings.push(...planned.warnings);
227
+ return planned.result;
228
+ }
229
+ function isRepoRootTarget(target) {
230
+ return target.kind === 'directory' && isRepoRoot(target);
231
+ }
232
+ function listOverrideDirectory(target) {
233
+ if (target.kind === 'directory') {
234
+ return isRepoRootTarget(target) ? [] : [target.directoryPath];
235
+ }
236
+ return target.isWorkspace ? [target.packagePath] : [];
237
+ }
238
+ function readHistory(readings, target) {
239
+ const history = readings.get(target.id);
240
+ if (history === undefined) {
241
+ throw new Error(`Target '${target.id}' was planned without a history`);
242
+ }
243
+ return history;
244
+ }
245
+ function reportUnroutedEntryScopes(planned, direct, settings, memberTargets) {
246
+ const releasing = planned.filter((target) => direct.decisions.get(target.id)?.outcome === 'release');
247
+ const windows = [...releasing, ...planned.filter((target) => !releasing.includes(target))].map((target) => ({
248
+ target,
249
+ history: readHistory(direct.readings, target),
250
+ }));
251
+ const findings = findUnroutedEntryScopes(windows.map(({ target, history }) => ({ dir: target.id, commits: history.unreleased.commits })), memberTargets.map((target) => target.id), settings.scopeAliases ?? {});
252
+ for (const { target, history } of windows) {
253
+ history.unreleased.diagnostics.unroutedEntryScopes = isRepoRootTarget(target)
254
+ ? []
255
+ : (findings.get(target.id) ?? []);
256
+ }
257
+ }
258
+ function runStage(stage, fn) {
259
+ if (stage === undefined) {
260
+ return fn();
261
+ }
262
+ try {
263
+ return fn();
264
+ }
265
+ catch (error) {
266
+ throw chainError(stage, error);
267
+ }
268
+ }
269
+ function selectStartIds(memberTargets, options) {
270
+ const { only, setVersions } = options;
271
+ if (only === undefined) {
272
+ return new Set(memberTargets.map((target) => target.id));
273
+ }
274
+ return new Set([...only, ...(setVersions?.keys() ?? [])]);
275
+ }
276
+ function topologicalSort(releaseSet, graph) {
277
+ const releaseIds = new Set(releaseSet.keys());
278
+ if (releaseIds.size === 0) {
279
+ return { sorted: [], cyclicIds: [] };
280
+ }
281
+ const inDegree = new Map();
282
+ const forwardEdges = new Map();
283
+ for (const id of releaseIds) {
284
+ inDegree.set(id, 0);
285
+ forwardEdges.set(id, []);
286
+ }
287
+ for (const [packageName, dependents] of graph.dependentsOf) {
288
+ const dependencyId = graph.packageNameToDir.get(packageName);
289
+ if (dependencyId === undefined || !releaseIds.has(dependencyId)) {
290
+ continue;
291
+ }
292
+ for (const dependent of dependents) {
293
+ if (!releaseIds.has(dependent.id)) {
294
+ continue;
295
+ }
296
+ const edges = forwardEdges.get(dependencyId);
297
+ if (edges !== undefined) {
298
+ edges.push(dependent.id);
299
+ }
300
+ inDegree.set(dependent.id, (inDegree.get(dependent.id) ?? 0) + 1);
301
+ }
302
+ }
303
+ const queue = [];
304
+ for (const [id, degree] of inDegree) {
305
+ if (degree === 0) {
306
+ queue.push(id);
307
+ }
308
+ }
309
+ const sorted = [];
310
+ while (queue.length > 0) {
311
+ const id = queue.shift();
312
+ if (id === undefined) {
313
+ break;
314
+ }
315
+ sorted.push(id);
316
+ const dependents = forwardEdges.get(id) ?? [];
317
+ for (const dependent of dependents) {
318
+ const newDegree = (inDegree.get(dependent) ?? 1) - 1;
319
+ inDegree.set(dependent, newDegree);
320
+ if (newDegree === 0) {
321
+ queue.push(dependent);
322
+ }
323
+ }
324
+ }
325
+ const sortedSet = new Set(sorted);
326
+ const cyclicIds = [];
327
+ for (const id of releaseIds) {
328
+ if (sortedSet.has(id)) {
329
+ continue;
330
+ }
331
+ sorted.push(id);
332
+ cyclicIds.push(id);
333
+ }
334
+ return { sorted, cyclicIds };
335
+ }
@@ -10,6 +10,29 @@ export interface RenderChangelogMarkdownOptions {
10
10
  */
11
11
  preservedSections?: readonly VersionedMarkdownSection[];
12
12
  }
13
+ /** Options for `renderChangelogFiles`. */
14
+ export interface RenderChangelogFilesOptions extends RenderChangelogMarkdownOptions {
15
+ /** How many of the newest sections the main file keeps; every section when omitted. Must be at least 1. */
16
+ keepVersions?: number;
17
+ }
18
+ /** A rendered archive file: the sections of one major version that the main file does not keep. */
19
+ export interface RenderedChangelogArchive {
20
+ major: number;
21
+ content: string;
22
+ }
23
+ /** The rendered `CHANGELOG.md` and its archive files, newest major first. */
24
+ export interface RenderedChangelogFiles {
25
+ main: string;
26
+ archives: RenderedChangelogArchive[];
27
+ }
28
+ /**
29
+ * Renders entries and preserved sections into `CHANGELOG.md` and its per-major archive files.
30
+ *
31
+ * The main file keeps the newest `keepVersions` sections, counting preserved sections, and links to each archive file
32
+ * from a paragraph below its header. Each older section goes to the archive file of its major version. Every file
33
+ * has the same header and footer.
34
+ */
35
+ export declare function renderChangelogFiles(entries: ChangelogEntry[], options?: RenderChangelogFilesOptions): RenderedChangelogFiles;
13
36
  /**
14
37
  * Renders `ChangelogEntry[]` into a complete `CHANGELOG.md`-shaped string.
15
38
  *
@@ -1,22 +1,43 @@
1
+ import semver from 'semver';
2
+ import { describeArchivePath } from "./changelogArchive.js";
1
3
  import { compareVersionsDescending } from "./compareVersions.js";
2
4
  import { renderReleaseNotesSingle } from "./renderReleaseNotes.js";
3
5
  const CHANGELOG_HEADER = `# Changelog
4
6
 
5
7
  All notable changes to this project will be documented in this file.`;
6
8
  const CHANGELOG_FOOTER = `<!-- Generated by release-kit. Do not edit this file. Use .meta/changelog-overrides.json to override entries. -->`;
9
+ export function renderChangelogFiles(entries, options) {
10
+ const sections = interleavePreservedSections(entries.map((entry) => ({ version: entry.version, text: renderReleaseNotesSingle(entry, options) })), options?.preservedSections ?? []).filter((section) => section.text.length > 0);
11
+ const keepVersions = options?.keepVersions;
12
+ if (keepVersions === undefined || sections.length <= keepVersions) {
13
+ return { main: renderFile(sections), archives: [] };
14
+ }
15
+ const archived = groupByMajor(sections.slice(keepVersions));
16
+ return {
17
+ main: renderFile(sections.slice(0, keepVersions), describeArchivePointer(archived.map(({ major }) => major))),
18
+ archives: archived.map(({ major, sections: majorSections }) => ({ major, content: renderFile(majorSections) })),
19
+ };
20
+ }
7
21
  export function renderChangelogMarkdown(entries, options) {
8
- const sections = interleavePreservedSections(entries.map((entry) => ({ version: entry.version, text: renderReleaseNotesSingle(entry, options) })), options?.preservedSections ?? []);
9
- const body = sections
10
- .map((part) => part.text)
11
- .filter((text) => text.length > 0)
12
- .join('\n');
13
- const trimmedBody = body.trimEnd();
14
- const parts = [CHANGELOG_HEADER];
15
- if (trimmedBody.length > 0) {
16
- parts.push(trimmedBody);
22
+ return renderChangelogFiles(entries, options).main;
23
+ }
24
+ function describeArchivePointer(majors) {
25
+ const links = majors.map((major) => `[v${major}](${describeArchivePath(major)})`);
26
+ return `Earlier versions: ${links.join(', ')}.`;
27
+ }
28
+ function groupByMajor(sections) {
29
+ const groups = new Map();
30
+ for (const section of sections) {
31
+ const major = semver.major(section.version);
32
+ const group = groups.get(major);
33
+ if (group === undefined) {
34
+ groups.set(major, [section]);
35
+ }
36
+ else {
37
+ group.push(section);
38
+ }
17
39
  }
18
- parts.push(CHANGELOG_FOOTER);
19
- return parts.join('\n\n') + '\n';
40
+ return Array.from(groups, ([major, majorSections]) => ({ major, sections: majorSections })).toSorted((a, b) => b.major - a.major);
20
41
  }
21
42
  function interleavePreservedSections(rendered, preserved) {
22
43
  const sorted = preserved.toSorted((a, b) => compareVersionsDescending(a.version, b.version));
@@ -35,6 +56,21 @@ function interleavePreservedSections(rendered, preserved) {
35
56
  result.push(...pending.slice(index));
36
57
  return result;
37
58
  }
59
+ function renderFile(sections, paragraph) {
60
+ const body = sections
61
+ .map((section) => section.text)
62
+ .join('\n')
63
+ .trimEnd();
64
+ const parts = [CHANGELOG_HEADER];
65
+ if (paragraph !== undefined) {
66
+ parts.push(paragraph);
67
+ }
68
+ if (body.length > 0) {
69
+ parts.push(body);
70
+ }
71
+ parts.push(CHANGELOG_FOOTER);
72
+ return parts.join('\n\n') + '\n';
73
+ }
38
74
  function withTrailingNewline(section) {
39
75
  return { version: section.version, text: `${section.text}\n` };
40
76
  }
@@ -11,9 +11,8 @@ export interface ReportPrepareOptions {
11
11
  /**
12
12
  * Formats a release plan into styled terminal output, without writing it.
13
13
  *
14
- * Single-workspace mode (without a `name` field) renders flat output; multi-workspace mode
15
- * renders section headers per workspace, an optional project section, and a tag summary
16
- * at the end.
14
+ * A plan whose package results lack a `name` and that lacks a composite result renders flat output, as a
15
+ * single-package release does; any other plan renders a section per target and a tag summary at the end.
17
16
  *
18
17
  * Whether the plan was applied is a property of the render, not of the plan: The same plan
19
18
  * produces the dry-run report and the applied report.
@@ -1,9 +1,10 @@
1
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 { PROJECT_TARGET_ID } from "./resolveReleaseTargets.js";
4
5
  const SUBJECT_COLUMN_BUDGET = 72;
5
6
  export function reportPrepare(plan, options) {
6
- const isMultiWorkspace = plan.workspaces.some((w) => w.name !== undefined) || plan.project !== undefined;
7
+ const isMultiWorkspace = plan.targets.some((target) => target.kind === 'composite' || target.name !== undefined);
7
8
  if (isMultiWorkspace) {
8
9
  return formatMultiWorkspace(plan, options);
9
10
  }
@@ -13,8 +14,8 @@ function formatSingleWorkspace(result, options) {
13
14
  const dryRun = !options.applied;
14
15
  const { style } = options;
15
16
  const lines = [];
16
- const workspace = result.workspaces[0];
17
- if (workspace === undefined) {
17
+ const workspace = result.targets[0];
18
+ if (workspace === undefined || workspace.kind === 'composite') {
18
19
  return '';
19
20
  }
20
21
  const since = workspace.previousTag === undefined ? 'the beginning' : workspace.previousTag;
@@ -62,11 +63,13 @@ function formatMultiWorkspace(result, options) {
62
63
  const dryRun = !options.applied;
63
64
  const { style } = options;
64
65
  const lines = [];
65
- for (const workspace of result.workspaces) {
66
- formatWorkspaceSection(lines, workspace, dryRun, style);
67
- }
68
- if (result.project !== undefined) {
69
- formatProjectSection(lines, result.project, dryRun, style);
66
+ for (const target of result.targets) {
67
+ if (target.kind === 'composite') {
68
+ formatProjectSection(lines, target, dryRun, style);
69
+ }
70
+ else {
71
+ formatWorkspaceSection(lines, target, dryRun, style);
72
+ }
70
73
  }
71
74
  formatFormatCommand(lines, result, options);
72
75
  formatWarnings(lines, result, style);
@@ -82,7 +85,7 @@ function formatMultiWorkspace(result, options) {
82
85
  return lines.join('\n');
83
86
  }
84
87
  function formatProjectSection(lines, project, dryRun, style) {
85
- lines.push(`\n${sectionHeader('project')}`);
88
+ lines.push(`\n${sectionHeader(project.targetId === PROJECT_TARGET_ID ? 'project' : project.targetId)}`);
86
89
  const since = project.previousTag === undefined ? '(no previous release found)' : `since ${project.previousTag}`;
87
90
  lines.push(dim(` Found ${project.commitCount} commits ${since}`));
88
91
  formatPolicyViolations(lines, project.policyViolations, style, ' ');
@@ -128,8 +131,8 @@ function formatWorkspaceSection(lines, workspace, dryRun, style) {
128
131
  lines.push(` ${formatStatusLine(style, 'skipped', workspace.skipReason)}`);
129
132
  return;
130
133
  }
131
- const { propagatedFrom } = workspace;
132
- const isPropagatedOnly = workspace.propagatedOnly === true;
134
+ const propagatedFrom = workspace.kind === 'package' ? workspace.propagatedFrom : undefined;
135
+ const isPropagatedOnly = workspace.kind === 'package' && workspace.propagatedOnly === true;
133
136
  formatCommitSummary(lines, workspace, propagatedFrom, isPropagatedOnly);
134
137
  formatUnparseableWarning(lines, workspace, style, ' ');
135
138
  formatPolicyViolations(lines, workspace.policyViolations, style, ' ');
@@ -198,7 +201,7 @@ function formatChangelogFiles(lines, release, dryRun, style, indent = '') {
198
201
  continue;
199
202
  }
200
203
  if (preservation.preservedVersions.length > 0) {
201
- lines.push(dim(`${indent} Kept from the existing file: ${preservation.preservedVersions.join(', ')}`));
204
+ lines.push(dim(`${indent} Kept from the existing changelog: ${preservation.preservedVersions.join(', ')}`));
202
205
  }
203
206
  const dropped = preservation.droppedUnversionedHeadings;
204
207
  if (dropped.length > 0) {
@@ -206,6 +209,15 @@ function formatChangelogFiles(lines, release, dryRun, style, indent = '') {
206
209
  lines.push(`${indent} ${formatStatusLine(style, 'warning', message)}`);
207
210
  }
208
211
  }
212
+ const removedFiles = release.removedChangelogFiles ?? [];
213
+ for (const file of removedFiles) {
214
+ if (dryRun) {
215
+ lines.push(dim(`${indent} [dry-run] Would remove changelog: ${file}`));
216
+ }
217
+ else {
218
+ lines.push(dim(`${indent} Removing changelog: ${file}`));
219
+ }
220
+ }
209
221
  }
210
222
  function formatPreviewFiles(lines, release, dryRun, indent = '') {
211
223
  const previewFiles = release.previewFiles ?? [];
@@ -7,7 +7,9 @@ import type { ReleaseKitConfig } from './types.js';
7
7
  * `mergeMonorepoConfig` rejects, and a workspace that does not resolve to any package, which is not single-package
8
8
  * mode.
9
9
  *
10
- * In monorepo mode the workspace list is `userConfig` merged over the discovered workspaces, as `prepare` builds it.
10
+ * In monorepo mode the workspace list is `userConfig` merged over the discovered workspaces, as `prepare` builds it,
11
+ * and each directory target and a declared composite (a `project` block or a `releases` composite) contribute their
12
+ * `tagPrefix`, so that their tags resolve alongside them.
11
13
  * A tag on HEAD whose workspace the config excludes still counts as a tag on HEAD, so `--tags` may name it and a
12
14
  * HEAD whose tags are all excluded returns an empty list rather than failing. Each excluded tag that the run
13
15
  * selects, every one without `--tags` or the named ones with it, is dropped with a warning.
@@ -1,4 +1,3 @@
1
- import { basename } from 'node:path';
2
1
  import { reportError } from '@williamthorsen/nmr-core';
3
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
3
  import { deriveWorkspaceConfig } from "./deriveWorkspaceConfig.js";
@@ -20,14 +19,21 @@ export function resolveCommandTags(tags, userConfig) {
20
19
  process.exit(1);
21
20
  }
22
21
  let workspaces;
22
+ let directories = [];
23
+ let project;
23
24
  let singleWorkspace;
24
- const excludedDirs = workspace.kind === 'packages' ? collectExcludedDirs(userConfig) : new Set();
25
+ let excludedPaths = new Set();
25
26
  try {
26
27
  if (workspace.kind === 'single-package') {
27
28
  singleWorkspace = deriveWorkspaceConfig('.');
28
29
  }
29
30
  else {
30
- workspaces = buildMonorepoWorkspaces(workspace.packageDirs, userConfig, excludedDirs);
31
+ const merged = mergeMonorepoConfig(workspace.packageDirs, userConfig, readRootPackageVersion());
32
+ const excluded = merged.excludedWorkspaces ?? [];
33
+ workspaces = [...merged.workspaces, ...excluded];
34
+ directories = merged.directories ?? [];
35
+ project = merged.project;
36
+ excludedPaths = new Set(excluded.map((excludedWorkspace) => excludedWorkspace.workspacePath));
31
37
  }
32
38
  }
33
39
  catch (error) {
@@ -36,7 +42,7 @@ export function resolveCommandTags(tags, userConfig) {
36
42
  }
37
43
  let resolvedTags;
38
44
  if (workspaces !== undefined) {
39
- resolvedTags = resolveReleaseTags({ workspaces });
45
+ resolvedTags = resolveReleaseTags({ workspaces, directories, ...(project !== undefined && { project }) });
40
46
  }
41
47
  else if (singleWorkspace !== undefined) {
42
48
  resolvedTags = resolveReleaseTags({ singleWorkspace });
@@ -59,27 +65,14 @@ export function resolveCommandTags(tags, userConfig) {
59
65
  }
60
66
  resolvedTags = resolvedTags.filter((t) => tags.includes(t.tag));
61
67
  }
62
- return dropExcludedTags(resolvedTags, excludedDirs);
68
+ return dropExcludedTags(resolvedTags, excludedPaths, userConfig?.releases === undefined ? 'legacy' : 'releases');
63
69
  }
64
- function buildMonorepoWorkspaces(packageDirs, userConfig, excludedDirs) {
65
- const { workspaces } = mergeMonorepoConfig(packageDirs, userConfig, readRootPackageVersion());
66
- if (excludedDirs.size === 0) {
67
- return workspaces;
68
- }
69
- const excluded = packageDirs
70
- .filter((packageDir) => excludedDirs.has(basename(packageDir)))
71
- .map((packageDir) => deriveWorkspaceConfig(packageDir));
72
- return [...workspaces, ...excluded];
73
- }
74
- function collectExcludedDirs(userConfig) {
75
- const overrides = userConfig?.workspaces ?? [];
76
- return new Set(overrides.filter((override) => override.shouldExclude === true).map((override) => override.dir));
77
- }
78
- function dropExcludedTags(resolvedTags, excludedDirs) {
70
+ function dropExcludedTags(resolvedTags, excludedPaths, surface) {
79
71
  const retained = [];
80
72
  for (const resolvedTag of resolvedTags) {
81
- if (excludedDirs.has(resolvedTag.dir)) {
82
- console.warn(formatExcludedSkip(resolvedTag));
73
+ if (resolvedTag.kind === 'package' && excludedPaths.has(resolvedTag.workspacePath)) {
74
+ const setting = surface === 'releases' ? `releases.${resolvedTag.dir}: false` : undefined;
75
+ console.warn(formatExcludedSkip(resolvedTag, setting));
83
76
  }
84
77
  else {
85
78
  retained.push(resolvedTag);
@@ -0,0 +1,45 @@
1
+ import type { ReleaseKitConfig, WorkspaceConfig } from './types.js';
2
+ /** The composite that a config declares, before the defaults and the root `package.json` check of `loadConfig`. */
3
+ export interface DeclaredComposite {
4
+ /** Name that selects the composite; `undefined` for a `project` block. */
5
+ name?: string;
6
+ /** Target names of the members; `undefined` means every retained workspace. */
7
+ members?: string[];
8
+ /** Commit window; `undefined` means the union of the members' windows. */
9
+ paths?: string[];
10
+ tagPrefix?: string;
11
+ }
12
+ /** A directory that a `releases` entry declares as a target, before `loadConfig` checks it on disk. */
13
+ export interface DeclaredDirectory {
14
+ /** Name of the target, its entry key. */
15
+ name: string;
16
+ /** Repo-relative POSIX path of the directory; `.` for the repo root. */
17
+ path: string;
18
+ /** Commit window; `undefined` for the repo root, whose window is the whole repo. */
19
+ paths?: string[];
20
+ tagPrefix?: string;
21
+ legacyIdentities?: WorkspaceConfig['legacyIdentities'];
22
+ }
23
+ /** The release targets that a config declares over the discovered workspaces. */
24
+ export interface ReleaseDeclaration {
25
+ /** Retained workspaces in discovery order, each with its target name and legacy identities applied. */
26
+ retained: WorkspaceConfig[];
27
+ /** Discovered workspaces that the config excludes, in discovery order. */
28
+ excluded: WorkspaceConfig[];
29
+ /** Directories that `releases` entries declare as targets, in config order. */
30
+ directories: DeclaredDirectory[];
31
+ composite?: DeclaredComposite;
32
+ }
33
+ /** Returns the name of a workspace's release target. */
34
+ export declare function getTargetName(workspace: WorkspaceConfig): string;
35
+ /** Normalizes a configured path to the repo-relative POSIX form of a discovered `workspacePath`. */
36
+ export declare function normalizeWorkspacePath(configuredPath: string): string;
37
+ /**
38
+ * Projects the config's `releases` block, or its `workspaces` and `project` blocks, onto the discovered workspaces.
39
+ *
40
+ * Every discovered workspace is retained under its `dir` unless an entry excludes or renames it, and a `releases`
41
+ * entry whose path isn't a discovered workspace declares a directory. Throws when a `releases` entry names a path
42
+ * outside the repo, sets `tagPrefix` on a workspace, excludes a name that isn't a workspace's `dir`, repeats a path,
43
+ * declares a second composite, or includes a target that is neither a retained workspace nor a directory.
44
+ */
45
+ export declare function resolveReleaseDeclaration(workspaces: readonly WorkspaceConfig[], userConfig: ReleaseKitConfig | undefined): ReleaseDeclaration;