@williamthorsen/release-kit 10.6.0 → 10.7.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.
@@ -35,8 +35,9 @@ Commands:
35
35
  work-types Check for or sync work-type taxonomy drift against the upstream canonical
36
36
 
37
37
  Options:
38
- --dry-run Preview changes without writing files
39
- --help, -h Show this help message
38
+ --dry-run Preview changes without writing files
39
+ --help, -h Show this help message
40
+ --version, -V Show the version number
40
41
  `);
41
42
  }
42
43
  function showSyncLabelsHelp() {
@@ -95,7 +96,7 @@ function showInitHelp() {
95
96
  Usage: release-kit init [options]
96
97
 
97
98
  Initialize release-kit in the current repository.
98
- By default, scaffolds only the GitHub Actions workflow file.
99
+ By default, scaffolds only the GitHub Actions workflow files.
99
100
 
100
101
  Options:
101
102
  --with-config Also scaffold .config/release-kit.config.ts and .config/git-cliff.toml
@@ -1,10 +1,12 @@
1
1
  import type { WorkspaceConfig } from './types.js';
2
- /** Reverse adjacency map from package names to their workspace dependents. */
2
+ /** Identities, current versions, and `workspace:` dependency edges of the workspaces' packages. */
3
3
  export interface DependencyGraph {
4
4
  /** Resolve a package name to its workspace `dir`. */
5
5
  packageNameToDir: Map<string, string>;
6
6
  /** Resolve a workspace `dir` to its package name (inverse of `packageNameToDir`). */
7
7
  dirToPackageName: Map<string, string>;
8
+ /** Resolve a workspace `dir` to its current version; absent when its `package.json` has no string `version`. */
9
+ dirToVersion: Map<string, string>;
8
10
  /** Map a package name to the workspaces that depend on it. */
9
11
  dependentsOf: Map<string, WorkspaceConfig[]>;
10
12
  /**
@@ -14,11 +16,10 @@ export interface DependencyGraph {
14
16
  dependenciesOf: Map<string, Set<string>>;
15
17
  }
16
18
  /**
17
- * Build a reverse dependency graph from the workspaces' `package.json` files.
19
+ * Builds the dependency graph from the workspaces' `package.json` files.
18
20
  *
19
21
  * Reads each workspace's primary `package.json` (first entry in `packageFiles`) to discover
20
- * `workspace:` references in `dependencies` and `peerDependencies`. Returns a map from each
21
- * package name to the workspaces that depend on it, enabling upward traversal from a bumped
22
- * package to all its dependents.
22
+ * `workspace:` references in `dependencies` and `peerDependencies`, and to record its current
23
+ * version.
23
24
  */
24
25
  export declare function buildDependencyGraph(workspaces: readonly WorkspaceConfig[]): DependencyGraph;
@@ -6,6 +6,7 @@ function isPackageJsonSubset(value) {
6
6
  export function buildDependencyGraph(workspaces) {
7
7
  const packageNameToDir = new Map();
8
8
  const dirToPackageName = new Map();
9
+ const dirToVersion = new Map();
9
10
  const dependentsOf = new Map();
10
11
  const dependenciesOf = new Map();
11
12
  const workspacePackages = new Map();
@@ -16,6 +17,9 @@ export function buildDependencyGraph(workspaces) {
16
17
  }
17
18
  const pkg = readPackageJsonSubset(primaryPackageFile);
18
19
  workspacePackages.set(workspace, pkg);
20
+ if (typeof pkg.version === 'string') {
21
+ dirToVersion.set(workspace.dir, pkg.version);
22
+ }
19
23
  if (pkg.name === undefined) {
20
24
  continue;
21
25
  }
@@ -44,7 +48,7 @@ export function buildDependencyGraph(workspaces) {
44
48
  }
45
49
  }
46
50
  }
47
- return { packageNameToDir, dirToPackageName, dependentsOf, dependenciesOf };
51
+ return { packageNameToDir, dirToPackageName, dirToVersion, dependentsOf, dependenciesOf };
48
52
  }
49
53
  function readPackageJsonSubset(filePath) {
50
54
  let content;
@@ -1,22 +1,30 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { findPackageRoot, writeFileWithCheck } from '@williamthorsen/nmr-core';
4
4
  import { describeError } from '@williamthorsen/toolbelt.errors';
5
5
  import { createGithubReleaseWorkflow, publishWorkflow, releaseConfigScript, releaseWorkflow } from "./templates.js";
6
6
  export function copyCliffTemplate(dryRun, overwrite) {
7
7
  const destPath = '.config/git-cliff.toml';
8
- const root = findPackageRoot(import.meta.url);
9
- const templatePath = resolve(root, 'cliff.toml.template');
10
- if (!existsSync(templatePath)) {
11
- return { filePath: destPath, outcome: 'failed', error: `Could not find bundled template at ${templatePath}` };
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}` };
12
15
  }
16
+ const templatePath = resolve(root, 'cliff.toml.template');
13
17
  let content;
14
18
  try {
15
19
  content = readFileSync(templatePath, 'utf8');
16
20
  }
