@williamthorsen/release-kit 14.1.0 → 14.2.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 (92) hide show
  1. package/.meta/changelog.json +125 -0
  2. package/.readyup/kits/default.js +63 -6
  3. package/.readyup/kits/npm-auto-publish.js +47 -35
  4. package/.readyup/manifest.json +12 -7
  5. package/CHANGELOG.md +33 -0
  6. package/README.md +18 -8
  7. package/dist/esm/assertTaggedBaseline.d.ts +2 -2
  8. package/dist/esm/attachChangelogDiagnostics.d.ts +2 -2
  9. package/dist/esm/bin/release-kit.js +5 -4
  10. package/dist/esm/buildChangelogEntries.d.ts +3 -3
  11. package/dist/esm/buildChangelogEntries.js +3 -3
  12. package/dist/esm/buildDependencyGraph.d.ts +11 -11
  13. package/dist/esm/buildDependencyGraph.js +13 -13
  14. package/dist/esm/buildReleaseSummary.d.ts +3 -3
  15. package/dist/esm/buildReleaseSummary.js +4 -12
  16. package/dist/esm/changelogArchive.d.ts +14 -0
  17. package/dist/esm/changelogArchive.js +21 -0
  18. package/dist/esm/changelogJsonFile.d.ts +2 -2
  19. package/dist/esm/changelogOverrides.d.ts +9 -8
  20. package/dist/esm/changelogOverrides.js +3 -6
  21. package/dist/esm/createGithubReleaseCommand.d.ts +2 -1
  22. package/dist/esm/createGithubReleaseCommand.js +7 -4
  23. package/dist/esm/deriveDirectoryConfig.d.ts +13 -0
  24. package/dist/esm/deriveDirectoryConfig.js +59 -0
  25. package/dist/esm/deriveWorkspaceConfig.js +0 -1
  26. package/dist/esm/discoverWorkspaces.d.ts +3 -3
  27. package/dist/esm/findTransitiveDependents.js +3 -3
  28. package/dist/esm/findUnroutedEntryScopes.d.ts +2 -2
  29. package/dist/esm/formatDirectoryPublishSkip.d.ts +3 -0
  30. package/dist/esm/formatDirectoryPublishSkip.js +3 -0
  31. package/dist/esm/formatExcludedSkip.d.ts +5 -2
  32. package/dist/esm/formatExcludedSkip.js +2 -2
  33. package/dist/esm/formatProjectPublishSkip.d.ts +3 -0
  34. package/dist/esm/formatProjectPublishSkip.js +3 -0
  35. package/dist/esm/generateChangelogs.d.ts +7 -7
  36. package/dist/esm/generateChangelogs.js +2 -2
  37. package/dist/esm/help/prepareHelp.d.ts +1 -1
  38. package/dist/esm/help/prepareHelp.js +7 -5
  39. package/dist/esm/init/templates.js +1 -1
  40. package/dist/esm/loadConfig.d.ts +12 -12
  41. package/dist/esm/loadConfig.js +79 -30
  42. package/dist/esm/prepareCommand.d.ts +1 -1
  43. package/dist/esm/prepareCommand.js +47 -46
  44. package/dist/esm/prepareTarget.d.ts +103 -0
  45. package/dist/esm/prepareTarget.js +274 -0
  46. package/dist/esm/previewTagPrefixes.d.ts +5 -3
  47. package/dist/esm/previewTagPrefixes.js +58 -13
  48. package/dist/esm/propagateBumps.js +1 -1
  49. package/dist/esm/publishCommand.js +13 -1
  50. package/dist/esm/readChangelogSections.d.ts +6 -4
  51. package/dist/esm/readChangelogSections.js +18 -4
  52. package/dist/esm/releasePlan.d.ts +4 -2
  53. package/dist/esm/releasePlan.js +34 -12
  54. package/dist/esm/releasePrepareTargets.d.ts +47 -0
  55. package/dist/esm/releasePrepareTargets.js +335 -0
  56. package/dist/esm/renderChangelogMarkdown.d.ts +23 -0
  57. package/dist/esm/renderChangelogMarkdown.js +47 -11
  58. package/dist/esm/reportPrepare.d.ts +2 -3
  59. package/dist/esm/reportPrepare.js +24 -12
  60. package/dist/esm/resolveCommandTags.d.ts +3 -1
  61. package/dist/esm/resolveCommandTags.js +15 -22
  62. package/dist/esm/resolveReleaseDeclaration.d.ts +45 -0
  63. package/dist/esm/resolveReleaseDeclaration.js +153 -0
  64. package/dist/esm/resolveReleaseTags.d.ts +14 -3
  65. package/dist/esm/resolveReleaseTags.js +36 -15
  66. package/dist/esm/resolveReleaseTargets.d.ts +29 -0
  67. package/dist/esm/resolveReleaseTargets.js +131 -0
  68. package/dist/esm/sync-labels/generateCommand.js +2 -1
  69. package/dist/esm/sync-labels/resolveLabels.d.ts +4 -3
  70. package/dist/esm/sync-labels/resolveLabels.js +30 -1
  71. package/dist/esm/sync-labels/types.d.ts +2 -0
  72. package/dist/esm/types.d.ts +251 -167
  73. package/dist/esm/types.js +22 -0
  74. package/dist/esm/validateConfig.js +80 -21
  75. package/dist/esm/validateOverridesCommand.d.ts +1 -1
  76. package/dist/esm/validateOverridesCommand.js +17 -28
  77. package/docs/api.md +25 -58
  78. package/docs/changelogs.md +26 -6
  79. package/docs/configuration.md +79 -16
  80. package/docs/labels.md +25 -2
  81. package/docs/project-releases.md +42 -2
  82. package/docs/publishing.md +3 -3
  83. package/docs/releasing.md +4 -4
  84. package/docs/work-types.md +1 -1
  85. package/package.json +2 -2
  86. package/presets/labels/common.yaml +11 -0
  87. package/dist/esm/releasePrepare.d.ts +0 -36
  88. package/dist/esm/releasePrepare.js +0 -230
  89. package/dist/esm/releasePrepareMono.d.ts +0 -29
  90. package/dist/esm/releasePrepareMono.js +0 -503
  91. package/dist/esm/releasePrepareProject.d.ts +0 -47
  92. package/dist/esm/releasePrepareProject.js +0 -164
