@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.
Files changed (69) hide show
  1. package/.meta/changelog.json +62 -2
  2. package/.readyup/kits/default.js +1 -17
  3. package/.readyup/manifest.json +4 -4
  4. package/CHANGELOG.md +40 -0
  5. package/README.md +25 -44
  6. package/dist/esm/attachChangelogDiagnostics.d.ts +4 -0
  7. package/dist/esm/attachChangelogDiagnostics.js +11 -0
  8. package/dist/esm/bin/release-kit.js +4 -3
  9. package/dist/esm/buildChangelogEntries.d.ts +59 -17
  10. package/dist/esm/buildChangelogEntries.js +194 -111
  11. package/dist/esm/buildReleaseSummary.js +2 -2
  12. package/dist/esm/changelogOverrides.d.ts +31 -13
  13. package/dist/esm/changelogOverrides.js +138 -58
  14. package/dist/esm/classifyChangelogCommit.d.ts +38 -0
  15. package/dist/esm/classifyChangelogCommit.js +33 -0
  16. package/dist/esm/decideRelease.d.ts +11 -37
  17. package/dist/esm/decideRelease.js +5 -27
  18. package/dist/esm/defaults.d.ts +1 -1
  19. package/dist/esm/defaults.js +3 -0
  20. package/dist/esm/determineBumpType.d.ts +11 -11
  21. package/dist/esm/determineBumpType.js +6 -7
  22. package/dist/esm/enumerateReleaseWindows.d.ts +50 -0
  23. package/dist/esm/enumerateReleaseWindows.js +124 -0
  24. package/dist/esm/generateChangelogs.d.ts +5 -23
  25. package/dist/esm/generateChangelogs.js +0 -14
  26. package/dist/esm/glyphs.d.ts +1 -1
  27. package/dist/esm/help/prepareHelp.d.ts +1 -1
  28. package/dist/esm/help/prepareHelp.js +4 -6
  29. package/dist/esm/index.d.ts +1 -1
  30. package/dist/esm/init/initCommand.js +1 -1
  31. package/dist/esm/init/scaffold.d.ts +0 -2
  32. package/dist/esm/init/scaffold.js +2 -30
  33. package/dist/esm/loadConfig.d.ts +1 -1
  34. package/dist/esm/loadConfig.js +1 -4
  35. package/dist/esm/parseChangeRecordBlock.d.ts +37 -0
  36. package/dist/esm/parseChangeRecordBlock.js +134 -0
  37. package/dist/esm/parseCommitMessage.d.ts +31 -6
  38. package/dist/esm/parseCommitMessage.js +6 -5
  39. package/dist/esm/prepareCommand.js +4 -12
  40. package/dist/esm/releasePrepare.d.ts +7 -8
  41. package/dist/esm/releasePrepare.js +53 -73
  42. package/dist/esm/releasePrepareMono.js +48 -85
  43. package/dist/esm/releasePrepareProject.d.ts +1 -1
  44. package/dist/esm/releasePrepareProject.js +19 -37
  45. package/dist/esm/reportPrepare.js +39 -28
  46. package/dist/esm/types.d.ts +99 -55
  47. package/dist/esm/types.js +1 -1
  48. package/dist/esm/validateConfig.js +4 -0
  49. package/dist/esm/validateOverridesCommand.d.ts +6 -5
  50. package/dist/esm/validateOverridesCommand.js +16 -23
  51. package/docs/changelogs.md +64 -25
  52. package/docs/configuration.md +2 -3
  53. package/docs/editorial-overrides.md +75 -16
  54. package/docs/project-releases.md +4 -4
  55. package/docs/releasing.md +13 -8
  56. package/docs/work-types.md +13 -9
  57. package/package.json +1 -2
  58. package/schemas/changelog-overrides.json +39 -0
  59. package/cliff.toml.template +0 -65
  60. package/dist/esm/collectPolicyViolations.d.ts +0 -15
  61. package/dist/esm/collectPolicyViolations.js +0 -12
  62. package/dist/esm/determineBumpFromCommits.d.ts +0 -17
  63. package/dist/esm/determineBumpFromCommits.js +0 -28
  64. package/dist/esm/getCommitsSinceTarget.d.ts +0 -21
  65. package/dist/esm/getCommitsSinceTarget.js +0 -66
  66. package/dist/esm/resolveCliffConfigPath.d.ts +0 -11
  67. package/dist/esm/resolveCliffConfigPath.js +0 -20
  68. package/dist/esm/runGitCliff.d.ts +0 -38
  69. package/dist/esm/runGitCliff.js +0 -25
