@williamthorsen/release-kit 10.9.0 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.meta/changelog.json +119 -2
  2. package/.readyup/kits/default.js +1 -17
  3. package/.readyup/kits/npm-auto-publish.js +42 -22
  4. package/.readyup/manifest.json +7 -7
  5. package/CHANGELOG.md +88 -0
  6. package/README.md +32 -32
  7. package/dist/esm/attachChangelogDiagnostics.d.ts +4 -0
  8. package/dist/esm/attachChangelogDiagnostics.js +11 -0
  9. package/dist/esm/bin/release-kit.js +34 -25
  10. package/dist/esm/buildChangelogEntries.d.ts +59 -17
  11. package/dist/esm/buildChangelogEntries.js +195 -111
  12. package/dist/esm/buildReleaseSummary.js +2 -2
  13. package/dist/esm/changelogOverrides.d.ts +31 -13
  14. package/dist/esm/changelogOverrides.js +138 -58
  15. package/dist/esm/classifyChangelogCommit.d.ts +38 -0
  16. package/dist/esm/classifyChangelogCommit.js +33 -0
  17. package/dist/esm/configFlagSchema.d.ts +12 -0
  18. package/dist/esm/configFlagSchema.js +3 -0
  19. package/dist/esm/createGithubReleaseCommand.js +6 -2
  20. package/dist/esm/decideRelease.d.ts +11 -37
  21. package/dist/esm/decideRelease.js +5 -27
  22. package/dist/esm/defaults.d.ts +1 -1
  23. package/dist/esm/defaults.js +3 -0
  24. package/dist/esm/determineBumpType.d.ts +11 -11
  25. package/dist/esm/determineBumpType.js +6 -7
  26. package/dist/esm/discoverWorkspaces.d.ts +53 -8
  27. package/dist/esm/discoverWorkspaces.js +57 -37
  28. package/dist/esm/enumerateReleaseWindows.d.ts +50 -0
  29. package/dist/esm/enumerateReleaseWindows.js +124 -0
  30. package/dist/esm/generateChangelogs.d.ts +5 -23
  31. package/dist/esm/generateChangelogs.js +0 -14
  32. package/dist/esm/glyphs.d.ts +1 -1
  33. package/dist/esm/help/prepareHelp.d.ts +1 -1
  34. package/dist/esm/help/prepareHelp.js +5 -6
  35. package/dist/esm/index.d.ts +1 -1
  36. package/dist/esm/init/initCommand.js +1 -1
  37. package/dist/esm/init/scaffold.d.ts +0 -2
  38. package/dist/esm/init/scaffold.js +2 -30
  39. package/dist/esm/loadConfig.d.ts +8 -5
  40. package/dist/esm/loadConfig.js +7 -7
  41. package/dist/esm/loadValidatedConfig.d.ts +46 -5
  42. package/dist/esm/loadValidatedConfig.js +32 -15
  43. package/dist/esm/parseChangeRecordBlock.d.ts +37 -0
  44. package/dist/esm/parseChangeRecordBlock.js +134 -0
  45. package/dist/esm/parseCommitMessage.d.ts +31 -6
  46. package/dist/esm/parseCommitMessage.js +6 -5
  47. package/dist/esm/prepareCommand.d.ts +5 -0
  48. package/dist/esm/prepareCommand.js +30 -47
  49. package/dist/esm/previewTagPrefixes.d.ts +9 -6
  50. package/dist/esm/previewTagPrefixes.js +12 -24
  51. package/dist/esm/publishCommand.d.ts +2 -2
  52. package/dist/esm/publishCommand.js +8 -2
  53. package/dist/esm/pushCommand.d.ts +1 -1
  54. package/dist/esm/pushCommand.js +2 -2
  55. package/dist/esm/releasePrepare.d.ts +7 -8
  56. package/dist/esm/releasePrepare.js +53 -73
  57. package/dist/esm/releasePrepareMono.js +48 -85
  58. package/dist/esm/releasePrepareProject.d.ts +1 -1
  59. package/dist/esm/releasePrepareProject.js +19 -37
  60. package/dist/esm/reportPrepare.js +45 -30
  61. package/dist/esm/resolveCommandTags.d.ts +3 -2
  62. package/dist/esm/resolveCommandTags.js +10 -6
  63. package/dist/esm/resolveReleaseNotesConfig.d.ts +9 -11
  64. package/dist/esm/resolveReleaseNotesConfig.js +9 -28
  65. package/dist/esm/showTagPrefixesCommand.d.ts +4 -2
  66. package/dist/esm/showTagPrefixesCommand.js +17 -3
  67. package/dist/esm/sync-labels/generateCommand.d.ts +16 -8
  68. package/dist/esm/sync-labels/generateCommand.js +12 -11
  69. package/dist/esm/sync-labels/initCommand.d.ts +9 -4
  70. package/dist/esm/sync-labels/initCommand.js +21 -15
  71. package/dist/esm/sync-labels/retiredConfig.d.ts +4 -2
  72. package/dist/esm/sync-labels/retiredConfig.js +2 -2
  73. package/dist/esm/types.d.ts +99 -55
  74. package/dist/esm/types.js +1 -1
  75. package/dist/esm/validateConfig.js +4 -0
  76. package/dist/esm/validateOverridesCommand.d.ts +16 -9
  77. package/dist/esm/validateOverridesCommand.js +39 -54
  78. package/docs/changelogs.md +64 -25
  79. package/docs/configuration.md +14 -3
  80. package/docs/editorial-overrides.md +75 -16
  81. package/docs/labels.md +2 -0
  82. package/docs/project-releases.md +4 -4
  83. package/docs/releasing.md +13 -8
  84. package/docs/work-types.md +13 -9
  85. package/package.json +5 -7
  86. package/schemas/changelog-overrides.json +39 -0
  87. package/cliff.toml.template +0 -65
  88. package/dist/esm/collectPolicyViolations.d.ts +0 -15
  89. package/dist/esm/collectPolicyViolations.js +0 -12
  90. package/dist/esm/determineBumpFromCommits.d.ts +0 -17
  91. package/dist/esm/determineBumpFromCommits.js +0 -28
  92. package/dist/esm/getCommitsSinceTarget.d.ts +0 -21
  93. package/dist/esm/getCommitsSinceTarget.js +0 -66
  94. package/dist/esm/resolveCliffConfigPath.d.ts +0 -11
  95. package/dist/esm/resolveCliffConfigPath.js +0 -20
  96. package/dist/esm/runGitCliff.d.ts +0 -38
  97. package/dist/esm/runGitCliff.js +0 -25
