@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
@@ -2,6 +2,7 @@
2
2
  import { parseArgsOrExit, readPackageVersion, reportError } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
4
  import { commitCommand } from "../commitCommand.js";
5
+ import { configFlagSchema } from "../configFlagSchema.js";
5
6
  import { createGithubReleaseCommand } from "../createGithubReleaseCommand.js";
6
7
  import { showPrepareHelp } from "../help/prepareHelp.js";
7
8
  import { initCommand } from "../init/initCommand.js";
@@ -68,9 +69,10 @@ retired packages, then generate .github/labels.yaml. When the config file alread
68
69
  exists, print the block for manual paste instead of rewriting the file.
69
70
 
70
71
  Options:
71
- --dry-run Preview changes without writing files
72
- --force Overwrite existing files instead of skipping them
73
- --help, -h Show this help message
72
+ --config <path> Path to config file (default: .config/release-kit.config.ts)
73
+ --dry-run Preview changes without writing files
74
+ --force Overwrite existing files instead of skipping them
75
+ --help, -h Show this help message
74
76
  `);
75
77
  }
76
78
  function showSyncLabelsGenerateHelp() {
@@ -80,8 +82,9 @@ Usage: release-kit sync-labels generate [options]
80
82
  Regenerate .github/labels.yaml from the repoLabels block of .config/release-kit.config.ts.
81
83
 
82
84
  Options:
83
- --check Report whether .github/labels.yaml is stale instead of writing it
84
- --help, -h Show this help message
85
+ --check Report whether .github/labels.yaml is stale instead of writing it
86
+ --config <path> Path to config file (default: .config/release-kit.config.ts)
87
+ --help, -h Show this help message
85
88
  `);
86
89
  }
87
90
  function showSyncLabelsSyncHelp() {
@@ -102,7 +105,7 @@ Initialize release-kit in the current repository.
102
105
  By default, scaffolds only the GitHub Actions workflow files.
103
106
 
104
107
  Options:
105
- --with-config Also scaffold .config/release-kit.config.ts and .config/git-cliff.toml
108
+ --with-config Also scaffold .config/release-kit.config.ts
106
109
  --force Overwrite existing files instead of skipping them
107
110
  --dry-run Preview changes without writing files
108
111
  --help, -h Show this help message
@@ -154,6 +157,7 @@ Create GitHub Releases from changelog.json for tags on HEAD. Private
154
157
  (package.json#private) workspaces are skipped with a warning and get no Release.
155
158
 
156
159
  Options:
160
+ --config <path> Path to config file (default: .config/release-kit.config.ts)
157
161
  --dry-run Preview without creating releases
158
162
  --tags=tag1,tag2 Only create releases for the named tags (comma-separated, full tag names)
159
163
  --help, -h Show this help message
@@ -169,7 +173,8 @@ derived prefix nor declared in \`legacyIdentities\`, with a copy-pasteable
169
173
  config snippet.
170
174
 
171
175
  Options:
172
- --help, -h Show this help message
176
+ --config <path> Path to config file (default: .config/release-kit.config.ts)
177
+ --help, -h Show this help message
173
178
  `);
174
179
  }
175
180
  function showOverridesHelp() {
@@ -190,15 +195,17 @@ function showOverridesValidateHelp() {
190
195
  Usage: release-kit overrides validate
191
196
 
192
197
  Validate every \`.meta/changelog-overrides.json\` file across the project and per-workspace
193
- scopes. Reports schema/parse errors, ambiguous-prefix errors, and stale-key warnings.
198
+ scopes. Reports schema/parse errors, ambiguous-prefix errors, conflicting-key errors, and
199
+ stale-key warnings.
194
200
 
195
201
  Exit codes:
196
202
  0 Clean — no errors, no stale keys
197
203
  1 Stale-key warnings only (no errors)
198
- 2 Schema/parse or ambiguous-prefix errors (errors dominate)
204
+ 2 Schema/parse, ambiguous-prefix, or conflicting-key errors (errors dominate)
199
205
 
200
206
  Options:
201
- --help, -h Show this help message
207
+ --config <path> Path to config file (default: .config/release-kit.config.ts)
208
+ --help, -h Show this help message
202
209
  `);
203
210
  }