@@ -9,12 +9,13 @@ export function validateConfig(raw) {
9
9
  if (!parseResult.success) {
10
10
  return {
11
11
  config: {},
12
- errors: [...deprecationErrors, ...parseResult.error.issues.map(formatZodIssue)],
12
+ errors: [...deprecationErrors, ...parseResult.error.issues.flatMap((issue) => formatZodIssue(issue, cleaned))],
13
13
  warnings: [],
14
14
  };
15
15
  }
16
16
  const config = parseResult.data;
17
17
  const errors = [...deprecationErrors];
18
+ detectReleasesConflicts(config, errors);
18
19
  detectLegacyIdentityDuplicates(config, errors);
19
20
  detectRetiredPackageDuplicates(config, errors);
20
21
  detectRetiredVsLegacyCollisions(config, errors);
@@ -57,12 +58,31 @@ function preprocessDeprecatedKeys(raw) {
57
58
  return wsCopy;
58
59
  });
59
60
  }
61
+ if (isRecord(cleaned['releases'])) {
62
+ const releasesCopy = { ...cleaned['releases'] };
63
+ for (const [name, entry] of Object.entries(releasesCopy)) {
64
+ if (!isRecord(entry) || !Object.hasOwn(entry, 'paths'))
65
+ continue;
66
+ errors.push(`releases.${name}: 'paths' is not supported; a package's or a directory's commit window is its path, and a composite's is the union of its members' windows`);
67
+ const entryCopy = { ...entry };
68
+ delete entryCopy['paths'];
69
+ releasesCopy[name] = entryCopy;
70
+ }
71
+ cleaned['releases'] = releasesCopy;
72
+ }
60
73
  return { cleaned, deprecationErrors: errors };
61
74
  }