@@ -0,0 +1,124 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { GIT_OUTPUT_LIMIT } from '@williamthorsen/nmr-core';
3
+ import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
4
+ const FIELD_SEPARATOR = '\u{1F}';
5
+ const RECORD_SEPARATOR = '\u{1E}';
6
+ export function enumerateReleaseWindows(options) {
7
+ const { now = Date.now, paths, tagPrefixes, unreleasedTag } = options;
8
+ if (tagPrefixes.length === 0) {
9
+ throw new Error('enumerateReleaseWindows: tagPrefixes must contain at least one entry');
10
+ }
11
+ const unreleasedWindow = {
12
+ version: unreleasedTag,
13
+ timestamp: Math.floor(now() / 1_000),
14
+ commits: [],
15
+ };
16
+ const ancestry = readAncestry();
17
+ if (ancestry.parentsByHash.size === 0) {
18
+ return [unreleasedWindow];
19
+ }
20
+ const boundaries = readTagBoundaries(tagPrefixes, ancestry.positionByHash);
21
+ const releasedWindows = boundaries.map((boundary) => ({
22
+ version: boundary.tag,
23
+ timestamp: boundary.timestamp,
24
+ commits: [],
25
+ }));
26
+ const windowIndexByHash = claimAncestors(boundaries, ancestry.parentsByHash);
27
+ for (const commit of readCommits(paths).toReversed()) {
28
+ const windowIndex = windowIndexByHash.get(commit.hash);
29
+ const window = windowIndex === undefined ? unreleasedWindow : (releasedWindows[windowIndex] ?? unreleasedWindow);
30
+ window.commits.push(commit);
31
+ }
32
+ return [unreleasedWindow, ...releasedWindows.toReversed()];
33
+ }
34
+ function claimAncestors(boundaries, parentsByHash) {
35
+ const windowIndexByHash = new Map();
36
+ for (const [windowIndex, boundary] of boundaries.entries()) {
37
+ const pending = [boundary.hash];
38
+ while (pending.length > 0) {
39
+ const hash = pending.pop();
40
+ if (hash === undefined || windowIndexByHash.has(hash)) {
41
+ continue;
42
+ }
43
+ windowIndexByHash.set(hash, windowIndex);
44
+ pending.push(...(parentsByHash.get(hash) ?? []));
45
+ }
46
+ }
47
+ return windowIndexByHash;
48
+ }
49
+ function matchesTagPrefix(tagName, tagPrefixes) {
50
+ return tagPrefixes.some((prefix) => tagName.startsWith(prefix) && /^\d/.test(tagName.slice(prefix.length)));
51
+ }
52
+ function parseCommitRecord(record) {
53
+ const [hash, subject, message] = record.replace(/^\n+/, '').split(FIELD_SEPARATOR);
54
+ if (hash === undefined || subject === undefined || message === undefined || hash === '') {
55
+ return undefined;
56
+ }
57
+ const trimmedMessage = message.replace(/\s+$/, '');
58
+ const bodyStart = trimmedMessage.indexOf('\n\n');
59
+ const body = bodyStart === -1 ? '' : trimmedMessage.slice(bodyStart + 2);
60
+ return { hash, subject, body, message: trimmedMessage };
61
+ }
62
+ function readAncestry() {
63
+ const args = ['rev-list', '--topo-order', '--parents', '--ignore-missing', 'HEAD'];
64
+ const output = runGit(args, `'git rev-list' for HEAD`);
65
+ const ancestry = { parentsByHash: new Map(), positionByHash: new Map() };
66
+ for (const [position, line] of output.split('\n').entries()) {
67
+ const [hash, ...parents] = line.split(' ');
68
+ if (hash === undefined || hash === '') {
69
+ continue;
70
+ }
71
+ ancestry.parentsByHash.set(hash, parents);
72
+ ancestry.positionByHash.set(hash, position);
73
+ }
74
+ return ancestry;
75
+ }
76
+ function readCommits(paths) {
77
+ const format = `%H${FIELD_SEPARATOR}%s${FIELD_SEPARATOR}%B${RECORD_SEPARATOR}`;
78
+ const args = ['log', 'HEAD', `--pretty=format:${format}`];
79
+ if (paths !== undefined && paths.length > 0) {
80
+ args.push('--', ...paths);
81
+ }
82
+ const output = runGit(args, `'git log' for HEAD`);
83
+ const commits = [];
84
+ for (const record of output.split(RECORD_SEPARATOR)) {
85
+ const commit = parseCommitRecord(record);
86
+ if (commit !== undefined) {
87
+ commits.push(commit);
88
+ }
89
+ }
90
+ return commits;
91
+ }
92
+ function readTagBoundaries(tagPrefixes, positionByHash) {
93
+ const fields = ['%(refname:strip=2)', '%(creatordate:unix)', '%(objectname)', '%(*objectname)'];
94
+ const args = ['for-each-ref', `--format=${fields.join('%1f')}`, 'refs/tags'];
95
+ const output = runGit(args, `'git for-each-ref' for refs/tags`);
96
+ const boundaries = [];
97
+ for (const line of output.split('\n')) {
98
+ const [tag, creatorDate, objectName, dereferencedName] = line.split(FIELD_SEPARATOR);
99
+ if (tag === undefined || creatorDate === undefined || objectName === undefined) {
100
+ continue;
101
+ }
102
+ if (!matchesTagPrefix(tag, tagPrefixes)) {
103
+ continue;
104
+ }
105
+ const hash = dereferencedName || objectName;
106
+ const position = positionByHash.get(hash);
107
+ if (position !== undefined) {
108
+ boundaries.push({ hash, position, tag, timestamp: Number(creatorDate) });
109
+ }
110
+ }
111
+ return boundaries.toSorted((left, right) => right.position - left.position);
112
+ }
113
+ function runGit(args, description) {
114
+ try {
115
+ return execFileSync('git', args, {
116
+ encoding: 'utf8',
117
+ maxBuffer: GIT_OUTPUT_LIMIT,
118
+ stdio: ['pipe', 'pipe', 'pipe'],
119
+ }).trim();
120
+ }
121
+ catch (error) {
122
+ throw chainError(`Failed to run ${description}`, error);
123
+ }
124
+ }
@@ -1,32 +1,14 @@
1
1
  import type { WorkspaceConfig } from './types.js';