17
21
  catch (error) {
18
22
  const message = describeError(error);
19
- return { filePath: destPath, outcome: 'failed', error: `Failed to read template ${templatePath}: ${message}` };
23
+ return {
24
+ filePath: destPath,
25
+ outcome: 'failed',
26
+ error: `Failed to read bundled template at ${templatePath}: ${message}`,
27
+ };
20
28
  }
21
29
  return writeFileWithCheck(destPath, content, { dryRun, overwrite });
22
30
  }
@@ -16,6 +16,7 @@ export interface VersionBumpPlan {
16
16
  export declare function planVersionBump(packageFiles: readonly string[], releaseType: ReleaseType): VersionBumpPlan;
17
17
  /**
18
18
  * Computes an explicit version assignment for a workspace's package files without writing them,
19
- * bypassing commit-derived bump logic. Backs the `--set-version` flag.
19
+ * bypassing commit-derived bump logic. Backs the `--set-version` flag, and so rejects a
20
+ * `newVersion` that is not greater than the current version.
20
21
  */
21
22
  export declare function planVersionSet(packageFiles: readonly string[], newVersion: string): VersionBumpPlan;
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
3
3
  import { bumpVersion } from "./bumpVersion.js";
4
+ import { isForwardVersion } from "./compareVersions.js";
4
5
  function isPackageJson(value) {
5
6
  return typeof value === 'object' && value !== null && 'version' in value && typeof value.version === 'string';
6
7
  }
@@ -13,6 +14,9 @@ export function planVersionBump(packageFiles, releaseType) {
13
14
  export function planVersionSet(packageFiles, newVersion) {
14
15
  const { firstFile, firstPkg } = readPrimaryPackage(packageFiles);
15
16
  const currentVersion = firstPkg.version;
17
+ if (!isForwardVersion(currentVersion, newVersion)) {
18
+ throw new Error(`--set-version ${newVersion} is not greater than current version ${currentVersion}`);
19
+ }
16
20
  return { currentVersion, newVersion, writes: renderVersionWrites(packageFiles, firstFile, firstPkg, newVersion) };
17
21
  }
18
22
  function readPrimaryPackage(packageFiles) {
@@ -12,8 +12,6 @@ export interface ReleaseEntry {
12
12
  */
13
13
  newVersionOverride?: string;
14
14
  }
15
- /** Map from workspace `dir` to its current version string (read from package.json). */
16
- export type CurrentVersions = Map<string, string>;
17
15
  /**
18
16
  * Walk upward through the dependency graph via BFS, adding `patch` bumps for dependents
19
17
  * not already in the release set with a higher bump.
@@ -21,4 +19,4 @@ export type CurrentVersions = Map<string, string>;
21
19
  * Returns the full release set (direct + propagated). Direct entries that also have a
22
20
  * propagated dependency get `propagatedFrom` metadata without changing their bump type.
23
21
  */
24
- export declare function propagateBumps(directBumps: Map<string, ReleaseEntry>, graph: DependencyGraph, currentVersions: CurrentVersions): Map<string, ReleaseEntry>;
22
+ export declare function propagateBumps(directBumps: Map<string, ReleaseEntry>, graph: DependencyGraph): Map<string, ReleaseEntry>;
@@ -1,5 +1,5 @@
1
1
  import { bumpVersion } from "./bumpVersion.js";
2
- export function propagateBumps(directBumps, graph, currentVersions) {
2
+ export function propagateBumps(directBumps, graph) {
3
3
  const result = new Map();
4
4
  for (const [dir, entry] of directBumps) {
5
5
  result.set(dir, { ...entry });
@@ -19,7 +19,7 @@ export function propagateBumps(directBumps, graph, currentVersions) {
19
19
  if (packageName === undefined) {
20
20
  continue;
21
21
  }
22
- const currentVersion = currentVersions.get(dir);
22
+ const currentVersion = graph.dirToVersion.get(dir);
23
23
  const entry = result.get(dir);
24
24
  if (currentVersion === undefined || entry === undefined) {
25
25
  continue;
@@ -5,7 +5,6 @@ import { buildReleaseSummary } from "./buildReleaseSummary.js";
5
5
  import { mergeChangelogEntriesWithDisk, renderChangelogJson, resolveChangelogJsonPath } from "./changelogJsonFile.js";
6
6
  import { applyChangelogOverrides, formatStaleOverrideKeyWarning, loadOverridesForScopes, } from "./changelogOverrides.js";
7
7
  import { createPolicyViolationCollector } from "./collectPolicyViolations.js";
8
- import { isForwardVersion } from "./compareVersions.js";
9
8
  import { DEFAULT_BREAKING_POLICIES, DEFAULT_VERSION_PATTERNS, DEFAULT_WORK_TYPES } from "./defaults.js";
10
9
  import { determineBumpFromCommits } from "./determineBumpFromCommits.js";
11
10
  import { getCommitsSinceTarget } from "./getCommitsSinceTarget.js";
@@ -13,7 +12,6 @@ import { hasPrettierConfig } from "./hasPrettierConfig.js";
13
12
  import { resolveWorkTypes } from "./loadConfig.js";
14
13
  import { planReleaseNotesPreviews } from "./planReleaseNotesPreviews.js";
15
14
  import { planVersionBump, planVersionSet } from "./planVersionBump.js";
16
- import { readCurrentVersion } from "./readCurrentVersion.js";
17
15
  import { renderChangelogMarkdown } from "./renderChangelogMarkdown.js";
18
16
  import { deriveSectionOrder } from "./resolveReleaseNotesConfig.js";
19
17
  export function releasePrepare(config, options) {
@@ -34,17 +32,6 @@ export function releasePrepare(config, options) {
34
32
  const collector = createPolicyViolationCollector();
35
33
  let bump;
36
34
  if (setVersion !== undefined) {
37
- const primaryPackageFile = config.packageFiles[0];
38
- if (primaryPackageFile === undefined) {
39
- throw new Error('No package files specified');
40
- }
41
- const currentVersion = readCurrentVersion(primaryPackageFile);
42
- if (currentVersion === undefined) {
43
- throw new Error(`Cannot validate --set-version: failed to read current version from ${primaryPackageFile}`);
44
- }
45
- if (!isForwardVersion(currentVersion, setVersion)) {
46
- throw new Error(`--set-version ${setVersion} is not greater than current version ${currentVersion}`);
47
- }
48
35
  bump = planVersionSet(config.packageFiles, setVersion);
49
36
  }
50
37
  else {
@@ -8,7 +8,6 @@ import { buildSyntheticChangelogEntry } from "./buildSyntheticChangelogEntry.js"
8
8
  import { mergeChangelogEntriesWithDisk, renderChangelogJson, resolveChangelogJsonPath } from "./changelogJsonFile.js";
9
9
  import { applyWorkspaceOverrides, createOverrideContext, formatStaleOverrideKeyWarning, } from "./changelogOverrides.js";
10
10
  import { createPolicyViolationCollector } from "./collectPolicyViolations.js";
11
- import { isForwardVersion } from "./compareVersions.js";
12
11
  import { decideRelease } from "./decideRelease.js";
13
12
  import { DEFAULT_BREAKING_POLICIES, DEFAULT_VERSION_PATTERNS, DEFAULT_WORK_TYPES } from "./defaults.js";
14
13
  import { detectUndeclaredTagPrefixes } from "./detectUndeclaredTagPrefixes.js";
@@ -19,7 +18,6 @@ import { resolveWorkTypes } from "./loadConfig.js";
19
18
  import { planReleaseNotesPreviews } from "./planReleaseNotesPreviews.js";
20
19
  import { planVersionBump, planVersionSet } from "./planVersionBump.js";
21
20
  import { propagateBumps } from "./propagateBumps.js";
22
- import { readCurrentVersion } from "./readCurrentVersion.js";
23
21
  import { releasePrepareProject } from "./releasePrepareProject.js";
24
22
  import { renderChangelogMarkdown } from "./renderChangelogMarkdown.js";
25
23
  import { deriveSectionOrder } from "./resolveReleaseNotesConfig.js";
@@ -32,7 +30,7 @@ export function releasePrepareMono(config, options) {
32
30
  }
33
31
  const sectionOrder = deriveSectionOrder(resolveWorkTypes(config.workTypes));
34
32
  const overrideContext = createOverrideContext(config.workspaces);
35
- const { directBumps, directResults, skippedResults, currentVersions } = determineDirectBumps(config, options);
33
+ const { directBumps, directResults, skippedResults } = determineDirectBumps(config, options);
36
34
  const previousTags = new Map();
37
35
  for (const result of directResults.values()) {
38
36
  previousTags.set(result.workspace.dir, result.tag);
@@ -41,7 +39,7 @@ export function releasePrepareMono(config, options) {
41
39
  previousTags.set(skipped.workspace.dir, skipped.tag);
42
40
  }
43
41
  const graph = buildDependencyGraph(config.workspaces);
44
- const fullReleaseSet = propagateBumps(directBumps, graph, currentVersions);
42
+ const fullReleaseSet = propagateBumps(directBumps, graph);
45
43
  const { sorted: sortedDirs, cyclicDirs } = topologicalSort(fullReleaseSet, graph);
46
44
  if (cyclicDirs.length > 0) {
47
45
  warnings.push(`Circular workspace dependencies detected among: ${cyclicDirs.join(', ')}. ` +
@@ -119,7 +117,6 @@ function determineDirectBumps(config, options) {
119
117
  const directBumps = new Map();
120
118
  const directResults = new Map();
121
119
  const skippedResults = [];
122
- const currentVersions = new Map();
123
120
  const hintState = { emitted: false };
124
121
  const knownPrefixes = config.workspaces.flatMap(getAllTagPrefixes);
125
122
  for (const workspace of config.workspaces) {
@@ -130,21 +127,7 @@ function determineDirectBumps(config, options) {
130
127
  if (tag === undefined) {
131
128
  maybeEmitBaselineHint(workspace, knownPrefixes, hintState);
132
129
  }
133
- const primaryPackageFile = workspace.packageFiles[0];
134
- if (primaryPackageFile !== undefined) {
135
- const currentVersion = tryStage(stageLabel, () => readCurrentVersion(primaryPackageFile));
136
- if (currentVersion !== undefined) {
137
- currentVersions.set(workspace.dir, currentVersion);
138
- }
139
- }
140
130
  if (setVersion !== undefined) {
141
- const currentVersion = currentVersions.get(workspace.dir);
142
- if (currentVersion === undefined) {
143
- throw new Error(`Cannot validate --set-version: failed to read current version from ${primaryPackageFile ?? '(no package file)'}`);
144
- }
145
- if (!isForwardVersion(currentVersion, setVersion)) {
146
- throw new Error(`--set-version ${setVersion} is not greater than current version ${currentVersion}`);
147
- }
148
131
  directBumps.set(workspace.dir, { releaseType: 'patch', newVersionOverride: setVersion });
149
132
  directResults.set(workspace.dir, {
150
133
  workspace,
@@ -199,7 +182,7 @@ function determineDirectBumps(config, options) {
199
182
  bumpOverride,
200
183
  });
201
184
  }
202
- return { directBumps, directResults, skippedResults, currentVersions };
185
+ return { directBumps, directResults, skippedResults };
203
186
  }
204
187
  function collectSkippedWorkspaces(skippedResults, fullReleaseSet) {
205
188
  const workspaces = [];
@@ -5,9 +5,11 @@ import type { LabelDefinition } from './types.js';
5
5
  *
6
6
  * Resolution is an ordered fold with last-writer-wins: presets are loaded in `extends` order (a later preset replaces
7
7
  * an earlier preset's label of the same name), then the `labels` record is applied: An entry adds a label, replaces
8
- * one an earlier layer defined, or removes it (`null`). Replacement is wholesale, so an entry omitting `description`
8
+ * one an earlier layer defined, or removes it (`null`). Names match case-insensitively, as GitHub matches them, and the
9
+ * replacing layer's spelling becomes the label's name. Replacement is wholesale, so an entry omitting `description`
9
10
  * drops the one an earlier layer supplied rather than inheriting it.
10
- * Throws only on a dangling `null`: a removal naming a label no preset defined, which is a stale reference that
11
- * would otherwise produce no output diff to review.
11
+ * Throws on two misstatements that would otherwise leave no trace in the output diff: keys in the `labels` record that
12
+ * differ only in case, all but one of which the fold would discard, and a dangling `null`, a removal naming a label that
13
+ * no preset defined.
12
14
  */
13
15
  export declare function resolveLabels(config: RepoLabelsConfig): LabelDefinition[];
@@ -1,22 +1,28 @@
1
1
  import { loadPreset } from "./presets.js";
2
2
  export function resolveLabels(config) {
3
+ const labelEntries = Object.entries(config.labels ?? {});
4
+ const caseVariantGroups = findCaseVariantGroups(labelEntries.map(([name]) => name));
5
+ if (caseVariantGroups.length > 0) {
6
+ const groupList = caseVariantGroups.map((group) => group.map((name) => `'${name}'`).join(' and ')).join('; ');
7
+ throw new Error(`Keys in 'labels' differ only in case, which GitHub treats as the same label: ${groupList}. Keep one entry per label.`);
8
+ }
3
9
  const resolved = new Map();
4
10
  const presetNames = config.extends ?? [];
5
11
  for (const presetName of presetNames) {
6
12
  for (const label of loadPreset(presetName)) {
7
- resolved.set(label.name, label);
13
+ resolved.set(toLabelKey(label.name), label);
8
14
  }
9
15
  }
10
- const labelEntries = Object.entries(config.labels ?? {});
11
16
  for (const [name, spec] of labelEntries) {
17
+ const key = toLabelKey(name);
12
18
  if (spec === null) {
13
- if (!resolved.has(name)) {
19
+ if (!resolved.has(key)) {
14
20
  throw new Error(`Label '${name}' is set to null (remove), but no preset in 'extends' defines it. Fix the name or delete the entry.`);
15
21
  }
16
- resolved.delete(name);
22
+ resolved.delete(key);
17
23
  }
18
24
  else {
19
- resolved.set(name, {
25
+ resolved.set(key, {
20
26
  name,
21
27
  color: spec.color,
22
28
  ...(spec.description !== undefined && { description: spec.description }),
@@ -25,6 +31,15 @@ export function resolveLabels(config) {
25
31
  }
26
32
  return sortLabels(resolved.values().toArray());
27
33
  }
34
+ function findCaseVariantGroups(names) {
35
+ return Map.groupBy(names, toLabelKey)
36
+ .values()
37
+ .filter((group) => group.length > 1)
38
+ .toArray();
39
+ }
28
40
  function sortLabels(labels) {
29
- return [...labels].sort((a, b) => a.name.localeCompare(b.name));
41
+ return labels.toSorted((a, b) => a.name.localeCompare(b.name));
42
+ }
43
+ function toLabelKey(name) {
44
+ return name.toLowerCase();
30
45
  }
@@ -388,8 +388,10 @@ export type LabelSpec = z.infer<typeof labelSpecSchema>;
388
388
  * the GitHub repo, distinct from labels applied to PRs and issues).
389
389
  *
390
390
  * Resolution is an ordered fold with last-writer-wins: presets in `extends` order, then the `labels` record,
391
- * where an entry adds a label, replaces one an earlier layer defined, or removes it (`null`).
392
- * A `null` naming a label no earlier layer defined is a resolve-time error (`resolveLabels`), not a schema error.
391
+ * where an entry adds a label, replaces one an earlier layer defined, or removes it (`null`). Names match
392
+ * case-insensitively, as GitHub matches them, and the replacing layer's spelling wins.
393
+ * Two misstatements are resolve-time errors (`resolveLabels`), not schema errors: `labels` keys that differ only in
394
+ * case, and a `null` naming a label that no earlier layer defined.
393
395
  */
394
396
  export declare const repoLabelsSchema: z.ZodObject<{
395
397
  extends: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -31,7 +31,7 @@ function preprocessDeprecatedKeys(raw) {
31
31
  const errors = [];
32
32
  const cleaned = { ...raw };
33
33
  if (isRecord(cleaned['releaseNotes']) && Object.hasOwn(cleaned['releaseNotes'], 'shouldCreateGithubRelease')) {
34
- errors.push('releaseNotes.shouldCreateGithubRelease is no longer supported. Adoption is now signaled by installing the create-github-release workflow. Remove this field from your config; see README for the updated workflow.');
34
+ errors.push('releaseNotes.shouldCreateGithubRelease is no longer supported. Adoption is now signaled by installing the create-github-release workflow, which `release-kit init` scaffolds. Remove this field from your config.');
35
35
  const releaseNotesCopy = { ...cleaned['releaseNotes'] };
36
36
  delete releaseNotesCopy['shouldCreateGithubRelease'];
37
37
  cleaned['releaseNotes'] = releaseNotesCopy;
package/docs/api.md ADDED
@@ -0,0 +1,73 @@
1
+ # Programmatic API
2
+
3
+ How to build a release config by hand with `deriveWorkspaceConfig()`, the script-based approach that predates the CLI, and the workspace records that `resolveReleaseTags` takes.
4
+
5
+ ## Using `deriveWorkspaceConfig()` for manual configuration
6
+
7
+ If you need to build a `MonorepoReleaseConfig` manually (e.g., for the legacy script-based approach), the exported `deriveWorkspaceConfig()` helper creates a `WorkspaceConfig` from a workspace-relative path. It reads the workspace's `package.json` to derive the tag prefix from the package name:
8
+
9
+ ```typescript
10
+ import { deriveWorkspaceConfig } from '@williamthorsen/release-kit';
11
+
12
+ // packages/arrays/package.json contains `"name": "@scope/arrays"`
13
+ deriveWorkspaceConfig('packages/arrays');
14
+ // => {
15
+ // dir: 'arrays',
16
+ // name: '@scope/arrays',
17
+ // tagPrefix: 'arrays-v',
18
+ // workspacePath: 'packages/arrays',
19
+ // packageFiles: ['packages/arrays/package.json'],
20
+ // changelogPaths: ['packages/arrays'],
21
+ // paths: ['packages/arrays/**'],
22
+ // }
23
+ ```
24
+
25
+ `dir` is the basename of the workspace path and is the stable internal identifier used by `--only`, `WorkspaceOverride.dir`, and the dependency graph. `tagPrefix` is derived from the unscoped `package.json` `name` — any leading `@scope/` is stripped — so tags reflect the package identity rather than the directory layout. For example, a workspace at `packages/core` with `"name": "@williamthorsen/nmr-core"` produces `tagPrefix: 'nmr-core-v'`, yielding tags like `nmr-core-v1.3.0`.
26
+
27
+ The workspace's `package.json` must declare a non-empty `name` field; `deriveWorkspaceConfig()` throws otherwise. If two workspaces produce the same `tagPrefix` (because their unscoped names collide), `mergeMonorepoConfig()` throws and names the colliding workspaces so you can rename one.
28
+
29
+ ## Legacy script-based approach
30
+
31
+ The CLI-driven approach is recommended for new setups. The script-based approach (using `runReleasePrepare` with a manually maintained config) is still supported for backward compatibility.
32
+
33
+ ```typescript
34
+ // .github/scripts/release.config.ts
35
+ import type { MonorepoReleaseConfig } from '@williamthorsen/release-kit';
36
+ import { deriveWorkspaceConfig } from '@williamthorsen/release-kit';
37
+
38
+ export const config: MonorepoReleaseConfig = {
39
+ workspaces: [deriveWorkspaceConfig('packages/arrays'), deriveWorkspaceConfig('packages/strings')],
40
+ formatCommand: 'npx prettier --write',
41
+ };
42
+ ```
43
+
44
+ ```typescript
45
+ // .github/scripts/release-prepare.ts
46
+ import { runReleasePrepare } from '@williamthorsen/release-kit';
47
+ import { config } from './release.config.ts';
48
+
49
+ runReleasePrepare(config);
50
+ ```
51
+
52
+ The key difference: the script-based approach requires manually listing every workspace, while the CLI auto-discovers them from `pnpm-workspace.yaml`.
53
+
54
+ ## `resolveReleaseTags` takes workspaces; `WorkspaceConfig` requires `workspacePath`
55
+
56
+ Tag resolution is now driven by workspace records rather than a caller-supplied directory map, so `resolveReleaseTags` can report both the workspace `dir` and its `workspacePath` for every resolved tag.
57
+
58
+ - `resolveReleaseTags` signature changed from `(workspaceMap?: Map<string, string>)` to `(workspaces?: readonly WorkspaceConfig[])`.
59
+ - `WorkspaceConfig` gained a required `workspacePath: string` field.
60
+
61
+ Replace direct `Map`-based calls with `deriveWorkspaceConfig()`, which now populates `workspacePath` for you:
62
+
63
+ ```diff
64
+ -import { resolveReleaseTags } from '@williamthorsen/release-kit';
65
+ -
66
+ -const workspaceMap = new Map([['core', 'packages/core']]);
67
+ -resolveReleaseTags(workspaceMap);
68
+ +import { deriveWorkspaceConfig, resolveReleaseTags } from '@williamthorsen/release-kit';
69
+ +
70
+ +resolveReleaseTags([deriveWorkspaceConfig('packages/core')]);
71
+ ```
72
+
73
+ If you construct `WorkspaceConfig` objects directly, add `workspacePath` alongside the other required fields.
@@ -0,0 +1,70 @@
1
+ # Changelogs
2
+
3
+ What reaches a published tarball, what each `.meta/changelog.json` item contains, how the git-cliff config is resolved, and how release notes reach a README.
4
+
5
+ Neither `CHANGELOG.md` nor `.meta/changelog.json` reaches a published tarball on its own. npm stopped including `CHANGELOG` automatically in npm 7, so a package that declares a `files` field ships a changelog only where that field names `CHANGELOG.md` and `.meta/changelog.json`. release-kit's readyup kit reports a publishable workspace whose `files` field omits either.
6
+
7
+ ## `changelog.json` item schema
8
+
9
+ Each item under a section in `.meta/changelog.json` carries one required field and four optional ones:
10
+
11
+ | Field | Type | Meaning |
12
+ | ------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
13
+ | `description` | `string` | The bullet headline, taken from the commit subject with the ticket ID and type prefix stripped. |
14
+ | `body` | `string` | The commit body, with trailing trailer metadata stripped. |
15
+ | `breaking` | `boolean` | Present and `true` where the commit subject carried the `!` prefix. See [`!` (breaking change) policy](work-types.md#-breaking-change-policy). |
16
+ | `migration` | `string` | The migration step for a consumer. See below. |
17
+ | `hash` | `string` | The full commit SHA, and the key on which an override entry matches. Absent on synthetic propagation entries. |
18
+
19
+ Every optional field is omitted rather than emitted as `null`, so a consumer tests for presence.
20
+
21
+ ### The `migration` field
22
+
23
+ A commit body states a migration step as a paragraph opening with the literal label `Migration:`. Where one is present, `migration` carries that paragraph with the label stripped:
24
+
25
+ ```jsonc
26
+ {
27
+ "description": "Rename the workspace field",
28
+ "body": "Renames `name` to `id`.\n\nMigration: Change any uses of `name` to `id`.",
29
+ "migration": "Change any uses of `name` to `id`.",
30
+ }
31
+ ```
32
+
33
+ Three properties are worth knowing:
34
+
35
+ - **It is independent of `breaking`.** A `fix` cannot carry `!` under the default breaking policy, and a `fix` that tightens validation still imposes a migration. Filter on `migration` to find every step; filter on `breaking` to find every breaking change.
36
+ - **`body` keeps the paragraph.** The field is an extraction, not a move, so `CHANGELOG.md` and `.meta/changelog.json` go on agreeing.
37
+ - **It is derived, not authored.** `migration` is not a field an override file can set (see [File shape](editorial-overrides.md#file-shape)); it is re-derived from whatever `body` an override installs, and cleared where that body carries no labeled paragraph. To change the migration text, override `body`.
38
+
39
+ The label match is exact: `migration:` and `**Migration:**` are not recognized.
40
+
41
+ ## cliff.toml setup
42
+
43
+ The package includes a bundled `cliff.toml.template` that is used automatically when no custom config is found. The resolution order:
44
+
45
+ | Priority | Path | Notes |
46
+ | -------- | ----------------------------- | ----------------------------------------------- |
47
+ | 1 | `cliffConfigPath` in config | Explicit path, returned without existence check |
48
+ | 2 | `.config/git-cliff.toml` | Project-level override |
49
+ | 3 | `cliff.toml` | Repo root fallback |
50
+ | 4 | Bundled `cliff.toml.template` | Automatic fallback |
51
+
52
+ The bundled template provides a generic git-cliff configuration that:
53
+
54
+ - Strips issue-ticket prefixes matching `^[A-Z]+-\d+\s+` (e.g., `TOOL-123 `, `AFG-456 `)
55
+ - Handles both `type: description` and `workspace|type: description` commit formats
56
+ - Groups commits by work type via `[git].commit_parsers`
57
+
58
+ The body template is intentionally empty: release-kit reads cliff's `--context` JSON output and renders `CHANGELOG.md` in-process via `renderChangelogMarkdown` (see [How it works](../README.md#how-it-works) for the rationale). The `[git].commit_parsers` section remains load-bearing for `--context` group assignment.
59
+
60
+ To customize, scaffold a local copy with `release-kit init --with-config` and edit `.config/git-cliff.toml`. Edit only the `[git]` section — body-template changes have no effect.
61
+
62
+ ## Release-notes injection
63
+
64
+ With `releaseNotes.shouldInjectIntoReadme` set to `true`, `release-kit publish` writes each package's release notes into its `README.md` for the duration of the publish, then restores the file. The notes are the all-audience sections of the released version's entry in `.meta/changelog.json`, under a `## Release notes — v<version> (<date>)` heading, and they replace whatever lies between the marker pair:
65
+
66
+ ```markdown
67
+ <!-- section:release-notes --><!-- /section:release-notes -->
68
+ ```
69
+
70
+ A README without the marker pair receives the notes at its top, above its title, and release-kit's readyup kit reports a README that lacks the markers. Injection is skipped with a warning when `changelogJson.enabled` is `false`, when the version has no entry, or when the entry has no all-audience content. `release-kit prepare --with-release-notes` writes the same injected README as a [preview](releasing.md#previewing-release-notes-with---with-release-notes).
@@ -0,0 +1,105 @@
1
+ # Configuration
2
+
3
+ The fields of `.config/release-kit.config.ts`, how a workspace declares its prior identities or a retired package, the rule against colliding tag prefixes, and the patterns that decide a bump.
4
+
5
+ A minimal config file is in the [README](../README.md#configuration).
6
+
7
+ ## `ReleaseKitConfig` reference
8
+
9
+ | Field | Type | Description |
10
+ | ------------------ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | `cliffConfigPath` | `string` | Explicit path to cliff config. If omitted, resolved automatically: `.config/git-cliff.toml` → `cliff.toml` → bundled template |
12
+ | `workspaces` | `WorkspaceOverride[]` | Override or exclude discovered workspaces (matched by `dir`) |
13
+ | `formatCommand` | `string` | Shell command to run after changelog generation; modified file paths are appended as arguments |
14
+ | `versionPatterns` | `VersionPatterns` | Rules for which commit types trigger major/minor bumps |
15
+ | `scopeAliases` | `Record<string, string>` | Maps shorthand scope names to canonical names in commits |
16
+ | `workTypes` | `Record<string, WorkTypeConfig>` | Work type definitions, merged with defaults by key |
17
+ | `breakingPolicies` | `Record<string, 'forbidden' \| 'optional' \| 'required'>` | Per-type `!`-policy lookup. Defaults to `DEFAULT_BREAKING_POLICIES`. Replaces the default entirely when provided. Set to `{}` to disable enforcement |
18
+ | `retiredPackages` | `RetiredPackage[]` | Packages that once lived in this repo but have been extracted or removed; suppresses undeclared-tag-prefix warnings |
19
+ | `project` | `ProjectConfig` | Opt-in project-level release block. Declaring `project: {}` (even empty) enables a project-release stage in `prepare` |
20
+ | `repoLabels` | `RepoLabelsConfig` | Declares the repository's label registry for `sync-labels`; see [Label configuration](labels.md#label-configuration) |
21
+ | `changelogJson` | `ChangelogJsonConfig` | Controls `.meta/changelog.json`: `enabled` (default `true`), `outputPath` (default `'.meta/changelog.json'`), and `devOnlySections`; see [Custom work types](work-types.md#custom-work-types) |
22
+ | `releaseNotes` | `ReleaseNotesConfig` | `shouldInjectIntoReadme` (default `false`) injects release notes into each package's README while it publishes; see [Release-notes injection](changelogs.md#release-notes-injection) |
23
+
24
+ All fields are optional.
25
+
26
+ ## `WorkspaceOverride`
27
+
28
+ ```typescript
29
+ interface WorkspaceOverride {
30
+ dir: string; // Package directory name (e.g., 'arrays')
31
+ shouldExclude?: boolean; // If true, exclude from release processing
32
+ legacyIdentities?: LegacyIdentity[]; // Prior `(name, tagPrefix)` identities for this workspace
33
+ }
34
+
35
+ interface LegacyIdentity {
36
+ name: string; // Full scoped npm name at the time (e.g., '@scope/pkg')
37
+ tagPrefix: string; // Tag prefix under which historical tags were published (e.g., 'core-v')
38
+ }
39
+ ```
40
+
41
+ `legacyIdentities` captures prior identities of a workspace as complete `(name, tagPrefix)` snapshots. The union of the current `tagPrefix` and each identity's `tagPrefix` is consulted when release-kit searches for the most recent baseline tag and when generating changelogs. Use it when a workspace's historical tags were published under a different npm name, a different tag prefix, or both — typically across a package rename. Both fields are required per identity: each entry must be a complete historical snapshot that stays valid regardless of subsequent renames. Run `release-kit show-tag-prefixes` to detect undeclared candidates and produce a paste-ready config snippet. Listing the current identity (full `(name, tagPrefix)` match) is rejected as a no-op duplicate; an identity whose `tagPrefix` matches the current but whose `name` differs is valid and documents a prior rename that reused the same tag shape. If the workspace no longer exists in this repo at all (the package was extracted or removed), use [`retiredPackages`](#retiredpackage) instead.
42
+
43
+ ## `RetiredPackage`
44
+
45
+ ```typescript
46
+ interface RetiredPackage {
47
+ name: string; // Final scoped npm name while the package lived in this repo
48
+ tagPrefix: string; // Tag prefix under which the package's historical tags were published
49
+ successor?: string; // Optional successor package name (e.g., 'readyup')
50
+ }
51
+ ```
52
+
53
+ `retiredPackages` is the repo-level complement to `legacyIdentities`. Use `legacyIdentities` when the workspace still exists in this repo under a new identity; use `retiredPackages` when no workspace for this package exists in this repo anymore — the package was extracted to another repo or removed outright. Retired entries are inert: release-kit never consults them for baseline lookup or changelog attribution. Their declared `tagPrefix` values are recognized as historical, so `show-tag-prefixes` stops flagging them under "Undeclared tag prefixes."
54
+
55
+ Worked example — `preflight` was extracted from this monorepo and continues as the standalone `readyup` project. Its tags stay in this repo as historical anchors:
56
+
57
+ ```typescript
58
+ import { defineConfig } from '@williamthorsen/release-kit/config';
59
+
60
+ export default defineConfig({
61
+ retiredPackages: [{ name: '@scope/preflight', tagPrefix: 'preflight-v', successor: 'readyup' }],
62
+ });
63
+ ```
64
+
65
+ Validation rules:
66
+
67
+ - `name` and `tagPrefix` are required per entry and must be non-empty strings.
68
+ - `successor` is optional; if present, it must be a non-empty string.
69
+ - Full-tuple `(name, tagPrefix)` duplicates within `retiredPackages` are rejected.
70
+ - Two entries sharing the same `tagPrefix` but different `name`s are accepted — this documents a package renamed within the repo before being retired.
71
+
72
+ `show-tag-prefixes` currently does not render a dedicated "Retired packages" section (deferred). Declaring a retired entry is verifiable by confirming that its `tagPrefix` stops appearing under "Undeclared tag prefixes" in the `show-tag-prefixes` output.
73
+
74
+ ## Tag prefix collisions
75
+
76
+ Tag prefixes from distinct owners must not be identical or be a strict prefix of one another. An owner is one of:
77
+
78
+ - An active workspace, comprising its derived `tagPrefix` plus any declared `legacyIdentities[].tagPrefix`. Identities of the same workspace are one owner, so their prefixes are allowed to overlap (this represents the same package across renames).
79
+ - A `retiredPackages[]` entry (one owner per entry).
80
+ - The `project` block, when configured.
81
+
82
+ release-kit resolves baseline tags via `git describe --match=<prefix>*`, so a strict-prefix overlap between distinct owners would cause that glob to return cross-matches against the wrong owner's history. For example, a project prefix of `v` collides with a workspace prefix of `vue-helpers-v`, since `git describe --match=v*` would return both project tags and `vue-helpers` tags.
83
+
84
+ The rule is enforced at config load; the resulting error identifies both colliding declarations.
85
+
86
+ ## `release-kit show-tag-prefixes`
87
+
88
+ Print a per-workspace table of derived tag prefixes, tag counts, and declared legacy prefixes. Also surfaces any release-shaped tag prefix in the repo that is neither a derived prefix nor declared via `legacyIdentities`, along with a copy-pasteable `workspaces: [...]` config snippet. The snippet uses a `TODO-fill-in-legacy-npm-name` placeholder for each identity's `name`; replace it with the package's prior npm name before pasting.
89
+
90
+ Exits `0` when every workspace derives a prefix and there are no cross-workspace collisions; exits `1` on any derivation failure or collision. Undeclared candidates do not affect the exit code — they surface as a warning via the `legacy tag prefixes are declared` readyup check.
91
+
92
+ In single-package mode, prints a single row with `workspacePath = .` and `derivedPrefix = v`; legacy entries and undeclared-candidate scanning are not applicable.
93
+
94
+ ## `VersionPatterns`
95
+
96
+ Defines which commit types trigger major or minor bumps. Any recognized type not listed defaults to a patch bump.
97
+
98
+ ```typescript
99
+ interface VersionPatterns {
100
+ major: string[]; // Patterns triggering a major bump ('!' = any breaking change)
101
+ minor: string[]; // Commit types triggering a minor bump
102
+ }
103
+ ```
104
+
105
+ Default: `{ major: ['!'], minor: ['feat'] }`