@williamthorsen/release-kit 5.0.0 → 5.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 (73) hide show
  1. package/CHANGELOG.md +149 -49
  2. package/README.md +275 -78
  3. package/cliff.toml.template +26 -17
  4. package/dist/esm/.cache +1 -1
  5. package/dist/esm/assertCleanWorkingTree.js +1 -1
  6. package/dist/esm/bin/release-kit.js +97 -4
  7. package/dist/esm/buildChangelogEntries.d.ts +4 -0
  8. package/dist/esm/buildChangelogEntries.js +173 -0
  9. package/dist/esm/buildDependencyGraph.d.ts +1 -0
  10. package/dist/esm/buildDependencyGraph.js +8 -1
  11. package/dist/esm/buildReleaseSummary.js +9 -1
  12. package/dist/esm/buildSyntheticChangelogEntry.d.ts +5 -0
  13. package/dist/esm/buildSyntheticChangelogEntry.js +13 -0
  14. package/dist/esm/changelogJsonFile.d.ts +4 -0
  15. package/dist/esm/changelogJsonFile.js +68 -0
  16. package/dist/esm/checkWorkTypesDrift.d.ts +11 -0
  17. package/dist/esm/checkWorkTypesDrift.js +110 -0
  18. package/dist/esm/collectPolicyViolations.d.ts +6 -0
  19. package/dist/esm/collectPolicyViolations.js +15 -0
  20. package/dist/esm/createGithubRelease.d.ts +12 -2
  21. package/dist/esm/createGithubRelease.js +12 -8
  22. package/dist/esm/createGithubReleaseCommand.js +10 -6
  23. package/dist/esm/decideRelease.d.ts +28 -0
  24. package/dist/esm/decideRelease.js +44 -0
  25. package/dist/esm/defaults.d.ts +8 -0
  26. package/dist/esm/defaults.js +43 -20
  27. package/dist/esm/deriveWorkspaceConfig.js +3 -0
  28. package/dist/esm/determineBumpFromCommits.d.ts +6 -1
  29. package/dist/esm/determineBumpFromCommits.js +9 -3
  30. package/dist/esm/generateChangelogs.js +14 -29
  31. package/dist/esm/index.d.ts +2 -43
  32. package/dist/esm/index.js +0 -82
  33. package/dist/esm/init/templates.js +2 -2
  34. package/dist/esm/loadConfig.d.ts +10 -1
  35. package/dist/esm/loadConfig.js +110 -24
  36. package/dist/esm/parseCommitMessage.d.ts +8 -2
  37. package/dist/esm/parseCommitMessage.js +32 -3
  38. package/dist/esm/prepareCommand.js +51 -9
  39. package/dist/esm/publish.d.ts +0 -1
  40. package/dist/esm/publish.js +3 -3
  41. package/dist/esm/publishCommand.js +31 -3
  42. package/dist/esm/releasePrepare.js +109 -41
  43. package/dist/esm/releasePrepareMono.js +156 -87
  44. package/dist/esm/releasePrepareProject.d.ts +9 -0
  45. package/dist/esm/releasePrepareProject.js +121 -0
  46. package/dist/esm/renderReleaseNotes.js +2 -1
  47. package/dist/esm/reportPrepare.js +88 -24
  48. package/dist/esm/resolveCommandTags.js +16 -6
  49. package/dist/esm/resolveReleaseTags.d.ts +8 -1
  50. package/dist/esm/resolveReleaseTags.js +11 -7
  51. package/dist/esm/runGitCliff.d.ts +2 -0
  52. package/dist/esm/runGitCliff.js +27 -0
  53. package/dist/esm/stripEmojiPrefix.d.ts +1 -0
  54. package/dist/esm/stripEmojiPrefix.js +7 -0
  55. package/dist/esm/syncWorkTypes.d.ts +10 -0
  56. package/dist/esm/syncWorkTypes.js +90 -0
  57. package/dist/esm/types.d.ts +72 -14
  58. package/dist/esm/validateConfig.js +26 -0
  59. package/dist/esm/validateOnlyExcludesStrandedDependents.d.ts +14 -0
  60. package/dist/esm/validateOnlyExcludesStrandedDependents.js +109 -0
  61. package/dist/esm/work-types.json +127 -0
  62. package/dist/esm/work-types.schema.json +73 -0
  63. package/dist/esm/workTypesData.d.ts +14 -0
  64. package/dist/esm/workTypesData.js +59 -0
  65. package/dist/esm/workTypesUtils.d.ts +5 -0
  66. package/dist/esm/workTypesUtils.js +16 -0
  67. package/package.json +9 -3
  68. package/presets/labels/common.yaml +9 -6
  69. package/schemas/label-map.json +24 -0
  70. package/dist/esm/generateChangelogJson.d.ts +0 -7
  71. package/dist/esm/generateChangelogJson.js +0 -232
  72. package/dist/esm/version.d.ts +0 -1
  73. package/dist/esm/version.js +0 -4