2
- /**
3
- * Build a git-cliff tag pattern from one or more tag prefixes.
4
- *
5
- * Single-prefix input preserves the historical `<prefix>[0-9].*` shape. Multi-prefix input
6
- * returns `(prefix1|prefix2|...)[0-9].*` so git-cliff's regex-based `--tag-pattern` accepts
7
- * tags under any listed prefix. Each prefix is regex-escaped so metacharacters in operator-
8
- * supplied legacy prefixes cannot alter the pattern's meaning.
9
- *
10
- * @param tagPrefixes - Tag prefixes to match as a union (must contain at least one entry).
11
- */
12
- export declare function buildTagPattern(tagPrefixes: readonly string[]): string;
13
2
  /**
14
3
  * Return the workspace's derived tag prefix followed by each declared legacy-identity tag
15
4
  * prefix. Shared by `releasePrepareMono` (the production prepare path) and
16
5
  * `validateOverridesCommand` so the two compute byte-equal per-workspace tag-prefix unions.
17
6
  */
18
7
  export declare function getAllTagPrefixes(workspace: WorkspaceConfig): string[];
19
- /**
20
- * Options for `buildChangelogEntries`-driven flows that consume git-cliff output.
21
- *
22
- * The fields here mirror the subset of git-cliff CLI options that the in-package pipeline
23
- * still passes through (tag-pattern union, include-path filtering for monorepo workspaces).
24
- * `generateChangelog` and `generateChangelogs` (the prior cliff `--output` invokers) are
25
- * removed; markdown rendering is now handled by `renderChangelogMarkdown.ts`.
26
- */
8
+ /** Options for `buildChangelogEntries`: the tag prefixes and pathspecs that bound its release windows. */
27
9
  export interface GenerateChangelogOptions {
28
- /** Tag pattern to match release tags (passed as --tag-pattern to git-cliff). */
29
- tagPattern?: string;
30
- /** Paths to include in the changelog (passed as --include-path to git-cliff). */
31
- includePaths?: string[];
10
+ /** Tag prefixes to match as a union; must contain at least one entry. */
11
+ tagPrefixes: readonly string[];
12
+ /** Git pathspecs restricting the history to commits that touch them; all paths when omitted. */
13
+ paths?: readonly string[];
32
14
  }
@@ -1,17 +1,3 @@
1
- export function buildTagPattern(tagPrefixes) {
2
- if (tagPrefixes.length === 0) {
3
- throw new Error('buildTagPattern: tagPrefixes must contain at least one entry');
4
- }
5
- if (tagPrefixes.length === 1) {
6
- const single = tagPrefixes[0] ?? '';
7
- return `${single}[0-9].*`;
8
- }
9
- const escaped = tagPrefixes.map(escapeRegex);
10
- return `(${escaped.join('|')})[0-9].*`;
11
- }
12
1
  export function getAllTagPrefixes(workspace) {
13
2
  return [workspace.tagPrefix, ...(workspace.legacyIdentities?.map((identity) => identity.tagPrefix) ?? [])];
14
3
  }
15
- function escapeRegex(value) {
16
- return value.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
17
- }
@@ -1,3 +1,3 @@
1
1
  /** Glyphs that name no status; a status marker comes from nmr-core's `STATUS_GLYPHS`. */
2
- export declare const RELEASE_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"bump" | "dryRun" | "tag", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
2
+ export declare const RELEASE_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"tag" | "bump" | "dryRun", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
3
3
  export type ReleaseGlyphName = keyof (typeof RELEASE_GLYPHS)['rich'];
@@ -3,6 +3,6 @@
3
3
  * dispatcher and asserted by the help drift guard test; kept free of import-time side
4
4
  * effects so both can load it.
5
5
  */
