@williamthorsen/release-kit 14.0.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 (110) hide show
  1. package/.meta/changelog.json +173 -0
  2. package/.readyup/kits/default.js +64 -7
  3. package/.readyup/kits/npm-auto-publish.js +127 -75
  4. package/.readyup/manifest.json +22 -7
  5. package/CHANGELOG.md +47 -0
  6. package/README.md +18 -4
  7. package/dist/esm/assertReleasesOnNpm.d.ts +7 -0
  8. package/dist/esm/assertReleasesOnNpm.js +56 -0
  9. package/dist/esm/assertTaggedBaseline.d.ts +2 -2
  10. package/dist/esm/attachChangelogDiagnostics.d.ts +2 -2
  11. package/dist/esm/bin/release-kit.js +10 -6
  12. package/dist/esm/buildChangelogEntries.d.ts +3 -3
  13. package/dist/esm/buildChangelogEntries.js +3 -3
  14. package/dist/esm/buildDependencyGraph.d.ts +11 -11
  15. package/dist/esm/buildDependencyGraph.js +13 -13
  16. package/dist/esm/buildReleaseSummary.d.ts +3 -3
  17. package/dist/esm/buildReleaseSummary.js +4 -12
  18. package/dist/esm/changelogArchive.d.ts +14 -0
  19. package/dist/esm/changelogArchive.js +21 -0
  20. package/dist/esm/changelogJsonFile.d.ts +2 -2
  21. package/dist/esm/changelogOverrides.d.ts +9 -8
  22. package/dist/esm/changelogOverrides.js +3 -6
  23. package/dist/esm/createGithubReleaseCommand.d.ts +2 -1
  24. package/dist/esm/createGithubReleaseCommand.js +7 -4
  25. package/dist/esm/deriveDirectoryConfig.d.ts +13 -0
  26. package/dist/esm/deriveDirectoryConfig.js +59 -0
  27. package/dist/esm/deriveWorkspaceConfig.js +2 -3
  28. package/dist/esm/discoverWorkspaces.d.ts +3 -3
  29. package/dist/esm/enumerateReleaseWindows.d.ts +1 -1
  30. package/dist/esm/enumerateReleaseWindows.js +1 -3
  31. package/dist/esm/findTransitiveDependents.js +3 -3
  32. package/dist/esm/findUnroutedEntryScopes.d.ts +2 -2
  33. package/dist/esm/formatDirectoryPublishSkip.d.ts +3 -0
  34. package/dist/esm/formatDirectoryPublishSkip.js +3 -0
  35. package/dist/esm/formatExcludedSkip.d.ts +5 -2
  36. package/dist/esm/formatExcludedSkip.js +2 -2
  37. package/dist/esm/formatProjectPublishSkip.d.ts +3 -0
  38. package/dist/esm/formatProjectPublishSkip.js +3 -0
  39. package/dist/esm/generateChangelogs.d.ts +7 -7
  40. package/dist/esm/generateChangelogs.js +2 -2
  41. package/dist/esm/help/prepareHelp.d.ts +1 -1
  42. package/dist/esm/help/prepareHelp.js +7 -5
  43. package/dist/esm/init/hasPublishablePackage.d.ts +10 -0
  44. package/dist/esm/init/hasPublishablePackage.js +28 -0
  45. package/dist/esm/init/initCommand.js +23 -13
  46. package/dist/esm/init/scaffold.d.ts +6 -1
  47. package/dist/esm/init/scaffold.js +10 -7
  48. package/dist/esm/init/templates.js +1 -1
  49. package/dist/esm/init/updateTemplatesCommand.d.ts +2 -1
  50. package/dist/esm/init/updateTemplatesCommand.js +12 -6
  51. package/dist/esm/isPublishableManifest.d.ts +2 -0
  52. package/dist/esm/isPublishableManifest.js +5 -0
  53. package/dist/esm/loadConfig.d.ts +12 -12
  54. package/dist/esm/loadConfig.js +79 -30
  55. package/dist/esm/matchesTagPrefix.d.ts +2 -0
  56. package/dist/esm/matchesTagPrefix.js +4 -0
  57. package/dist/esm/npmRegistry.d.ts +35 -0
  58. package/dist/esm/npmRegistry.js +58 -0
  59. package/dist/esm/prepareCommand.d.ts +1 -1
  60. package/dist/esm/prepareCommand.js +50 -36
  61. package/dist/esm/prepareTarget.d.ts +103 -0
  62. package/dist/esm/prepareTarget.js +274 -0
  63. package/dist/esm/previewTagPrefixes.d.ts +5 -3
  64. package/dist/esm/previewTagPrefixes.js +60 -14
  65. package/dist/esm/propagateBumps.js +1 -1
  66. package/dist/esm/publishCommand.js +13 -1
  67. package/dist/esm/readChangelogSections.d.ts +6 -4
  68. package/dist/esm/readChangelogSections.js +18 -4
  69. package/dist/esm/releasePlan.d.ts +4 -2
  70. package/dist/esm/releasePlan.js +34 -12
  71. package/dist/esm/releasePrepareTargets.d.ts +47 -0
  72. package/dist/esm/releasePrepareTargets.js +335 -0
  73. package/dist/esm/renderChangelogMarkdown.d.ts +23 -0
  74. package/dist/esm/renderChangelogMarkdown.js +47 -11
  75. package/dist/esm/reportPrepare.d.ts +2 -3
  76. package/dist/esm/reportPrepare.js +24 -12
  77. package/dist/esm/resolveCommandTags.d.ts +3 -1
  78. package/dist/esm/resolveCommandTags.js +15 -22
  79. package/dist/esm/resolveReleaseDeclaration.d.ts +45 -0
  80. package/dist/esm/resolveReleaseDeclaration.js +153 -0
  81. package/dist/esm/resolveReleaseTags.d.ts +14 -3
  82. package/dist/esm/resolveReleaseTags.js +36 -15
  83. package/dist/esm/resolveReleaseTargets.d.ts +29 -0
  84. package/dist/esm/resolveReleaseTargets.js +131 -0
  85. package/dist/esm/sync-labels/generateCommand.js +2 -1
  86. package/dist/esm/sync-labels/resolveLabels.d.ts +4 -3
  87. package/dist/esm/sync-labels/resolveLabels.js +30 -1
  88. package/dist/esm/sync-labels/templates.js +1 -1
  89. package/dist/esm/sync-labels/types.d.ts +2 -0
  90. package/dist/esm/types.d.ts +251 -167
  91. package/dist/esm/types.js +22 -0
  92. package/dist/esm/validateConfig.js +80 -21
  93. package/dist/esm/validateOverridesCommand.d.ts +1 -1
  94. package/dist/esm/validateOverridesCommand.js +17 -28
  95. package/docs/api.md +25 -58
  96. package/docs/changelogs.md +26 -6
  97. package/docs/configuration.md +80 -17
  98. package/docs/labels.md +30 -5
  99. package/docs/project-releases.md +42 -2
  100. package/docs/publishing.md +3 -3
  101. package/docs/releasing.md +12 -4
  102. package/docs/work-types.md +1 -1
  103. package/package.json +6 -6
  104. package/presets/labels/common.yaml +11 -0
  105. package/dist/esm/releasePrepare.d.ts +0 -36
  106. package/dist/esm/releasePrepare.js +0 -230
  107. package/dist/esm/releasePrepareMono.d.ts +0 -29
  108. package/dist/esm/releasePrepareMono.js +0 -503
  109. package/dist/esm/releasePrepareProject.d.ts +0 -47
  110. package/dist/esm/releasePrepareProject.js +0 -164