62
- function formatZodIssue(issue) {
75
+ function formatZodIssue(issue, input) {
76
+ if (issue.code === 'invalid_union' && issue.path.length === 2 && issue.path[0] === 'releases') {
77
+ const branchIssues = issue.errors[selectReleaseEntryBranch(readAtPath(input, issue.path))];
78
+ if (branchIssues === undefined) {
79
+ return [`${renderPath(issue.path)}: ${RELEASE_ENTRY_SHAPES}`];
80
+ }
81
+ return branchIssues.flatMap((branchIssue) => formatZodIssue({ ...branchIssue, path: [...issue.path, ...branchIssue.path] }, input));
82
+ }
63
83
  const path = renderPath(issue.path);
64
84
  const message = customizeMessage(issue);
65
- return path === '' ? message : `${path}: ${message}`;
85
+ return [path === '' ? message : `${path}: ${message}`];
66
86
  }
67
87
  function customizeMessage(issue) {
68
88
  if (issue.code === 'too_small' && issue.origin === 'string') {
@@ -70,6 +90,7 @@ function customizeMessage(issue) {
70
90
  }
71
91
  return issue.message;
72
92
  }
93
+ const RELEASE_ENTRY_SHAPES = 'must be false, { path, tagPrefix?, legacyIdentities? }, or { includes, tagPrefix? }';
73
94
  function renderPath(path) {
74
95
  let rendered = '';
75
96
  for (const segment of path) {
@@ -86,16 +107,12 @@ function renderPath(path) {
86
107
  return rendered;
87
108
  }
88
109
  function detectLegacyIdentityDuplicates(config, errors) {
89
- if (config.workspaces === undefined)
90
- return;
91
- for (const [wsIndex, workspace] of config.workspaces.entries()) {
92
- if (workspace.legacyIdentities === undefined)
93
- continue;
110
+ for (const { label, legacyIdentities } of listLegacyIdentityDeclarations(config)) {
94
111
  const seen = new Set();
95
- for (const [entryIndex, identity] of workspace.legacyIdentities.entries()) {
112
+ for (const [entryIndex, identity] of legacyIdentities.entries()) {
96
113
  const key = `${identity.name}\0${identity.tagPrefix}`;
97
114
  if (seen.has(key)) {
98
- errors.push(`workspaces[${wsIndex}].legacyIdentities[${entryIndex}]: duplicate identity (name='${identity.name}', tagPrefix='${identity.tagPrefix}')`);
115
+ errors.push(`${label}.legacyIdentities[${entryIndex}]: duplicate identity (name='${identity.name}', tagPrefix='${identity.tagPrefix}')`);
99
116
  }
100
117
  seen.add(key);
101
118
  }
@@ -114,22 +131,64 @@ function detectRetiredPackageDuplicates(config, errors) {
114
131
  }
115
132
  }
116
133
  function detectRetiredVsLegacyCollisions(config, errors) {
117
- if (config.retiredPackages === undefined || config.workspaces === undefined)
134
+ if (config.retiredPackages === undefined)
118
135
  return;
119
- const legacyPrefixToWorkspace = new Map();
120
- for (const workspace of config.workspaces) {
121
- if (workspace.legacyIdentities === undefined)
122
- continue;
123
- for (const identity of workspace.legacyIdentities) {
124
- if (!legacyPrefixToWorkspace.has(identity.tagPrefix)) {
125
- legacyPrefixToWorkspace.set(identity.tagPrefix, workspace.dir);
136
+ const legacyPrefixToOwner = new Map();
137
+ for (const { owner, legacyIdentities } of listLegacyIdentityDeclarations(config)) {
138
+ for (const identity of legacyIdentities) {
139
+ if (!legacyPrefixToOwner.has(identity.tagPrefix)) {
140
+ legacyPrefixToOwner.set(identity.tagPrefix, owner);
126
141
  }
127
142
  }
128
143
  }
129
144
  for (const [index, retired] of config.retiredPackages.entries()) {
130
- const collidingDir = legacyPrefixToWorkspace.get(retired.tagPrefix);
131
- if (collidingDir !== undefined) {
132
- errors.push(`retiredPackages[${index}]: tagPrefix '${retired.tagPrefix}' collides with a declared legacyIdentities[].tagPrefix on workspace '${collidingDir}'`);
145
+ const collidingOwner = legacyPrefixToOwner.get(retired.tagPrefix);
146
+ if (collidingOwner !== undefined) {
147
+ errors.push(`retiredPackages[${index}]: tagPrefix '${retired.tagPrefix}' collides with a declared legacyIdentities[].tagPrefix on ${collidingOwner}`);
133
148
  }
134
149
  }
135
150
  }
151
+ function detectReleasesConflicts(config, errors) {
152
+ if (config.releases === undefined)
153
+ return;
154
+ const legacyBlocks = [
155
+ ...(config.workspaces === undefined ? [] : ['workspaces']),
156
+ ...(config.project === undefined ? [] : ['project']),
157
+ ];
158
+ if (legacyBlocks.length === 0)
159
+ return;
160
+ errors.push(`releases cannot be combined with ${legacyBlocks.join(' or ')}; declare every target under releases, or remove releases`);
161
+ }
162
+ function listLegacyIdentityDeclarations(config) {
163
+ const fromWorkspaces = (config.workspaces ?? []).flatMap((workspace, index) => workspace.legacyIdentities === undefined
164
+ ? []
165
+ : [
166
+ {
167
+ label: `workspaces[${index}]`,
168
+ owner: `workspace '${workspace.dir}'`,
169
+ legacyIdentities: workspace.legacyIdentities,
170
+ },
171
+ ]);
172
+ const fromReleases = Object.entries(config.releases ?? {}).flatMap(([name, entry]) => entry === false || !('path' in entry) || entry.legacyIdentities === undefined
173
+ ? []
174
+ : [{ label: `releases.${name}`, owner: `release '${name}'`, legacyIdentities: entry.legacyIdentities }]);
175
+ return [...fromWorkspaces, ...fromReleases];
176
+ }
177
+ function readAtPath(input, path) {
178
+ let value = input;
179
+ for (const segment of path) {
180
+ if (!isRecord(value) || typeof segment === 'symbol')
181
+ return undefined;
182
+ value = value[String(segment)];
183
+ }
184
+ return value;
185
+ }
186
+ function selectReleaseEntryBranch(entry) {
187
+ if (!isRecord(entry))
188
+ return -1;
189
+ if (Object.hasOwn(entry, 'includes'))
190
+ return 2;
191
+ if (Object.hasOwn(entry, 'path'))
192
+ return 1;
193
+ return -1;
194
+ }
@@ -2,7 +2,7 @@ import { type OutputStyle, type StreamStyles } from '@williamthorsen/nmr-core';
2
2
  import { type ReleaseHistoryConfig } from './buildChangelogEntries.js';
3
3
  import { type ValidateAllChangelogOverridesInputs, type ValidateAllChangelogOverridesResult } from './changelogOverrides.js';
4
4
  import { type WorkspaceDiscovery } from './discoverWorkspaces.js';
5
- import { type GenerateChangelogOptions } from './generateChangelogs.js';
5
+ import type { GenerateChangelogOptions } from './generateChangelogs.js';
6
6
  import { type LoadValidatedConfigResult } from './loadValidatedConfig.js';
7
7
  import type { ChangelogEntry } from './types.js';
8
8
  /**
@@ -3,9 +3,9 @@ import { describeError } from '@williamthorsen/toolbelt.errors';
3
3
  import { buildChangelogEntries } from "./buildChangelogEntries.js";
4
4
  import { resolveOverridePath, validateAllChangelogOverrides, } from "./changelogOverrides.js";
5
5
  import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
6
- import { getAllTagPrefixes } from "./generateChangelogs.js";
7
- import { mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
8
6
  import { loadValidatedConfig } from "./loadValidatedConfig.js";
7
+ import { describeHistoryWindow } from "./prepareTarget.js";
8
+ import { isSinglePackageTargets, resolveReleaseTargets } from "./resolveReleaseTargets.js";
9
9
  const SYNTHETIC_VALIDATE_TAG = 'validate-only';
10
10
  export async function validateOverridesCommand(styles, configPath, dependencies = {}) {
11
11
  const discover = dependencies.discoverWorkspaces ?? discoverWorkspaces;
@@ -32,10 +32,7 @@ export async function validateOverridesCommand(styles, configPath, dependencies
32
32
  }
33
33
  let inputs;
34
34
  try {
35
- inputs =
36
- workspace.kind === 'single-package'
37
- ? buildSinglePackageInputs(userConfig, buildEntries)
38
- : buildMonorepoInputs(workspace.packageDirs, userConfig, buildEntries);
35
+ inputs = buildValidationInputs(resolveReleaseTargets(userConfig, workspace), buildEntries);
39
36
  }
40
37
  catch (error) {
41
38
  return { exitCode: 2, message: formatErrorLine(`Failed to resolve overrides scope: ${describeError(error)}`) };
@@ -89,30 +86,22 @@ function formatConfigProblem(problem) {
89
86
  ? formatErrorLine(`Failed to load config: ${problem.message}`)
90
87
  : `Invalid config:\n - ${problem.errors.join('\n - ')}`;
91
88
  }
92
- function buildSinglePackageInputs(userConfig, buildEntries) {
93
- const config = mergeSinglePackageConfig(userConfig);
94
- const items = flattenEntriesToItems(buildEntries(config, { tagPrefixes: [config.tagPrefix] }));
95
- return {
96
- project: { filePath: resolveOverridePath('.'), items },
97
- };
98
- }
99
- function buildMonorepoInputs(discoveredPaths, userConfig, buildEntries) {
100
- const rootPackage = readRootPackageVersion();
101
- const config = mergeMonorepoConfig(discoveredPaths, userConfig, rootPackage);
102
- const workspaces = config.workspaces.map((workspace) => {
103
- const options = { tagPrefixes: getAllTagPrefixes(workspace), paths: workspace.paths, workspaceDir: workspace.dir };
104
- return {
105
- filePath: resolveOverridePath(workspace.workspacePath),
106
- items: flattenEntriesToItems(buildEntries(config, options)),
107
- };
108
- });
109
- const project = config.project;
89
+ function buildValidationInputs({ targets, settings }, buildEntries) {
110
90
  const projectScope = {
111
91
  filePath: resolveOverridePath('.'),
112
92
  };
113
- if (project !== undefined) {
114
- const options = { tagPrefixes: [project.tagPrefix], paths: project.paths };
115
- projectScope.items = flattenEntriesToItems(buildEntries(config, options));
93
+ const workspaces = [];
94
+ for (const target of targets) {
95
+ const items = flattenEntriesToItems(buildEntries(settings, describeHistoryWindow(target)));
96
+ if (target.kind === 'package' && target.isWorkspace) {
97
+ workspaces.push({ filePath: resolveOverridePath(target.packagePath), items });
98
+ }
99
+ else if (target.kind === 'directory' && target.directoryPath !== '.') {
100
+ workspaces.push({ filePath: resolveOverridePath(target.directoryPath), items });
101
+ }
102
+ else {
103
+ projectScope.items = items;
104
+ }
116
105
  }
117
- return { project: projectScope, workspaces };
106
+ return isSinglePackageTargets(targets) ? { project: projectScope } : { project: projectScope, workspaces };
118
107
  }
package/docs/api.md CHANGED
@@ -1,73 +1,40 @@
1
1
  # Programmatic API
2
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.
3
+ The package exports two entries: `.`, which validates changelog override files, and `./config`, which types a config file. Every other module is internal, and the CLI is the supported way to release.
4
4
 
5
- ## Using `deriveWorkspaceConfig()` for manual configuration
5
+ ## `./config`: typing a config file
6
6
 
7
- For a `MonorepoReleaseConfig` built 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:
7
+ `defineConfig()` returns its argument unchanged, typed as a `ReleaseKitConfig`. The entry also exports the `ReleaseKitConfig`, `RepoLabelsConfig`, and `LabelSpec` types.
8
8
 
9
9
  ```typescript
10
- import { deriveWorkspaceConfig } from '@williamthorsen/release-kit';
10
+ // .config/release-kit.config.ts
11
+ import { defineConfig } from '@williamthorsen/release-kit/config';
11
12
 
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` (with any leading `@scope/` 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 that the developer 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')],
13
+ export default defineConfig({
40
14
  formatCommand: 'npx prettier --write',
41
- };
15
+ });
42
16
  ```
43
17
 
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`.
18
+ The entry does not import any value, so a config load does not pull in the rest of the package. [Configuration](configuration.md) describes the fields.
53
19
 
54
- ## `resolveReleaseTags` takes workspaces; `WorkspaceConfig` requires `workspacePath`
20
+ ## `.`: validating override files
55
21
 
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.
22
+ `validateAllChangelogOverrides()` checks override files against the changelog items of their scopes, which the caller supplies, and returns every error and stale-key warning, each prefixed with the path of its file. `formatValidateOverridesResult()` turns that result into the exit code and message that `release-kit overrides validate` prints.
57
23
 
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`:
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')]);
24
+ ```typescript
25
+ import { formatValidateOverridesResult, validateAllChangelogOverrides } from '@williamthorsen/release-kit';
26
+
27
+ const result = validateAllChangelogOverrides({
28
+ project: { filePath: '.meta/changelog-overrides.json' },
29
+ workspaces: [
30
+ {
31
+ filePath: 'packages/core/.meta/changelog-overrides.json',
32
+ items: [{ hash: '3f2a9c1e0b7d4a6f8e5c2b1a0d9e8f7a6b5c4d3e' }, { hash: '9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b', entry: 2 }],
33
+ },
34
+ ],
35
+ });
36
+
37
+ const { exitCode, message } = formatValidateOverridesResult(result, 'plain');
71
38
  ```
72
39
 
73
- When constructing `WorkspaceConfig` objects directly, add `workspacePath` alongside the other required fields.
40
+ An item is a commit hash, with `entry` set to the 1-based position of the change-record entry from which the item derives. A relative `filePath` resolves against the working directory. The exported types are `ValidateAllChangelogOverridesInputs`, `ChangelogOverrideScope`, `OverrideTargetItem`, `ValidateAllChangelogOverridesResult`, and `ValidateOverridesCommandResult`. [Editorial overrides](editorial-overrides.md) describes the file format and the tiers.
@@ -1,8 +1,8 @@
1
1
  # Changelogs
2
2
 
3
- What a published tarball includes, what each `.meta/changelog.json` item contains, which commits appear in a changelog, how a merge commit's change-record block becomes items, and how release notes are written into a README.
3
+ What a published tarball includes, what each `.meta/changelog.json` item contains, how `CHANGELOG.md` archives older versions, which commits appear in a changelog, how a merge commit's change-record block becomes items, and how release notes are written into a README.
4
4
 
5
- npm doesn't include either `CHANGELOG.md` or `.meta/changelog.json` in a published tarball on its own. npm stopped including `CHANGELOG` automatically in npm 7, so a package that declares a `files` field publishes a changelog only when that field names `CHANGELOG.md` and `.meta/changelog.json`. release-kit's readyup kit reports a publishable workspace whose `files` field omits either.
5
+ npm doesn't include either `CHANGELOG.md` or `.meta/changelog.json` in a published tarball on its own. npm stopped including `CHANGELOG` automatically in npm 7, so a package that declares a `files` field publishes a changelog only when that field names `CHANGELOG.md` and `.meta/changelog.json`. release-kit's readyup kit reports a publishable workspace whose `files` field omits either, and, when [`changelogMarkdown.keepVersions`](#archived-versions) is set, one whose `files` field omits `changelogs/`.
6
6
 
7
7
  ## `changelog.json` item schema
8
8
 
@@ -41,6 +41,26 @@ Three properties are worth knowing:
41
41
 
42
42
  The label match is exact: `migration:` and `**Migration:**` are not recognized.
43
43
 
44
+ ## Archived versions
45
+
46
+ `changelogMarkdown.keepVersions` bounds `CHANGELOG.md`, which otherwise grows by one section per release. `.meta/changelog.json` is not affected: It contains every version whatever the option says.
47
+
48
+ ```typescript
49
+ export default defineConfig({
50
+ changelogMarkdown: { keepVersions: 20 },
51
+ });
52
+ ```
53
+
54
+ With the option set, `prepare` writes each changelog as follows:
55
+
56
+ - **`CHANGELOG.md`** contains the newest `keepVersions` sections, counting [hand-written sections](#existing-changelogmd-sections), and a paragraph below its header that links to each archive file.
57
+ - **`changelogs/CHANGELOG-v{major}.md`**, beside `CHANGELOG.md`, contains every older section of that major version, newest first, under the same header and footer. `0.x` versions share `CHANGELOG-v0.md`, and a prerelease belongs to its major.
58
+ - **An archive file that the render no longer produces**, after `keepVersions` is raised or the option is removed: `prepare` deletes it and renders its sections in `CHANGELOG.md` again. Every section is therefore in exactly one file.
59
+
60
+ `prepare` regenerates the archive files on every release of the target, so an override applies to an archived version as it does to any other. A target that does not release keeps its unbounded `CHANGELOG.md` until its next release. The prepare report lists each archive file that it writes and each that it removes.
61
+
62
+ A package that declares a `files` field publishes the archive files only when the field covers `changelogs/`; without them, the links from `CHANGELOG.md` do not resolve in the published package.
63
+
44
64
  ## Which commits appear in a changelog
45
65
 
46
66
  release-kit splits the history reachable from `HEAD` into one window per release tag that matches the scope's tag prefixes, restricted to commits that touch the scope's `paths`. The bump reads the same items: Its level is the highest that the unreleased window's items call for, so a commit that bumps is one that the changelog lists.
@@ -55,7 +75,7 @@ A commit whose last `change-record` block records entries yields its items from
55
75
 
56
76
  ### Existing `CHANGELOG.md` sections
57
77
 
58
- `prepare` also reads the `CHANGELOG.md` that it regenerates, so a version recorded only there, such as history written by hand before release-kit was adopted, survives. The first SemVer token in a `##` heading names the section's version: `## 1.2.0`, `## v1.2.0`, and `## [1.2.0] - 2024-01-01` all name 1.2.0, and so does `## Upgrading from 1.2.0`.
78
+ `prepare` also reads the `CHANGELOG.md` that it regenerates, and its [archive files](#archived-versions), so a version recorded only there, such as history written by hand before release-kit was adopted, survives. The first SemVer token in a `##` heading names the section's version: `## 1.2.0`, `## v1.2.0`, and `## [1.2.0] - 2024-01-01` all name 1.2.0, and so does `## Upgrading from 1.2.0`.
59
79
 
60
80
  - **A version that neither the release windows nor `changelog.json` contain** keeps its section verbatim, placed among the rendered sections in version order.
61
81
  - **A version that either contains** is rendered from its entries, and its old text is discarded.
@@ -67,11 +87,11 @@ The header above the first section and the footer comment are regenerated. The p
67
87
 
68
88
  A workspace's window contains every commit that touches the workspace's paths. An item derived from a title is routed to every workspace whose window contains its commit. An item derived from a change-record entry is routed to a workspace in that window only when the entry's `scopes` route it there:
69
89
 
70
- - **A scope that names the workspace's `dir`** routes the item to that workspace. release-kit resolves each scope through [`scopeAliases`](configuration.md) before it compares the scope with the `dir`.
90
+ - **A scope that names the workspace's target name**, which is its `dir` unless a [`releases`](configuration.md#releases) entry renames it, routes the item to that workspace. release-kit resolves each scope through [`scopeAliases`](configuration.md) before it compares the scope with the name.
71
91
  - **`*`, or an empty or absent `scopes`,** routes the item to every workspace in the window.
72
92
  - **`root`** doesn't name a workspace, so it routes the item nowhere and is not reported. A workspace whose `dir` is `root` is the exception: `root` then names that workspace.
73
93
 
74
- A scope narrows the window and never widens it: A scope that names a workspace whose window does not contain the commit doesn't route the item to any workspace, and `prepare` reports it (see [Reported diagnostics](#reported-diagnostics)). The project changelog and a single-package repo take every entry, whatever its scopes.
94
+ A scope narrows the window and never widens it: A scope that names a workspace whose window does not contain the commit doesn't route the item to any workspace, and `prepare` reports it (see [Reported diagnostics](#reported-diagnostics)). A composite's changelog, such as the project changelog, and a single-package repo take every entry, whatever its scopes.
75
95
 
76
96
  A workspace's bump reads the items routed to it, so a breaking entry scoped to one workspace does not raise a major bump in another that the same commit touched. A workspace whose window has commits but lacks a routed item doesn't call for a release.
77
97
 
@@ -117,7 +137,7 @@ A commit is read from its title, as [Which commits appear in a changelog](#which
117
137
  - **A malformed block**, with the defect that stopped the read.
118
138
  - **An entry whose type is not declared**, with its position. It doesn't yield an item.
119
139
  - **An entry that violates its type's breaking policy**, as a policy violation at the entry's position. Its item is not marked breaking.
120
- - **An entry scope that doesn't name a workspace in its commit's window**, with the commit's hash, the entry's position, and the scope as the entry declares it. release-kit reports it under every workspace whose window contains the commit. A scope that names nothing, or a workspace whose window does not contain the commit, is reported; `*`, `root`, and a scope that names a workspace in the window are not. Under `--only`, a scope that names a configured workspace left out of the run is not reported either. A workspace excluded with `shouldExclude: true` is not a configured workspace, so a scope that names one is reported.
140
+ - **An entry scope that doesn't name a workspace in its commit's window**, with the commit's hash, the entry's position, and the scope as the entry declares it. release-kit reports it under every workspace whose window contains the commit. A scope that names nothing, or a workspace whose window does not contain the commit, is reported; `*`, `root`, and a scope that names a workspace in the window are not. Under `--only`, a scope that names a configured workspace left out of the run is not reported either. A workspace excluded with `shouldExclude: true` or a `releases` entry of `false` is not a configured workspace, so a scope that names one is reported.
121
141
 
122
142
  The result that `prepare` returns includes these as `malformedBlocks`, `undeclaredEntryTypes`, `policyViolations` entries whose `surface` is `'entry'`, and `unroutedEntryScopes`, whether the target releases or is skipped. Because a block whose entries are all undeclared or excluded doesn't yield an item, the commit doesn't raise a bump.
123
143
 
@@ -1,6 +1,6 @@
1
1
  # Configuration
2
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, the patterns that decide a bump, and the environment variable that sets the output style.
3
+ The fields of `.config/release-kit.config.ts`, how the `releases` block names release targets, how a workspace declares its prior identities or a retired package, the rule against colliding tag prefixes, the patterns that decide a bump, and the environment variable that sets the output style.
4
4
 
5
5
  A minimal config file is in the [README](../README.md#configuration).
6
6
 
@@ -16,22 +16,84 @@ The two paths differ in what an absent file means. An absent default path means
16
16
 
17
17
  ## `ReleaseKitConfig` reference
18
18
 
19
- | Field | Type | Description |
20
- | ------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
21
- | `workspaces` | `WorkspaceOverride[]` | Override or exclude discovered workspaces (matched by `dir`) |
22
- | `formatCommand` | `string` | Shell command to run after changelog generation; modified file paths are appended as arguments |
23
- | `versionPatterns` | `VersionPatterns` | Rules for which commit types trigger major/minor bumps |
24
- | `scopeAliases` | `Record<string, string>` | Maps shorthand scope names to canonical names in commit titles and in change-record entry `scopes`, which [route items to workspaces](changelogs.md#routing-to-workspaces). A renamed workspace directory needs an entry from its old name to its new one |
25
- | `workTypes` | `Record<string, WorkTypeConfig>` | Work type definitions, merged with defaults by key |
26
- | `breakingPolicies` | `Record<string, 'forbidden' \| 'optional'>` | Per-type `!`-policy lookup. Defaults to `DEFAULT_BREAKING_POLICIES`. Replaces the default entirely when provided. Set to `{}` to disable enforcement |
27
- | `retiredPackages` | `RetiredPackage[]` | Packages that once lived in this repo but have been extracted or removed; suppresses undeclared-tag-prefix warnings |
28
- | `project` | `ProjectConfig` | Opt-in project-level release block. Declaring `project: {}` (even empty) enables a project-release stage in `prepare` |
29
- | `repoLabels` | `RepoLabelsConfig` | Declares the repository's label registry for `sync-labels`; see [Label configuration](labels.md#label-configuration) |
30
- | `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) |
31
- | `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) |
19
+ | Field | Type | Description |
20
+ | ------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
21
+ | `releases` | `Record<string, ReleaseEntry>` | Names, excludes, or composes release targets; see [`releases`](#releases). Can't be combined with `workspaces` or `project` |
22
+ | `workspaces` | `WorkspaceOverride[]` | Override or exclude discovered workspaces (matched by `dir`) |
23
+ | `formatCommand` | `string` | Shell command to run after changelog generation; modified file paths are appended as arguments |
24
+ | `versionPatterns` | `VersionPatterns` | Rules for which commit types trigger major/minor bumps |
25
+ | `scopeAliases` | `Record<string, string>` | Maps shorthand scope names to canonical names in commit titles and in change-record entry `scopes`, which [route items to workspaces](changelogs.md#routing-to-workspaces). A renamed workspace directory or [release target](#releases) needs an entry from its old name to its new one |
26
+ | `workTypes` | `Record<string, WorkTypeConfig>` | Work type definitions, merged with defaults by key |
27
+ | `breakingPolicies` | `Record<string, 'forbidden' \| 'optional'>` | Per-type `!`-policy lookup. Defaults to `DEFAULT_BREAKING_POLICIES`. Replaces the default entirely when provided. Set to `{}` to disable enforcement |
28
+ | `retiredPackages` | `RetiredPackage[]` | Packages that once lived in this repo but have been extracted or removed; suppresses undeclared-tag-prefix warnings |
29
+ | `project` | `ProjectConfig` | Opt-in project-level release block. Declaring `project: {}` (even empty) enables a project-release stage in `prepare` |
30
+ | `repoLabels` | `RepoLabelsConfig` | Declares the repository's label registry for `sync-labels`; see [Label configuration](labels.md#label-configuration) |
31
+ | `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) |
32
+ | `changelogMarkdown` | `ChangelogMarkdownConfig` | `keepVersions` (an integer, at least 1) bounds `CHANGELOG.md` to that many of the newest versions and moves the rest into per-major archive files; omitted, `CHANGELOG.md` renders every version. See [Archived versions](changelogs.md#archived-versions) |
33
+ | `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) |
32
34
 
33
35
  All fields are optional.
34
36
 
37
+ ## `releases`
38
+
39
+ `releases` maps each release target's name to its declaration. Every discovered workspace is a target, named by its directory, unless an entry excludes or renames it, so the block lists only what differs from the discovered set, along with any directory that releases without being a workspace.
40
+
41
+ ```typescript
42
+ const config: ReleaseKitConfig = {
43
+ releases: {
44
+ core: { path: 'packages/nmr-core', legacyIdentities: [{ name: '@scope/core', tagPrefix: 'core-v' }] },
45
+ docs: { path: 'docs' },
46
+ scratch: false,
47
+ suite: { includes: ['nmr', 'core', 'docs'], tagPrefix: 'v' },
48
+ },
49
+ };
50
+ ```
51
+
52
+ An entry takes one of three shapes:
53
+
54
+ - **`false`** excludes the discovered workspace that the entry's name names by its directory. An excluded workspace is treated as a `shouldExclude` one is: `prepare` never releases it, and `push`, `publish`, and `create-github-release` skip its tags with a warning.
55
+ - **`{ path, tagPrefix?, legacyIdentities? }`** names the discovered workspace at `path`, a repo-relative directory. A `path` that isn't a discovered workspace declares a [directory target](#directory-targets) instead. `legacyIdentities` has the meaning that it has in [`WorkspaceOverride`](#workspaceoverride). `tagPrefix` applies only to a directory target: A workspace's prefix derives from its package name, and an entry that sets one for a workspace is an error.
56
+ - **`{ includes, tagPrefix? }`** declares the composite, a release that rolls up the named package and directory targets. It is versioned by the root `package.json`, writes `./CHANGELOG.md`, and reads the commits of its members' windows. Its tag prefix defaults to `'v'`. A config declares at most one composite, because the root `package.json` holds one version. `includes` names targets by their names in this block, and it names at least one. Like a `project` release, the composite's changelog takes every entry in its window, whatever the entry's scopes.
57
+
58
+ A target's name is its only identity. `--only` and `--set-version` select it by name, and a change-record entry routes to it when a scope names it. A target's name never reaches its tag prefix: A workspace's prefix stays `{unscoped package name}-v` whatever the entry calls it, and a directory's derives from its path. Renaming a workspace in `releases` therefore changes the scope that routes to it; add a [`scopeAliases`](#releasekitconfig-reference) entry from the old name to the new one, so that entries written before the rename still route to the target.
59
+
60
+ `releases` can't be combined with `workspaces` or `project`, which declare the same targets, and a single-package repo rejects it. None of its entries accepts `paths`: A package's or a directory's commit window is its path, and a composite's is its members' windows. [Moving to `releases`](project-releases.md#moving-to-releases) maps each `workspaces` and `project` setting onto the block; both blocks keep working unchanged.
61
+
62
+ ### Directory targets
63
+
64
+ A `{ path }` entry whose path isn't a discovered workspace declares a directory target: a Swift app, a Go binary, a container image, or a docs site that has a version, a changelog, and tags but isn't a JS package.
65
+
66
+ - **Version file.** The directory's `package.json` when it has one, otherwise `version.json` in the directory. Either is a JSON file with a string `version` field, which `prepare` bumps. A directory without either is a config error that names the file to create, such as `{ "version": "0.0.0" }`.
67
+ - **Commit window.** The directory. A commit can appear in several targets' changelogs when their windows overlap.
68
+ - **Tag prefix.** The directory's basename followed by `-v`, such as `devopticon-v` for `apps/devopticon`, unless the entry sets `tagPrefix`. A basename that can't form a tag name, such as one containing a space, is an error that asks for `tagPrefix`.
69
+ - **Changelog.** `CHANGELOG.md` in the directory. A change-record entry routes to the target when a scope names it, as it does to a workspace.
70
+ - **Releases.** `--only` and `--set-version` take the target's name. A directory target never takes part in dependency propagation, and `publish` never publishes its tag. `push` pushes the tag, and `create-github-release` creates a GitHub Release for it from the directory's `.meta/changelog.json`.
71
+
72
+ A path outside the repo, or one that isn't a directory, is an error.
73
+
74
+ #### The repo root
75
+
76
+ `{ path: '.' }` declares the repo root as a directory target, which is how a release states whole-repo coverage. Its window is the whole repo, its version file is the root `package.json`, its tag prefix is `v`, and its `CHANGELOG.md` takes every entry in its window, whatever the entry's scopes. A workspace added later is a target of its own and doesn't narrow the root target's window.
77
+
78
+ The root target and a composite would both version the root `package.json`, so declaring both is an error.
79
+
80
+ #### Worked example: an app that is the repo's deliverable
81
+
82
+ `williamthorsen/devopticon` releases a SwiftPM app in `apps/devopticon`, keeps its version in the root `package.json`, and stamps it into the app bundle at build time. Its only workspace is a scaffold under `packages/`. A `project` block covered the whole repo only while the workspace union was empty, so adding a releasable package would have dropped the app and every root-level commit from the changelog. The root target states the coverage:
83
+
84
+ ```typescript
85
+ export default defineConfig({
86
+ releases: {
87
+ devopticon: { path: '.' },
88
+ scaffold: false,
89
+ },
90
+ });
91
+ ```
92
+
93
+ `prepare` bumps the root `package.json`, writes `./CHANGELOG.md` from every commit since the last `v` tag, and tags `v{version}`. A package added under `packages/` later releases under its own `{name}-v` tags, and the root target still reads the whole repo.
94
+
95
+ To version the app on its own instead, declare `app: { path: 'apps/devopticon' }` with an `apps/devopticon/version.json`; its window is then the app's directory, and its tags are `devopticon-v{version}`.
96
+
35
97
  ## `WorkspaceOverride`
36
98
 
37
99
  ```typescript
@@ -87,8 +149,9 @@ Validation rules:
87
149
  Tag prefixes from distinct owners must not be identical or be a strict prefix of one another. An owner is one of:
88
150
 
89
151
  - 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).
152
+ - A directory target, comprising its `tagPrefix` plus any declared `legacyIdentities[].tagPrefix`, as one owner.
90
153
  - A `retiredPackages[]` entry (one owner per entry).
91
- - The `project` block, when configured.
154
+ - The `project` block or the `releases` composite, when configured.
92
155
 
93
156
  Because release-kit matches a tag to a prefix when the tag name is the prefix followed by a complete SemVer version, a strict-prefix overlap between distinct owners can match the wrong owner's tags. For example, a retired prefix of `pkg-` collides with a workspace prefix of `pkg-1.0.0-v`, since `pkg-1.0.0-v1.2.3` is `pkg-` followed by the pre-release version `1.0.0-v1.2.3`. The rule rejects every strict-prefix overlap, including one such as `v` and `vue-helpers-v`, whose shorter prefix can't match any tag of the longer one.
94
157
 
@@ -96,7 +159,7 @@ The rule is enforced at config load; the resulting error identifies both collidi
96
159
 
97
160
  ## `release-kit show-tag-prefixes`
98
161
 
99
- Prints a per-workspace table of derived tag prefixes, tag counts, and declared legacy prefixes. Also reports 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.
162
+ Prints a per-workspace table of derived tag prefixes, tag counts, and declared legacy prefixes, with a row for each directory target. Also reports 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.
100
163
 
101
164
  Exits `0` when every workspace derives a prefix and the workspaces' prefixes don't collide; exits `1` on any derivation failure or collision. Undeclared candidates do not affect the exit code; the `legacy tag prefixes are declared` readyup check reports them as a warning.
102
165
 
package/docs/labels.md CHANGED
@@ -36,11 +36,13 @@ The block declares the repository's label registry: the set of labels defined on
36
36
 
37
37
  The `common` preset defines a label for every work type in change-grammar's taxonomy, named by the type's `trackerLabel`, in addition to the labels in its bundled YAML file.
38
38
 
39
+ The bundled file's `confidence:` family grades how advisable a recommendation is, for example one that a triage of spike findings turns into a ticket: `confidence:high` is clearly worth doing, `confidence:medium` is worth doing but arguable, and `confidence:low` is not worth a ticket now.
40
+
39
41
  Names match case-insensitively, as GitHub matches them: `Bug` in the `labels` record replaces a preset's `bug`, and `Bug: null` removes it. A label takes the spelling of the last layer to write it.
40
42
 
41
- `description` is optional throughout, in a preset as in the `labels` record, and `sync-labels init` generates none for a scope label. The generated file spells an absent description `''` because `github-label-sync` reads an omitted description as "leave the label's current one alone"; the empty form clears it.
43
+ `description` is optional throughout, in a preset as in the `labels` record, and `sync-labels init` generates none for a scope label. The generated file spells an absent description `''` because `github-label-sync` reads an omitted description as "leave the label's current one alone"; the empty form clears it. Because a scope label without a description groups compactly in GitHub's label list, release-kit's readyup kit warns about each `scope:` label in the generated file that has one.
42
44
 
43
- Overlaps between layers are never errors; order resolves them, and the committed `.github/labels.yaml` diff shows any unexpected change at review. Two misstatements are config errors, because neither is visible in the output diff: a dangling `null`, which removes a name not defined by any preset, and `labels` keys that differ only in case, all but one of which the fold would discard.
45
+ Overlaps between layers are never errors; order resolves them, and the committed `.github/labels.yaml` diff shows any unexpected change at review. Three misstatements are config errors, because none is visible in the output diff: a dangling `null`, which removes a name not defined by any preset; `labels` keys that differ only in case, all but one of which the fold would discard; and an alias that names a resolved label or repeats another alias, which would make the sync merge one label into another and delete it (see [Renaming a label](#renaming-a-label)).
44
46
 
45
47
  ### Retiring a label
46
48
 
@@ -56,6 +58,27 @@ The sync workflow archives a marked label rather than deleting it. An archived l
56
58
 
57
59
  The mark is set only in the `labels` record, not in a preset. To retire a preset's label, replace it with an entry that restates its color and adds the mark.
58
60
 
61
+ ### Renaming a label
62
+
63
+ List a label's old names in `aliases` to rename it:
64
+
65
+ ```typescript
66
+ labels: {
67
+ 'scope:core': { color: '00ff96', aliases: ['scope:basic'] }, // rename `scope:basic` to `scope:core`
68
+ },
69
+ ```
70
+
71
+ The sync renames an existing label that an alias names, in place, rather than deleting it and creating a new one, so the label stays on the issues and pull requests that have it. The dry-run **Label diff** lists the rename. When the repository has both the old name and the new one, the sync adds the new label to every issue and pull request that has the old one, then deletes the old label. A label that an alias names is not reported as undeclared.
72
+
73
+ `aliases` is set only in the `labels` record, not in a preset, and it takes at least one name. Names match case-insensitively. An alias must not name a label that the config resolves, including the label that declares it, and must not appear twice: Either would make the sync merge one declared label into another and delete it. To rename a preset's label, remove it with `null` and alias it from the new entry:
74
+
75
+ ```typescript
76
+ labels: {
77
+ bug: null,
78
+ 'type:bug': { color: 'd73a4a', aliases: ['bug'] },
79
+ },
80
+ ```
81
+
59
82
  ### When labels are applied
60
83
 
61
84
  The scaffolded workflow declares three triggers: