@williamthorsen/release-kit 11.0.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.
- package/.meta/changelog.json +62 -2
- package/.readyup/kits/default.js +1 -17
- package/.readyup/manifest.json +4 -4
- package/CHANGELOG.md +40 -0
- package/README.md +25 -44
- package/dist/esm/attachChangelogDiagnostics.d.ts +4 -0
- package/dist/esm/attachChangelogDiagnostics.js +11 -0
- package/dist/esm/bin/release-kit.js +4 -3
- package/dist/esm/buildChangelogEntries.d.ts +59 -17
- package/dist/esm/buildChangelogEntries.js +194 -111
- package/dist/esm/buildReleaseSummary.js +2 -2
- package/dist/esm/changelogOverrides.d.ts +31 -13
- package/dist/esm/changelogOverrides.js +138 -58
- package/dist/esm/classifyChangelogCommit.d.ts +38 -0
- package/dist/esm/classifyChangelogCommit.js +33 -0
- package/dist/esm/decideRelease.d.ts +11 -37
- package/dist/esm/decideRelease.js +5 -27
- package/dist/esm/defaults.d.ts +1 -1
- package/dist/esm/defaults.js +3 -0
- package/dist/esm/determineBumpType.d.ts +11 -11
- package/dist/esm/determineBumpType.js +6 -7
- package/dist/esm/enumerateReleaseWindows.d.ts +50 -0
- package/dist/esm/enumerateReleaseWindows.js +124 -0
- package/dist/esm/generateChangelogs.d.ts +5 -23
- package/dist/esm/generateChangelogs.js +0 -14
- package/dist/esm/glyphs.d.ts +1 -1
- package/dist/esm/help/prepareHelp.d.ts +1 -1
- package/dist/esm/help/prepareHelp.js +4 -6
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/init/initCommand.js +1 -1
- package/dist/esm/init/scaffold.d.ts +0 -2
- package/dist/esm/init/scaffold.js +2 -30
- package/dist/esm/loadConfig.d.ts +1 -1
- package/dist/esm/loadConfig.js +1 -4
- package/dist/esm/parseChangeRecordBlock.d.ts +37 -0
- package/dist/esm/parseChangeRecordBlock.js +134 -0
- package/dist/esm/parseCommitMessage.d.ts +31 -6
- package/dist/esm/parseCommitMessage.js +6 -5
- package/dist/esm/prepareCommand.js +4 -12
- package/dist/esm/releasePrepare.d.ts +7 -8
- package/dist/esm/releasePrepare.js +53 -73
- package/dist/esm/releasePrepareMono.js +48 -85
- package/dist/esm/releasePrepareProject.d.ts +1 -1
- package/dist/esm/releasePrepareProject.js +19 -37
- package/dist/esm/reportPrepare.js +39 -28
- package/dist/esm/types.d.ts +99 -55
- package/dist/esm/types.js +1 -1
- package/dist/esm/validateConfig.js +4 -0
- package/dist/esm/validateOverridesCommand.d.ts +6 -5
- package/dist/esm/validateOverridesCommand.js +16 -23
- package/docs/changelogs.md +64 -25
- package/docs/configuration.md +2 -3
- package/docs/editorial-overrides.md +75 -16
- package/docs/project-releases.md +4 -4
- package/docs/releasing.md +13 -8
- package/docs/work-types.md +13 -9
- package/package.json +1 -2
- package/schemas/changelog-overrides.json +39 -0
- package/cliff.toml.template +0 -65
- package/dist/esm/collectPolicyViolations.d.ts +0 -15
- package/dist/esm/collectPolicyViolations.js +0 -12
- package/dist/esm/determineBumpFromCommits.d.ts +0 -17
- package/dist/esm/determineBumpFromCommits.js +0 -28
- package/dist/esm/getCommitsSinceTarget.d.ts +0 -21
- package/dist/esm/getCommitsSinceTarget.js +0 -66
- package/dist/esm/resolveCliffConfigPath.d.ts +0 -11
- package/dist/esm/resolveCliffConfigPath.js +0 -20
- package/dist/esm/runGitCliff.d.ts +0 -38
- package/dist/esm/runGitCliff.js +0 -25
|
@@ -6,7 +6,9 @@ import { isRecord } from "./typeGuards.js";
|
|
|
6
6
|
const OVERRIDES_FILENAME = '.meta/changelog-overrides.json';
|
|
7
7
|
const VALID_AUDIENCE_VALUES = new Set(['all', 'dev', 'skip']);
|
|
8
8
|
const V1_SUPPORTED_AUDIENCE_VALUES = new Set(['skip']);
|
|
9
|
-
const KNOWN_OVERRIDE_FIELDS = new Set(['audience', 'description', 'body', 'breaking']);
|
|
9
|
+
export const KNOWN_OVERRIDE_FIELDS = new Set(['audience', 'description', 'body', 'breaking']);
|
|
10
|
+
export const OVERRIDE_KEY_PATTERN = /^[0-9a-f]+(:[1-9][0-9]*)?$/;
|
|
11
|
+
const SCHEMA_KEY = '$schema';
|
|
10
12
|
export function loadChangelogOverrides(path) {
|
|
11
13
|
if (!existsSync(path)) {
|
|
12
14
|
return { overrides: new Map() };
|
|
@@ -43,8 +45,14 @@ export function validateChangelogOverrides(raw) {
|
|
|
43
45
|
return { overrides, errors };
|
|
44
46
|
}
|
|
45
47
|
for (const [key, rawEntry] of Object.entries(raw)) {
|
|
46
|
-
if (key ===
|
|
47
|
-
|
|
48
|
+
if (key === SCHEMA_KEY) {
|
|
49
|
+
if (typeof rawEntry !== 'string') {
|
|
50
|
+
errors.push(`Override file: '${SCHEMA_KEY}' must be a string`);
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!OVERRIDE_KEY_PATTERN.test(key)) {
|
|
55
|
+
errors.push(`overrides['${key}']: key must be a lowercase hex commit hash or prefix, optionally followed by ':<n>'`);
|
|
48
56
|
continue;
|
|
49
57
|
}
|
|
50
58
|
const validated = validateSingleOverride(key, rawEntry, errors);
|
|
@@ -54,6 +62,13 @@ export function validateChangelogOverrides(raw) {
|
|
|
54
62
|
}
|
|
55
63
|
return { overrides, errors };
|
|
56
64
|
}
|
|
65
|
+
export function parseOverrideKey(key) {
|
|
66
|
+
const separatorIndex = key.indexOf(':');
|
|
67
|
+
if (separatorIndex === -1) {
|
|
68
|
+
return { hashPrefix: key };
|
|
69
|
+
}
|
|
70
|
+
return { hashPrefix: key.slice(0, separatorIndex), entry: Number(key.slice(separatorIndex + 1)) };
|
|
71
|
+
}
|
|
57
72
|
function validateSingleOverride(key, rawEntry, errors) {
|
|
58
73
|
if (!isRecord(rawEntry)) {
|
|
59
74
|
errors.push(`overrides['${key}']: must be an object`);
|
|
@@ -125,53 +140,27 @@ function validateAudience(key, value, errors) {
|
|
|
125
140
|
return 'skip';
|
|
126
141
|
}
|
|
127
142
|
export function formatStaleOverrideKeyWarning(key) {
|
|
128
|
-
return `Override key '${key}' did not match any
|
|
143
|
+
return `Override key '${key}' did not match any item in the changelog (likely a stale reference)`;
|
|
129
144
|
}
|
|
130
145
|
export function applyChangelogOverrides(entries, overrides) {
|
|
131
146
|
const warnings = [];
|
|
132
|
-
const errors = [];
|
|
133
|
-
const matchedKeys = [];
|
|
134
147
|
if (overrides.size === 0) {
|
|
135
|
-
return { entries: entries.map(cloneEntry), warnings, errors, matchedKeys };
|
|
136
|
-
}
|
|
137
|
-
const allHashes = [];
|
|
138
|
-
for (const entry of entries) {
|
|
139
|
-
for (const section of entry.sections) {
|
|
140
|
-
for (const item of section.items) {
|
|
141
|
-
if (item.hash !== undefined) {
|
|
142
|
-
allHashes.push(item.hash);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
148
|
+
return { entries: entries.map(cloneEntry), warnings, errors: [], matchedKeys: [] };
|
|
146
149
|
}
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (matches.length === 0) {
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
if (matches.length > 1) {
|
|
154
|
-
errors.push(`Override key '${overrideKey}' is ambiguous: matches multiple commits (${matches.join(', ')}). ` +
|
|
155
|
-
'Use a longer prefix or the full commit hash.');
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
keyToMatchedHashes.set(overrideKey, matches);
|
|
159
|
-
matchedKeys.push(overrideKey);
|
|
160
|
-
}
|
|
161
|
-
const hashToOverride = new Map();
|
|
162
|
-
for (const [overrideKey, matchedHashes] of keyToMatchedHashes) {
|
|
150
|
+
const { errors, itemIdToKey } = resolveOverrideKeys(indexEntryPositions(entries), overrides);
|
|
151
|
+
const itemIdToOverride = new Map();
|
|
152
|
+
for (const [itemId, overrideKey] of itemIdToKey) {
|
|
163
153
|
const override = overrides.get(overrideKey);
|
|
164
|
-
if (override
|
|
165
|
-
|
|
166
|
-
for (const hash of matchedHashes) {
|
|
167
|
-
hashToOverride.set(hash, override);
|
|
154
|
+
if (override !== undefined) {
|
|
155
|
+
itemIdToOverride.set(itemId, override);
|
|
168
156
|
}
|
|
169
157
|
}
|
|
158
|
+
const matchedKeys = [...new Set(itemIdToKey.values())];
|
|
170
159
|
const transformedEntries = [];
|
|
171
160
|
for (const entry of entries) {
|
|
172
161
|
const transformedSections = [];
|
|
173
162
|
for (const section of entry.sections) {
|
|
174
|
-
const transformedItems = applyOverridesToItems(section.items,
|
|
163
|
+
const transformedItems = applyOverridesToItems(section.items, itemIdToOverride);
|
|
175
164
|
if (transformedItems.length === 0) {
|
|
176
165
|
continue;
|
|
177
166
|
}
|
|
@@ -181,14 +170,93 @@ export function applyChangelogOverrides(entries, overrides) {
|
|
|
181
170
|
}
|
|
182
171
|
return { entries: transformedEntries, warnings, errors, matchedKeys };
|
|
183
172
|
}
|
|
184
|
-
function
|
|
173
|
+
function resolveOverrideKeys(positionsByHash, overrides) {
|
|
174
|
+
const errors = [];
|
|
175
|
+
const failedKeys = new Set();
|
|
176
|
+
const itemIdToKey = new Map();
|
|
177
|
+
for (const [overrideKey, override] of overrides) {
|
|
178
|
+
const resolution = resolveOverrideKey(overrideKey, positionsByHash);
|
|
179
|
+
if (resolution.kind === 'ambiguous') {
|
|
180
|
+
errors.push(`Override key '${overrideKey}' is ambiguous: matches multiple commits (${resolution.hashes.join(', ')}). ` +
|
|
181
|
+
'Use a longer prefix or the full commit hash.');
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (resolution.kind === 'none') {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const { hash, positions } = resolution;
|
|
188
|
+
if (parseOverrideKey(overrideKey).entry === undefined && positions.length > 1) {
|
|
189
|
+
const fieldsSet = ['description', 'body'].filter((field) => override[field] !== undefined);
|
|
190
|
+
if (fieldsSet.length > 0) {
|
|
191
|
+
const ordinalKeys = positions.map((position) => `${overrideKey}:${position}`).join(', ');
|
|
192
|
+
errors.push(`Override key '${overrideKey}' sets ${fieldsSet.join(' and ')} on a commit with several items; use ${ordinalKeys}`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const position of positions) {
|
|
197
|
+
const itemId = formatItemId(hash, position);
|
|
198
|
+
const claimingKey = itemIdToKey.get(itemId);
|
|
199
|
+
if (claimingKey === undefined) {
|
|
200
|
+
itemIdToKey.set(itemId, overrideKey);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const target = position === undefined ? `commit ${hash}` : `the item at ${hash}:${position}`;
|
|
204
|
+
errors.push(`Override keys '${claimingKey}' and '${overrideKey}' both match ${target}; keep one`);
|
|
205
|
+
failedKeys.add(claimingKey);
|
|
206
|
+
failedKeys.add(overrideKey);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const [itemId, overrideKey] of itemIdToKey) {
|
|
210
|
+
if (failedKeys.has(overrideKey)) {
|
|
211
|
+
itemIdToKey.delete(itemId);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { errors, itemIdToKey };
|
|
215
|
+
}
|
|
216
|
+
function resolveOverrideKey(overrideKey, positionsByHash) {
|
|
217
|
+
const { hashPrefix, entry } = parseOverrideKey(overrideKey);
|
|
218
|
+
const hashes = positionsByHash
|
|
219
|
+
.keys()
|
|
220
|
+
.filter((hash) => hash.startsWith(hashPrefix))
|
|
221
|
+
.toArray();
|
|
222
|
+
const [hash] = hashes;
|
|
223
|
+
if (hash === undefined) {
|
|
224
|
+
return { kind: 'none' };
|
|
225
|
+
}
|
|
226
|
+
if (hashes.length > 1) {
|
|
227
|
+
return { kind: 'ambiguous', hashes };
|
|
228
|
+
}
|
|
229
|
+
const available = [...(positionsByHash.get(hash) ?? [])];
|
|
230
|
+
const positions = entry === undefined
|
|
231
|
+
? available.toSorted((left, right) => (left ?? 0) - (right ?? 0))
|
|
232
|
+
: available.filter((position) => position === entry);
|
|
233
|
+
return positions.length === 0 ? { kind: 'none' } : { kind: 'items', hash, positions };
|
|
234
|
+
}
|
|
235
|
+
function indexEntryPositions(entries) {
|
|
236
|
+
return indexItemPositions(entries.flatMap((entry) => entry.sections.flatMap((section) => section.items)));
|
|
237
|
+
}
|
|
238
|
+
function indexItemPositions(items) {
|
|
239
|
+
const positionsByHash = new Map();
|
|
240
|
+
for (const item of items) {
|
|
241
|
+
if (item.hash === undefined)
|
|
242
|
+
continue;
|
|
243
|
+
const positions = positionsByHash.get(item.hash) ?? new Set();
|
|
244
|
+
positions.add(item.entry);
|
|
245
|
+
positionsByHash.set(item.hash, positions);
|
|
246
|
+
}
|
|
247
|
+
return positionsByHash;
|
|
248
|
+
}
|
|
249
|
+
function formatItemId(hash, entry) {
|
|
250
|
+
return entry === undefined ? hash : `${hash}:${entry}`;
|
|
251
|
+
}
|
|
252
|
+
function applyOverridesToItems(items, itemIdToOverride) {
|
|
185
253
|
const result = [];
|
|
186
254
|
for (const item of items) {
|
|
187
255
|
if (item.hash === undefined) {
|
|
188
256
|
result.push(cloneItem(item));
|
|
189
257
|
continue;
|
|
190
258
|
}
|
|
191
|
-
const override =
|
|
259
|
+
const override = itemIdToOverride.get(formatItemId(item.hash, item.entry));
|
|
192
260
|
if (override === undefined) {
|
|
193
261
|
result.push(cloneItem(item));
|
|
194
262
|
continue;
|
|
@@ -207,6 +275,8 @@ function applyOverrideToItem(item, override) {
|
|
|
207
275
|
}
|
|
208
276
|
if (override.body !== undefined) {
|
|
209
277
|
result.body = override.body;
|
|
278
|
+
}
|
|
279
|
+
if (override.body !== undefined && item.entry === undefined) {
|
|
210
280
|
const migration = extractMigration(override.body);
|
|
211
281
|
if (migration === undefined) {
|
|
212
282
|
delete result.migration;
|
|
@@ -288,7 +358,7 @@ export function validateAllChangelogOverrides(inputs) {
|
|
|
288
358
|
const projectMap = loadScopeMap(projectFilePath, errors);
|
|
289
359
|
const workspaceMaps = (inputs.workspaces ?? []).map((scope) => ({
|
|
290
360
|
filePath: scope.filePath,
|
|
291
|
-
|
|
361
|
+
items: scope.items,
|
|
292
362
|
map: loadScopeMap(scope.filePath, errors),
|
|
293
363
|
}));
|
|
294
364
|
const globalMatchedRootKeys = new Set();
|
|
@@ -302,9 +372,9 @@ export function validateAllChangelogOverrides(inputs) {
|
|
|
302
372
|
globalMatchedRootKeys,
|
|
303
373
|
});
|
|
304
374
|
}
|
|
305
|
-
const
|
|
306
|
-
if (projectFilePath !== undefined &&
|
|
307
|
-
processProjectScope({ projectFilePath, projectMap,
|
|
375
|
+
const projectItems = inputs.project?.items;
|
|
376
|
+
if (projectFilePath !== undefined && projectItems !== undefined) {
|
|
377
|
+
processProjectScope({ projectFilePath, projectMap, projectItems, errors, globalMatchedRootKeys });
|
|
308
378
|
}
|
|
309
379
|
if (projectFilePath !== undefined) {
|
|
310
380
|
collectRootStaleWarnings(projectFilePath, projectMap, globalMatchedRootKeys, warnings);
|
|
@@ -313,39 +383,49 @@ export function validateAllChangelogOverrides(inputs) {
|
|
|
313
383
|
}
|
|
314
384
|
function processWorkspaceScope(args) {
|
|
315
385
|
const { workspace, projectFilePath, projectMap, errors, warnings, globalMatchedRootKeys } = args;
|
|
316
|
-
const { filePath,
|
|
317
|
-
const
|
|
386
|
+
const { filePath, items, map } = workspace;
|
|
387
|
+
const validationEntries = makeValidationEntries(items);
|
|
388
|
+
const positionsByHash = indexItemPositions(items);
|
|
389
|
+
const workspaceApplied = applyChangelogOverrides(validationEntries, map);
|
|
318
390
|
for (const message of workspaceApplied.errors) {
|
|
319
391
|
errors.push(prefixWithFilePath(filePath, message));
|
|
320
392
|
}
|
|
321
393
|
if (projectFilePath !== undefined && projectMap.size > 0) {
|
|
322
394
|
const projectMinusShadowed = filterShadowedKeys(projectMap, map);
|
|
323
|
-
const projectApplied = applyChangelogOverrides(
|
|
395
|
+
const projectApplied = applyChangelogOverrides(validationEntries, projectMinusShadowed);
|
|
324
396
|
for (const message of projectApplied.errors) {
|
|
325
397
|
errors.push(prefixWithFilePath(projectFilePath, message));
|
|
326
398
|
}
|
|
399
|
+
const perFileErrors = new Set([...workspaceApplied.errors, ...projectApplied.errors]);
|
|
400
|
+
const composedApplied = applyChangelogOverrides(validationEntries, composeOverrides(projectMap, map));
|
|
401
|
+
for (const message of composedApplied.errors) {
|
|
402
|
+
if (!perFileErrors.has(message)) {
|
|
403
|
+
errors.push(prefixWithFilePath(filePath, message));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
327
406
|
}
|
|
328
407
|
for (const key of map.keys()) {
|
|
329
|
-
if (!hasAnyMatch(key,
|
|
408
|
+
if (!hasAnyMatch(key, positionsByHash)) {
|
|
330
409
|
warnings.push(formatWorkspaceStaleWarning(filePath, key));
|
|
331
410
|
}
|
|
332
411
|
}
|
|
333
412
|
for (const key of projectMap.keys()) {
|
|
334
413
|
if (map.has(key))
|
|
335
414
|
continue;
|
|
336
|
-
if (hasAnyMatch(key,
|
|
415
|
+
if (hasAnyMatch(key, positionsByHash)) {
|
|
337
416
|
globalMatchedRootKeys.add(key);
|
|
338
417
|
}
|
|
339
418
|
}
|
|
340
419
|
}
|
|
341
420
|
function processProjectScope(args) {
|
|
342
|
-
const { projectFilePath, projectMap,
|
|
343
|
-
const applied = applyChangelogOverrides(makeValidationEntries(
|
|
421
|
+
const { projectFilePath, projectMap, projectItems, errors, globalMatchedRootKeys } = args;
|
|
422
|
+
const applied = applyChangelogOverrides(makeValidationEntries(projectItems), projectMap);
|
|
344
423
|
for (const message of applied.errors) {
|
|
345
424
|
errors.push(prefixWithFilePath(projectFilePath, message));
|
|
346
425
|
}
|
|
426
|
+
const positionsByHash = indexItemPositions(projectItems);
|
|
347
427
|
for (const key of projectMap.keys()) {
|
|
348
|
-
if (hasAnyMatch(key,
|
|
428
|
+
if (hasAnyMatch(key, positionsByHash)) {
|
|
349
429
|
globalMatchedRootKeys.add(key);
|
|
350
430
|
}
|
|
351
431
|
}
|
|
@@ -357,8 +437,8 @@ function collectRootStaleWarnings(projectFilePath, projectMap, globalMatchedRoot
|
|
|
357
437
|
}
|
|
358
438
|
}
|
|
359
439
|
}
|
|
360
|
-
function hasAnyMatch(key,
|
|
361
|
-
return
|
|
440
|
+
function hasAnyMatch(key, positionsByHash) {
|
|
441
|
+
return resolveOverrideKey(key, positionsByHash).kind !== 'none';
|
|
362
442
|
}
|
|
363
443
|
function filterShadowedKeys(projectMap, workspaceMap) {
|
|
364
444
|
const result = new Map();
|
|
@@ -382,7 +462,7 @@ function loadScopeMap(filePath, errors) {
|
|
|
382
462
|
}
|
|
383
463
|
return result.overrides;
|
|
384
464
|
}
|
|
385
|
-
function makeValidationEntries(
|
|
465
|
+
function makeValidationEntries(items) {
|
|
386
466
|
return [
|
|
387
467
|
{
|
|
388
468
|
version: '0.0.0',
|
|
@@ -391,7 +471,7 @@ function makeValidationEntries(hashes) {
|
|
|
391
471
|
{
|
|
392
472
|
title: 'Validation',
|
|
393
473
|
audience: 'all',
|
|
394
|
-
items:
|
|
474
|
+
items: items.map((item) => ({ ...item, description: '' })),
|
|
395
475
|
},
|
|
396
476
|
],
|
|
397
477
|
},
|
|
@@ -401,10 +481,10 @@ function prefixWithFilePath(filePath, message) {
|
|
|
401
481
|
return `${filePath}: ${message}`;
|
|
402
482
|
}
|
|
403
483
|
function formatWorkspaceStaleWarning(filePath, key) {
|
|
404
|
-
return `${filePath}: Override key '${key}' did not match any
|
|
484
|
+
return `${filePath}: Override key '${key}' did not match any item in this workspace's history (likely a stale reference)`;
|
|
405
485
|
}
|
|
406
486
|
function formatRootStaleWarning(filePath, key) {
|
|
407
|
-
return `${filePath}: Override key '${key}' did not match any
|
|
487
|
+
return `${filePath}: Override key '${key}' did not match any item in any scope (likely a stale reference)`;
|
|
408
488
|
}
|
|
409
489
|
export function applyWorkspaceOverrides(newEntries, workspacePath, overrideContext) {
|
|
410
490
|
const { project, perWorkspace, overrideWarnings, globalMatchedRootKeys } = overrideContext;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ParseCommitMessageOptions } from './parseCommitMessage.js';
|
|
2
|
+
import type { Commit, WorkTypeConfig } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* How a commit's title places it in a changelog: under a section header, excluded by design, or unreadable.
|
|
5
|
+
*
|
|
6
|
+
* `breaking` is the parser's flag under the breaking policies passed in, which a `BREAKING CHANGE:` footer can set.
|
|
7
|
+
*/
|
|
8
|
+
export type ChangelogClassification = {
|
|
9
|
+
kind: 'header';
|
|
10
|
+
header: string;
|
|
11
|
+
type: string;
|
|
12
|
+
breaking: boolean;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'excluded';
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'unparseable';
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Classifies a commit by its title.
|
|
20
|
+
*
|
|
21
|
+
* Four gates decide, in order: a `release:` or `Merge` subject is excluded; a subject carrying no ticket-ID prefix is
|
|
22
|
+
* unparseable; a type that `parseCommitMessage` cannot resolve against `workTypes` is unparseable; and a type that
|
|
23
|
+
* `workTypes` excludes from the changelog is excluded. A commit that passes all four takes the header its type declares.
|
|
24
|
+
*
|
|
25
|
+
* `options` reaches the parse, so a policy violation is reported for every ticketed commit whose type resolves,
|
|
26
|
+
* excluded types included.
|
|
27
|
+
*
|
|
28
|
+
* The header carries no order encoding. `buildChangelogEntries` sorts sections by canonical priority, so
|
|
29
|
+
* the position a section occupies comes from the taxonomy rather than from the header string.
|
|
30
|
+
*/
|
|
31
|
+
export declare function classifyChangelogCommit(commit: Pick<Commit, 'hash' | 'message'>, workTypes: Record<string, WorkTypeConfig>, options?: ParseCommitMessageOptions): ChangelogClassification;
|
|
32
|
+
/**
|
|
33
|
+
* Reports whether a subject belongs to a commit that never reaches a changelog: a `release:` commit, which records
|
|
34
|
+
* version bumps, or a git merge commit, whose content the merged commits already report.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isNonChangeSubject(subject: string): boolean;
|
|
37
|
+
/** Reports whether a subject belongs to a `release:` commit, which records version bumps rather than a change. */
|
|
38
|
+
export declare function isReleaseSubject(subject: string): boolean;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { COMMIT_PREPROCESSOR_PATTERNS, parseCommitMessage, } from "./parseCommitMessage.js";
|
|
2
|
+
const RELEASE_SUBJECT_PATTERN = /^release:/;
|
|
3
|
+
const MERGE_SUBJECT_PATTERN = /^Merge/;
|
|
4
|
+
export function classifyChangelogCommit(commit, workTypes, options) {
|
|
5
|
+
const subject = commit.message.split('\n', 1)[0] ?? '';
|
|
6
|
+
if (isNonChangeSubject(subject)) {
|
|
7
|
+
return { kind: 'excluded' };
|
|
8
|
+
}
|
|
9
|
+
if (!hasTicketPrefix(subject)) {
|
|
10
|
+
return { kind: 'unparseable' };
|
|
11
|
+
}
|
|
12
|
+
const parsed = parseCommitMessage(commit.message, commit.hash, workTypes, undefined, options);
|
|
13
|
+
if (parsed === undefined) {
|
|
14
|
+
return { kind: 'unparseable' };
|
|
15
|
+
}
|
|
16
|
+
const config = workTypes[parsed.type];
|
|
17
|
+
if (config === undefined) {
|
|
18
|
+
return { kind: 'unparseable' };
|
|
19
|
+
}
|
|
20
|
+
if (config.excludedFromChangelog === true) {
|
|
21
|
+
return { kind: 'excluded' };
|
|
22
|
+
}
|
|
23
|
+
return { kind: 'header', header: config.header, type: parsed.type, breaking: parsed.breaking };
|
|
24
|
+
}
|
|
25
|
+
export function isNonChangeSubject(subject) {
|
|
26
|
+
return isReleaseSubject(subject) || MERGE_SUBJECT_PATTERN.test(subject);
|
|
27
|
+
}
|
|
28
|
+
export function isReleaseSubject(subject) {
|
|
29
|
+
return RELEASE_SUBJECT_PATTERN.test(subject);
|
|
30
|
+
}
|
|
31
|
+
function hasTicketPrefix(subject) {
|
|
32
|
+
return COMMIT_PREPROCESSOR_PATTERNS.some((pattern) => pattern.test(subject));
|
|
33
|
+
}
|
|
@@ -1,23 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
/** Inputs to the unified release decision used by both pipelines. */
|
|
1
|
+
import type { ReleaseType } from './types.js';
|
|
2
|
+
/** Inputs to the release decision that every release path makes. */
|
|
4
3
|
export interface DecideReleaseArgs {
|
|
5
|
-
|
|
4
|
+
/** The bump that the window's changelog items call for; undefined when they call for none. */
|
|
5
|
+
naturalBump: ReleaseType | undefined;
|
|
6
|
+
/** The number of commits in the window, which selects the skip reason. */
|
|
7
|
+
commitCount: number;
|
|
6
8
|
/** True when `--force` was passed; treats absence of a natural bump as patch. Defaults to false. */
|
|
7
9
|
force?: boolean | undefined;
|
|
8
10
|
/** Explicit `--bump=X` override. When set, the level is X regardless of the natural bump. */
|
|
9
11
|
bumpOverride: ReleaseType | undefined;
|
|
10
|
-
workTypes: Record<string, WorkTypeConfig>;
|
|
11
|
-
versionPatterns: VersionPatterns;
|
|
12
|
-
scopeAliases: Record<string, string> | undefined;
|
|
13
|
-
/** Per-canonical-type breaking-policy lookup. When provided, drives `!` policy enforcement in `parseCommitMessage`. */
|
|
14
|
-
breakingPolicies?: Record<string, 'forbidden' | 'optional' | 'required'>;
|
|
15
|
-
/** Receives policy-violation notifications from `parseCommitMessage`. */
|
|
16
|
-
onPolicyViolation?: PolicyViolationHandler;
|
|
17
12
|
/**
|
|
18
|
-
* Skip wordings to use when no commits exist (`noCommits`) or when commits exist but
|
|
19
|
-
*
|
|
20
|
-
* own pre-rendered wordings (with `{name}` / `since` substitutions already applied).
|
|
13
|
+
* Skip wordings to use when no commits exist (`noCommits`) or when commits exist but none yields a changelog item
|
|
14
|
+
* (`noBumpWorthy`). Each path passes its own pre-rendered wordings.
|
|
21
15
|
*/
|
|
22
16
|
skipReasons: {
|
|
23
17
|
noCommits: string;
|
|
@@ -28,38 +22,18 @@ export interface DecideReleaseArgs {
|
|
|
28
22
|
export type DecideReleaseResult = {
|
|
29
23
|
outcome: 'release';
|
|
30
24
|
releaseType: ReleaseType;
|
|
31
|
-
parsedCommitCount: number;
|
|
32
|
-
unparseableCommits: Commit[] | undefined;
|
|
33
25
|
} | {
|
|
34
26
|
outcome: 'skip';
|
|
35
27
|
skipReason: string;
|
|
36
|
-
parsedCommitCount: number;
|
|
37
|
-
unparseableCommits: Commit[] | undefined;
|
|
38
28
|
};
|
|
39
29
|
/**
|
|
40
|
-
*
|
|
41
|
-
* and the per-workspace path in `releasePrepareMono`.
|
|
30
|
+
* Decides whether a window releases, and at which level.
|
|
42
31
|
*
|
|
43
32
|
* Algorithm:
|
|
44
33
|
* shouldRelease = naturalBump !== undefined OR force === true
|
|
45
34
|
* releaseLevel = bumpOverride ?? naturalBump ?? 'patch'
|
|
46
35
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* bump-worthy work type. Note that this differs from `determineBumpFromCommits`, which
|
|
50
|
-
* applies a patch floor whenever any commits exist; the patch floor is intentionally
|
|
51
|
-
* not applied here so that `--bump=X` and `--force` remain orthogonal (the floor is
|
|
52
|
-
* applied only when `--force` is set, by virtue of the `naturalBump ?? 'patch'`
|
|
53
|
-
* fallback in the release-level expression above).
|
|
54
|
-
*
|
|
55
|
-
* `parsedCommitCount` and `unparseableCommits` are returned in both branches so callers
|
|
56
|
-
* can surface diagnostic data uniformly regardless of skip/release outcome.
|
|
57
|
-
*
|
|
58
|
-
* Scope: this is the canonical algorithm for the orthogonal-flag model in the
|
|
59
|
-
* monorepo path (`releasePrepareProject` + per-workspace `determineDirectBumps`). The
|
|
60
|
-
* single-package executor (`releasePrepare`) deliberately retains
|
|
61
|
-
* `determineBumpFromCommits` and the legacy patch-floor semantics; `prepareCommand`
|
|
62
|
-
* rejects `--force` without `--bump` in single-package mode to surface that gap. A
|
|
63
|
-
* future ticket can unify the single-package path; see #313's external plan.
|
|
36
|
+
* A window without a natural bump releases only under `--force`, so `--bump=X` chooses a level without triggering a
|
|
37
|
+
* release, and `--force` triggers one without choosing a level.
|
|
64
38
|
*/
|
|
65
39
|
export declare function decideRelease(args: DecideReleaseArgs): DecideReleaseResult;
|
|
@@ -1,30 +1,8 @@
|
|
|
1
|
-
import { determineBumpType } from "./determineBumpType.js";
|
|
2
|
-
import { parseCommitMessage, } from "./parseCommitMessage.js";
|
|
3
1
|
export function decideRelease(args) {
|
|
4
|
-
const {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
};
|
|
9
|
-
const parsedCommits = [];
|
|
10
|
-
const unparseable = [];
|
|
11
|
-
for (const commit of commits) {
|
|
12
|
-
const parsed = parseCommitMessage(commit.message, commit.hash, workTypes, scopeAliases, parseOptions);
|
|
13
|
-
if (parsed === undefined) {
|
|
14
|
-
unparseable.push(commit);
|
|
15
|
-
}
|
|
16
|
-
else {
|
|
17
|
-
parsedCommits.push(parsed);
|
|
18
|
-
}
|
|
2
|
+
const { naturalBump, commitCount, force = false, bumpOverride, skipReasons } = args;
|
|
3
|
+
if (naturalBump === undefined && !force) {
|
|
4
|
+
const skipReason = commitCount === 0 ? skipReasons.noCommits : skipReasons.noBumpWorthy;
|
|
5
|
+
return { outcome: 'skip', skipReason };
|
|
19
6
|
}
|
|
20
|
-
|
|
21
|
-
const unparseableCommits = unparseable.length > 0 ? unparseable : undefined;
|
|
22
|
-
const naturalBump = determineBumpType(parsedCommits, workTypes, versionPatterns);
|
|
23
|
-
const shouldRelease = naturalBump !== undefined || force;
|
|
24
|
-
if (!shouldRelease) {
|
|
25
|
-
const skipReason = commits.length === 0 ? skipReasons.noCommits : skipReasons.noBumpWorthy;
|
|
26
|
-
return { outcome: 'skip', skipReason, parsedCommitCount, unparseableCommits };
|
|
27
|
-
}
|
|
28
|
-
const releaseType = bumpOverride ?? naturalBump ?? 'patch';
|
|
29
|
-
return { outcome: 'release', releaseType, parsedCommitCount, unparseableCommits };
|
|
7
|
+
return { outcome: 'release', releaseType: bumpOverride ?? naturalBump ?? 'patch' };
|
|
30
8
|
}
|
package/dist/esm/defaults.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { WORK_TYPES_DATA } from './workTypesData.js';
|
|
|
6
6
|
* Compose the rendered section heading for a work-type entry as `${emoji} ${label}`.
|
|
7
7
|
*
|
|
8
8
|
* Single-sourced here so callers (DEFAULT_WORK_TYPES derivation, devOnlySections
|
|
9
|
-
* derivation
|
|
9
|
+
* derivation) cannot disagree on the composition rule.
|
|
10
10
|
*/
|
|
11
11
|
export declare function composeHeader(entry: {
|
|
12
12
|
emoji: string;
|
package/dist/esm/defaults.js
CHANGED
|
@@ -13,6 +13,9 @@ function deriveDefaultWorkTypes() {
|
|
|
13
13
|
if (entry.aliases.length > 0) {
|
|
14
14
|
config.aliases = [...entry.aliases];
|
|
15
15
|
}
|
|
16
|
+
if (entry.excludedFromChangelog === true) {
|
|
17
|
+
config.excludedFromChangelog = true;
|
|
18
|
+
}
|
|
16
19
|
result[entry.key] = config;
|
|
17
20
|
}
|
|
18
21
|
return result;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReleaseType, VersionPatterns, WorkTypeConfig } from './types.js';
|
|
2
|
+
/** The part of a changelog item or parsed commit that decides its bump: its resolved work type and breaking flag. */
|
|
3
|
+
export interface BumpSignal {
|
|
4
|
+
type: string;
|
|
5
|
+
breaking: boolean;
|
|
6
|
+
}
|
|
2
7
|
/**
|
|
3
|
-
* Determines the overall bump type from a set of
|
|
8
|
+
* Determines the overall bump type from a set of changes, returning undefined when none has a known work type.
|
|
4
9
|
*
|
|
5
|
-
* Uses `versionPatterns` to decide which
|
|
6
|
-
* The `'!'` sentinel in `versionPatterns.major` means "any breaking
|
|
7
|
-
* Any recognized
|
|
8
|
-
*
|
|
9
|
-
* @param commits - The parsed commits to analyze.
|
|
10
|
-
* @param workTypes - The work type configurations to verify recognized types.
|
|
11
|
-
* @param versionPatterns - Rules for determining major and minor bumps.
|
|
12
|
-
* @returns The determined release type, or undefined if no commits match a known work type.
|
|
10
|
+
* Uses `versionPatterns` to decide which types trigger major/minor bumps.
|
|
11
|
+
* The `'!'` sentinel in `versionPatterns.major` means "any breaking change triggers major".
|
|
12
|
+
* Any recognized type not listed in major or minor patterns defaults to patch.
|
|
13
13
|
*/
|
|
14
|
-
export declare function determineBumpType(
|
|
14
|
+
export declare function determineBumpType(changes: readonly BumpSignal[], workTypes: Record<string, WorkTypeConfig>, versionPatterns: VersionPatterns): ReleaseType | undefined;
|
|
@@ -3,23 +3,22 @@ const RELEASE_PRIORITY = {
|
|
|
3
3
|
minor: 2,
|
|
4
4
|
patch: 1,
|
|
5
5
|
};
|
|
6
|
-
export function determineBumpType(
|
|
6
|
+
export function determineBumpType(changes, workTypes, versionPatterns) {
|
|
7
7
|
const knownTypes = new Set(Object.keys(workTypes));
|
|
8
8
|
let highestPriority = 0;
|
|
9
9
|
let result;
|
|
10
|
-
for (const
|
|
11
|
-
if (
|
|
10
|
+
for (const change of changes) {
|
|
11
|
+
if (change.breaking && versionPatterns.major.includes('!')) {
|
|
12
12
|
return 'major';
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
if (!knownTypes.has(commitType)) {
|
|
14
|
+
if (!knownTypes.has(change.type)) {
|
|
16
15
|
continue;
|
|
17
16
|
}
|
|
18
17
|
let bump;
|
|
19
|
-
if (versionPatterns.major.includes(
|
|
18
|
+
if (versionPatterns.major.includes(change.type)) {
|
|
20
19
|
bump = 'major';
|
|
21
20
|
}
|
|
22
|
-
else if (versionPatterns.minor.includes(
|
|
21
|
+
else if (versionPatterns.minor.includes(change.type)) {
|
|
23
22
|
bump = 'minor';
|
|
24
23
|
}
|
|
25
24
|
else {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** A commit as git records it, before any parsing or filtering. */
|
|
2
|
+
export interface RawCommit {
|
|
3
|
+
/** The full 40-character hash, which `changelogOverrides` keys on. */
|
|
4
|
+
hash: string;
|
|
5
|
+
/** The first line, with a wrapped subject folded onto one line by git. */
|
|
6
|
+
subject: string;
|
|
7
|
+
/** The message after its first blank line, empty when the commit carries no body. */
|
|
8
|
+
body: string;
|
|
9
|
+
/** The raw commit message, subject and body together. */
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
/** The commits one release contains, with the version and date under which they shipped. */
|
|
13
|
+
export interface ReleaseWindow {
|
|
14
|
+
/** The tag name for a released window, the caller's unreleased-tag name for the newest one. */
|
|
15
|
+
version: string;
|
|
16
|
+
/** Unix seconds: the tag's creation date for a released window, the clock for the unreleased one. */
|
|
17
|
+
timestamp: number;
|
|
18
|
+
/** Oldest first. */
|
|
19
|
+
commits: RawCommit[];
|
|
20
|
+
}
|
|
21
|
+
/** Inputs for {@link enumerateReleaseWindows}. */
|
|
22
|
+
export interface EnumerateReleaseWindowsOptions {
|
|
23
|
+
/** Git pathspecs restricting the enumeration to commits that touch them. */
|
|
24
|
+
paths?: readonly string[];
|
|
25
|
+
/** Tag prefixes to match as a union; a matching tag name continues with a digit. */
|
|
26
|
+
tagPrefixes: readonly string[];
|
|
27
|
+
/** The version that the unreleased window reports, such as the tag that the release being prepared will write. */
|
|
28
|
+
unreleasedTag: string;
|
|
29
|
+
/** Returns the current time in epoch milliseconds; dates the unreleased window. */
|
|
30
|
+
now?: () => number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Splits the history reachable from HEAD into release windows, newest first.
|
|
34
|
+
*
|
|
35
|
+
* The newest window is always the unreleased one, empty when HEAD sits on a tag. Released
|
|
36
|
+
* windows follow in descending order, one per reachable tag matching a prefix. A tag that
|
|
37
|
+
* HEAD cannot reach is dropped.
|
|
38
|
+
*
|
|
39
|
+
* A commit belongs to the oldest matching tag that contains it, and to the unreleased window
|
|
40
|
+
* when no matching tag does. Containment is read from the ancestry graph rather than from a
|
|
41
|
+
* position in a linear walk, because a branch commit authored before a tag and merged after it
|
|
42
|
+
* is not in that tag's release.
|
|
43
|
+
*
|
|
44
|
+
* Three git invocations serve any number of tags: `git rev-list --topo-order --parents` for the
|
|
45
|
+
* ancestry graph, `git for-each-ref` for the tag names with their creation dates and target
|
|
46
|
+
* commits, and one path-filtered `git log` for the commits themselves.
|
|
47
|
+
*
|
|
48
|
+
* @throws If `tagPrefixes` is empty, or if any git invocation fails.
|
|
49
|
+
*/
|
|
50
|
+
export declare function enumerateReleaseWindows(options: EnumerateReleaseWindowsOptions): ReleaseWindow[];
|