@williamthorsen/release-kit 18.1.1 → 18.3.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 (95) hide show
  1. package/.meta/changelog.json +150 -0
  2. package/.readyup/kits/default.js +596 -32
  3. package/.readyup/kits/npm-auto-publish.js +1 -1
  4. package/.readyup/manifest.json +55 -33
  5. package/CHANGELOG.md +44 -0
  6. package/README.md +19 -2
  7. package/dist/esm/assertCleanWorkingTree.d.ts +2 -1
  8. package/dist/esm/assertCleanWorkingTree.js +4 -8
  9. package/dist/esm/assertReleasesOnNpm.js +2 -9
  10. package/dist/esm/assertTarballsComplete.js +2 -9
  11. package/dist/esm/buildChangelogEntries.d.ts +5 -3
  12. package/dist/esm/buildChangelogEntries.js +9 -4
  13. package/dist/esm/buildChangelogItem.d.ts +1 -1
  14. package/dist/esm/buildDependencyGraph.js +2 -16
  15. package/dist/esm/buildReleasedResult.d.ts +15 -3
  16. package/dist/esm/buildReleasedResult.js +23 -2
  17. package/dist/esm/changelogJsonFile.d.ts +3 -2
  18. package/dist/esm/changelogJsonFile.js +14 -17
  19. package/dist/esm/changelogJsonUtils.d.ts +22 -2
  20. package/dist/esm/changelogJsonUtils.js +19 -10
  21. package/dist/esm/cli/maintenanceCommands.js +1 -1
  22. package/dist/esm/commitCommand.d.ts +2 -1
  23. package/dist/esm/commitCommand.js +8 -11
  24. package/dist/esm/configSchema.d.ts +3 -1
  25. package/dist/esm/configSchema.js +1 -0
  26. package/dist/esm/createGithubRelease.d.ts +13 -10
  27. package/dist/esm/createGithubRelease.js +9 -13
  28. package/dist/esm/createGithubReleaseCommand.d.ts +1 -1
  29. package/dist/esm/createGithubReleaseCommand.js +25 -3
  30. package/dist/esm/createTags.d.ts +2 -1
  31. package/dist/esm/createTags.js +12 -12
  32. package/dist/esm/decideDirectReleases.d.ts +2 -1
  33. package/dist/esm/decideDirectReleases.js +5 -5
  34. package/dist/esm/decideRelease.d.ts +1 -1
  35. package/dist/esm/defaults.d.ts +4 -2
  36. package/dist/esm/defaults.js +1 -0
  37. package/dist/esm/deriveDirectoryConfig.js +5 -4
  38. package/dist/esm/deriveWorkspaceConfig.js +2 -9
  39. package/dist/esm/detectUndeclaredTagPrefixes.d.ts +2 -1
  40. package/dist/esm/detectUndeclaredTagPrefixes.js +4 -13
  41. package/dist/esm/determineBumpType.d.ts +2 -1
  42. package/dist/esm/determineBumpType.js +5 -0
  43. package/dist/esm/enumerateReleaseWindows.d.ts +3 -11
  44. package/dist/esm/enumerateReleaseWindows.js +17 -60
  45. package/dist/esm/findUnroutedEntryScopes.d.ts +1 -1
  46. package/dist/esm/git.d.ts +61 -0
  47. package/dist/esm/git.js +101 -0
  48. package/dist/esm/init/checks.d.ts +2 -1
  49. package/dist/esm/init/checks.js +5 -10
  50. package/dist/esm/init/eligibility.d.ts +12 -0
  51. package/dist/esm/init/eligibility.js +50 -0
  52. package/dist/esm/init/initCommand.d.ts +0 -2
  53. package/dist/esm/init/initCommand.js +4 -45
  54. package/dist/esm/init/managed-workflows.d.ts +10 -0
  55. package/dist/esm/init/managed-workflows.js +18 -0
  56. package/dist/esm/init/templates.js +2 -2
  57. package/dist/esm/init/updateTemplatesCommand.js +9 -42
  58. package/dist/esm/injectReleaseNotesIntoReadme.d.ts +29 -7
  59. package/dist/esm/injectReleaseNotesIntoReadme.js +13 -21
  60. package/dist/esm/loadConfig.js +3 -16
  61. package/dist/esm/mergeConfig.d.ts +2 -1
  62. package/dist/esm/packFromCleanCheckout.js +3 -9
  63. package/dist/esm/planVersionBump.js +2 -16
  64. package/dist/esm/prepareCommand.js +2 -1
  65. package/dist/esm/prepareComposite.js +1 -1
  66. package/dist/esm/prepareReleaseTargets.d.ts +2 -1
  67. package/dist/esm/prepareReleaseTargets.js +3 -2
  68. package/dist/esm/prepareRunContext.d.ts +2 -0
  69. package/dist/esm/prepareTarget.d.ts +6 -4
  70. package/dist/esm/prepareTarget.js +9 -3
  71. package/dist/esm/previewTagPrefixes.d.ts +9 -2
  72. package/dist/esm/previewTagPrefixes.js +22 -22
  73. package/dist/esm/publishCommand.d.ts +1 -1
  74. package/dist/esm/publishCommand.js +27 -2
  75. package/dist/esm/pushRelease.d.ts +2 -1
  76. package/dist/esm/pushRelease.js +11 -11
  77. package/dist/esm/readJsonFile.d.ts +2 -0
  78. package/dist/esm/readJsonFile.js +17 -0
  79. package/dist/esm/reportPrepare.js +68 -114
  80. package/dist/esm/resolveReleaseTags.d.ts +2 -1
  81. package/dist/esm/resolveReleaseTags.js +4 -12
  82. package/dist/esm/showTagPrefixesCommand.d.ts +6 -6
  83. package/dist/esm/showTagPrefixesCommand.js +94 -39
  84. package/dist/esm/sync-labels/generateCommand.js +0 -4
  85. package/dist/esm/sync-labels/initCommand.js +0 -4
  86. package/dist/esm/sync-labels/syncCommand.js +0 -4
  87. package/dist/esm/types.d.ts +8 -2
  88. package/docs/configuration.md +15 -6
  89. package/docs/labels.md +2 -2
  90. package/docs/releasing.md +3 -2
  91. package/docs/work-types.md +2 -0
  92. package/package.json +7 -7
  93. package/presets/labels/common.yaml +14 -0
  94. package/dist/esm/sync-labels/retiredConfig.d.ts +0 -11
  95. package/dist/esm/sync-labels/retiredConfig.js +0 -12
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { isRecord } from '@williamthorsen/nmr-core';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
3
4
  import { isUnknownArray } from "./typeGuards.js";