6
- export declare const prepareHelpText = "\nUsage: release-kit prepare [options]\n\nRun release preparation with automatic workspace discovery.\n\nOptions:\n --config <path> Path to config file (default: .config/release-kit.config.ts)\n --dry-run Run without modifying any files\n --bump=major|minor|patch Override the bump type for all workspaces\n --set-version=X.Y.Z Set an explicit version; bypasses commit-derived bumps. In monorepo\n mode it requires --only, and is rejected when a 'project' block is configured.\n --force Release even when no commits or no bump-worthy commits exist\n since the last tag. In monorepo and project mode, defaults to\n patch when --bump is not given; use --bump=X for a different\n level. In single-package mode, a bare --force is rejected \u2014\n pass --bump=major|minor|patch.\n --no-git-checks, -n Skip the clean-working-tree check\n --only=name1,name2 Only process the named workspaces (comma-separated, monorepo only).\n When a 'project' block is configured, the project release is skipped.\n --with-release-notes Also write per-workspace release-notes previews under <workspacePath>/docs/\n (docs/README.v<version>.md and docs/RELEASE_NOTES.v<version>.md).\n Recommended .gitignore entry: packages/*/docs/*.v*.md (or docs/*.v*.md).\n --help, -h Show this help message\n";
6
+ export declare const prepareHelpText = "\nUsage: release-kit prepare [options]\n\nRun release preparation with automatic workspace discovery.\n\nOptions:\n --config <path> Path to config file (default: .config/release-kit.config.ts)\n --dry-run Run without modifying any files\n --bump=major|minor|patch Set the level of every release that happens; does not trigger one\n --set-version=X.Y.Z Set an explicit version; bypasses the changelog-derived bump. In monorepo\n mode it requires --only, and is rejected when a 'project' block is configured.\n --force Release even when no commits or no bump-worthy commits exist\n since the last tag. Defaults to patch when --bump is not given;\n use --bump=X for a different level.\n --no-git-checks, -n Skip the clean-working-tree check\n --only=name1,name2 Only process the named workspaces (comma-separated, monorepo only).\n When a 'project' block is configured, the project release is skipped.\n --with-release-notes Also write per-workspace release-notes previews under <workspacePath>/docs/\n (docs/README.v<version>.md and docs/RELEASE_NOTES.v<version>.md).\n Recommended .gitignore entry: packages/*/docs/*.v*.md (or docs/*.v*.md).\n --help, -h Show this help message\n";
7
7
  /** Prints the prepare command's help text. */
8
8
  export declare function showPrepareHelp(): void;
@@ -6,14 +6,12 @@ Run release preparation with automatic workspace discovery.
6
6
  Options:
7
7
  --config <path> Path to config file (default: .config/release-kit.config.ts)
8
8
  --dry-run Run without modifying any files
9
- --bump=major|minor|patch Override the bump type for all workspaces
10
- --set-version=X.Y.Z Set an explicit version; bypasses commit-derived bumps. In monorepo
9
+ --bump=major|minor|patch Set the level of every release that happens; does not trigger one
10
+ --set-version=X.Y.Z Set an explicit version; bypasses the changelog-derived bump. In monorepo
11
11
  mode it requires --only, and is rejected when a 'project' block is configured.
12
12
  --force Release even when no commits or no bump-worthy commits exist
13
- since the last tag. In monorepo and project mode, defaults to
14
- patch when --bump is not given; use --bump=X for a different
15
- level. In single-package mode, a bare --force is rejected —
16
- pass --bump=major|minor|patch.
13
+ since the last tag. Defaults to patch when --bump is not given;
14
+ use --bump=X for a different level.
17
15
  --no-git-checks, -n Skip the clean-working-tree check
18
16
  --only=name1,name2 Only process the named workspaces (comma-separated, monorepo only).
19
17
  When a 'project' block is configured, the project release is skipped.
@@ -1,2 +1,2 @@
1
- export { type ChangelogOverrideScope, validateAllChangelogOverrides, type ValidateAllChangelogOverridesInputs, type ValidateAllChangelogOverridesResult, } from './changelogOverrides.js';
1
+ export { type ChangelogOverrideScope, type OverrideTargetItem, validateAllChangelogOverrides, type ValidateAllChangelogOverridesInputs, type ValidateAllChangelogOverridesResult, } from './changelogOverrides.js';
2
2
  export { formatValidateOverridesResult, type ValidateOverridesCommandResult } from './validateOverridesCommand.js';
@@ -62,7 +62,7 @@ export function initCommand({ dryRun, force, styles, withConfig }) {
62
62
  }
63
63
  printStep('Next steps');
64
64
  const configHint = withConfig
65
- ? '1. (Optional) Customize .config/release-kit.config.ts and .config/git-cliff.toml.'
65
+ ? '1. (Optional) Customize .config/release-kit.config.ts.'
66
66
  : '1. (Optional) Run again with --with-config to scaffold config files.';
