@williamthorsen/release-kit 10.6.0 → 10.8.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 (42) hide show
  1. package/.meta/changelog.json +96 -0
  2. package/.readyup/kits/default.js +17 -3
  3. package/.readyup/kits/npm-auto-publish.js +87 -23
  4. package/.readyup/manifest.json +9 -9
  5. package/CHANGELOG.md +61 -0
  6. package/README.md +53 -1010
  7. package/dist/esm/bin/release-kit.js +7 -6
  8. package/dist/esm/buildChangelogEntries.d.ts +1 -1
  9. package/dist/esm/buildChangelogEntries.js +14 -5
  10. package/dist/esm/buildDependencyGraph.d.ts +6 -5
  11. package/dist/esm/buildDependencyGraph.js +5 -1
  12. package/dist/esm/checkWorkTypesDrift.d.ts +2 -3
  13. package/dist/esm/checkWorkTypesDrift.js +1 -1
  14. package/dist/esm/init/scaffold.js +14 -6
  15. package/dist/esm/parseCommitMessage.d.ts +4 -5
  16. package/dist/esm/planVersionBump.d.ts +2 -1
  17. package/dist/esm/planVersionBump.js +4 -0
  18. package/dist/esm/propagateBumps.d.ts +1 -3
  19. package/dist/esm/propagateBumps.js +2 -2
  20. package/dist/esm/releasePrepare.js +0 -13
  21. package/dist/esm/releasePrepareMono.js +3 -20
  22. package/dist/esm/sync-labels/resolveLabels.d.ts +5 -3
  23. package/dist/esm/sync-labels/resolveLabels.js +21 -6
  24. package/dist/esm/types.d.ts +12 -9
  25. package/dist/esm/validateConfig.js +1 -1
  26. package/dist/esm/work-types.json +68 -52
  27. package/dist/esm/work-types.schema.json +12 -2
  28. package/dist/esm/workTypesData.d.ts +10 -9
  29. package/dist/esm/workTypesData.js +97 -11
  30. package/docs/api.md +73 -0
  31. package/docs/changelogs.md +70 -0
  32. package/docs/configuration.md +105 -0
  33. package/docs/editorial-overrides.md +144 -0
  34. package/docs/labels.md +83 -0
  35. package/docs/project-releases.md +74 -0
  36. package/docs/publishing.md +30 -0
  37. package/docs/readiness-checks.md +18 -0
  38. package/docs/releasing.md +108 -0
  39. package/docs/work-types.md +116 -0
  40. package/package.json +7 -4
  41. package/dist/esm/readCurrentVersion.d.ts +0 -2
  42. package/dist/esm/readCurrentVersion.js +0 -18
@@ -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
@@ -208,7 +209,7 @@ Subcommands:
208
209
  sync Overwrite the local work-types.json with the upstream contents
209
210
 
210
211
  Exit codes (check):
211
- 0 Match (or upstream missing — transitional warning printed)
212
+ 0 Match (or upstream not found, with a warning)
212
213
  1 Drift detected
213
214
  2 Network error
214
215
  3 Schema mismatch
@@ -222,8 +223,8 @@ function showWorkTypesCheckHelp() {
222
223
  Usage: release-kit work-types check
223
224
 
224
225
  Compare the local work-types.json against the upstream codeassembly canonical and report
225
- drift. Exit 0 on match, 1 on drift, 0 + warning when upstream is missing (transitional),
226
- 2 on network error, 3 on schema mismatch.
226
+ drift. Exit 0 on match, 1 on drift, 0 + warning when upstream is not found, 2 on network
227
+ error, 3 on schema mismatch.
227
228
 
228
229
  Options:
229
230
  --help, -h Show this help message
@@ -23,4 +23,4 @@ export declare function stripGroupDecorations(group: string): string;
23
23
  * not whether git-cliff runs). This means dry-run exercises the full git-cliff toolchain and surfaces missing-binary,
24
24
  * malformed-config, and template-resolution failures earlier than before.
25
25
  */
26
- export declare function buildChangelogEntries(config: Pick<ReleaseConfig, 'cliffConfigPath' | 'changelogJson'>, tag: string, options?: GenerateChangelogOptions): ChangelogEntry[];
26
+ export declare function buildChangelogEntries(config: Pick<ReleaseConfig, 'breakingPolicies' | 'changelogJson' | 'cliffConfigPath' | 'workTypes'>, tag: string, options?: GenerateChangelogOptions): ChangelogEntry[];
@@ -1,8 +1,8 @@
1
1
  import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
2
2
  import { extractVersion } from "./changelogJsonUtils.js";
3
- import { DEFAULT_WORK_TYPES } from "./defaults.js";
3
+ import { DEFAULT_BREAKING_POLICIES, DEFAULT_WORK_TYPES } from "./defaults.js";
4
4
  import { extractMigration } from "./extractMigration.js";