@@ -1,30 +1,8 @@
1
- import { determineBumpType } from "./determineBumpType.js";
2
- import { parseCommitMessage, } from "./parseCommitMessage.js";
3
1
  export function decideRelease(args) {
4
- const { commits, force = false, bumpOverride, workTypes, versionPatterns, scopeAliases, breakingPolicies, onPolicyViolation, skipReasons, } = args;
5
- const parseOptions = {
6
- ...(breakingPolicies !== undefined && { breakingPolicies }),
7
- ...(onPolicyViolation !== undefined && { onPolicyViolation }),
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
- const parsedCommitCount = parsedCommits.length;
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
  }
@@ -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, cliff-template drift comparison) cannot disagree on the composition rule.
9
+ * derivation) cannot disagree on the composition rule.
10
10
  */
11
11
  export declare function composeHeader(entry: {
12
12
  emoji: string;
@@ -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 { ParsedCommit, ReleaseType, VersionPatterns, WorkTypeConfig } from './types.js';
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 parsed commits.
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 commit types trigger major/minor bumps.
6
- * The `'!'` sentinel in `versionPatterns.major` means "any breaking commit triggers major".
7
- * Any recognized commit type not listed in major or minor patterns defaults to patch.
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(commits: readonly ParsedCommit[], workTypes: Record<string, WorkTypeConfig>, versionPatterns: VersionPatterns): ReleaseType | undefined;
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(commits, workTypes, versionPatterns) {
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 commit of commits) {
11
- if (commit.breaking && versionPatterns.major.includes('!')) {
10
+ for (const change of changes) {
11
+ if (change.breaking && versionPatterns.major.includes('!')) {
12
12
  return 'major';
13
13
  }
14
- const commitType = commit.type;
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(commitType)) {
18
+ if (versionPatterns.major.includes(change.type)) {
20
19
  bump = 'major';
21
20
  }
22
- else if (versionPatterns.minor.includes(commitType)) {
21
+ else if (versionPatterns.minor.includes(change.type)) {
23
22
  bump = 'minor';
24
23
  }
25
24
  else {
@@ -1,13 +1,58 @@
1
+ import { type EmptyWorkspaceCause } from '@williamthorsen/nmr-core/workspace';
2
+ export type { EmptyWorkspaceCause } from '@williamthorsen/nmr-core/workspace';
1
3
  /**
2
- * Reads `pnpm-workspace.yaml` and resolves its `packages` globs to discover workspace directories.
4
+ * What release-kit's workspace discovery produced: the repo releases as a single package, it resolves to a
5
+ * set of package directories, or it declares a workspace that resolves to none and the cause says which
6
+ * condition emptied it.
3
7
  *
4
- * Returns an array of workspace-relative directory paths (e.g., `['packages/arrays', 'packages/strings']`).
5
- * Only directories containing a `package.json` are included.
8
+ * Held apart from nmr-core's `WorkspaceResolution`, whose `packageDirs` are absolute, because these are
9
+ * relative to the monorepo root. The two carry incompatible path contracts, and a distinct `kind` for the
10
+ * single-package case keeps either from standing in for the other.
11
+ */
12
+ export type WorkspaceDiscovery = {
13
+ kind: 'empty';
14
+ cause: FailingWorkspaceCause;
15
+ patterns: string[];
16
+ } | {
17
+ kind: 'packages';
18
+ packageDirs: string[];
19
+ patterns: string[];
20
+ } | {
21
+ kind: 'single-package';
22
+ };
23
+ /** A workspace that resolved to no package directory, which every release-kit command treats as a failure. */
24
+ export type EmptyWorkspace = Extract<WorkspaceDiscovery, {
25
+ kind: 'empty';
26
+ }>;
27
+ /**
28
+ * The empty-workspace causes that reach a command as a failure.
29
+ *
30
+ * `no-packages-list` is absent because a manifest declaring nothing releases as a single package, so no
31
+ * command ever composes a message for it.
32
+ */
33
+ export type FailingWorkspaceCause = Exclude<EmptyWorkspaceCause, 'no-packages-list'>;
34
+ /**
35
+ * Returns the sentence naming which of the five conditions left a workspace holding no package directory, and
36
+ * the remedy for that one. Each quotes the `packages` list the manifest declares, apart from the two that
37
+ * reach the matcher with no list to quote.
38
+ *
39
+ * The `package.json` requirement is stated under `no-package` because it is a divergence from pnpm, which
40
+ * recognizes two further manifests, and the reader of a workspace that pnpm resolves has no way to infer it.
41
+ */
42
+ export declare function describeEmptyWorkspace(workspace: EmptyWorkspace): string;
43
+ /**
44
+ * Reads `pnpm-workspace.yaml` at `monorepoRoot` and resolves its `packages` patterns, applying pnpm's
45
+ * semantics — `!`-prefixed exclusions included.
6
46
  *
7
- * Note: The returned paths are full relative paths from the repo root (e.g., `packages/arrays`,
8
- * `libs/core`). These paths are passed directly to `deriveWorkspaceConfig()`, which uses them for all
9
- * file path construction and derives the display name via `path.basename()`.
47
+ * A directory holding no manifest, and one whose manifest declares no `packages` list, both release as a
48
+ * single package: pnpm resolves each to the root package alone, and a workspace file kept for `catalog:`,
49
+ * `overrides:`, or `onlyBuiltDependencies:` is the second of them. Every other empty resolution is a failure,
50
+ * whether the reader declared patterns that matched nothing or a `packages` value the resolver could not read
51
+ * as patterns at all.
10
52
  *
11
- * @returns The discovered workspace paths, or `undefined` if no workspace config is found.
53
+ * `packageDirs` come back relative to `monorepoRoot` as POSIX paths, because that is what every consumer
54
+ * needs: `WorkspaceConfig.paths` feeds `git log -- <paths>`, and `packageFiles` and `changelogPaths` are read
55
+ * from disk relative to the working directory. The workspace root itself relativizes to `.`, the path
56
+ * `deriveWorkspaceConfig` reads a root package's manifest through.
12
57
  */
13
- export declare function discoverWorkspaces(): Promise<string[] | undefined>;
58
+ export declare function discoverWorkspaces(monorepoRoot?: string): WorkspaceDiscovery;
@@ -1,42 +1,62 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { describeError } from '@williamthorsen/toolbelt.errors';
4
- import { glob } from 'glob';
5
- import { parse } from 'yaml';
6
- import { isRecord } from "./typeGuards.js";
7
- export async function discoverWorkspaces() {
8
- const workspaceFile = 'pnpm-workspace.yaml';
9
- if (!existsSync(workspaceFile)) {
10
- return undefined;
11
- }
12
- let content;
13
- try {
14
- content = readFileSync(workspaceFile, 'utf8');
15
- }
16
- catch (error) {
17
- console.warn(`Warning: Failed to read ${workspaceFile}: ${describeError(error)}`);
18
- return undefined;
19
- }
20
- const parsed = parse(content);
21
- if (!isRecord(parsed)) {
22
- return undefined;
1
+ import path from 'node:path';
2
+ import { resolveWorkspace } from '@williamthorsen/nmr-core/workspace';
3
+ export function describeEmptyWorkspace(workspace) {
4
+ const declaredClause = describeDeclaredPatterns(workspace.patterns);
5
+ switch (workspace.cause) {
6
+ case 'all-excluded':
7
+ return (`pnpm-workspace.yaml ${declaredClause}, whose \`!\` entries exclude every directory matched by the ` +
8
+ 'positive patterns. Drop or narrow the exclusion.');
9
+ case 'no-package':
10
+ return (`pnpm-workspace.yaml ${declaredClause}, and the matcher found no directory holding a \`package.json\`. ` +
11
+ 'Add one to the directory that should be a package, or declare a pattern reaching a directory that ' +
12
+ 'holds one.');
13
+ case 'no-pattern':
14
+ return (`pnpm-workspace.yaml ${declaredClause}, so no pattern reaches the matcher. Declare a positive pattern ` +
15
+ 'such as `packages/*`, and quote any `!` entry, which YAML reads as a tag rather than a string where ' +
16
+ 'it stands bare.');
17
+ case 'unreadable-manifest':
18
+ return ('pnpm-workspace.yaml holds no valid YAML, so nothing it declares reaches the matcher. Repair the ' +
19
+ 'syntax error and run the command again. An unterminated quoted string and a mis-indented entry are ' +
20
+ 'the usual ones.');
21
+ case 'unreadable-packages':
22
+ return ('pnpm-workspace.yaml declares a `packages` value that is not a list of strings, so nothing it ' +
23
+ 'declares reaches the matcher. Make it a list whose every entry is a quoted pattern; a bare value ' +
24
+ 'and an entry YAML read as a number or a map are the usual ones.');
25
+ default: {
26
+ const unhandledCause = workspace.cause;
27
+ throw new Error(`Unhandled empty-workspace cause: ${String(unhandledCause)}`);
28
+ }
23
29
  }
24
- const packagesField = parsed['packages'];
25
- if (!Array.isArray(packagesField)) {
26
- return undefined;
30
+ }
31
+ export function discoverWorkspaces(monorepoRoot = process.cwd()) {
32
+ const resolution = resolveWorkspace(monorepoRoot);
33
+ if (resolution.kind === 'not-a-workspace') {
34
+ return { kind: 'single-package' };
27
35
  }
28
- const patterns = packagesField.filter((p) => typeof p === 'string');
29
- if (patterns.length === 0) {
30
- return undefined;
36
+ if (resolution.kind === 'empty') {
37
+ const { cause, patterns } = resolution;
38
+ return cause === 'no-packages-list' ? { kind: 'single-package' } : { cause, kind: 'empty', patterns };
31
39
  }
32
- const directories = [];
33
- for (const pattern of patterns) {
34
- const matches = await glob(pattern, { posix: true });
35
- for (const match of matches) {
36
- if (existsSync(join(match, 'package.json'))) {
37
- directories.push(match);
38
- }
39
- }
40
+ return {
41
+ ...resolution,
42
+ packageDirs: resolution.packageDirs.map((packageDir) => relativizeToPosix(monorepoRoot, packageDir)),
43
+ };
44
+ }
45
+ function describeDeclaredPatterns(patterns) {
46
+ const quotablePatterns = patterns.filter((pattern) => pattern.trim() !== '');
47
+ const emptiedCount = patterns.length - quotablePatterns.length;
48
+ if (emptiedCount === 0) {
49
+ return `declares ${renderQuotedList(patterns)}`;
40
50
  }
41
- return directories.length > 0 ? [...directories].sort() : undefined;
51
+ const emptiedClause = `${String(emptiedCount)} ${emptiedCount === 1 ? 'entry' : 'entries'} that YAML left empty`;
52
+ return quotablePatterns.length === 0
53
+ ? `declares ${emptiedClause}`
54
+ : `declares ${renderQuotedList(quotablePatterns)}, beside ${emptiedClause}`;
55
+ }
56
+ function relativizeToPosix(monorepoRoot, packageDir) {
57
+ const relativeDir = path.relative(monorepoRoot, packageDir);
58
+ return relativeDir === '' ? '.' : relativeDir.split(path.sep).join('/');
59
+ }
60
+ function renderQuotedList(patterns) {
61
+ return patterns.map((pattern) => `\`${pattern}\``).join(', ');
42
62
  }
@@ -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[];
@@ -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 --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;
@@ -4,15 +4,14 @@ Usage: release-kit prepare [options]
4
4
  Run release preparation with automatic workspace discovery.
5
5
 
6
6
  Options:
7
+ --config <path> Path to config file (default: .config/release-kit.config.ts)
7
8
  --dry-run Run without modifying any files
8
- --bump=major|minor|patch Override the bump type for all workspaces
9
- --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
10
11
  mode it requires --only, and is rejected when a 'project' block is configured.
11
12
  --force Release even when no commits or no bump-worthy commits exist
12
- since the last tag. In monorepo and project mode, defaults to
13
- patch when --bump is not given; use --bump=X for a different
14
- level. In single-package mode, a bare --force is rejected —
15
- 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.
16
15
  --no-git-checks, -n Skip the clean-working-tree check
17
16
  --only=name1,name2 Only process the named workspaces (comma-separated, monorepo only).
18
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
  }