204
211
  function showPublishHelp() {
@@ -211,6 +218,7 @@ silently filtered out. With --tags, an unpublishable tag is skipped with a warni
211
218
  any publishable tags still publish.
212
219
 
213
220
  Options:
221
+ --config <path> Path to config file (default: .config/release-kit.config.ts)
214
222
  --dry-run Preview without publishing
215
223
  --no-git-checks Skip the clean-working-tree check
216
224
  --tags=tag1,tag2 Only publish the named tags (comma-separated, full tag names)
@@ -269,7 +277,7 @@ if (command === 'push') {
269
277
  showPushHelp();
270
278
  process.exit(0);
271
279
  }
272
- await pushCommand(flags);
280
+ pushCommand(flags);
273
281
  process.exit(0);
274
282
  }
275
283
  if (command === 'create-github-release') {
@@ -293,11 +301,8 @@ if (command === 'show-tag-prefixes') {
293
301
  showShowTagPrefixesHelp();
294
302
  process.exit(0);
295
303
  }
296
- if (flags.length > 0) {
297
- reportError(`Unknown option: ${flags[0]}`);
298
- process.exit(1);
299
- }
300
- const exitCode = await showTagPrefixesCommand(styles);
304
+ const { config } = parseArgsOrExit(flags, configFlagSchema).flags;
305
+ const exitCode = await showTagPrefixesCommand(styles, config);
301
306
  process.exit(exitCode);
302
307
  }
303
308
  if (command === 'init') {
@@ -327,11 +332,17 @@ if (command === 'sync-labels') {
327
332
  process.exit(0);
328
333
  }
329
334
  const syncLabelsInitFlagSchema = {
335
+ ...configFlagSchema,
330
336
  dryRun: { long: '--dry-run', type: 'boolean' },
331
337
  force: { long: '--force', type: 'boolean' },
332
338
  };
333
- const { dryRun, force } = parseArgsOrExit(subflags, syncLabelsInitFlagSchema).flags;
334
- const exitCode = await syncLabelsInitCommand({ dryRun, force, styles });
339
+ const { config, dryRun, force } = parseArgsOrExit(subflags, syncLabelsInitFlagSchema).flags;
340
+ const exitCode = await syncLabelsInitCommand({
341
+ ...(config !== undefined && { configPath: config }),
342
+ dryRun,
343
+ force,
344
+ styles,
345
+ });
335
346
  process.exit(exitCode);
336
347
  }
337
348
  if (subcommand === 'generate') {
@@ -340,10 +351,11 @@ if (command === 'sync-labels') {
340
351
  process.exit(0);
341
352
  }
342
353
  const generateFlagSchema = {
354
+ ...configFlagSchema,
343
355
  check: { long: '--check', type: 'boolean' },
344
356
  };
345
- const { check } = parseArgsOrExit(subflags, generateFlagSchema).flags;
346
- const exitCode = await generateCommand({ check, styles });
357
+ const { check, config } = parseArgsOrExit(subflags, generateFlagSchema).flags;
358
+ const exitCode = await generateCommand({ check, ...(config !== undefined && { configPath: config }), styles });
347
359
  process.exit(exitCode);
348
360
  }