5
- import { COMMIT_PREPROCESSOR_PATTERNS, PIPE_SCOPE_SOURCE } from "./parseCommitMessage.js";
5
+ import { COMMIT_PREPROCESSOR_PATTERNS, parseCommitMessage, PIPE_SCOPE_SOURCE } from "./parseCommitMessage.js";
6
6
  import { resolveCliffConfigPath } from "./resolveCliffConfigPath.js";
7
7
  import { runGitCliff } from "./runGitCliff.js";
8
8
  import { stripEmojiPrefix } from "./stripEmojiPrefix.js";
@@ -30,7 +30,9 @@ export function buildChangelogEntries(config, tag, options) {
30
30
  const contextJson = runGitCliff(resolvedConfigPath, cliffArgs);
31
31
  const releases = parseCliffContext(contextJson);
32
32
  const devOnlySections = new Set(config.changelogJson.devOnlySections);
33
- return transformReleases(releases, devOnlySections);
33
+ const workTypes = config.workTypes ?? DEFAULT_WORK_TYPES;
34
+ const breakingPolicies = config.breakingPolicies ?? DEFAULT_BREAKING_POLICIES;
35
+ return transformReleases(releases, devOnlySections, workTypes, breakingPolicies);
34
36
  }
35
37
  catch (error) {
36
38
  throw chainError(`Failed to build changelog entries for tag ${tag}`, error);
@@ -74,7 +76,7 @@ function toCliffContextCommit(value) {
74
76
  }
75
77
  return commit;
76
78
  }