67
67
  console.info(`
68
68
  ${configHint}
@@ -6,8 +6,6 @@ interface ScaffoldOptions {
6
6
  overwrite: boolean;
7
7
  withConfig: boolean;
8
8
  }
9
- /** Copy the bundled cliff.toml.template to `.config/git-cliff.toml` in the target repo. */
10
- export declare function copyCliffTemplate(dryRun: boolean, overwrite: boolean): WriteResult;
11
9
  /** Scaffold release-kit files for the target repo. Returns a result for each file attempted. */
12
10
  export declare function scaffoldFiles({ repoType, dryRun, overwrite, withConfig }: ScaffoldOptions): WriteResult[];
13
11
  export {};
@@ -1,33 +1,5 @@
1
- import { readFileSync } from 'node:fs';
2
- import { resolve } from 'node:path';
3
- import { findPackageRoot, writeFileWithCheck } from '@williamthorsen/nmr-core';
4
- import { describeError } from '@williamthorsen/toolbelt.errors';
1
+ import { writeFileWithCheck } from '@williamthorsen/nmr-core';
5
2
  import { createGithubReleaseWorkflow, publishWorkflow, releaseConfigScript, releaseWorkflow } from "./templates.js";
6
- export function copyCliffTemplate(dryRun, overwrite) {
7
- const destPath = '.config/git-cliff.toml';
8
- let root;
9
- try {
10
- root = findPackageRoot(import.meta.url);
11
- }
12
- catch (error) {
13
- const message = describeError(error);
14
- return { filePath: destPath, outcome: 'failed', error: `Failed to resolve package root: ${message}` };
15
- }
16
- const templatePath = resolve(root, 'cliff.toml.template');
17
- let content;
18
- try {
19
- content = readFileSync(templatePath, 'utf8');
20
- }
21
- catch (error) {
22
- const message = describeError(error);
23
- return {
24
- filePath: destPath,
25
- outcome: 'failed',
26
- error: `Failed to read bundled template at ${templatePath}: ${message}`,
27
- };
28
- }
29
- return writeFileWithCheck(destPath, content, { dryRun, overwrite });
30
- }
31
3
  export function scaffoldFiles({ repoType, dryRun, overwrite, withConfig }) {
32
4
  const results = [
33
5
  writeFileWithCheck('.github/workflows/create-github-release.yaml', createGithubReleaseWorkflow(repoType), {
@@ -38,7 +10,7 @@ export function scaffoldFiles({ repoType, dryRun, overwrite, withConfig }) {
38
10
  writeFileWithCheck('.github/workflows/release.yaml', releaseWorkflow(repoType), { dryRun, overwrite }),
39
11
  ];
40
12
  if (withConfig) {
41
- results.push(writeFileWithCheck('.config/release-kit.config.ts', releaseConfigScript(repoType), { dryRun, overwrite }), copyCliffTemplate(dryRun, overwrite));
13
+ results.push(writeFileWithCheck('.config/release-kit.config.ts', releaseConfigScript(repoType), { dryRun, overwrite }));
42
14
  }
43
15
  return results;
44
16
  }
@@ -40,7 +40,7 @@ export interface RootPackageInfo {
40
40
  * removes the workspace; unlisted packages keep defaults.
41
41
  * - `workTypes`: shallow merge — consumer entries override or add to defaults by key.
42
42
  * - `versionPatterns`: consumer value replaces defaults entirely.
43
- * - `formatCommand`, `cliffConfigPath`, `scopeAliases`: consumer value wins.
43
+ * - `formatCommand`, `scopeAliases`: consumer value wins.
44
44
  * - `project`: present iff `userConfig.project` is declared. Resolves `tagPrefix` to
45
45
  * `DEFAULT_PROJECT_TAG_PREFIX` and `paths` to the union of the retained workspaces' `paths`
46
46
  * when omitted. Requires `rootPackage` to be passed and to contain a valid `version` field;
@@ -99,9 +99,6 @@ function applyOptionalPassthroughFields(result, userConfig) {
99
99
  if (userConfig?.formatCommand !== undefined) {
100
100
  result.formatCommand = userConfig.formatCommand;
101
101
  }
102
- if (userConfig?.cliffConfigPath !== undefined) {
103
- result.cliffConfigPath = userConfig.cliffConfigPath;
104
- }
105
102
  if (userConfig?.scopeAliases !== undefined) {
106
103
  result.scopeAliases = userConfig.scopeAliases;
107
104
  }
@@ -215,7 +212,7 @@ function assertNoTagPrefixCollisions(workspaces, retiredPackages, project) {
215
212
  if (isPrefixCollision(a.prefix, b.prefix)) {
216
213
  throw new Error(`Tag prefix collision: '${a.prefix}' (${a.label}) and '${b.prefix}' (${b.label}). ` +
217
214
  'One prefix is identical to or a strict prefix of the other; ' +
218
- 'this would cause `git describe --match=<prefix>*` to return cross-matches.');
215
+ 'a tag under the longer prefix can then match the shorter one, whose releases would absorb it.');
219
216
  }
220
217
  }
221
218
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Reads the last `change-record` block in a commit message into the change entries that it records.
3
+ *
4
+ * The block's grammar is codeassembly's `change-record.md`: a fence whose info string is exactly `change-record`,
5
+ * containing a YAML mapping. A merge commit's block carries `entries` plus the top-level `pr_number` and `ticket_ref`.
6
+ * A key that the grammar does not declare is ignored, and a declared key whose value is null reads as absent. Any
7
+ * defect makes the whole block malformed; no entry is salvaged from a defective list.
8
+ */
9
+ export declare function parseChangeRecordBlock(message: string): ChangeRecordBlockReading;
10
+ /**
11
+ * Removes every `change-record` block from a message, fences included. A block that never closes runs to the end of
12
+ * the message, as a Markdown renderer reads it.
13
+ */
14
+ export declare function stripChangeRecordBlocks(message: string): string;
15
+ /**
16
+ * What a commit's last `change-record` block reads as: absent, malformed with the defect named, or the entries that
17
+ * it records with the merge's pull-request number and ticket reference when the block declares them.
18
+ */
19
+ export type ChangeRecordBlockReading = {
20
+ kind: 'absent';
21
+ } | {
22
+ kind: 'malformed';
23
+ reason: string;
24
+ } | {
25
+ kind: 'read';
26
+ entries: ChangeRecordEntry[];
27
+ prNumber?: number;
28
+ ticketRef?: string;
29
+ };
30
+ /** One outcome of a change, as the block records it. */
31
+ export interface ChangeRecordEntry {
32
+ breaking: boolean;
33
+ migration?: string;
34
+ scopes: string[];
35
+ text: string;
36
+ type: string;
37
+ }
@@ -0,0 +1,134 @@
1
+ import { parseDocument } from 'yaml';
2
+ import { isRecord } from "./typeGuards.js";
3
+ export function parseChangeRecordBlock(message) {
4
+ const lines = message.split(/\r?\n/);
5
+ const fence = findFences(lines).at(-1);
6
+ if (fence === undefined) {
7
+ return { kind: 'absent' };
8
+ }
9
+ if (fence.close === undefined) {
10
+ return { kind: 'malformed', reason: 'the block opens but never closes' };
11
+ }
12
+ const document = parseDocument(lines.slice(fence.open + 1, fence.close).join('\n'));
13
+ const [error] = document.errors;
14
+ if (error !== undefined) {
15
+ const detail = (error.message.split('\n', 1)[0] ?? '').replace(/:$/, '');
16
+ return { kind: 'malformed', reason: `the payload is not valid YAML: ${detail}` };
17
+ }
18
+ return readPayload(document.toJS());
19
+ }
20
+ export function stripChangeRecordBlocks(message) {
21
+ const lines = message.split(/\r?\n/);
22
+ const fences = findFences(lines);
23
+ return lines
24
+ .filter((_line, index) => fences.every((fence) => index < fence.open || index > (fence.close ?? lines.length)))
25
+ .join('\n');
26
+ }
27
+ const FENCE = '```';
28
+ function findFences(lines) {
29
+ const fences = [];
30
+ let open;
31
+ for (const [index, line] of lines.entries()) {
32
+ if (open === undefined) {
33
+ if (line.trim() === `${FENCE}${INFO_STRING}`) {
34
+ open = index;
35
+ }
36
+ }
37
+ else if (line.trim() === FENCE) {
38
+ fences.push({ close: index, open });
39
+ open = undefined;
40
+ }
41
+ }
42
+ if (open !== undefined) {
43
+ fences.push({ open });
44
+ }
45
+ return fences;
46
+ }
47
+ const INFO_STRING = 'change-record';
48
+ function readEntry(item, index) {
49
+ const at = `entries[${index}]`;
50
+ if (!isRecord(item)) {
51
+ return { reason: `\`${at}\` is not a mapping` };
52
+ }
53
+ const { breaking, migration, scopes } = item;
54
+ if (breaking !== undefined && breaking !== null && typeof breaking !== 'boolean') {
55
+ return { reason: `\`${at}.breaking\` is not a boolean` };
56
+ }
57
+ if (migration !== undefined && migration !== null && typeof migration !== 'string') {
58
+ return { reason: `\`${at}.migration\` is not a string` };
59
+ }
60
+ if (scopes !== undefined && scopes !== null && !Array.isArray(scopes)) {
61
+ return { reason: `\`${at}.scopes\` is not a list` };
62
+ }
63
+ const declaredScopes = scopes ?? [];
64
+ const readScopes = [];
65
+ for (const [position, scope] of declaredScopes.entries()) {
66
+ if (typeof scope !== 'string') {
67
+ return { reason: `\`${at}.scopes[${position}]\` is not a string` };
68
+ }
69
+ readScopes.push(scope.trim());
70
+ }
71
+ const type = readRequiredString(item, at, 'type');
72
+ if ('reason' in type) {
73
+ return type;
74
+ }
75
+ const text = readRequiredString(item, at, 'text');
76
+ if ('reason' in text) {
77
+ return text;
78
+ }
79
+ const trimmedMigration = migration?.trim();
80
+ return {
81
+ entry: {
82
+ breaking: breaking === true,
83
+ ...(trimmedMigration !== undefined && trimmedMigration !== '' && { migration: trimmedMigration }),
84
+ scopes: readScopes,
85
+ text: text.value,
86
+ type: type.value,
87
+ },
88
+ };
89
+ }
90
+ function readPayload(payload) {
91
+ if (!isRecord(payload)) {
92
+ return { kind: 'malformed', reason: 'the payload is not a mapping' };
93
+ }
94
+ const { entries, pr_number: prNumber, ticket_ref: ticketRef } = payload;
95
+ if (prNumber !== undefined &&
96
+ prNumber !== null &&
97
+ (typeof prNumber !== 'number' || !Number.isSafeInteger(prNumber) || prNumber <= 0)) {
98
+ return { kind: 'malformed', reason: '`pr_number` is not a positive integer' };
99
+ }
100
+ if (ticketRef !== undefined && ticketRef !== null && typeof ticketRef !== 'string') {
101
+ return { kind: 'malformed', reason: '`ticket_ref` is not a string' };
102
+ }
103
+ const readEntries = [];
104
+ if (entries !== undefined && entries !== null) {
105
+ if (!Array.isArray(entries)) {
106
+ return { kind: 'malformed', reason: '`entries` is not a list' };
107
+ }
108
+ for (const [index, item] of entries.entries()) {
109
+ const read = readEntry(item, index);
110
+ if ('reason' in read) {
111
+ return { kind: 'malformed', reason: read.reason };
112
+ }
113
+ readEntries.push(read.entry);
114
+ }
115
+ }
116
+ const trimmedTicketRef = ticketRef?.trim();
117
+ return {
118
+ kind: 'read',
119
+ entries: readEntries,
120
+ ...(typeof prNumber === 'number' && { prNumber }),
121
+ ...(trimmedTicketRef !== undefined && trimmedTicketRef !== '' && { ticketRef: trimmedTicketRef }),
122
+ };
123
+ }
124
+ function readRequiredString(item, at, name) {
125
+ const value = item[name];
126
+ if (value === undefined || value === null) {
127
+ return { reason: `\`${at}.${name}\` is missing` };
128
+ }
129
+ if (typeof value !== 'string') {
130
+ return { reason: `\`${at}.${name}\` is not a string` };
131
+ }
132
+ const trimmed = value.trim();
133
+ return trimmed === '' ? { reason: `\`${at}.${name}\` is blank` } : { value: trimmed };
134
+ }
@@ -6,18 +6,20 @@ export declare const COMMIT_PREPROCESSOR_PATTERNS: readonly RegExp[];
6
6
  *
7
7
  * Deliberately permissive: `*` is the sanctioned structural scope for changes spanning every
8
8
  * workspace, so the atom admits any run of non-pipe characters rather than word characters.
9
- * `cliff.toml.template` embeds the same atom in every ticketed commit parser, and
10
- * `cliffConfigAlignment.unit.test.ts` asserts that parity — exported so neither side can drift.
9
+ * Exported because `buildChangelogEntries.ts` builds its breaking-marker pattern from the same atom.
11
10
  */
12
11
  export declare const PIPE_SCOPE_SOURCE = "[^|]+";
13
- /** Surface where a `!`/`BREAKING CHANGE:` policy violation was detected. */
14
- export type PolicyViolationSurface = 'prefix' | 'body';
12
+ /**
13
+ * Surface where a breaking-policy violation was detected: a subject's `!`, a `BREAKING CHANGE:` footer, or a
14
+ * change-record entry's `breaking`.
15
+ */
16
+ export type PolicyViolationSurface = 'prefix' | 'body' | 'entry';
15
17
  /**
16
18
  * Callback invoked when `parseCommitMessage` detects a `!`-policy violation.
17
19
  *
18
20
  * The parser warns and continues on a violation, so legacy log entries don't block releases.
19
- * Callers (`decideRelease`, `releasePrepareMono`, etc.) collect callback invocations and
20
- * surface them in the release report.
21
+ * `readReleaseHistory` collects the invocations for the unreleased window, and the release
22
+ * report lists them.
21
23
  */
22
24
  export type PolicyViolationHandler = (commit: Commit, type: string, surface: PolicyViolationSurface) => void;
23
25
  /** Optional configuration for `parseCommitMessage` beyond the core inputs. */
@@ -49,3 +51,26 @@ export interface ParseCommitMessageOptions {
49
51
  * same warning path.
50
52
  */
51
53
  export declare function parseCommitMessage(message: string, hash: string, workTypes: Record<string, WorkTypeConfig>, scopeAliases?: Record<string, string>, options?: ParseCommitMessageOptions): ParsedCommit | undefined;
54
+ /** Inputs for {@link evaluateBreakingPolicy}. */
55
+ export interface BreakingPolicyInputs {
56
+ commit: Commit;
57
+ resolvedType: string;
58
+ /** Whether the marker that `prefixSurface` names is present. */
59
+ hasPrefixBreaking: boolean;
60
+ hasFooterBreaking: boolean;
61
+ policy: 'forbidden' | 'optional' | 'required';
62
+ onPolicyViolation: PolicyViolationHandler | undefined;
63
+ /** The surface reported for a violation of the marker; `'prefix'` when omitted. */
64
+ prefixSurface?: PolicyViolationSurface;
65
+ }
66
+ /**
67
+ * Applies the `!`-policy rules and returns the effective `breaking` flag.
68
+ *
69
+ * A violation invokes `onPolicyViolation` and drops the marker (`breaking: false`); the
70
+ * commit still parses.
71
+ */
72
+ export declare function evaluateBreakingPolicy(inputs: BreakingPolicyInputs): boolean;
73
+ /**
74
+ * Resolves a raw type string to its canonical type name using the record keys and aliases.
75
+ */
76
+ export declare function resolveType(rawType: string, workTypes: Record<string, WorkTypeConfig>): string | undefined;
@@ -20,7 +20,7 @@ export function parseCommitMessage(message, hash, workTypes, scopeAliases, optio
20
20
  if (resolvedType === undefined) {
21
21
  return undefined;
22
22
  }
23
- const commit = { message, hash };
23
+ const commit = { message, subject: firstLine, hash };
24
24
  const breaking = evaluateBreakingPolicy({
25
25
  commit,
26
26
  resolvedType,
@@ -40,11 +40,12 @@ export function parseCommitMessage(message, hash, workTypes, scopeAliases, optio
40
40
  ...(resolvedScope !== undefined && { scope: resolvedScope }),
41
41
  };
42
42
  }
43
- function evaluateBreakingPolicy(inputs) {
43
+ export function evaluateBreakingPolicy(inputs) {
44
44
  const { commit, resolvedType, hasPrefixBreaking, hasFooterBreaking, policy, onPolicyViolation } = inputs;
45
+ const prefixSurface = inputs.prefixSurface ?? 'prefix';
45
46
  if (policy === 'forbidden') {
46
47
  if (hasPrefixBreaking) {
47
- onPolicyViolation?.(commit, resolvedType, 'prefix');
48
+ onPolicyViolation?.(commit, resolvedType, prefixSurface);
48
49
  }
49
50
  if (hasFooterBreaking) {
50
51
  onPolicyViolation?.(commit, resolvedType, 'body');
@@ -53,14 +54,14 @@ function evaluateBreakingPolicy(inputs) {
53
54
  }
54
55
  if (policy === 'required') {
55
56
  if (!hasPrefixBreaking) {
56
- onPolicyViolation?.(commit, resolvedType, 'prefix');
57
+ onPolicyViolation?.(commit, resolvedType, prefixSurface);
57
58
  return false;
58
59
  }
59
60
  return true;
60
61
  }
61
62
  return hasPrefixBreaking || hasFooterBreaking;
62
63
  }
63
- function resolveType(rawType, workTypes) {
64
+ export function resolveType(rawType, workTypes) {
64
65
  const lowered = rawType.toLowerCase();
65
66
  for (const [key, config] of Object.entries(workTypes)) {
66
67
  if (key === lowered) {
@@ -2,11 +2,12 @@ import { execSync } from 'node:child_process';
2
2
  import { formatGlyphLine, parseArgsOrExit, reportError, } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
4
  import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
5
+ import { readReleaseHistory } from "./buildChangelogEntries.js";
5
6
  import { buildDependencyGraph } from "./buildDependencyGraph.js";
6
7
  import { configFlagSchema } from "./configFlagSchema.js";
7
8
  import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
8
9
  import { dim } from "./format.js";
9
- import { getCommitsSinceTarget } from "./getCommitsSinceTarget.js";
10
+ import { getAllTagPrefixes } from "./generateChangelogs.js";
10
11
  import { RELEASE_GLYPHS } from "./glyphs.js";
11
12
  import { mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
12
13
  import { loadValidatedConfig, reportConfigProblem, reportConfigWarnings } from "./loadValidatedConfig.js";
@@ -130,11 +131,6 @@ function runSinglePackageMode(userConfig, options, only, dryRun, style) {
130
131
  reportError('--only is only supported for monorepo configurations');
131
132
  process.exit(1);
132
133
  }
133
- if (options.force && options.bumpOverride === undefined) {
134
- reportError('--force without --bump is only supported for monorepo configurations. ' +
135
- 'Use --bump=major|minor|patch to set the level for a single-package release.');
136
- process.exit(1);
137
- }
138
134
  const config = mergeSinglePackageConfig(userConfig);
139
135
  runAndReport(() => releasePrepare(config, options), dryRun, style);
140
136
  }
@@ -165,12 +161,8 @@ function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion,
165
161
  }
166
162
  const graph = buildDependencyGraph(config.workspaces);
167
163
  const violations = validateOnlyExcludesStrandedDependents(config.workspaces, only, graph, (workspace) => {
168
- const tagPrefixes = [
169
- workspace.tagPrefix,
170
- ...(workspace.legacyIdentities?.map((identity) => identity.tagPrefix) ?? []),
171
- ];
172
- const result = getCommitsSinceTarget(tagPrefixes, workspace.paths);
173
- return { has: result.commits.length > 0, tag: result.tag };
164
+ const history = readReleaseHistory(config, { tagPrefixes: getAllTagPrefixes(workspace), paths: workspace.paths });
165
+ return { has: history.unreleased.commits.length > 0, tag: history.previousTag };
174
166
  });
175
167
  if (violations !== undefined) {
176
168
  reportError('--only excludes packages with changes that would be stranded by the release.');