349
361
  if (subcommand === 'sync') {
@@ -374,11 +386,8 @@ if (command === 'overrides') {
374
386
  showOverridesValidateHelp();
375
387
  process.exit(0);
376
388
  }
377
- if (subflags.length > 0) {
378
- reportError(`Unknown option: ${subflags[0]}`);
379
- process.exit(1);
380
- }
381
- const result = await validateOverridesCommand(styles);
389
+ const { config } = parseArgsOrExit(subflags, configFlagSchema).flags;
390
+ const result = await validateOverridesCommand(styles, config);
382
391
  if (result.exitCode === 0) {
383
392
  console.info(result.message);
384
393
  }
@@ -1,26 +1,68 @@
1
+ import { type RawCommit } from './enumerateReleaseWindows.js';
1
2
  import type { GenerateChangelogOptions } from './generateChangelogs.js';
2
- import type { ChangelogEntry, ReleaseConfig } from './types.js';
3
+ import type { ChangelogEntry, ChangelogSection, MalformedChangeRecordBlock, PolicyViolation, ReleaseConfig, ReleaseType, UndeclaredEntryType } from './types.js';
3
4
  /**
4
- * Strip cliff-template decorations from a group string, returning the bare section name.
5
+ * Strips a section title down to its bare name.
5
6
  *
6
- * The bundled `cliff.toml.template` encodes canonical row order as a hidden HTML comment
7
- * (e.g. `"<!-- 04 -->🐛 Bug fixes"`) so tera's `group_by` filter sorts groups predictably.
8
- * The body template's `striptags` filter erases the comment from the rendered heading,
9
- * but downstream consumers that read the raw `group` value (changelog.json titles, the
10
- * dev-vs-public classifier, the drift test) see the prefix and must strip it. The trailing
11
- * `stripEmojiPrefix` keeps the helper backward-compatible with consumer overrides written
12
- * as bare names.
7
+ * A default title carries a leading emoji (e.g. `"🐛 Bug fixes"`), while a consumer's
8
+ * `devOnlySections` or `workTypes` override may be written as a bare name. Comparing both sides
9
+ * through this helper lets the two forms match.
13
10
  */
14
11
  export declare function stripGroupDecorations(group: string): string;
15
12
  /**
16
- * Build structured changelog entries from git history using git-cliff `--context`.
13
+ * Builds structured changelog entries from the release windows of git history, naming the unreleased window `tag`.
17
14
  *
18
- * Pure data: invokes git-cliff, parses its `--context` output, and returns the transformed
19
- * `ChangelogEntry[]`. Performs no `changelog.json` I/O — callers persist the entries via
20
- * `renderChangelogJson`.
15
+ * Composes `readReleaseHistory` and `toChangelogEntries` for a caller that knows the tag before it reads. Performs no
16
+ * `changelog.json` I/O; callers persist the entries via `renderChangelogJson`.
17
+ */
18
+ export declare function buildChangelogEntries(config: ReleaseHistoryConfig, tag: string, options: GenerateChangelogOptions): {
19
+ entries: ChangelogEntry[];
20
+ diagnostics: ChangelogDiagnostics;
21
+ };
22
+ /** What reading the unreleased window reports beyond its items. */
23
+ export interface ChangelogDiagnostics {
24
+ malformedBlocks: MalformedChangeRecordBlock[];
25
+ /** Breaking-policy violations of the window's titles and change-record entries. */
26
+ policyViolations: PolicyViolation[];
27
+ undeclaredEntryTypes: UndeclaredEntryType[];
28
+ }
29
+ /**
30
+ * Reads a scope's release windows once and returns the changelog items of every window, with what the unreleased
31
+ * window decides: its commits, its bump, and its diagnostics.
21
32
  *
22
- * Always invokes git-cliff: dry-run is the caller's concern (it governs whether the file write happens,
23
- * not whether git-cliff runs). This means dry-run exercises the full git-cliff toolchain and surfaces missing-binary,
24
- * malformed-config, and template-resolution failures earlier than before.
33
+ * A commit whose last `change-record` block records entries yields one item per entry; any other commit is
34
+ * classified by its title through `classifyChangelogCommit`. A `release:` or merge subject yields nothing either
35
+ * way. Only the unreleased window records diagnostics, since the released windows were reported when they were
36
+ * prepared.
25
37
  */
26
- export declare function buildChangelogEntries(config: Pick<ReleaseConfig, 'breakingPolicies' | 'changelogJson' | 'cliffConfigPath' | 'workTypes'>, tag: string, options?: GenerateChangelogOptions): ChangelogEntry[];
38
+ export declare function readReleaseHistory(config: ReleaseHistoryConfig, options: GenerateChangelogOptions): ReleaseHistory;
39
+ /** The configuration that `readReleaseHistory` reads. */
40
+ export type ReleaseHistoryConfig = Pick<ReleaseConfig, 'breakingPolicies' | 'changelogJson' | 'versionPatterns' | 'workTypes'>;
41
+ /** One read of a scope's release windows. */
42
+ export interface ReleaseHistory {
43
+ /** The newest matching tag that HEAD reaches; undefined when none does. */
44
+ previousTag: string | undefined;
45
+ /** Entries of the released windows, newest first; a window that yields no item contributes none. */
46
+ releasedEntries: ChangelogEntry[];
47
+ unreleased: UnreleasedWindowReading;
48
+ }
49
+ /** The unreleased window: its changelog sections and commits, and the bump and diagnostics that they determine. */
50
+ export interface UnreleasedWindowReading {
51
+ /** The highest bump that the window's items call for; undefined when the window yields no item. */
52
+ bump: ReleaseType | undefined;
53
+ /** The window's commits, newest first, without release commits. */
54
+ commits: RawCommit[];
55
+ /** The date of the window's entry, from the time of the read. */
56
+ date: string;
57
+ diagnostics: ChangelogDiagnostics;
58
+ /** The number of commits that yield at least one item. */
59
+ parsedCommitCount: number;
60
+ sections: ChangelogSection[];
61
+ /**
62
+ * Commits that yield no item and that neither an exclusion nor a diagnostic accounts for, newest first; undefined
63
+ * when none.
64
+ */
65
+ unparseableCommits: RawCommit[] | undefined;
66
+ }
67
+ /** Labels the unreleased window of `history` with `tag` and returns the entries of every window, newest first. */
68
+ export declare function toChangelogEntries(history: ReleaseHistory, tag: string): ChangelogEntry[];
@@ -1,145 +1,228 @@
1
1
  import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
2
2
  import { extractVersion } from "./changelogJsonUtils.js";
3
- import { DEFAULT_BREAKING_POLICIES, DEFAULT_WORK_TYPES } from "./defaults.js";
3
+ import { classifyChangelogCommit, isNonChangeSubject, isReleaseSubject } from "./classifyChangelogCommit.js";
4
+ import { DEFAULT_BREAKING_POLICIES, DEFAULT_VERSION_PATTERNS, DEFAULT_WORK_TYPES } from "./defaults.js";
5
+ import { determineBumpType } from "./determineBumpType.js";
6
+ import { enumerateReleaseWindows } from "./enumerateReleaseWindows.js";
4
7
  import { extractMigration } from "./extractMigration.js";
5
- import { COMMIT_PREPROCESSOR_PATTERNS, parseCommitMessage, PIPE_SCOPE_SOURCE } from "./parseCommitMessage.js";
6
- import { resolveCliffConfigPath } from "./resolveCliffConfigPath.js";
7
- import { runGitCliff } from "./runGitCliff.js";
8
+ import { parseChangeRecordBlock, stripChangeRecordBlocks } from "./parseChangeRecordBlock.js";
9
+ import { COMMIT_PREPROCESSOR_PATTERNS, evaluateBreakingPolicy, PIPE_SCOPE_SOURCE, resolveType, } from "./parseCommitMessage.js";
8
10
  import { stripEmojiPrefix } from "./stripEmojiPrefix.js";
9
- import { isRecord, isUnknownArray } from "./typeGuards.js";
10
- const HTML_COMMENT_PREFIX_PATTERN = /^<!--[^>]*-->/;
11
+ const UNRELEASED_TAG = 'unreleased';
11
12
  const CANONICAL_SECTION_ORDER = new Map(Object.values(DEFAULT_WORK_TYPES).map((config, index) => [stripGroupDecorations(config.header), index]));
12
13
  function canonicalSectionPriority(title) {
13
14
  const index = CANONICAL_SECTION_ORDER.get(stripGroupDecorations(title));
14
15
  return index ?? Infinity;
15
16
  }
16
17
  export function stripGroupDecorations(group) {
17
- return stripEmojiPrefix(group.replace(HTML_COMMENT_PREFIX_PATTERN, ''));
18
+ return stripEmojiPrefix(group);
18
19
  }
19
20
  export function buildChangelogEntries(config, tag, options) {
20
- const resolvedConfigPath = resolveCliffConfigPath(config.cliffConfigPath, import.meta.url);
21
- const cliffArgs = ['--context', '--tag', tag];
22
- if (options?.tagPattern !== undefined) {
23
- cliffArgs.push('--tag-pattern', options.tagPattern);
24
- }
25
- const includePaths = options?.includePaths ?? [];
26
- for (const includePath of includePaths) {
27
- cliffArgs.push('--include-path', includePath);
28
- }
21
+ const history = readReleaseHistory(config, options);
22
+ return { entries: toChangelogEntries(history, tag), diagnostics: history.unreleased.diagnostics };
23
+ }
24
+ export function readReleaseHistory(config, options) {
29
25
  try {
30
- const contextJson = runGitCliff(resolvedConfigPath, cliffArgs);
31
- const releases = parseCliffContext(contextJson);
32
- const devOnlySections = new Set(config.changelogJson.devOnlySections);
33
- const workTypes = config.workTypes ?? DEFAULT_WORK_TYPES;
34
- const breakingPolicies = config.breakingPolicies ?? DEFAULT_BREAKING_POLICIES;
35
- return transformReleases(releases, devOnlySections, workTypes, breakingPolicies);
26
+ const windows = enumerateReleaseWindows({
27
+ ...(options.paths !== undefined && { paths: options.paths }),
28
+ tagPrefixes: options.tagPrefixes,
29
+ unreleasedTag: UNRELEASED_TAG,
30
+ });
31
+ const context = {
32
+ breakingPolicies: config.breakingPolicies ?? DEFAULT_BREAKING_POLICIES,
33
+ devOnlySections: new Set(config.changelogJson.devOnlySections.map(stripGroupDecorations)),
34
+ versionPatterns: config.versionPatterns ?? DEFAULT_VERSION_PATTERNS,
35
+ workTypes: config.workTypes ?? DEFAULT_WORK_TYPES,
36
+ };
37
+ return transformReleases(windows, context);
36
38
  }
37
39
  catch (error) {
38
- throw chainError(`Failed to build changelog entries for tag ${tag}`, error);
40
+ throw chainError(`Failed to read the release history for ${options.tagPrefixes.join(', ')}`, error);
39
41
  }
40
42
  }
41
- function parseCliffContext(json) {
42
- const parsed = JSON.parse(json);
43
- if (!isUnknownArray(parsed)) {
44
- throw new TypeError('Expected git-cliff --context output to be an array');
43
+ export function toChangelogEntries(history, tag) {
44
+ const { unreleased } = history;
45
+ if (unreleased.sections.length === 0) {
46
+ return [...history.releasedEntries];
45
47
  }
46
- return parsed.map(toCliffContextRelease);
48
+ return [
49
+ { version: extractVersion(tag), date: unreleased.date, sections: unreleased.sections },
50
+ ...history.releasedEntries,
51
+ ];
47
52
  }
48
- function toCliffContextRelease(value) {
49
- if (!isRecord(value)) {
50
- return {};
53
+ function transformReleases(windows, context) {
54
+ const [unreleasedWindow, baselineWindow] = windows;
55
+ const diagnostics = { malformedBlocks: [], policyViolations: [], undeclaredEntryTypes: [] };
56
+ const signals = [];
57
+ const unparseable = [];
58
+ let parsedCommitCount = 0;
59
+ const sectionMap = new Map();
60
+ const unreleasedCommits = unreleasedWindow?.commits ?? [];
61
+ for (const commit of unreleasedCommits) {
62
+ const reading = readCommit(commit, context, diagnostics);
63
+ for (const derived of reading.items) {
64
+ appendItem(sectionMap, derived.header, derived.item);
65
+ signals.push(derived.signal);
66
+ }
67
+ if (reading.items.length > 0) {
68
+ parsedCommitCount += 1;
69
+ }
70
+ else if (reading.isUnparseable) {
71
+ unparseable.push(commit);
72
+ }
51
73
  }
52
- const release = {};
53
- if (typeof value['version'] === 'string') {
54
- release.version = value['version'];
74
+ const releasedEntries = [];
75
+ for (const release of windows.slice(1)) {
76
+ const releaseSections = new Map();
77
+ for (const commit of release.commits) {
78
+ for (const derived of readCommit(commit, context, undefined).items) {
79
+ appendItem(releaseSections, derived.header, derived.item);
80
+ }
81
+ }
82
+ const sections = buildSections(releaseSections, context.devOnlySections);
83
+ if (sections.length > 0) {
84
+ releasedEntries.push({ version: extractVersion(release.version), date: formatDate(release.timestamp), sections });
85
+ }
55
86
  }
56
- if (typeof value['timestamp'] === 'number') {
57
- release.timestamp = value['timestamp'];
87
+ return {
88
+ previousTag: baselineWindow?.version,
89
+ releasedEntries,
90
+ unreleased: {
91
+ bump: determineBumpType(signals, context.workTypes, context.versionPatterns),
92
+ commits: unreleasedCommits.filter((commit) => !isReleaseSubject(commit.subject)).toReversed(),
93
+ date: formatDate(unreleasedWindow?.timestamp ?? Math.floor(Date.now() / 1_000)),
94
+ diagnostics,
95
+ parsedCommitCount,
96
+ sections: buildSections(sectionMap, context.devOnlySections),
97
+ unparseableCommits: unparseable.length > 0 ? unparseable.toReversed() : undefined,
98
+ },
99
+ };
100
+ }
101
+ function readCommit(commit, context, diagnostics) {
102
+ if (isNonChangeSubject(commit.subject)) {
103
+ return { items: [], isUnparseable: false };
58
104
  }
59
- if (isUnknownArray(value['commits'])) {
60
- release.commits = value['commits'].map(toCliffContextCommit);
105
+ const reading = parseChangeRecordBlock(commit.message);
106
+ if (reading.kind === 'malformed') {
107
+ diagnostics?.malformedBlocks.push({
108
+ commitHash: commit.hash,
109
+ commitSubject: commit.subject,
110
+ reason: reading.reason,
111
+ });
61
112
  }
62
- return release;
63
- }
64
- function toCliffContextCommit(value) {
65
- if (!isRecord(value)) {
66
- return { message: '' };
113
+ if (reading.kind === 'read' && reading.entries.length > 0) {
114
+ const items = reading.entries.flatMap((entry, entryIndex) => buildEntryItem(commit, entry, entryIndex + 1, reading.prNumber, context, diagnostics) ?? []);
115
+ return { items, isUnparseable: false };
67
116
  }
68
- const commit = {
69
- message: typeof value['message'] === 'string' ? value['message'] : '',
70
- };
71
- if (typeof value['group'] === 'string') {
72
- commit.group = value['group'];
117
+ const classification = classifyChangelogCommit(commit, context.workTypes, {
118
+ breakingPolicies: context.breakingPolicies,
119
+ ...(diagnostics !== undefined && {
120
+ onPolicyViolation: (violating, type, surface) => {
121
+ diagnostics.policyViolations.push({
122
+ commitHash: violating.hash,
123
+ commitSubject: violating.subject,
124
+ type,
125
+ surface,
126
+ });
127
+ },
128
+ }),
129
+ });
130
+ if (classification.kind !== 'header') {
131
+ return { items: [], isUnparseable: classification.kind === 'unparseable' && reading.kind !== 'malformed' };
73
132
  }
74
- if (typeof value['id'] === 'string') {
75
- commit.id = value['id'];
133
+ const item = buildTitleItem(commit, classification.breaking);
134
+ return {
135
+ items: [
136
+ { header: classification.header, item, signal: { type: classification.type, breaking: item.breaking === true } },
137
+ ],
138
+ isUnparseable: false,
139
+ };
140
+ }
141
+ function buildSections(sectionMap, devOnlySections) {
142
+ const sections = [];
143
+ for (const [title, items] of sectionMap) {
144
+ sections.push({
145
+ title,
146
+ audience: devOnlySections.has(stripGroupDecorations(title)) ? 'dev' : 'all',
147
+ items,
148
+ });
76
149
  }
77
- return commit;
150
+ return sections.toSorted((a, b) => canonicalSectionPriority(a.title) - canonicalSectionPriority(b.title));
78
151
  }
79
- function transformReleases(releases, devOnlySections, workTypes, breakingPolicies) {
80
- const entries = [];
81
- const devOnlyNormalised = new Set([...devOnlySections].map(stripGroupDecorations));
82
- for (const release of releases) {
83
- if (release.version === undefined) {
84
- continue;
85
- }
86
- const version = extractVersion(release.version);
87
- const date = release.timestamp !== undefined ? new Date(release.timestamp * 1_000).toISOString().slice(0, 10) : 'unreleased';
88
- const sectionMap = new Map();
89
- const commits = release.commits ?? [];
90
- for (const commit of commits) {
91
- const group = stripCommentPrefix(commit.group ?? 'Other');
92
- const description = extractDescription(commit.message);
93
- const body = extractBody(commit.message);
94
- const breaking = isBreakingUnderPolicy(commit, workTypes, breakingPolicies);
95
- let items = sectionMap.get(group);
96
- if (items === undefined) {
97
- items = [];
98
- sectionMap.set(group, items);
99
- }
100
- const item = { description };
101
- if (body !== undefined) {
102
- item.body = body;
103
- }
104
- if (breaking) {
105
- item.breaking = true;
106
- }
107
- const migration = extractMigration(body);
108
- if (migration !== undefined) {
109
- item.migration = migration;
110
- }
111
- if (commit.id !== undefined && commit.id !== '') {
112
- item.hash = commit.id;
113
- }
114
- items.push(item);
115
- }
116
- const sections = [];
117
- for (const [title, items] of sectionMap) {
118
- if (items.length === 0) {
119
- continue;
120
- }
121
- sections.push({
122
- title,
123
- audience: devOnlyNormalised.has(stripGroupDecorations(title)) ? 'dev' : 'all',
124
- items,
125
- });
126
- }
127
- sections.sort((a, b) => canonicalSectionPriority(a.title) - canonicalSectionPriority(b.title));
128
- if (sections.length > 0) {
129
- entries.push({ version, date, sections });
130
- }
152
+ function formatDate(timestamp) {
153
+ return new Date(timestamp * 1_000).toISOString().slice(0, 10);
154
+ }
155
+ function appendItem(sectionMap, header, item) {
156
+ let items = sectionMap.get(header);
157
+ if (items === undefined) {
158
+ items = [];
159
+ sectionMap.set(header, items);
131
160
  }
132
- return entries;
161
+ items.push(item);
133
162
  }
134
- function stripCommentPrefix(group) {
135
- return group.replace(HTML_COMMENT_PREFIX_PATTERN, '');
163
+ function buildEntryItem(commit, entry, position, prNumber, context, diagnostics) {
164
+ const { breakingPolicies, workTypes } = context;
165
+ const type = resolveType(entry.type, workTypes);
166
+ if (type === undefined) {
167
+ diagnostics?.undeclaredEntryTypes.push({
168
+ commitHash: commit.hash,
169
+ commitSubject: commit.subject,
170
+ entryPosition: position,
171
+ type: entry.type,
172
+ });
173
+ return undefined;
174
+ }
175
+ const config = workTypes[type];
176
+ if (config === undefined || config.excludedFromChangelog === true) {
177
+ return undefined;
178
+ }
179
+ const breaking = evaluateBreakingPolicy({
180
+ commit: { message: commit.message, subject: commit.subject, hash: commit.hash },
181
+ resolvedType: type,
182
+ hasPrefixBreaking: entry.breaking,
183
+ hasFooterBreaking: false,
184
+ policy: breakingPolicies[type] ?? 'optional',
185
+ prefixSurface: 'entry',
186
+ onPolicyViolation: diagnostics === undefined
187
+ ? undefined
188
+ : (violating, violatedType, surface) => {
189
+ diagnostics.policyViolations.push({
190
+ commitHash: violating.hash,
191
+ commitSubject: violating.subject,
192
+ type: violatedType,
193
+ surface,
194
+ entryPosition: position,
195
+ });
196
+ },
197
+ });
198
+ const item = {
199
+ description: prNumber === undefined ? entry.text : `${entry.text} (#${prNumber})`,
200
+ };
201
+ if (breaking) {
202
+ item.breaking = true;
203
+ }
204
+ if (entry.migration !== undefined) {
205
+ item.migration = entry.migration;
206
+ }
207
+ item.hash = commit.hash;
208
+ item.entry = position;
209
+ return { header: config.header, item, signal: { type, breaking } };
136
210
  }
137
- function isBreakingUnderPolicy(commit, workTypes, breakingPolicies) {
138
- if (!subjectHasBreakingMarker(commit.message)) {
139
- return false;
211
+ function buildTitleItem(commit, isParsedBreaking) {
212
+ const body = extractBody(stripChangeRecordBlocks(commit.message));
213
+ const item = { description: extractDescription(commit.message) };
214
+ if (body !== undefined) {
215
+ item.body = body;
216
+ }
217
+ if (isParsedBreaking && subjectHasBreakingMarker(commit.message)) {
218
+ item.breaking = true;
219
+ }
220
+ const migration = extractMigration(body);
221
+ if (migration !== undefined) {
222
+ item.migration = migration;
140
223
  }
141
- const parsed = parseCommitMessage(commit.message, commit.id ?? '', workTypes, undefined, { breakingPolicies });
142
- return parsed?.breaking ?? true;
224
+ item.hash = commit.hash;
225
+ return item;
143
226
  }
144
227
  const SUBJECT_BREAKING_MARKER_PATTERN = new RegExp(String.raw `^(?:${PIPE_SCOPE_SOURCE}\|)?\w+(?:\([^)]+\))?!:`);
145
228
  function subjectHasBreakingMarker(message) {
@@ -158,6 +241,7 @@ function extractDescription(message) {
158
241
  return firstLine;
159
242
  }
160
243
  const TRAILER_PATTERNS = [
244
+ /^Change:/i,
161
245
  /^Signed-off-by:/i,
162
246
  /^Co-authored-by:/i,
163
247
  /^(Closes|Fixes|Resolves)\s+#\d+\s*$/i,
@@ -11,7 +11,7 @@ export function buildReleaseSummary(result) {
11
11
  }
12
12
  const lines = [workspace.tag];
13
13
  for (const commit of commits) {
14
- lines.push(`- ${stripScope(commit.message)}`);
14
+ lines.push(`- ${stripScope(commit.subject)}`);
15
15
  }
16
16
  sections.push(lines.join('\n'));
17
17
  }
@@ -19,7 +19,7 @@ export function buildReleaseSummary(result) {
19
19
  if (project !== undefined && project.status === 'released' && project.commits.length > 0) {
20
20
  const lines = [project.tag];
21
21
  for (const commit of project.commits) {
22
- lines.push(`- ${stripScope(commit.message)}`);
22
+ lines.push(`- ${stripScope(commit.subject)}`);
23
23
  }
24
24
  sections.push(lines.join('\n'));
25
25
  }