@@ -1,13 +1,41 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import {
4
4
  DEFAULT_CHANGELOG_JSON_CONFIG,
5
+ DEFAULT_PROJECT_TAG_PREFIX,
5
6
  DEFAULT_RELEASE_NOTES_CONFIG,
6
7
  DEFAULT_VERSION_PATTERNS,
7
8
  DEFAULT_WORK_TYPES
8
9
  } from "./defaults.js";
9
10
  import { deriveWorkspaceConfig } from "./deriveWorkspaceConfig.js";
10
11
  import { isRecord } from "./typeGuards.js";
12
+ const ROOT_PACKAGE_JSON_PATH = "package.json";
13
+ function readRootPackageVersion() {
14
+ const absolutePath = path.resolve(process.cwd(), ROOT_PACKAGE_JSON_PATH);
15
+ if (!existsSync(absolutePath)) {
16
+ return { exists: false, version: void 0 };
17
+ }
18
+ let contents;
19
+ try {
20
+ contents = readFileSync(absolutePath, "utf8");
21
+ } catch (error) {
22
+ throw new Error(
23
+ `Failed to read root ${ROOT_PACKAGE_JSON_PATH}: ${error instanceof Error ? error.message : String(error)}`
24
+ );
25
+ }
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(contents);
29
+ } catch (error) {
30
+ throw new Error(
31
+ `Failed to parse root ${ROOT_PACKAGE_JSON_PATH}: ${error instanceof Error ? error.message : String(error)}`
32
+ );
33
+ }
34
+ if (!isRecord(parsed)) {
35
+ return { exists: true, version: void 0 };
36
+ }
37
+ return { exists: true, version: typeof parsed.version === "string" ? parsed.version : void 0 };
38
+ }
11
39
  const CONFIG_FILE_PATH = ".config/release-kit.config.ts";
12
40
  async function loadConfig() {
13
41
  const absoluteConfigPath = path.resolve(process.cwd(), CONFIG_FILE_PATH);
@@ -28,7 +56,7 @@ async function loadConfig() {
28
56
  }
29
57
  return resolved;
30
58
  }