77
- function transformReleases(releases, devOnlySections) {
79
+ function transformReleases(releases, devOnlySections, workTypes, breakingPolicies) {
78
80
  const entries = [];
79
81
  const devOnlyNormalised = new Set([...devOnlySections].map(stripGroupDecorations));
80
82
  for (const release of releases) {
@@ -89,7 +91,7 @@ function transformReleases(releases, devOnlySections) {
89
91
  const group = stripCommentPrefix(commit.group ?? 'Other');
90
92
  const description = extractDescription(commit.message);
91
93
  const body = extractBody(commit.message);
92
- const breaking = subjectHasBreakingMarker(commit.message);
94
+ const breaking = isBreakingUnderPolicy(commit, workTypes, breakingPolicies);
93
95
  let items = sectionMap.get(group);
94
96
  if (items === undefined) {
95
97
  items = [];
@@ -132,6 +134,13 @@ function transformReleases(releases, devOnlySections) {
132
134
  function stripCommentPrefix(group) {
133
135
  return group.replace(HTML_COMMENT_PREFIX_PATTERN, '');
134
136
  }
137
+ function isBreakingUnderPolicy(commit, workTypes, breakingPolicies) {
138
+ if (!subjectHasBreakingMarker(commit.message)) {
139
+ return false;
140
+ }
141
+ const parsed = parseCommitMessage(commit.message, commit.id ?? '', workTypes, undefined, { breakingPolicies });
142
+ return parsed?.breaking ?? true;
143
+ }
135
144
  const SUBJECT_BREAKING_MARKER_PATTERN = new RegExp(String.raw `^(?:${PIPE_SCOPE_SOURCE}\|)?\w+(?:\([^)]+\))?!:`);
136
145
  function subjectHasBreakingMarker(message) {
137
146
  let subject = message.split('\n', 1)[0] ?? '';
@@ -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;
@@ -4,7 +4,7 @@ export declare const UPSTREAM_WORK_TYPES_URL = "https://raw.githubusercontent.co
4
4
  export interface DriftCheckResult {
5
5
  /**
6
6
  * Process exit code semantics:
7
- * - `0` — match (or upstream missing/transitional, with a warning).
7
+ * - `0` — match (or upstream not found, with a warning).
8
8
  * - `1` — drift detected.
9
9
  * - `2` — network error.
10
10
  * - `3` — schema mismatch (upstream JSON does not parse or fails the schema invariants).
@@ -27,8 +27,7 @@ export interface CheckWorkTypesDriftDependencies {
27
27
  *
28
28
  * Failure modes:
29
29
  * - Network error → exit 2 with a diagnostic.
30
- * - Upstream 404 → exit 0 with a transitional warning (codeassembly hasn't published the
31
- * JSON yet; the check is informational while migration is in flight).
30
+ * - Upstream 404 → exit 0 with a warning.
32
31
  * - Schema mismatch → exit 3 with a diagnostic.
33
32
  * - Drift → exit 1 with a unified-diff-style message.
34
33
  * - Match → exit 0.
@@ -38,7 +38,7 @@ export async function checkWorkTypesDrift(dependencies = {}) {
38
38
  if (response.status === 404) {
39
39
  return {
40
40
  exitCode: 0,
41
- message: `Upstream work-types.json not yet published at ${url}; skipping drift check.`,
41
+ message: `Upstream work-types.json not found at ${url}; skipping drift check.`,
42
42
  };
43
43
  }
44
44
  if (!response.ok) {
@@ -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
  }
@@ -15,10 +15,9 @@ export type PolicyViolationSurface = 'prefix' | 'body';
15
15
  /**
16
16
  * Callback invoked when `parseCommitMessage` detects a `!`-policy violation.
17
17
  *
18
- * Two-tier policy: at write-time (commit-msg hook, OOS for ticket #355), violations are
19
- * rejected outright; at release-time (this parser), violations are warn-and-continue so
20
- * legacy log entries don't block releases. Callers (`decideRelease`, `releasePrepareMono`,
21
- * etc.) collect callback invocations and surface them in the release report.
18
+ * The parser warns and continues on a violation, so legacy log entries don't block releases.
19
+ * Callers (`decideRelease`, `releasePrepareMono`, etc.) collect callback invocations and
20
+ * surface them in the release report.
22
21
  */
23
22
  export type PolicyViolationHandler = (commit: Commit, type: string, surface: PolicyViolationSurface) => void;
24
23
  /** Optional configuration for `parseCommitMessage` beyond the core inputs. */
@@ -47,6 +46,6 @@ export interface ParseCommitMessageOptions {
47
46
  * `!`-policy enforcement is **release-time tolerant**: when the resolved type's policy
48
47
  * forbids `!`, the marker is dropped from the parse (`breaking: false`) and
49
48
  * `onPolicyViolation` is invoked. When the policy requires `!`, a bare type triggers the
50
- * same warning path. The strict write-time gate (commit-msg hook) is out of scope.
49
+ * same warning path.
51
50
  */
52
51
  export declare function parseCommitMessage(message: string, hash: string, workTypes: Record<string, WorkTypeConfig>, scopeAliases?: Record<string, string>, options?: ParseCommitMessageOptions): ParsedCommit | undefined;
@@ -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
  }
@@ -11,19 +11,20 @@ export interface ChangelogItem {
11
11
  /**
12
12
  * Whether this item represents a breaking change.
13
13
  *
14
- * `true` when the commit subject carries the `!` prefix (e.g. `feat!:` or `drop(scope)!:`).
15
- * The `BREAKING CHANGE:` body footer is intentionally NOT considered here — only the prefix
16
- * `!` marks a changelog item as breaking. Renderers prefix breaking-item bullets with the
17
- * marker constructed from `WORK_TYPES_DATA.markers.breaking` (rendered as `🚨 **Breaking:** `
18
- * with the canonical SSOT values) to surface them prominently in release notes.
14
+ * `true` when the commit subject carries the `!` prefix (e.g. `feat!:` or `drop(scope)!:`) and
15
+ * the commit's work-type policy permits it, so the item agrees with the version bump.
16
+ * The `BREAKING CHANGE:` body footer is intentionally NOT considered here. Renderers prefix
17
+ * breaking-item bullets with the marker constructed from `WORK_TYPES_DATA.markers.breaking`
18
+ * (rendered as `🚨 **Breaking:** ` with the canonical SSOT values) to surface them prominently
19
+ * in release notes.
19
20
  */
20
21
  breaking?: boolean;
21
22
  /**
22
23
  * Migration instruction for a consumer, taken from the `Migration:` paragraph of `body`.
23
24
  *
24
25
  * Derived from `body` wherever `body` is set, and absent where the body carries no labeled
25
- * paragraph. Independent of `breaking`: a `fix` that tightens validation cannot carry `!` under
26
- * the default breaking policy and still imposes a migration. `body` keeps the paragraph, so
26
+ * paragraph. Independent of `breaking`: a `deprecate` commit cannot carry `!` under the default
27
+ * breaking policy and still calls for a migration. `body` keeps the paragraph, so
27
28
  * `CHANGELOG.md` goes on rendering it in place.
28
29
  */
29
30
  migration?: string;
@@ -388,8 +389,10 @@ export type LabelSpec = z.infer<typeof labelSpecSchema>;
388
389
  * the GitHub repo, distinct from labels applied to PRs and issues).
389
390
  *
390
391
  * 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.
392
+ * where an entry adds a label, replaces one an earlier layer defined, or removes it (`null`). Names match
393
+ * case-insensitively, as GitHub matches them, and the replacing layer's spelling wins.
394
+ * Two misstatements are resolve-time errors (`resolveLabels`), not schema errors: `labels` keys that differ only in
395
+ * case, and a `null` naming a label that no earlier layer defined.
393
396
  */
394
397
  export declare const repoLabelsSchema: z.ZodObject<{
395
398
  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;