4
5
  export function isChangelogEntry(value) {
5
6
  return (isRecord(value) &&
@@ -11,19 +12,27 @@ export function extractVersion(tag) {
11
12
  const match = /(\d+\.\d+\.\d+.*)$/.exec(tag);
12
13
  return match?.[1] ?? tag;
13
14
  }
14
- export function readChangelogEntries(filePath) {
15
+ export function describeChangelogFileProblem(status, filePath) {
16
+ return status === 'missing' ? `${filePath} not found` : `could not parse ${filePath}`;
17
+ }
18
+ export function loadChangelogEntries(filePath) {
15
19
  if (!existsSync(filePath)) {
16
- return undefined;
20
+ return { status: 'missing' };
17
21
  }
22
+ let parsedJson;
18
23
  try {
19
- const content = readFileSync(filePath, 'utf8');
20
- const parsedJson = JSON.parse(content);
21
- if (!isUnknownArray(parsedJson)) {
22
- return undefined;
23
- }
24
- return parsedJson.filter(isChangelogEntry);
24
+ parsedJson = JSON.parse(readFileSync(filePath, 'utf8'));
25
+ }
26
+ catch (error) {
27
+ return { status: 'unreadable', reason: describeError(error) };
25
28
  }
26
- catch {
27
- return undefined;
29
+ if (!isUnknownArray(parsedJson)) {
30
+ return { status: 'unreadable', reason: 'the top-level value is not an array' };
28
31
  }
32
+ const entries = parsedJson.filter(isChangelogEntry);
33
+ return { status: 'loaded', entries, rejectedCount: parsedJson.length - entries.length };
34
+ }
35
+ export function readChangelogEntries(filePath) {
36
+ const result = loadChangelogEntries(filePath);
37
+ return result.status === 'loaded' ? result.entries : undefined;
29
38
  }
@@ -12,7 +12,7 @@ import { CONFIG_FLAG, runReportingFailures } from "./commandKit.js";
12
12
  const { defineCommand, defineGroup } = createCli();
13
13
  export const showTagPrefixes = defineCommand({
14
14
  summary: 'Show derived and declared legacy tag prefixes per workspace',
15
- description: 'Print a per-workspace table of derived tag prefixes, tag counts, and declared legacy tag prefixes. Reports any release-shaped tags whose prefix is neither a derived prefix nor declared in `legacyIdentities`, with a copy-pasteable config snippet.',
15
+ description: 'Print one block per workspace with its derived tag prefix, declared legacy identities, and tag counts, followed by the declared retired packages. Reports any release-shaped tags whose prefix is neither derived nor declared, with a copy-pasteable config snippet.',
16
16
  flags: CONFIG_FLAG,
17
17
  run: ({ flags, context }) => runReportingFailures(context.styles, () => {
18
18
  enterRepoRoot();
@@ -1,3 +1,4 @@
1
+ import { type Git } from './git.js';
1
2
  export interface CommitCommandOptions {
2
3
  isDryRun: boolean;
3
4
  }
@@ -5,4 +6,4 @@ export interface CommitCommandOptions {
5
6
  * Runs the CLI `commit` command: reads the tags and summary files written by `prepare`, stages all changes, and
6
7
  * creates the release commit.
7
8
  */
8
- export declare function commitCommand({ isDryRun }: CommitCommandOptions): void;
9
+ export declare function commitCommand({ isDryRun }: CommitCommandOptions, git?: Git): void;
@@ -1,9 +1,9 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import { readFileSync } from 'node:fs';
3
- import { GIT_OUTPUT_LIMIT_BYTES, hasErrnoCode, UserError } from '@williamthorsen/nmr-core';
2
+ import { hasErrnoCode, UserError } from '@williamthorsen/nmr-core';
3
+ import { realGit } from "./git.js";
4
4
  import { readReleaseTags, RELEASE_SUMMARY_FILE, resolveReleaseTagsPath } from "./releaseFiles.js";
5
5
  import { asUserError } from "./user-error-chaining.js";
6
- export function commitCommand({ isDryRun }) {
6
+ export function commitCommand({ isDryRun }, git = realGit) {
7
7
  const tags = readReleaseTags();
8
8
  if (tags.length === 0) {
9
9
  throw new UserError(`Tags file at ${resolveReleaseTagsPath()} is empty. Run \`release-kit prepare\` first.`);
@@ -25,13 +25,10 @@ export function commitCommand({ isDryRun }) {
25
25
  console.info('[dry-run] Would create commit with message:\n');
26
26
  console.info(message);
27
27
  try {
28
- const status = execFileSync('git', ['status', '--porcelain'], {
29
- encoding: 'utf8',
30
- maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
31
- });
32
- if (status.trim().length > 0) {
28
+ const status = git.readStatus();
29
+ if (status.length > 0) {
33
30
  console.info('\nUncommitted changes:');
34
- console.info(status.trimEnd());
31
+ console.info(status);
35
32
  }
36
33
  }
37
34
  catch {
@@ -40,8 +37,8 @@ export function commitCommand({ isDryRun }) {
40
37
  return;
41
38
  }
42
39
  try {
43
- execFileSync('git', ['add', '-A'], { maxBuffer: GIT_OUTPUT_LIMIT_BYTES });
44
- execFileSync('git', ['commit', '-m', message], { maxBuffer: GIT_OUTPUT_LIMIT_BYTES });
40
+ git.addAll();
41
+ git.commit(message);
45
42
  }
46
43
  catch (error) {
47
44
  throw asUserError(error);
@@ -105,10 +105,11 @@ export declare const workTypeConfigSchema: z.ZodObject<{
105
105
  aliases: z.ZodOptional<z.ZodArray<z.ZodString>>;
106
106
  excludedFromChangelog: z.ZodOptional<z.ZodBoolean>;
107
107
  }, z.core.$strict>;
108
- /** Schema for the `versionPatterns` block. Both arrays are required when the field is provided. */
108
+ /** Schema for the `versionPatterns` block. `major` and `minor` are required when the field is provided. */
109
109
  export declare const versionPatternsSchema: z.ZodObject<{
110
110
  major: z.ZodArray<z.ZodString>;
111
111
  minor: z.ZodArray<z.ZodString>;
112
+ none: z.ZodOptional<z.ZodArray<z.ZodString>>;
112
113
  }, z.core.$strict>;
113
114
  /** Schema for a single `breakingPolicies` value. */
114
115
  export declare const breakingPolicyValueSchema: z.ZodEnum<{
@@ -204,6 +205,7 @@ export declare const releaseKitConfigSchema: z.ZodObject<{
204
205
  versionPatterns: z.ZodOptional<z.ZodObject<{
205
206
  major: z.ZodArray<z.ZodString>;
206
207
  minor: z.ZodArray<z.ZodString>;
208
+ none: z.ZodOptional<z.ZodArray<z.ZodString>>;
207
209
  }, z.core.$strict>>;
208
210
  workspaces: z.ZodOptional<z.ZodArray<z.ZodObject<{
209
211
  dir: z.ZodString;
@@ -67,6 +67,7 @@ export const versionPatternsSchema = z
67
67
  .object({
68
68
  major: z.array(z.string()),
69
69
  minor: z.array(z.string()),
70
+ none: z.array(z.string()).optional(),
70
71
  })
71
72
  .strict();
72
73
  export const breakingPolicyValueSchema = z.enum(['forbidden', 'optional']);
@@ -7,10 +7,10 @@ export interface CreateGithubReleaseOptions {
7
7
  sectionOrder?: string[];
8
8
  }
9
9
  /**
10
- * Why a tag was skipped. `'no-entry'` covers every case of missing data (no changelog file, unparseable JSON, no entry
11
- * for the version); the warnings that `createGithubRelease` prints tell them apart.
10
+ * Why a tag was skipped. `'missing-file'` and `'unreadable-file'` concern the `changelog.json` itself; the others mean
11
+ * that the file does not contain releasable content for the tag.
12
12
  */
13
- export type CreateReleaseSkipReason = 'no-entry' | 'no-audience-content' | 'empty-body';
13
+ export type CreateReleaseSkipReason = 'missing-file' | 'unreadable-file' | 'no-entry' | 'no-audience-content' | 'empty-body';
14
14
  /** Discriminated outcome of attempting to create a single GitHub Release. */
15
15
  export type CreateReleaseResult = {
16
16
  status: 'created';
@@ -22,19 +22,22 @@ export type CreateReleaseResult = {
22
22
  * Creates a GitHub Release for a tag through the `gh` CLI, with the all-audience notes of the tag's `changelog.json`
23
23
  * entry as its body.
24
24
  *
25
- * Returns a skip when the entry is missing (with a warning), doesn't have any all-audience content, or renders an empty
26
- * body. Throws when `gh` fails, so that the caller does not exit 0 on a failed release.
25
+ * Returns a skip when the changelog is missing or unreadable, the entry is missing, doesn't have any all-audience
26
+ * content, or renders an empty body. Throws when `gh` fails, so that the caller does not exit 0 on a failed release.
27
27
  */
28
28
  export declare function createGithubRelease(options: CreateGithubReleaseOptions): CreateReleaseResult;
29
29
  /** Outcome of a `createGithubReleases` invocation. */
30
30
  export interface CreateGithubReleasesOutcome {
31
31
  /** Tags for which a Release was created (or would be, under `--dry-run`). */
32
32
  createdTags: string[];
33
- /** Tags that were skipped, paired with the discriminated reason for each skip. */
34
- skippedTags: Array<{
35
- tag: string;
36
- reason: CreateReleaseSkipReason;
37
- }>;
33
+ /** Tags that were skipped, each with the reason and the `changelog.json` path that was read for it. */
34
+ skippedTags: SkippedReleaseTag[];
35
+ }
36
+ /** A tag that `createGithubReleases` skipped. */
37
+ export interface SkippedReleaseTag {
38
+ tag: string;
39
+ reason: CreateReleaseSkipReason;
40
+ changelogJsonPath: string;
38
41
  }
39
42
  /** Creates a GitHub Release for each tag and records each skip with its reason; throws at once when `gh` fails. */
40
43
  export declare function createGithubReleases(tags: Array<{
@@ -1,24 +1,20 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
2
  import { join } from 'node:path';
4
- import { extractVersion, readChangelogEntries } from "./changelogJsonUtils.js";
3
+ import { extractVersion, loadChangelogEntries } from "./changelogJsonUtils.js";
5
4
  import { matchesAudience, renderReleaseNotesSingle } from "./renderReleaseNotes.js";
6
5
  import { asUserError } from "./user-error-chaining.js";
7
6
  export function createGithubRelease(options) {
8
7
  const { tag, changelogJsonPath, isDryRun, sectionOrder } = options;
9
- if (!existsSync(changelogJsonPath)) {
10
- console.warn(`Warning: ${changelogJsonPath} not found; skipping GitHub Release creation`);
11
- return { status: 'skipped', reason: 'no-entry' };
8
+ const loaded = loadChangelogEntries(changelogJsonPath);
9
+ if (loaded.status === 'missing') {
10
+ return { status: 'skipped', reason: 'missing-file' };
12
11
  }
13
- const version = extractVersion(tag);
14
- const entries = readChangelogEntries(changelogJsonPath);
15
- if (entries === undefined) {
16
- console.warn(`Warning: could not parse ${changelogJsonPath}; skipping GitHub Release creation`);
17
- return { status: 'skipped', reason: 'no-entry' };
12
+ if (loaded.status === 'unreadable') {
13
+ return { status: 'skipped', reason: 'unreadable-file' };
18
14
  }
19
- const entry = entries.find((e) => e.version === version);
15
+ const version = extractVersion(tag);
16
+ const entry = loaded.entries.find((e) => e.version === version);
20
17
  if (entry === undefined) {
21
- console.warn(`Warning: no changelog entry for version ${version}; skipping GitHub Release creation`);
22
18
  return { status: 'skipped', reason: 'no-entry' };
23
19
  }
24
20
  if (!entry.sections.some(matchesAudience('all'))) {
@@ -60,7 +56,7 @@ export function createGithubReleases(tags, changelogJsonOutputPath, isDryRun, se
60
56
  createdTags.push(tag);
61
57
  }
62
58
  else {
63
- skippedTags.push({ tag, reason: result.reason });
59
+ skippedTags.push({ tag, reason: result.reason, changelogJsonPath });
64
60
  }
65
61
  }
66
62
  return { createdTags, skippedTags };
@@ -1,4 +1,4 @@
1
- import type { StreamStyles } from '@williamthorsen/nmr-core';
1
+ import { type StreamStyles } from '@williamthorsen/nmr-core';
2
2
  export interface CreateGithubReleaseCommandOptions {
3
3
  configPath: string | undefined;
4
4
  isDryRun: boolean;
@@ -1,3 +1,5 @@
1
+ import { formatStatusLine } from '@williamthorsen/nmr-core';
2
+ import { describeChangelogFileProblem } from "./changelogJsonUtils.js";
1
3
  import { createGithubReleases } from "./createGithubRelease.js";
2
4
  import { deriveReleaseNotesConfig } from "./deriveReleaseNotesConfig.js";
3
5
  import { formatPrivateSkip } from "./formatPrivateSkip.js";
@@ -24,11 +26,31 @@ export async function createGithubReleaseCommand({ configPath, isDryRun, tags, s
24
26
  catch (error) {
25
27
  throw prefixUserError('Failed to create GitHub Releases', error);
26
28
  }
27
- if (outcome.skippedTags.length === 0) {
29
+ const contentSkips = [];
30
+ for (const skippedTag of outcome.skippedTags) {
31
+ const fileProblem = describeFileProblem(skippedTag);
32
+ if (fileProblem === undefined) {
33
+ contentSkips.push(skippedTag);
34
+ }
35
+ else {
36
+ console.warn(` ${formatStatusLine(styles.stderr, 'warning', `Skipping GitHub Release for ${skippedTag.tag}: ${fileProblem}`)}`);
37
+ }
38
+ }
39
+ if (contentSkips.length === 0) {
28
40
  return;
29
41
  }
30
- const formattedSkips = outcome.skippedTags.map((s) => `${s.tag} (${s.reason})`).join(', ');
31
- console.info(`Skipped ${outcome.skippedTags.length} tag(s) without releasable content: ${formattedSkips}.`);
42
+ const formattedSkips = contentSkips.map((s) => `${s.tag} (${s.reason})`).join(', ');
43
+ console.info(`Skipped ${contentSkips.length} tag(s) without releasable content: ${formattedSkips}.`);
44
+ }
45
+ function describeFileProblem(skippedTag) {
46
+ switch (skippedTag.reason) {
47
+ case 'missing-file':
48
+ return describeChangelogFileProblem('missing', skippedTag.changelogJsonPath);
49
+ case 'unreadable-file':
50
+ return describeChangelogFileProblem('unreadable', skippedTag.changelogJsonPath);
51
+ default:
52
+ return undefined;
53
+ }
32
54
  }
33
55
  function isReleasable(resolvedTag) {
34
56
  return resolvedTag.kind !== 'package' || resolvedTag.isPublishable;
@@ -1,4 +1,5 @@
1
1
  import { type OutputStyle } from '@williamthorsen/nmr-core';
2
+ import { type Git } from './git.js';
2
3
  export interface CreateTagsOptions {
3
4
  isDryRun: boolean;
4
5
  shouldSkipGitChecks: boolean;
@@ -8,4 +9,4 @@ export interface CreateTagsOptions {
8
9
  * Creates an annotated git tag for each name in the tags file written by `prepare`, then deletes the tags and summary
9
10
  * files, and returns the tag names. In dry-run mode, it only lists the names.
10
11
  */
11
- export declare function createTags(options: CreateTagsOptions): string[];
12
+ export declare function createTags(options: CreateTagsOptions, git?: Git): string[];
@@ -1,17 +1,17 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { formatGlyphLine, GIT_OUTPUT_LIMIT_BYTES, hasErrnoCode, UserError, } from '@williamthorsen/nmr-core';
1
+ import { formatGlyphLine, UserError } from '@williamthorsen/nmr-core';
3
2
  import { deleteFileIfExists } from "./deleteFileIfExists.js";
3
+ import { realGit } from "./git.js";
4
4
  import { RELEASE_GLYPHS } from "./glyphs.js";
5
5
  import { readReleaseTags, RELEASE_SUMMARY_FILE, RELEASE_TAGS_FILE } from "./releaseFiles.js";
6
6
  import { asUserError } from "./user-error-chaining.js";
7
- export function createTags(options) {
7
+ export function createTags(options, git = realGit) {
8
8
  const { isDryRun, shouldSkipGitChecks, style } = options;
9
9
  const tags = readReleaseTags();
10
10
  if (tags.length === 0) {
11
11
  return [];
12
12
  }
13
13
  if (!isDryRun && !shouldSkipGitChecks) {
14
- assertCleanWorkingTree();
14
+ assertCleanWorkingTree(git);
15
15
  }
16
16
  if (isDryRun) {
17
17
  console.info('[dry-run] Would create tags:');
@@ -23,7 +23,7 @@ export function createTags(options) {
23
23
  const createdTags = [];
24
24
  for (const tag of tags) {
25
25
  try {
26
- execFileSync('git', ['tag', '-a', tag, '-m', tag], { maxBuffer: GIT_OUTPUT_LIMIT_BYTES });
26
+ git.createAnnotatedTag(tag);
27
27
  createdTags.push(tag);
28
28
  }
29
29
  catch (error) {
@@ -45,15 +45,15 @@ export function createTags(options) {
45
45
  deleteFileIfExists(RELEASE_SUMMARY_FILE);
46
46
  return tags;
47
47
  }
48
- function assertCleanWorkingTree() {
48
+ function assertCleanWorkingTree(git) {
49
+ let isDirty;
49
50
  try {
50
- execFileSync('git', ['diff', '--quiet'], { maxBuffer: GIT_OUTPUT_LIMIT_BYTES });
51
- execFileSync('git', ['diff', '--quiet', '--cached'], { maxBuffer: GIT_OUTPUT_LIMIT_BYTES });
51
+ isDirty = git.hasTrackedChanges();
52
52
  }
53
53
  catch (error) {
54
- if (hasErrnoCode(error, 'ENOENT')) {
55
- throw asUserError(error);
56
- }
57
- throw new UserError('Working tree is dirty. Commit or stash changes before tagging, or use `--no-git-checks` to skip this check.', { cause: error });
54
+ throw asUserError(error);
55
+ }
56
+ if (isDirty) {
57
+ throw new UserError('Working tree is dirty. Commit or stash changes before tagging, or use `--no-git-checks` to skip this check.');
58
58
  }
59
59
  }
@@ -1,5 +1,6 @@
1
1
  import type { UntaggedBaseline } from './assertTaggedBaseline.js';
2
2
  import type { ReleaseHistory } from './buildChangelogEntries.js';
3
+ import type { Git } from './git.js';
3
4
  import { type MemberTarget } from './prepareRunContext.js';
4
5
  import { type TargetDecision, type TargetPrepareSettings } from './prepareTarget.js';
5
6
  import type { ReleaseEntry } from './propagateBumps.js';
@@ -20,7 +21,7 @@ export declare function decideDirectReleases(plannedTargets: readonly MemberTarg
20
21
  shouldForce?: boolean;
21
22
  bumpOverride?: ReleaseType;
22
23
  versionOverridesById?: ReadonlyMap<string, string>;
23
- }, startIds: ReadonlySet<string>): DirectReleases;
24
+ }, startIds: ReadonlySet<string>, git: Git): DirectReleases;
24
25
  /**
25
26
  * Records on each planned workspace's and directory's history the entry scopes that route nowhere in its unreleased
26
27
  * window, which only a pass across every target's window can find. The windows go to the finder with the releasing
@@ -3,7 +3,7 @@ import { findUnroutedEntryScopes } from "./findUnroutedEntryScopes.js";
3
3
  import { getAllTagPrefixes } from "./generateChangelogs.js";
4
4
  import { describeStage, isRepoRootTarget, readHistory, runStage } from "./prepareRunContext.js";
5
5
  import { decideTargetRelease, readTargetHistory, } from "./prepareTarget.js";
6
- export function decideDirectReleases(plannedTargets, settings, options, startIds) {
6
+ export function decideDirectReleases(plannedTargets, settings, options, startIds, git) {
7
7
  const { shouldForce, bumpOverride, versionOverridesById } = options;
8
8
  const directReleases = {
9
9
  readings: new Map(),
@@ -14,13 +14,13 @@ export function decideDirectReleases(plannedTargets, settings, options, startIds
14
14
  const hintState = { emitted: false };
15
15
  const knownPrefixes = plannedTargets.flatMap(getAllTagPrefixes);
16
16
  for (const target of plannedTargets) {
17
- const { history, untaggedBaseline } = runStage(describeStage(target), () => readTargetHistory(target, settings));
17
+ const { history, untaggedBaseline } = runStage(describeStage(target), () => readTargetHistory(target, settings, git));
18
18
  directReleases.readings.set(target.id, history);
19
19
  if (untaggedBaseline !== undefined) {
20
20
  directReleases.untaggedBaselines.set(target.id, untaggedBaseline);
21
21
  }
22
22
  if (history.previousTag === undefined && target.kind === 'package' && target.isWorkspace) {
23
- emitBaselineHintOnce(target, knownPrefixes, hintState);
23
+ emitBaselineHintOnce(target, knownPrefixes, hintState, git);
24
24
  }
25
25
  const versionOverride = versionOverridesById?.get(target.id);
26
26
  const decision = decideTargetRelease(target, history, {
@@ -51,10 +51,10 @@ export function reportUnroutedEntryScopes(plannedTargets, directReleases, settin
51
51
  : (findings.get(target.id) ?? []);
52
52
  }
53
53
  }
54
- function emitBaselineHintOnce(target, knownPrefixes, state) {
54
+ function emitBaselineHintOnce(target, knownPrefixes, state, git) {
55
55
  if (state.emitted || (target.legacyIdentities?.length ?? 0) > 0)
56
56
  return;
57
- const candidates = detectUndeclaredTagPrefixes(knownPrefixes);
57
+ const candidates = detectUndeclaredTagPrefixes(knownPrefixes, git);
58
58
  if (candidates.length === 0)
59
59
  return;
60
60
  const totalTags = candidates.reduce((sum, candidate) => sum + candidate.tagCount, 0);
@@ -11,7 +11,7 @@ export interface DecideReleaseArgs {
11
11
  bumpOverride: ReleaseType | undefined;
12
12
  /**
13
13
  * Skip wordings to use when the window doesn't contain any commits (`noCommits`) or when it contains commits but none
14
- * yields a changelog item (`noBumpWorthy`). Each path passes its own pre-rendered wordings.
14
+ * calls for a bump (`noBumpWorthy`). Each path passes its own pre-rendered wordings.
15
15
  */
16
16
  skipReasons: {
17
17
  noCommits: string;
@@ -9,8 +9,10 @@ export declare function composeHeader(entry: {
9
9
  export declare const DEFAULT_WORK_TYPES: Record<string, WorkTypeConfig>;
10
10
  /** The breaking policy of each canonical work type, which decides whether a `!` on it is a policy violation. */
11
11
  export declare const DEFAULT_BREAKING_POLICIES: Record<string, BreakingPolicy>;
12
- /** Default version bump patterns. */
13
- export declare const DEFAULT_VERSION_PATTERNS: VersionPatterns;
12
+ /** Default version bump patterns. The `none` types do not change the code that a consumer runs. */
13
+ export declare const DEFAULT_VERSION_PATTERNS: VersionPatterns & {
14
+ none: string[];
15
+ };
14
16
  /** Default configuration for structured changelog JSON generation. */
15
17
  export declare const DEFAULT_CHANGELOG_JSON_CONFIG: ChangelogJsonConfig;
16
18
  /** Default configuration for release notes consumption. */
@@ -34,6 +34,7 @@ export const DEFAULT_BREAKING_POLICIES = Object.fromEntries(CANONICAL_TAXONOMY.t
34
34
  export const DEFAULT_VERSION_PATTERNS = {
35
35
  major: ['!'],
36
36
  minor: ['feat'],
37
+ none: ['ai', 'ci', 'docs', 'tests', 'tooling'],
37
38
  };
38
39
  export const DEFAULT_CHANGELOG_JSON_CONFIG = {
39
40
  enabled: true,
@@ -1,7 +1,8 @@
1
- import { existsSync, readFileSync, statSync } from 'node:fs';
1
+ import { existsSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isRecord, UserError } from '@williamthorsen/nmr-core';
4
- import { chainUserError } from "./user-error-chaining.js";
4
+ import { readJsonFile } from "./readJsonFile.js";
5
+ import { prefixUserError } from "./user-error-chaining.js";
5
6
  export const DIRECTORY_VERSION_FILE = 'version.json';
6
7
  const TAG_PREFIX_PATTERN = /^[\w.-]+$/;
7
8
  export function deriveDirectoryConfig(declaredDirectory, root = process.cwd()) {
@@ -32,10 +33,10 @@ function assertVersionField(label, versionFile, root) {
32
33
  }
33
34
  let parsedJson;
34
35
  try {
35
- parsedJson = JSON.parse(readFileSync(absolutePath, 'utf8'));
36
+ parsedJson = readJsonFile(absolutePath);
36
37
  }
37
38
  catch (error) {
38
- throw chainUserError(`${label}: failed to read ${versionFile}`, error);
39
+ throw prefixUserError(label, error);
39
40
  }
40
41
  if (!isRecord(parsedJson) || typeof parsedJson['version'] !== 'string') {
41
42
  throw new UserError(`${label}: ${versionFile} does not contain a string 'version' field; add one`);
@@ -1,18 +1,11 @@
1
- import { readFileSync } from 'node:fs';
2
1
  import { basename } from 'node:path';
3
2
  import { isRecord, UserError } from '@williamthorsen/nmr-core';
4
3
  import { isPublishableManifest } from "./isPublishableManifest.js";
5
- import { chainUserError } from "./user-error-chaining.js";
4
+ import { readJsonFile } from "./readJsonFile.js";
6
5
  export function deriveWorkspaceConfig(workspacePath) {
7
6
  const dir = basename(workspacePath);
8
7
  const packageJsonPath = `${workspacePath}/package.json`;
9
- let parsedJson;
10
- try {
11
- parsedJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
12
- }
13
- catch (error) {
14
- throw chainUserError(`Failed to read ${packageJsonPath}`, error);
15
- }
8
+ const parsedJson = readJsonFile(packageJsonPath);
16
9
  const name = isRecord(parsedJson) ? parsedJson['name'] : undefined;
17
10
  if (typeof name !== 'string' || name.length === 0) {
18
11
  throw new UserError(`${packageJsonPath} is missing a 'name' field (required for tag derivation).`);
@@ -1,3 +1,4 @@
1
+ import type { Git } from './git.js';
1
2
  /** A candidate tag prefix found in the repo that is not in the known-prefix set. */
2
3
  export interface UndeclaredTagPrefix {
3
4
  /** The extracted prefix, including the trailing `-v` (e.g., `'core-v'`). */
@@ -20,4 +21,4 @@ export interface UndeclaredTagPrefix {
20
21
  * Reads only the local tag list, so a remote tag that has not been fetched is not reported. Returns an empty array
21
22
  * when the repo doesn't have any tags, or any candidate-shaped tags outside the known set.
22
23
  */
23
- export declare function detectUndeclaredTagPrefixes(knownPrefixes: readonly string[]): UndeclaredTagPrefix[];
24
+ export declare function detectUndeclaredTagPrefixes(knownPrefixes: readonly string[], git: Git): UndeclaredTagPrefix[];
@@ -1,25 +1,16 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { GIT_OUTPUT_LIMIT_BYTES } from '@williamthorsen/nmr-core';
3
1
  const EXAMPLE_TAG_LIMIT = 3;
4
2
  const CANDIDATE_TAG_PATTERN = /^(?<prefix>[a-z][a-z0-9-]*-v)\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/;
5
- export function detectUndeclaredTagPrefixes(knownPrefixes) {
3
+ export function detectUndeclaredTagPrefixes(knownPrefixes, git) {
6
4
  const knownPrefixSet = new Set(knownPrefixes);
7
- let rawOutput;
5
+ let tags;
8
6
  try {
9
- rawOutput = execFileSync('git', ['tag', '--list'], {
10
- encoding: 'utf8',
11
- maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
12
- stdio: ['pipe', 'pipe', 'pipe'],
13
- });
7
+ tags = git.listTags();
14
8
  }
15
9
  catch {
16
10
  return [];
17
11
  }
18
12
  const tagsByPrefix = new Map();
19
- for (const line of rawOutput.split('\n')) {
20
- const tag = line.trim();
21
- if (tag === '')
22
- continue;
13
+ for (const tag of tags) {
23
14
  const match = CANDIDATE_TAG_PATTERN.exec(tag);
24
15
  if (match === null)
25
16
  continue;
@@ -9,6 +9,7 @@ export interface BumpSignal {
9
9
  *
10
10
  * Uses `versionPatterns` to decide which types trigger major/minor bumps.
11
11
  * The `'!'` sentinel in `versionPatterns.major` means "any breaking change triggers major".
12
- * Any recognized type not listed in major or minor patterns defaults to patch.
12
+ * A recognized type listed in `versionPatterns.none` (the default list when omitted) triggers no bump; any other
13
+ * recognized type not listed in major or minor patterns defaults to patch.
13
14
  */
14
15
  export declare function determineBumpType(changes: readonly BumpSignal[], workTypes: Record<string, WorkTypeConfig>, versionPatterns: VersionPatterns): ReleaseType | undefined;
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_VERSION_PATTERNS } from "./defaults.js";
1
2
  const RELEASE_PRIORITY = {
2
3
  major: 3,
3
4
  minor: 2,
@@ -5,6 +6,7 @@ const RELEASE_PRIORITY = {
5
6
  };
6
7
  export function determineBumpType(changes, workTypes, versionPatterns) {
7
8
  const knownTypes = new Set(Object.keys(workTypes));
9
+ const nonBumpingTypes = new Set(versionPatterns.none ?? DEFAULT_VERSION_PATTERNS.none);
8
10
  let highestPriority = 0;
9
11
  let result;
10
12
  for (const change of changes) {
@@ -21,6 +23,9 @@ export function determineBumpType(changes, workTypes, versionPatterns) {
21
23
  else if (versionPatterns.minor.includes(change.type)) {
22
24
  bump = 'minor';
23
25
  }
26
+ else if (nonBumpingTypes.has(change.type)) {
27
+ continue;
28
+ }
24
29
  else {
25
30
  bump = 'patch';
26
31
  }
@@ -1,14 +1,4 @@
1
- /** A commit as git records it, before any parsing or filtering. */
2
- export interface RawCommit {
3
- /** The full 40-character hash. */
4
- hash: string;
5
- /** The first line, with a wrapped subject folded onto one line by git. */
6
- subject: string;
7
- /** The message after its first blank line, empty when the commit does not have a body. */
8
- body: string;
9
- /** The raw commit message, subject and body together. */
10
- message: string;
11
- }
1
+ import type { Git, RawCommit } from './git.js';
12
2
  /** The commits that one release contains, with the version and date under which they were released. */
13
3
  export interface ReleaseWindow {
14
4
  /** The tag name for a released window, the caller's unreleased-tag name for the newest one. */
@@ -20,6 +10,8 @@ export interface ReleaseWindow {
20
10
  }
21
11
  /** Inputs for {@link enumerateReleaseWindows}. */
22
12
  export interface EnumerateReleaseWindowsOptions {
13
+ /** The git through which the history is read. */
14
+ git: Git;
23
15
  /** Git pathspecs restricting the enumeration to commits that touch them. */
24
16
  paths?: readonly string[];
25
17
  /** Tag prefixes to match as a union; a matching tag name continues with a complete SemVer version. */