@@ -0,0 +1,153 @@
1
+ import path from 'node:path';
2
+ export function getTargetName(workspace) {
3
+ return workspace.targetName ?? workspace.dir;
4
+ }
5
+ export function normalizeWorkspacePath(configuredPath) {
6
+ const normalized = path.posix.normalize(configuredPath.replaceAll('\\', '/'));
7
+ return normalized.replace(/^\.\//, '').replace(/\/+$/, '');
8
+ }
9
+ export function resolveReleaseDeclaration(workspaces, userConfig) {
10
+ if (userConfig?.releases !== undefined) {
11
+ return resolveReleasesBlock(workspaces, userConfig.releases);
12
+ }
13
+ return resolveLegacyBlocks(workspaces, userConfig?.workspaces ?? [], userConfig?.project);
14
+ }
15
+ function applyLegacyIdentities(workspace, legacyIdentities) {
16
+ if (legacyIdentities === undefined) {
17
+ return workspace;
18
+ }
19
+ return { ...workspace, legacyIdentities: legacyIdentities.map((identity) => ({ ...identity })) };
20
+ }
21
+ function claimPath(entryByPath, name, claimedPath, kind) {
22
+ const claimedBy = entryByPath.get(claimedPath);
23
+ if (claimedBy !== undefined) {
24
+ throw new Error(`releases.${name}: ${kind} '${claimedPath}' is already declared by releases.${claimedBy}; declare each ${kind} once`);
25
+ }
26
+ entryByPath.set(claimedPath, name);
27
+ }
28
+ function declareDirectory(name, entry, normalizedPath) {
29
+ if (path.posix.isAbsolute(normalizedPath) || normalizedPath === '..' || normalizedPath.startsWith('../')) {
30
+ throw new Error(`releases.${name}: path '${entry.path}' is outside the repo; declare a repo-relative directory`);
31
+ }
32
+ const isRoot = normalizedPath === '.' || normalizedPath === '';
33
+ return {
34
+ name,
35
+ path: isRoot ? '.' : normalizedPath,
36
+ ...(!isRoot && { paths: [`${normalizedPath}/**`] }),
37
+ ...(entry.tagPrefix !== undefined && { tagPrefix: entry.tagPrefix }),
38
+ ...(entry.legacyIdentities !== undefined && {
39
+ legacyIdentities: entry.legacyIdentities.map((identity) => ({ ...identity })),
40
+ }),
41
+ };
42
+ }
43
+ function resolveCompositeMembers(composite, retained, excluded, directories) {
44
+ const membersByName = new Map([
45
+ ...retained.map((workspace) => [getTargetName(workspace), { name: getTargetName(workspace), paths: workspace.paths }]),
46
+ ...directories.map((directory) => [directory.name, { name: directory.name, paths: directory.paths }]),
47
+ ]);
48
+ return composite.includes.map((memberName) => {
49
+ const member = membersByName.get(memberName);
50
+ if (member !== undefined) {
51
+ return member;
52
+ }
53
+ const label = `releases.${composite.name}.includes: '${memberName}'`;
54
+ if (memberName === composite.name) {
55
+ throw new Error(`${label} is the composite itself; a composite includes package and directory targets only`);
56
+ }
57
+ if (excluded.some((workspace) => workspace.dir === memberName)) {
58
+ throw new Error(`${label} is excluded, so the composite cannot include it`);
59
+ }
60
+ throw new Error(`${label} is not a release target. Known targets: ${membersByName.keys().toArray().join(', ')}`);
61
+ });
62
+ }
63
+ function resolveLegacyBlocks(workspaces, overrides, project) {
64
+ const overridesByDir = new Map(overrides.map((override) => [override.dir, override]));
65
+ const retained = [];
66
+ const excluded = [];
67
+ for (const workspace of workspaces) {
68
+ const override = overridesByDir.get(workspace.dir);
69
+ if (override?.shouldExclude === true) {
70
+ excluded.push(workspace);
71
+ }
72
+ else {
73
+ retained.push(applyLegacyIdentities(workspace, override?.legacyIdentities));
74
+ }
75
+ }
76
+ const declaration = { retained, excluded, directories: [] };
77
+ if (project !== undefined) {
78
+ declaration.composite = {
79
+ ...(project.paths !== undefined && { paths: [...project.paths] }),
80
+ ...(project.tagPrefix !== undefined && { tagPrefix: project.tagPrefix }),
81
+ };
82
+ }
83
+ return declaration;
84
+ }
85
+ function resolveReleasesBlock(workspaces, releases) {
86
+ const workspaceByPath = new Map(workspaces.map((workspace) => [workspace.workspacePath, workspace]));
87
+ const workspaceByDir = new Map(workspaces.map((workspace) => [workspace.dir, workspace]));
88
+ const entryByPath = new Map();
89
+ const excludedPaths = new Set();
90
+ const renamed = new Map();
91
+ const directories = [];
92
+ let composite;
93
+ for (const [name, entry] of Object.entries(releases)) {
94
+ if (entry !== false && 'includes' in entry) {
95
+ if (composite !== undefined) {
96
+ throw new Error(`releases declares more than one composite ('${composite.name}', '${name}'); ` +
97
+ 'a composite is versioned by the root package.json, which only one composite can own');
98
+ }
99
+ composite = {
100
+ name,
101
+ includes: entry.includes,
102
+ ...(entry.tagPrefix !== undefined && { tagPrefix: entry.tagPrefix }),
103
+ };
104
+ continue;
105
+ }
106
+ if (entry === false) {
107
+ const workspace = workspaceByDir.get(name);
108
+ if (workspace === undefined) {
109
+ throw new Error(`releases.${name}: false excludes a discovered workspace by its directory name, and no workspace is named '${name}'. ` +
110
+ `Known workspaces: ${workspaceByDir.keys().toArray().join(', ')}`);
111
+ }
112
+ claimPath(entryByPath, name, workspace.workspacePath, 'workspace');
113
+ excludedPaths.add(workspace.workspacePath);
114
+ continue;
115
+ }
116
+ const normalizedPath = normalizeWorkspacePath(entry.path);
117
+ const workspace = workspaceByPath.get(normalizedPath);
118
+ if (workspace === undefined) {
119
+ const directory = declareDirectory(name, entry, normalizedPath);
120
+ claimPath(entryByPath, name, directory.path, 'directory');
121
+ directories.push(directory);
122
+ continue;
123
+ }
124
+ if (entry.tagPrefix !== undefined) {
125
+ throw new Error(`releases.${name}: tagPrefix applies only to a directory target, and '${workspace.workspacePath}' is a workspace, ` +
126
+ "whose tag prefix derives from its package name; declare the workspace's earlier prefixes in legacyIdentities");
127
+ }
128
+ claimPath(entryByPath, name, workspace.workspacePath, 'workspace');
129
+ const named = applyLegacyIdentities(workspace, entry.legacyIdentities);
130
+ renamed.set(workspace.workspacePath, name === workspace.dir ? named : { ...named, targetName: name });
131
+ }
132
+ const retained = [];
133
+ const excluded = [];
134
+ for (const workspace of workspaces) {
135
+ if (excludedPaths.has(workspace.workspacePath)) {
136
+ excluded.push(workspace);
137
+ }
138
+ else {
139
+ retained.push(renamed.get(workspace.workspacePath) ?? workspace);
140
+ }
141
+ }
142
+ const declaration = { retained, excluded, directories };
143
+ if (composite !== undefined) {
144
+ const members = resolveCompositeMembers(composite, retained, excluded, directories);
145
+ declaration.composite = {
146
+ name: composite.name,
147
+ members: members.map((member) => member.name),
148
+ paths: members.flatMap((member) => member.paths ?? []),
149
+ ...(composite.tagPrefix !== undefined && { tagPrefix: composite.tagPrefix }),
150
+ };
151
+ }
152
+ return declaration;
153
+ }
@@ -1,17 +1,26 @@
1
- import type { WorkspaceConfig } from './types.js';
1
+ import type { DirectoryConfig, ResolvedProjectConfig, WorkspaceConfig } from './types.js';
2
2
  export interface ResolvedTag {
3
3
  tag: string;
4
+ /**
5
+ * Whether the tag belongs to a workspace package, to a directory target, or to the composite. A directory or project
6
+ * tag is never published to a registry but always qualifies for a GitHub Release.
7
+ */
8
+ kind: 'package' | 'directory' | 'project';
9
+ /** Name of the release target that owns the tag; `PROJECT_TARGET_ID` for the composite of a `project` block. */
4
10
  dir: string;
11
+ /** Repo-relative path of the target's package or directory; `.` for the composite. */
5
12
  workspacePath: string;
6
13
  /**
7
14
  * Whether the workspace to which this tag belongs can be published to a registry, copied from the matched
8
- * workspace's `WorkspaceConfig.isPublishable`.
15
+ * workspace's `WorkspaceConfig.isPublishable`. Always false for a project tag.
9
16
  */
10
17
  isPublishable: boolean;
11
18
  }
12
19
  /** Discriminated argument to `resolveReleaseTags`: exactly one of the two keys is meaningful. */
13
20
  type ResolveReleaseTagsArgs = {
14
21
  workspaces: readonly WorkspaceConfig[];
22
+ directories?: readonly DirectoryConfig[];
23
+ project?: Pick<ResolvedProjectConfig, 'tagPrefix' | 'name'>;
15
24
  } | {
16
25
  singleWorkspace: WorkspaceConfig;
17
26
  };
@@ -20,7 +29,9 @@ type ResolveReleaseTagsArgs = {
20
29
  *
21
30
  * Pass `{ workspaces }` for monorepo mode: Each tag is matched against the workspace
22
31
  * whose `tagPrefix` it starts with, and that workspace's `isPublishable` propagates onto
23
- * the `ResolvedTag`. Because `tagPrefix` is derived from `deriveWorkspaceConfig()` (the
32
+ * the `ResolvedTag`. Add `directories` to also match each directory target's tag, which resolves at
33
+ * the directory with `kind: 'directory'`, and `project` to match the project tag, which resolves at the
34
+ * repo root with `kind: 'project'`. Because `tagPrefix` is derived from `deriveWorkspaceConfig()` (the
24
35
  * same source that produced the tag), encoding and decoding stay colocated.
25
36
  *
26
37
  * Pass `{ singleWorkspace }` for single-package mode: Tags like `v1.2.3` are matched and
@@ -1,5 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { GIT_OUTPUT_LIMIT } from '@williamthorsen/nmr-core';
3
+ import { getTargetName } from "./resolveReleaseDeclaration.js";
4
+ import { PROJECT_TARGET_ID } from "./resolveReleaseTargets.js";
3
5
  const VERSION_PATTERN = /^v\d+\.\d+\.\d+/;
4
6
  const SEMVER_SUFFIX_PATTERN = /^\d+\.\d+\.\d+/;
5
7
  export function resolveReleaseTags(args) {
@@ -15,7 +17,7 @@ export function resolveReleaseTags(args) {
15
17
  return resolveSinglePackageTags(tags);
16
18
  }
17
19
  if ('workspaces' in args) {
18
- return resolveMonorepoTags(tags, args.workspaces);
20
+ return resolveMonorepoTags(tags, args.workspaces, args.directories ?? [], args.project);
19
21
  }
20
22
  return resolveSinglePackageTags(tags, args.singleWorkspace);
21
23
  }
@@ -25,30 +27,49 @@ function resolveSinglePackageTags(tags, singleWorkspace) {
25
27
  if (matched.length > 1) {
26
28
  console.warn(`Warning: Multiple version tags found on HEAD: ${matched.join(', ')}. ` +
27
29
  `Publishing the same package multiple times is almost certainly unintended. Using only the first tag.`);
28
- return matched.slice(0, 1).map((tag) => ({ tag, dir: '.', workspacePath: '.', isPublishable }));
30
+ return matched.slice(0, 1).map((tag) => ({ tag, kind: 'package', dir: '.', workspacePath: '.', isPublishable }));
29
31
  }
30
- return matched.map((tag) => ({ tag, dir: '.', workspacePath: '.', isPublishable }));
32
+ return matched.map((tag) => ({ tag, kind: 'package', dir: '.', workspacePath: '.', isPublishable }));
31
33
  }
32
- function resolveMonorepoTags(tags, workspaces) {
34
+ function resolveMonorepoTags(tags, workspaces, directories, project) {
33
35
  const sortedWorkspaces = workspaces.toSorted((a, b) => b.tagPrefix.length - a.tagPrefix.length);
34
36
  const resolved = [];
35
37
  for (const tag of tags) {
36
38
  const match = findMatchingWorkspace(tag, sortedWorkspaces);
39
+ const directory = directories.find((candidate) => hasVersionAfterPrefix(tag, candidate.tagPrefix));
37
40
  if (match !== undefined) {
38
- resolved.push({ tag, dir: match.dir, workspacePath: match.workspacePath, isPublishable: match.isPublishable });
41
+ resolved.push({
42
+ tag,
43
+ kind: 'package',
44
+ dir: getTargetName(match),
45
+ workspacePath: match.workspacePath,
46
+ isPublishable: match.isPublishable,
47
+ });
48
+ }
49
+ else if (directory !== undefined) {
50
+ resolved.push({
51
+ tag,
52
+ kind: 'directory',
53
+ dir: directory.name,
54
+ workspacePath: directory.path,
55
+ isPublishable: false,
56
+ });
57
+ }
58
+ else if (project !== undefined && hasVersionAfterPrefix(tag, project.tagPrefix)) {
59
+ resolved.push({
60
+ tag,
61
+ kind: 'project',
62
+ dir: project.name ?? PROJECT_TARGET_ID,
63
+ workspacePath: '.',
64
+ isPublishable: false,
65
+ });
39
66
  }
40
67
  }
41
68
  return resolved;
42
69
  }
43
70
  function findMatchingWorkspace(tag, sortedWorkspaces) {
44
- for (const workspace of sortedWorkspaces) {
45
- if (!tag.startsWith(workspace.tagPrefix)) {
46
- continue;
47
- }
48
- const versionSuffix = tag.slice(workspace.tagPrefix.length);
49
- if (SEMVER_SUFFIX_PATTERN.test(versionSuffix)) {
50
- return workspace;
51
- }
52
- }
53
- return undefined;
71
+ return sortedWorkspaces.find((workspace) => hasVersionAfterPrefix(tag, workspace.tagPrefix));
72
+ }
73
+ function hasVersionAfterPrefix(tag, tagPrefix) {
74
+ return tag.startsWith(tagPrefix) && SEMVER_SUFFIX_PATTERN.test(tag.slice(tagPrefix.length));
54
75
  }
@@ -0,0 +1,29 @@
1
+ import type { WorkspaceDiscovery } from './discoverWorkspaces.js';
2
+ import type { MonorepoReleaseConfig, ReleaseKitConfig, ReleaseSettings, ReleaseTarget } from './types.js';
3
+ /** Id of the composite target that a `project` block declares. A target name is never empty, so ids cannot clash. */
4
+ export declare const PROJECT_TARGET_ID = "";
5
+ /** Id of the package target at the root of a single-package repo. */
6
+ export declare const SINGLE_PACKAGE_TARGET_ID = ".";
7
+ /** The targets of a release run, in the order in which `prepare` reports them, and the settings that they share. */
8
+ export interface ResolvedReleaseTargets {
9
+ targets: ReleaseTarget[];
10
+ settings: ReleaseSettings;
11
+ }
12
+ /**
13
+ * Projects the user config onto the release targets of the discovered repo shape.
14
+ *
15
+ * A single-package repo yields one package target at the root. A monorepo yields one package target per retained
16
+ * workspace, in discovery order, then one directory target per declared directory, in config order, then a composite
17
+ * target when the config declares one.
18
+ * Throws the config-merge errors of the shape, and when two targets share an id.
19
+ */
20
+ export declare function resolveReleaseTargets(userConfig: ReleaseKitConfig | undefined, discovery: Exclude<WorkspaceDiscovery, {
21
+ kind: 'empty';
22
+ }>): ResolvedReleaseTargets;
23
+ /** Reports whether the targets are those of a single-package repo: its root package target and nothing else. */
24
+ export declare function isSinglePackageTargets(targets: readonly ReleaseTarget[]): boolean;
25
+ /**
26
+ * Projects a merged monorepo config onto its targets: one package target per workspace, in config order, then one
27
+ * directory target per directory, then a composite when the config has one. Throws when two targets share an id.
28
+ */
29
+ export declare function projectMonorepoConfig(config: MonorepoReleaseConfig): ResolvedReleaseTargets;
@@ -0,0 +1,131 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { isPublishableManifest } from "./isPublishableManifest.js";
3
+ import { mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
4
+ import { getTargetName } from "./resolveReleaseDeclaration.js";
5
+ export const PROJECT_TARGET_ID = '';
6
+ export const SINGLE_PACKAGE_TARGET_ID = '.';
7
+ export function resolveReleaseTargets(userConfig, discovery) {
8
+ if (discovery.kind === 'single-package') {
9
+ const settings = mergeSinglePackageConfig(userConfig);
10
+ return { targets: [buildSinglePackageTarget()], settings: pickSettings(settings) };
11
+ }
12
+ return projectMonorepoConfig(mergeMonorepoConfig(discovery.packageDirs, userConfig, readRootPackageVersion()));
13
+ }
14
+ export function isSinglePackageTargets(targets) {
15
+ const [only] = targets;
16
+ return targets.length === 1 && only?.kind === 'package' && !only.isWorkspace;
17
+ }
18
+ export function projectMonorepoConfig(config) {
19
+ const targets = [
20
+ ...config.workspaces.map(buildWorkspaceTarget),
21
+ ...(config.directories ?? []).map(buildDirectoryTarget),
22
+ ];
23
+ if (config.project !== undefined) {
24
+ targets.push(buildProjectTarget(config.project, config.workspaces));
25
+ }
26
+ assertUniqueTargetIds(targets);
27
+ return { targets, settings: pickSettings(config) };
28
+ }
29
+ function assertUniqueTargetIds(targets) {
30
+ const pathsById = new Map();
31
+ for (const target of targets) {
32
+ const path = describeTargetPath(target);
33
+ pathsById.set(target.id, [...(pathsById.get(target.id) ?? []), path]);
34
+ }
35
+ for (const [id, paths] of pathsById) {
36
+ if (paths.length > 1) {
37
+ throw new Error(`Duplicate release target id '${id}' for workspaces: ${paths.join(', ')}`);
38
+ }
39
+ }
40
+ }
41
+ function buildDirectoryTarget(directory) {
42
+ const target = {
43
+ id: directory.name,
44
+ kind: 'directory',
45
+ tagPrefix: directory.tagPrefix,
46
+ versionFiles: [directory.versionFile],
47
+ changelogDir: directory.path,
48
+ isPublishable: false,
49
+ directoryPath: directory.path,
50
+ };
51
+ if (directory.paths !== undefined) {
52
+ target.paths = [...directory.paths];
53
+ }
54
+ if (directory.legacyIdentities !== undefined) {
55
+ target.legacyIdentities = directory.legacyIdentities;
56
+ }
57
+ return target;
58
+ }
59
+ function buildProjectTarget(project, workspaces) {
60
+ return {
61
+ id: project.name ?? PROJECT_TARGET_ID,
62
+ kind: 'composite',
63
+ members: project.members ?? workspaces.map(getTargetName),
64
+ tagPrefix: project.tagPrefix,
65
+ versionFiles: ['./package.json'],
66
+ changelogDir: '.',
67
+ paths: [...project.paths],
68
+ isPublishable: false,
69
+ };
70
+ }
71
+ function buildSinglePackageTarget() {
72
+ return {
73
+ id: SINGLE_PACKAGE_TARGET_ID,
74
+ kind: 'package',
75
+ tagPrefix: 'v',
76
+ versionFiles: ['package.json'],
77
+ changelogDir: '.',
78
+ packagePath: '.',
79
+ isPublishable: readRootPublishability(),
80
+ isWorkspace: false,
81
+ };
82
+ }
83
+ function buildWorkspaceTarget(workspace) {
84
+ const target = {
85
+ id: getTargetName(workspace),
86
+ kind: 'package',
87
+ packageName: workspace.name,
88
+ tagPrefix: workspace.tagPrefix,
89
+ versionFiles: [...workspace.packageFiles],
90
+ changelogDir: workspace.workspacePath,
91
+ paths: [...workspace.paths],
92
+ isPublishable: workspace.isPublishable,
93
+ packagePath: workspace.workspacePath,
94
+ isWorkspace: true,
95
+ };
96
+ if (workspace.legacyIdentities !== undefined) {
97
+ target.legacyIdentities = workspace.legacyIdentities;
98
+ }
99
+ return target;
100
+ }
101
+ function describeTargetPath(target) {
102
+ switch (target.kind) {
103
+ case 'package':
104
+ return target.packagePath;
105
+ case 'directory':
106
+ return target.directoryPath;
107
+ case 'composite':
108
+ return target.changelogDir;
109
+ }
110
+ }
111
+ function pickSettings(config) {
112
+ const { breakingPolicies, changelogJson, changelogMarkdown, formatCommand, releaseNotes, scopeAliases, versionPatterns, workTypes, } = config;
113
+ return {
114
+ changelogJson,
115
+ releaseNotes,
116
+ ...(breakingPolicies !== undefined && { breakingPolicies }),
117
+ ...(changelogMarkdown !== undefined && { changelogMarkdown }),
118
+ ...(formatCommand !== undefined && { formatCommand }),
119
+ ...(scopeAliases !== undefined && { scopeAliases }),
120
+ ...(versionPatterns !== undefined && { versionPatterns }),
121
+ ...(workTypes !== undefined && { workTypes }),
122
+ };
123
+ }
124
+ function readRootPublishability() {
125
+ try {
126
+ return isPublishableManifest(JSON.parse(readFileSync('package.json', 'utf8')));
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
@@ -15,10 +15,11 @@ export function formatLabelsYaml(labels, presetHashes, configFilePath = CONFIG_F
15
15
  for (const [name, hash] of sortedPresetHashes) {
16
16
  headerLines.push(`# ${name} preset hash: ${hash}`);
17
17
  }
18
- const declared = labels.map(({ archived, color, description, name }) => ({
18
+ const declared = labels.map(({ aliases, archived, color, description, name }) => ({
19
19
  name,
20
20
  color,
21
21
  description: description ?? '',
22
+ ...(aliases !== undefined && { aliases }),
22
23
  ...(archived !== undefined && { archived }),
23
24
  }));
24
25
  const yamlBody = stringify(declared, { singleQuote: true, lineWidth: 0 });
@@ -8,8 +8,9 @@ import type { LabelDefinition } from './types.js';
8
8
  * one that an earlier layer defined, or removes it (`null`). Names match case-insensitively, as GitHub matches them,
9
9
  * and the replacing layer's spelling becomes the label's name. Replacement is wholesale, so an entry omitting
10
10
  * `description` drops the one that an earlier layer supplied rather than inheriting it.
11
- * Throws on two misstatements that the output diff would otherwise not show: keys in the `labels` record that differ
12
- * only in case, all but one of which the fold would discard, and a dangling `null`, a removal naming a label that the
13
- * `extends` presets did not define.
11
+ * Throws on three misstatements that the output diff would otherwise not show: keys in the `labels` record that differ
12
+ * only in case, all but one of which the fold would discard; a dangling `null`, a removal naming a label that the
13
+ * `extends` presets did not define; and an alias that names a resolved label or repeats another alias, which makes
14
+ * `github-label-sync` merge one label into another and delete it.
14
15
  */
15
16
  export declare function resolveLabels(config: RepoLabelsConfig): LabelDefinition[];
@@ -26,11 +26,40 @@ export function resolveLabels(config) {
26
26
  name,
27
27
  color: spec.color,
28
28
  ...(spec.description !== undefined && { description: spec.description }),
29
+ ...(spec.aliases !== undefined && { aliases: spec.aliases }),
29
30
  ...(spec.archived !== undefined && { archived: spec.archived }),
30
31
  });
31
32
  }
32
33
  }
33
- return sortLabels(resolved.values().toArray());
34
+ const labels = sortLabels(resolved.values().toArray());
35
+ const aliasCollisions = findAliasCollisions(labels);
36
+ if (aliasCollisions.length > 0) {
37
+ throw new Error(`Aliases must name labels that are not declared and must not repeat, or the sync merges one label into another and deletes it: ${aliasCollisions.join('; ')}.`);
38
+ }
39
+ return labels;
40
+ }
41
+ function findAliasCollisions(labels) {
42
+ const labelNamesByKey = new Map(labels.map((label) => [toLabelKey(label.name), label.name]));
43
+ const aliasOwnersByKey = new Map();
44
+ const collisions = [];
45
+ for (const label of labels) {
46
+ const aliases = label.aliases ?? [];
47
+ for (const alias of aliases) {
48
+ const key = toLabelKey(alias);
49
+ const namedLabel = labelNamesByKey.get(key);
50
+ const earlierOwner = aliasOwnersByKey.get(key);
51
+ if (namedLabel !== undefined) {
52
+ collisions.push(`'${label.name}' declares alias '${alias}', which names the label '${namedLabel}'`);
53
+ }
54
+ else if (earlierOwner !== undefined) {
55
+ collisions.push(`'${label.name}' declares alias '${alias}', which '${earlierOwner}' also declares`);
56
+ }
57
+ else {
58
+ aliasOwnersByKey.set(key, label.name);
59
+ }
60
+ }
61
+ }
62
+ return collisions;
34
63
  }
35
64
  function findCaseVariantGroups(names) {
36
65
  return Map.groupBy(names, toLabelKey)
@@ -11,7 +11,7 @@ on:
11
11
  paths:
12
12
  - .github/labels.yaml
13
13
 
14
- # Preview on review: The check's log has a full computed diff, including deletions of labels not declared by the file.
14
+ # Preview on review: The check's log has the computed diff, and the check warns about each label not declared by the file.
15
15
  pull_request:
16
16
  paths:
17
17
  - .github/labels.yaml
@@ -6,6 +6,8 @@ export interface LabelDefinition {
6
6
  color: string;
7
7
  /** Short description shown in the GitHub UI. Absent when the label has none. */
8
8
  description?: string;
9
+ /** Names of existing labels that the sync renames to this one. Absent when the label has none. */
10
+ aliases?: string[];
9
11
  /** Marks the label for the sync to archive rather than keep active. Absent for an active label. */
10
12
  archived?: true;
11
13
  }