31
- function mergeMonorepoConfig(discoveredPaths, userConfig) {
59
+ function mergeMonorepoConfig(discoveredPaths, userConfig, rootPackage) {
32
60
  let workspaces = discoveredPaths.map((workspacePath) => deriveWorkspaceConfig(workspacePath));
33
61
  assertUniqueTagPrefixes(workspaces);
34
62
  if (userConfig?.workspaces !== void 0) {
@@ -48,10 +76,12 @@ function mergeMonorepoConfig(discoveredPaths, userConfig) {
48
76
  if (userConfig?.retiredPackages !== void 0) {
49
77
  assertRetiredPackagesDoNotCollideWithActive(workspaces, userConfig.retiredPackages);
50
78
  }
79
+ const project = resolveProjectConfig(userConfig?.project, rootPackage);
51
80
  const workTypes = resolveWorkTypes(userConfig?.workTypes);
52
81
  const versionPatterns = userConfig?.versionPatterns === void 0 ? { ...DEFAULT_VERSION_PATTERNS } : { ...userConfig.versionPatterns };
53
82
  const changelogJson = mergeChangelogJsonConfig(userConfig?.changelogJson);
54
83
  const releaseNotes = mergeReleaseNotesConfig(userConfig?.releaseNotes);
84
+ assertNoTagPrefixCollisions(workspaces, userConfig?.retiredPackages, project);
55
85
  const result = {
56
86
  workspaces,
57
87
  workTypes,
@@ -59,21 +89,30 @@ function mergeMonorepoConfig(discoveredPaths, userConfig) {
59
89
  changelogJson,
60
90
  releaseNotes
61
91
  };
62
- const formatCommand = userConfig?.formatCommand;
63
- if (formatCommand !== void 0) {
64
- result.formatCommand = formatCommand;
92
+ if (project !== void 0) {
93
+ result.project = project;
65
94
  }
66
- const cliffConfigPath = userConfig?.cliffConfigPath;
67
- if (cliffConfigPath !== void 0) {
68
- result.cliffConfigPath = cliffConfigPath;
95
+ applyOptionalPassthroughFields(result, userConfig);
96
+ return result;
97
+ }
98
+ function applyOptionalPassthroughFields(result, userConfig) {
99
+ if (userConfig?.formatCommand !== void 0) {
100
+ result.formatCommand = userConfig.formatCommand;
69
101
  }
70
- const scopeAliases = userConfig?.scopeAliases;
71
- if (scopeAliases !== void 0) {
72
- result.scopeAliases = scopeAliases;
102
+ if (userConfig?.cliffConfigPath !== void 0) {
103
+ result.cliffConfigPath = userConfig.cliffConfigPath;
104
+ }
105
+ if (userConfig?.scopeAliases !== void 0) {
106
+ result.scopeAliases = userConfig.scopeAliases;
107
+ }
108
+ if (userConfig?.breakingPolicies !== void 0) {
109
+ result.breakingPolicies = userConfig.breakingPolicies;
73
110
  }
74
- return result;
75
111
  }
76
112
  function mergeSinglePackageConfig(userConfig) {
113
+ if (userConfig?.project !== void 0) {
114
+ throw new Error("project block is not supported in single-package mode");
115
+ }
77
116
  const workTypes = resolveWorkTypes(userConfig?.workTypes);
78
117
  const versionPatterns = userConfig?.versionPatterns === void 0 ? { ...DEFAULT_VERSION_PATTERNS } : { ...userConfig.versionPatterns };
79
118
  const changelogJson = mergeChangelogJsonConfig(userConfig?.changelogJson);
@@ -87,18 +126,7 @@ function mergeSinglePackageConfig(userConfig) {
87
126
  changelogJson,
88
127
  releaseNotes
89
128
  };
90
- const formatCommand = userConfig?.formatCommand;
91
- if (formatCommand !== void 0) {
92
- result.formatCommand = formatCommand;
93
- }
94
- const cliffConfigPath = userConfig?.cliffConfigPath;
95
- if (cliffConfigPath !== void 0) {
96
- result.cliffConfigPath = cliffConfigPath;
97
- }
98
- const scopeAliases = userConfig?.scopeAliases;
99
- if (scopeAliases !== void 0) {
100
- result.scopeAliases = scopeAliases;
101
- }
129
+ applyOptionalPassthroughFields(result, userConfig);
102
130
  return result;
103
131
  }
104
132
  function resolveWorkTypes(userWorkTypes) {
@@ -137,6 +165,62 @@ function assertRetiredPackagesDoNotCollideWithActive(workspaces, retiredPackages
137
165
  }
138
166
  }
139
167
  }
168
+ function resolveProjectConfig(userProject, rootPackage) {
169
+ if (userProject === void 0) {
170
+ return void 0;
171
+ }
172
+ if (rootPackage === void 0 || !rootPackage.exists) {
173
+ throw new Error(
174
+ `project block requires a root ${ROOT_PACKAGE_JSON_PATH}; create one with a 'version' field at the repo root`
175
+ );
176
+ }
177
+ if (rootPackage.version === void 0) {
178
+ throw new Error(
179
+ `project block requires root ${ROOT_PACKAGE_JSON_PATH} to have a 'version' field; add a 'version' field to your root package.json`
180
+ );
181
+ }
182
+ return { tagPrefix: userProject.tagPrefix ?? DEFAULT_PROJECT_TAG_PREFIX };
183
+ }
184
+ function assertNoTagPrefixCollisions(workspaces, retiredPackages, project) {
185
+ const sources = [];
186
+ for (const workspace of workspaces) {
187
+ const owner = `ws:${workspace.dir}`;
188
+ sources.push({ prefix: workspace.tagPrefix, label: `workspace '${workspace.dir}'`, owner });
189
+ for (const identity of workspace.legacyIdentities ?? []) {
190
+ sources.push({
191
+ prefix: identity.tagPrefix,
192
+ label: `workspace '${workspace.dir}' legacyIdentities entry (name='${identity.name}')`,
193
+ owner
194
+ });
195
+ }
196
+ }
197
+ for (const [index, retired] of (retiredPackages ?? []).entries()) {
198
+ sources.push({
199
+ prefix: retired.tagPrefix,
200
+ label: `retiredPackages entry (name='${retired.name}')`,
201
+ owner: `retired:${index}`
202
+ });
203
+ }
204
+ if (project !== void 0) {
205
+ sources.push({ prefix: project.tagPrefix, label: "project", owner: "project" });
206
+ }
207
+ for (let i = 0; i < sources.length; i++) {
208
+ for (let j = i + 1; j < sources.length; j++) {
209
+ const a = sources[i];
210
+ const b = sources[j];
211
+ if (a === void 0 || b === void 0) continue;
212
+ if (a.owner === b.owner) continue;
213
+ if (isPrefixCollision(a.prefix, b.prefix)) {
214
+ throw new Error(
215
+ `Tag prefix collision: '${a.prefix}' (${a.label}) and '${b.prefix}' (${b.label}). One prefix is identical to or a strict prefix of the other; this would cause \`git describe --match=<prefix>*\` to return cross-matches.`
216
+ );
217
+ }
218
+ }
219
+ }
220
+ }
221
+ function isPrefixCollision(a, b) {
222
+ return a === b || a.startsWith(b) || b.startsWith(a);
223
+ }
140
224
  function assertUniqueTagPrefixes(workspaces) {
141
225
  const pathsByPrefix = /* @__PURE__ */ new Map();
142
226
  for (const workspace of workspaces) {
@@ -163,8 +247,10 @@ function mergeReleaseNotesConfig(partial) {
163
247
  }
164
248
  export {
165
249
  CONFIG_FILE_PATH,
250
+ ROOT_PACKAGE_JSON_PATH,
166
251
  loadConfig,
167
252
  mergeMonorepoConfig,
168
253
  mergeSinglePackageConfig,
254
+ readRootPackageVersion,
169
255
  resolveWorkTypes
170
256
  };
@@ -1,3 +1,9 @@
1
- import type { ParsedCommit, WorkTypeConfig } from './types.ts';
1
+ import type { Commit, ParsedCommit, WorkTypeConfig } from './types.ts';
2
2
  export declare const COMMIT_PREPROCESSOR_PATTERNS: readonly RegExp[];
3
- export declare function parseCommitMessage(message: string, hash: string, workTypes: Record<string, WorkTypeConfig>, scopeAliases?: Record<string, string>): ParsedCommit | undefined;
3
+ export type PolicyViolationSurface = 'prefix' | 'body';
4
+ export type PolicyViolationHandler = (commit: Commit, type: string, surface: PolicyViolationSurface) => void;
5
+ export interface ParseCommitMessageOptions {
6
+ breakingPolicies?: Record<string, 'forbidden' | 'optional' | 'required'>;
7
+ onPolicyViolation?: PolicyViolationHandler;
8
+ }
9
+ export declare function parseCommitMessage(message: string, hash: string, workTypes: Record<string, WorkTypeConfig>, scopeAliases?: Record<string, string>, options?: ParseCommitMessageOptions): ParsedCommit | undefined;
@@ -1,6 +1,7 @@
1
1
  const COMMIT_PREPROCESSOR_PATTERNS = [/^##\s+/, /^#\d+([.-]\d+)?\s+/, /^[A-Z]+-\d+\s+/];
2
- function parseCommitMessage(message, hash, workTypes, scopeAliases) {
3
- const stripped = stripTicketPrefix(message);
2
+ function parseCommitMessage(message, hash, workTypes, scopeAliases, options) {
3
+ const firstLine = message.split("\n", 1)[0] ?? "";
4
+ const stripped = stripTicketPrefix(firstLine);
4
5
  const match = stripped.match(/^(?:([^|]+)\|)?(\w+)(?:\(([^)]+)\))?(!)?:\s*(.*)$/);
5
6
  if (!match) {
6
7
  return void 0;
@@ -17,7 +18,15 @@ function parseCommitMessage(message, hash, workTypes, scopeAliases) {
17
18
  if (resolvedType === void 0) {
18
19
  return void 0;
19
20
  }
20
- const breaking = breakingMarker === "!" || message.includes("BREAKING CHANGE:");
21
+ const commit = { message, hash };
22
+ const breaking = evaluateBreakingPolicy({
23
+ commit,
24
+ resolvedType,
25
+ hasPrefixBreaking: breakingMarker === "!",
26
+ hasFooterBreaking: message.includes("BREAKING CHANGE:"),
27
+ policy: options?.breakingPolicies?.[resolvedType] ?? "optional",
28
+ onPolicyViolation: options?.onPolicyViolation
29
+ });
21
30
  const rawScope = pipeScope ?? parenthesizedScope;
22
31
  const resolvedScope = rawScope !== void 0 && scopeAliases !== void 0 ? scopeAliases[rawScope] ?? rawScope : rawScope;
23
32
  return {
@@ -29,6 +38,26 @@ function parseCommitMessage(message, hash, workTypes, scopeAliases) {
29
38
  ...resolvedScope !== void 0 && { scope: resolvedScope }
30
39
  };
31
40
  }
41
+ function evaluateBreakingPolicy(inputs) {
42
+ const { commit, resolvedType, hasPrefixBreaking, hasFooterBreaking, policy, onPolicyViolation } = inputs;
43
+ if (policy === "forbidden") {
44
+ if (hasPrefixBreaking) {
45
+ onPolicyViolation?.(commit, resolvedType, "prefix");
46
+ }
47
+ if (hasFooterBreaking) {
48
+ onPolicyViolation?.(commit, resolvedType, "body");
49
+ }
50
+ return false;
51
+ }
52
+ if (policy === "required") {
53
+ if (!hasPrefixBreaking) {
54
+ onPolicyViolation?.(commit, resolvedType, "prefix");
55
+ return false;
56
+ }
57
+ return true;
58
+ }
59
+ return hasPrefixBreaking || hasFooterBreaking;
60
+ }
32
61
  function resolveType(rawType, workTypes) {
33
62
  const lowered = rawType.toLowerCase();
34
63
  for (const [key, config] of Object.entries(workTypes)) {
@@ -1,13 +1,16 @@
1
1
  import { parseArgs as coreParseArgs, translateParseError, writeFileWithCheck } from "@williamthorsen/nmr-core";
2
2
  import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
3
+ import { buildDependencyGraph } from "./buildDependencyGraph.js";
3
4
  import { buildReleaseSummary } from "./buildReleaseSummary.js";
4
5
  import { discoverWorkspaces } from "./discoverWorkspaces.js";
5
6
  import { dim } from "./format.js";
6
- import { loadConfig, mergeMonorepoConfig, mergeSinglePackageConfig } from "./loadConfig.js";
7
+ import { getCommitsSinceTarget } from "./getCommitsSinceTarget.js";
8
+ import { loadConfig, mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
7
9
  import { releasePrepare } from "./releasePrepare.js";
8
10
  import { releasePrepareMono } from "./releasePrepareMono.js";
9
11
  import { reportPrepare } from "./reportPrepare.js";
10
12
  import { validateConfig } from "./validateConfig.js";
13
+ import { validateOnlyExcludesStrandedDependents } from "./validateOnlyExcludesStrandedDependents.js";
11
14
  const RELEASE_TAGS_FILE = "tmp/.release-tags";
12
15
  const RELEASE_SUMMARY_FILE = "tmp/.release-summary";
13
16
  const VALID_BUMP_TYPES = ["major", "minor", "patch"];
@@ -22,10 +25,14 @@ Usage: npx @williamthorsen/release-kit prepare [options]
22
25
  Options:
23
26
  --dry-run Run without modifying any files
24
27
  --bump=major|minor|patch Override the bump type for all workspaces
25
- --set-version=X.Y.Z Set an explicit version; bypasses commit-derived bumps. Requires --only in monorepo mode.
26
- --force Force a release even when there are no commits since the last tag (requires --bump)
28
+ --set-version=X.Y.Z Set an explicit version; bypasses commit-derived bumps.
29
+ Requires --only in monorepo mode (rejected when a 'project' block is configured).
30
+ --force Release even when no commits or no bump-worthy commits exist
31
+ since the last tag. Defaults to patch when --bump is not given;
32
+ use --bump=X to release at a different level.
27
33
  --no-git-checks, -n Skip the clean-working-tree check
28
- --only=name1,name2 Only process the named workspaces (comma-separated, monorepo only)
34
+ --only=name1,name2 Only process the named workspaces (comma-separated, monorepo only;
35
+ rejected when a 'project' block is configured)
29
36
  --with-release-notes Also write per-workspace release-notes previews under {workspacePath}/docs/
30
37
  (docs/README.v{version}.md and docs/RELEASE_NOTES.v{version}.md).
31
38
  Recommended .gitignore entry: packages/*/docs/*.v*.md (or docs/*.v*.md).
@@ -84,9 +91,6 @@ function parseArgs(argv) {
84
91
  if (setVersion !== void 0 && flags.force) {
85
92
  throw new Error("--set-version cannot be combined with --force");
86
93
  }
87
- if (flags.force && bumpOverride === void 0) {
88
- throw new Error("--force requires --bump to specify the version bump type");
89
- }
90
94
  return {
91
95
  dryRun: flags.dryRun,
92
96
  force: flags.force,
@@ -157,17 +161,36 @@ function runSinglePackageMode(userConfig, options, only, dryRun) {
157
161
  console.error("Error: --only is only supported for monorepo configurations");
158
162
  process.exit(1);
159
163
  }
164
+ if (options.force && options.bumpOverride === void 0) {
165
+ console.error(
166
+ "Error: --force without --bump is only supported for monorepo configurations. Use --bump=major|minor|patch to set the level for a single-package release."
167
+ );
168
+ process.exit(1);
169
+ }
160
170
  const config = mergeSinglePackageConfig(userConfig);
161
171
  runAndReport(() => releasePrepare(config, options), dryRun);
162
172
  }
163
173
  function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion, dryRun) {
164
174
  let config;
165
175
  try {
166
- config = mergeMonorepoConfig(discoveredPaths, userConfig);
176
+ const rootPackage = readRootPackageVersion();
177
+ config = mergeMonorepoConfig(discoveredPaths, userConfig, rootPackage);
167
178
  } catch (error) {
168
179
  console.error(`Error resolving workspaces: ${error instanceof Error ? error.message : String(error)}`);
169
180
  process.exit(1);
170
181
  }
182
+ if (setVersion !== void 0 && config.project !== void 0) {
183
+ console.error(
184
+ "Error: --set-version cannot be combined with a project release. --set-version operates on a single workspace; a project release rolls up every contributing workspace. To use --set-version, run on a config without a `project` block."
185
+ );
186
+ process.exit(1);
187
+ }
188
+ if (only !== void 0 && config.project !== void 0) {
189
+ console.error(
190
+ "Error: --only cannot be combined with a project release. To release a single workspace, use a config without a `project` block, or run a full `prepare` (no --only) to include the project release."
191
+ );
192
+ process.exit(1);
193
+ }
171
194
  if (only !== void 0) {
172
195
  const knownNames = config.workspaces.map((w) => w.dir);
173
196
  for (const name of only) {
@@ -176,6 +199,25 @@ function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion,
176
199
  process.exit(1);
177
200
  }
178
201
  }
202
+ const graph = buildDependencyGraph(config.workspaces);
203
+ const violations = validateOnlyExcludesStrandedDependents(config.workspaces, only, graph, (workspace) => {
204
+ const tagPrefixes = [
205
+ workspace.tagPrefix,
206
+ ...workspace.legacyIdentities?.map((identity) => identity.tagPrefix) ?? []
207
+ ];
208
+ const result = getCommitsSinceTarget(tagPrefixes, workspace.paths);
209
+ return { has: result.commits.length > 0, tag: result.tag };
210
+ });
211
+ if (violations !== void 0) {
212
+ console.error("Error: --only excludes packages with changes that would be stranded by the release.");
213
+ console.error("The following packages must be added to --only or have their dependencies removed:");
214
+ for (const violation of violations) {
215
+ const since = violation.tag ?? "the beginning";
216
+ console.error(` - ${violation.dir} (downstream of ${violation.downstreamOf}; has commits since ${since})`);
217
+ }
218
+ console.error("Alternatively, run `release-kit prepare` without --only to release everything.");
219
+ process.exit(1);
220
+ }
179
221
  config.workspaces = config.workspaces.filter((w) => only.includes(w.dir));
180
222
  }
181
223
  if (setVersion !== void 0) {
@@ -221,7 +263,7 @@ function runAndReport(execute, dryRun) {
221
263
  try {
222
264
  result = execute();
223
265
  } catch (error) {
224
- console.error("Error preparing release:", error instanceof Error ? error.message : String(error));
266
+ console.error(error instanceof Error ? error.message : String(error));
225
267
  process.exit(1);
226
268
  }
227
269
  process.stdout.write(reportPrepare(result) + "\n");
@@ -2,7 +2,6 @@ import type { PackageManager } from './detectPackageManager.ts';
2
2
  import type { ResolvedTag } from './resolveReleaseTags.ts';
3
3
  export interface PublishOptions {
4
4
  dryRun: boolean;
5
- noGitChecks: boolean;
6
5
  provenance: boolean;
7
6
  }
8
7
  export declare function publishPackage(resolvedTag: ResolvedTag, packageManager: PackageManager, options: PublishOptions): void;
@@ -1,8 +1,8 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  function publishPackage(resolvedTag, packageManager, options) {
3
- const { dryRun, noGitChecks, provenance } = options;
3
+ const { dryRun, provenance } = options;
4
4
  const executable = resolveExecutable(packageManager);
5
- const args = buildPublishArgs(packageManager, { dryRun, noGitChecks, provenance });
5
+ const args = buildPublishArgs(packageManager, { dryRun, provenance });
6
6
  console.info(
7
7
  `
8
8
  ${dryRun ? "[dry-run] " : ""}Running: ${executable} ${args.join(" ")} (cwd: ${resolvedTag.workspacePath})`
@@ -20,7 +20,7 @@ function buildPublishArgs(packageManager, options) {
20
20
  if (options.dryRun) {
21
21
  args.push("--dry-run");
22
22
  }
23
- if (options.noGitChecks && packageManager === "pnpm") {
23
+ if (packageManager === "pnpm") {
24
24
  args.push("--no-git-checks");
25
25
  }
26
26
  if (options.provenance && packageManager !== "yarn") {
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parseArgs, translateParseError } from "@williamthorsen/nmr-core";
4
+ import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
4
5
  import { detectPackageManager } from "./detectPackageManager.js";
5
6
  import { injectReleaseNotesIntoReadme, resolveReadmePath } from "./injectReleaseNotesIntoReadme.js";
6
7
  import { parseRequestedTags } from "./parseRequestedTags.js";
@@ -22,21 +23,34 @@ async function publishCommand(argv) {
22
23
  process.exit(1);
23
24
  }
24
25
  const { dryRun, noGitChecks, provenance } = parsed.flags;
26
+ if (!dryRun && !noGitChecks) {
27
+ try {
28
+ assertCleanWorkingTree();
29
+ } catch (error) {
30
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
31
+ process.exit(1);
32
+ }
33
+ }
25
34
  const requestedTags = parseRequestedTags(parsed.flags.tags);
26
35
  const resolvedTags = await resolveCommandTags(requestedTags);
27
36
  if (resolvedTags.length === 0) {
28
37
  return;
29
38
  }
39
+ const publishableTags = filterPublishableTags(resolvedTags, requestedTags !== void 0);
40
+ if (publishableTags.length === 0) {
41
+ console.info("Nothing to publish.");
42
+ return;
43
+ }
30
44
  const packageManager = detectPackageManager();
31
45
  const { releaseNotes, changelogJsonOutputPath, sectionOrder } = await resolveReleaseNotesConfig();
32
46
  const shouldInject = releaseNotes.shouldInjectIntoReadme;
33
47
  console.info(dryRun ? "[dry-run] Would publish:" : "Publishing:");
34
- for (const { tag, workspacePath } of resolvedTags) {
48
+ for (const { tag, workspacePath } of publishableTags) {
35
49
  console.info(` ${tag} (${workspacePath})`);
36
50
  }
37
51
  const published = [];
38
52
  try {
39
- for (const resolvedTag of resolvedTags) {
53
+ for (const resolvedTag of publishableTags) {
40
54
  let readmePath;
41
55
  let originalReadme;
42
56
  if (shouldInject) {
@@ -51,7 +65,7 @@ async function publishCommand(argv) {
51
65
  }
52
66
  }
53
67
  try {
54
- publishPackage(resolvedTag, packageManager, { dryRun, noGitChecks, provenance });
68
+ publishPackage(resolvedTag, packageManager, { dryRun, provenance });
55
69
  published.push(resolvedTag.tag);
56
70
  } finally {
57
71
  if (readmePath !== void 0 && originalReadme !== void 0) {
@@ -70,6 +84,20 @@ async function publishCommand(argv) {
70
84
  process.exit(1);
71
85
  }
72
86
  }
87
+ function filterPublishableTags(resolvedTags, isExplicit) {
88
+ const publishable = [];
89
+ const unpublishable = [];
90
+ for (const tag of resolvedTags) {
91
+ (tag.isPublishable ? publishable : unpublishable).push(tag);
92
+ }
93
+ if (isExplicit && unpublishable.length > 0) {
94
+ for (const { tag, workspacePath } of unpublishable) {
95
+ console.error(`Error: ${tag} (${workspacePath}) cannot be published: package.json#private is true.`);
96
+ }
97
+ process.exit(1);
98
+ }
99
+ return publishable;
100
+ }
73
101
  export {
74
102
  publishCommand
75
103
  };