@williamthorsen/release-kit 14.0.0 → 14.1.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.
@@ -1,4 +1,52 @@
1
1
  [
2
+ {
3
+ "version": "14.1.0",
4
+ "date": "2026-10-11",
5
+ "sections": [
6
+ {
7
+ "title": "🎉 Features",
8
+ "audience": "all",
9
+ "items": [
10
+ {
11
+ "description": "Stops `release-kit init` from scaffolding `publish.yaml` and `create-github-release.yaml` when every package is private, even with `--force`, and removes the provenance and trusted-publisher steps from the next steps that it prints. (#995)",
12
+ "hash": "93355490219535fac282ab2256179094ca920e47",
13
+ "entry": 1
14
+ },
15
+ {
16
+ "description": "Stops `release-kit update-templates` from re-creating a deleted `publish.yaml` or `create-github-release.yaml` when every package is private, while it still refreshes either workflow that exists. (#995)",
17
+ "hash": "93355490219535fac282ab2256179094ca920e47",
18
+ "entry": 2
19
+ },
20
+ {
21
+ "description": "Makes `release-kit prepare` refuse, before it writes any file and in a dry run too, to release a publishable package that npm does not list or that it cannot look up, and prints `npm publish --access public` as the fix for each unlisted package. (#996)",
22
+ "hash": "7b8b6d67b264af56dff761d0bf987eca8bc2f934",
23
+ "entry": 1
24
+ }
25
+ ]
26
+ },
27
+ {
28
+ "title": "🐛 Bug fixes",
29
+ "audience": "all",
30
+ "items": [
31
+ {
32
+ "description": "Fixes the issue that the `npm-auto-publish` kit's \"published to npm\" check reported a failed lookup, such as an unreachable registry or an authentication error, as an unpublished package and ignored `publishConfig.registry`. (#996)",
33
+ "hash": "7b8b6d67b264af56dff761d0bf987eca8bc2f934",
34
+ "entry": 2
35
+ },
36
+ {
37
+ "description": "Fixes the issue that release-kit's changelog generation treated a floating major tag such as `v1` as a release and added a spurious release to the history, by counting a tag only when the rest of its name after the prefix is a complete SemVer version such as `1.0.0` or `1.0.0-beta.1`. (#1000)",
38
+ "hash": "712e7500109b4bb3db579b60c72e4bf85b308098",
39
+ "entry": 1
40
+ },
41
+ {
42
+ "description": "Fixes the issue that `release-kit show-tag-prefixes` counted every tag that starts with a workspace's prefix, including floating pointers such as `core-v1` and non-release tags such as `core-vnext`, rather than only the tags that release-kit counts as releases. (#1000)",
43
+ "hash": "712e7500109b4bb3db579b60c72e4bf85b308098",
44
+ "entry": 2
45
+ }
46
+ ]
47
+ }
48
+ ]
49
+ },
2
50
  {
3
51
  "version": "14.0.0",
4
52
  "date": "2026-10-08",
@@ -98,7 +98,7 @@ function translateGlob(pattern) {
98
98
 
99
99
  // .readyup/kits/default.ts
100
100
  function getMinVersion() {
101
- const picked = { "version": "14.0.0" };
101
+ const picked = { "version": "14.1.0" };
102
102
  if (typeof picked["version"] !== "string") {
103
103
  throw new TypeError("release-kit/package.json: 'version' must be a string");
104
104
  }
@@ -115,7 +115,7 @@ var CONFIG_EXPORT_PATTERNS = [
115
115
  /export\s*\*/
116
116
  ];
117
117
  var COMMON_PRESET_HASH = "618bb5cbcbdd4f4f0c2a777add7e14d992d48483350dd9651bf8cf4c1fa9d418";
118
- var SYNC_LABELS_WORKFLOW_HASH = "6b50015e2919ffa4b16d0b4a1f340e585ff3e5b779a2256010d3c62fa07ff8f2";
118
+ var SYNC_LABELS_WORKFLOW_HASH = "0a88829d154c0de7cdd4246fede99e9311a89ab4199034dbe601663d641ab1e4";
119
119
  var RELEASE_WORKFLOW_HASH_MONOREPO = "4a20ff3315155500f64a27133c54eb1bc1f07a4acc370ec0d83176109041c60d";
120
120
  var RELEASE_WORKFLOW_HASH_SINGLE = "d26dd4ca186632b388ea46bd885721124c03d19918e511baa40821d2d760da69";
121
121
  var PUBLISH_WORKFLOW_HASH_MONOREPO = "0afa9ffe914f3dc8f043e68252ebc604c8cc1a953422fcea37a909a4def370ee";
@@ -13,7 +13,7 @@ import {
13
13
  fileContains,
14
14
  fileExists,
15
15
  getJsonValue,
16
- isRecord,
16
+ isRecord as isRecord2,
17
17
  readFile,
18
18
  readJsonFile
19
19
  } from "readyup/check-utils";
@@ -23,10 +23,16 @@ function hasProvenance(workflowContent) {
23
23
  return /^[^#]*provenance:\s*['"]?true['"]?/im.test(workflowContent);
24
24
  }
25
25
 
26
- // .readyup/kits/npm-auto-publish.ts
26
+ // src/npmRegistry.ts
27
+ import { execFileSync } from "node:child_process";
28
+
29
+ // src/typeGuards.ts
30
+ function isRecord(value) {
31
+ return typeof value === "object" && value !== null && !Array.isArray(value);
32
+ }
33
+
34
+ // src/npmRegistry.ts
27
35
  var AUTH_ERROR_CODES = /* @__PURE__ */ new Set(["E401", "ENEEDAUTH"]);
28
- var OTP_ERROR_CODE = "EOTP";
29
- var PUBLISH_WORKFLOW_FILE = "publish.yaml";
30
36
  var UNREACHABLE_ERROR_CODES = /* @__PURE__ */ new Set([
31
37
  "EAI_AGAIN",
32
38
  "ECONNREFUSED",
@@ -36,6 +42,54 @@ var UNREACHABLE_ERROR_CODES = /* @__PURE__ */ new Set([
36
42
  "ERR_SOCKET_TIMEOUT",
37
43
  "ETIMEDOUT"
38
44
  ]);
45
+ function classifyNpmPackageLookup(result) {
46
+ if (result.exitOk) {
47
+ return { status: "published" };
48
+ }
49
+ const error = readNpmError(result.stdout);
50
+ if (error === void 0) {
51
+ return { status: "unverifiable", detail: "The npm registry query failed without a readable error payload" };
52
+ }
53
+ if (error.code === "E404") {
54
+ return { status: "unpublished" };
55
+ }
56
+ if (AUTH_ERROR_CODES.has(error.code)) {
57
+ return { status: "unverifiable", detail: `The npm registry rejected the session (${error.code})` };
58
+ }
59
+ if (UNREACHABLE_ERROR_CODES.has(error.code)) {
60
+ return { status: "unverifiable", detail: `Cannot reach the npm registry (${error.code})` };
61
+ }
62
+ return { status: "unverifiable", detail: `The npm registry query failed (${error.code}): ${error.summary}` };
63
+ }
64
+ function lookUpNpmPackage(packageName, registry) {
65
+ const args = ["view", packageName, "version", "--json", ...registry === void 0 ? [] : ["--registry", registry]];
66
+ return classifyNpmPackageLookup(runNpmJson(args));
67
+ }
68
+ function readNpmError(stdout) {
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(stdout);
72
+ } catch {
73
+ return void 0;
74
+ }
75
+ if (!isRecord(parsed) || !isRecord(parsed["error"]) || typeof parsed["error"]["code"] !== "string") {
76
+ return void 0;
77
+ }
78
+ const { code, summary } = parsed["error"];
79
+ return { code, summary: typeof summary === "string" ? summary : "" };
80
+ }
81
+ function runNpmJson(args) {
82
+ try {
83
+ return { exitOk: true, stdout: execFileSync("npm", args, { encoding: "utf8", stdio: "pipe" }) };
84
+ } catch (error) {
85
+ const stdout = isRecord(error) && typeof error["stdout"] === "string" ? error["stdout"] : "";
86
+ return { exitOk: false, stdout };
87
+ }
88
+ }
89
+
90
+ // .readyup/kits/npm-auto-publish.ts
91
+ var OTP_ERROR_CODE = "EOTP";
92
+ var PUBLISH_WORKFLOW_FILE = "publish.yaml";
39
93
  var repoChecklist = defineRdyChecklist({
40
94
  name: "repo",
41
95
  checks: [
@@ -149,7 +203,7 @@ function buildWorkspaceCheck(workspace) {
149
203
  const auth = getCachedNpmAuthStatus();
150
204
  return auth.status === "unreachable" ? auth.detail : false;
151
205
  },
152
- check: () => isPublishedToNpm(displayName),
206
+ check: () => describeNpmPackageLookup(lookUpNpmPackage(displayName, readPublishRegistry(workspace))),
153
207
  fix: `Run "npm publish --access public" from ${workspace.dir} to bootstrap the package on npm`,
154
208
  checks: [
155
209
  {
@@ -290,6 +344,19 @@ function classifyTrustQuery(result, expectedRepo, expectedFile) {
290
344
  detail: `Expected github ${expectedRepo} (${expectedFile}); found ${describeTrustRelationships(relationships)}`
291
345
  };
292
346
  }
347
+ function describeNpmPackageLookup(lookup) {
348
+ if (lookup.status === "published") {
349
+ return { ok: true };
350
+ }
351
+ if (lookup.status === "unpublished") {
352
+ return { ok: false, detail: "The npm registry does not list this package" };
353
+ }
354
+ return {
355
+ ok: false,
356
+ detail: lookup.detail,
357
+ fix: "Restore access to the npm registry, then run this check again"
358
+ };
359
+ }
293
360
  function describeTrustRelationships(relationships) {
294
361
  return relationships.map((relationship) => {
295
362
  const type = typeof relationship.type === "string" ? relationship.type : "(unknown)";
@@ -300,7 +367,7 @@ function describeTrustRelationships(relationships) {
300
367
  }
301
368
  var getCachedNpmAuthStatus = /* @__PURE__ */ (() => {
302
369
  let cached;
303
- return () => cached ??= classifyNpmAuth(runNpmJson("npm whoami --json"));
370
+ return () => cached ??= classifyNpmAuth(runNpmJson(["whoami", "--json"]));
304
371
  })();
305
372
  var getCachedOwnerRepo = /* @__PURE__ */ (() => {
306
373
  let cached;
@@ -331,7 +398,7 @@ function getTrustQueryResult(packageName) {
331
398
  return cached;
332
399
  }
333
400
  const result = classifyTrustQuery(
334
- runNpmJson(`npm trust list ${packageName} --json`),
401
+ runNpmJson(["trust", "list", packageName, "--json"]),
335
402
  getCachedOwnerRepo(),
336
403
  PUBLISH_WORKFLOW_FILE
337
404
  );
@@ -352,17 +419,6 @@ function hasTokenReferences() {
352
419
  }
353
420
  return false;
354
421
  }
355
- function isPublishedToNpm(packageName) {
356
- try {
357
- execSync(`npm view ${packageName} version`, {
358
- encoding: "utf8",
359
- stdio: "pipe"
360
- });
361
- return true;
362
- } catch {
363
- return false;
364
- }
365
- }
366
422
  function isRepoPrivate() {
367
423
  const ownerRepo = getOwnerRepo();
368
424
  const result = execSync(`gh api repos/${ownerRepo} --jq .private`, {
@@ -374,18 +430,9 @@ function probeTrustQuery() {
374
430
  const probeName = selectProbeName();
375
431
  return probeName === void 0 ? void 0 : getTrustQueryResult(probeName);
376
432
  }
377
- function readNpmError(stdout) {
378
- let parsed;
379
- try {
380
- parsed = JSON.parse(stdout);
381
- } catch {
382
- return void 0;
383
- }
384
- if (!isRecord(parsed) || !isRecord(parsed["error"]) || typeof parsed["error"]["code"] !== "string") {
385
- return void 0;
386
- }
387
- const { code, summary } = parsed["error"];
388
- return { code, summary: typeof summary === "string" ? summary : "" };
433
+ function readPublishRegistry(workspace) {
434
+ const registry = getJsonValue(workspace.packageJson, "publishConfig", "registry");
435
+ return typeof registry === "string" ? registry : void 0;
389
436
  }
390
437
  function readTrustRelationships(stdout) {
391
438
  let parsed;
@@ -395,9 +442,9 @@ function readTrustRelationships(stdout) {
395
442
  return void 0;
396
443
  }
397
444
  if (Array.isArray(parsed)) {
398
- return parsed.filter((entry) => isRecord(entry) && "type" in entry);
445
+ return parsed.filter((entry) => isRecord2(entry) && "type" in entry);
399
446
  }
400
- if (!isRecord(parsed)) {
447
+ if (!isRecord2(parsed)) {
401
448
  return void 0;
402
449
  }
403
450
  return "type" in parsed ? [parsed] : [];
@@ -406,14 +453,6 @@ function resolveTrustCapability() {
406
453
  const auth = getCachedNpmAuthStatus();
407
454
  return classifyTrustCapability(auth, auth.status === "authenticated" ? probeTrustQuery() : void 0);
408
455
  }
409
- function runNpmJson(command) {
410
- try {
411
- return { exitOk: true, stdout: execSync(command, { encoding: "utf8", stdio: "pipe" }) };
412
- } catch (error) {
413
- const stdout = isRecord(error) && typeof error["stdout"] === "string" ? error["stdout"] : "";
414
- return { exitOk: false, stdout };
415
- }
416
- }
417
456
  function selectProbeName() {
418
457
  return discoverWorkspaces({ filter: (workspace) => workspace.isPackage }).find(
419
458
  (workspace) => workspace.name !== void 0
@@ -433,6 +472,7 @@ export {
433
472
  classifyTrustCapability,
434
473
  classifyTrustQuery,
435
474
  npm_auto_publish_default as default,
475
+ describeNpmPackageLookup,
436
476
  packagesChecklist,
437
477
  selectProbeName,
438
478
  skipIfNotPublishable,
@@ -8,12 +8,12 @@
8
8
  "esbuildVersion": "0.28.2",
9
9
  "inputs": [
10
10
  {
11
- "hash": "7a8522cd",
11
+ "hash": "dee24ab4",
12
12
  "kind": "module",
13
13
  "path": "kits/default.ts"
14
14
  },
15
15
  {
16
- "hash": "6eab98d1",
16
+ "hash": "4aa0967e",
17
17
  "kind": "inline",
18
18
  "path": "../package.json",
19
19
  "paths": [
@@ -45,8 +45,8 @@
45
45
  "path": "kits/default.js",
46
46
  "readyupVersion": "0.40.0",
47
47
  "source": "kits/default.ts",
48
- "sourceHash": "7a8522cd",
49
- "targetHash": "4e21467f"
48
+ "sourceHash": "dee24ab4",
49
+ "targetHash": "25d17b58"
50
50
  },
51
51
  {
52
52
  "checklists": [
@@ -56,7 +56,7 @@
56
56
  "esbuildVersion": "0.28.2",
57
57
  "inputs": [
58
58
  {
59
- "hash": "d9910d60",
59
+ "hash": "70c0bb99",
60
60
  "kind": "module",
61
61
  "path": "kits/npm-auto-publish.ts"
62
62
  },
@@ -64,14 +64,24 @@
64
64
  "hash": "d360febf",
65
65
  "kind": "module",
66
66
  "path": "../src/init/hasProvenance.ts"
67
+ },
68
+ {
69
+ "hash": "3e39b9be",
70
+ "kind": "module",
71
+ "path": "../src/npmRegistry.ts"
72
+ },
73
+ {
74
+ "hash": "1424b343",
75
+ "kind": "module",
76
+ "path": "../src/typeGuards.ts"
67
77
  }
68
78
  ],
69
79
  "name": "npm-auto-publish",
70
80
  "path": "kits/npm-auto-publish.js",
71
81
  "readyupVersion": "0.40.0",
72
82
  "source": "kits/npm-auto-publish.ts",
73
- "sourceHash": "d9910d60",
74
- "targetHash": "2a6931b6"
83
+ "sourceHash": "70c0bb99",
84
+ "targetHash": "d97d6123"
75
85
  }
76
86
  ]
77
87
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 14.1.0 — 2026-10-11
6
+
7
+ ### 🎉 Features
8
+
9
+ - Stops `release-kit init` from scaffolding `publish.yaml` and `create-github-release.yaml` when every package is private, even with `--force`, and removes the provenance and trusted-publisher steps from the next steps that it prints. (#995)
10
+ - Stops `release-kit update-templates` from re-creating a deleted `publish.yaml` or `create-github-release.yaml` when every package is private, while it still refreshes either workflow that exists. (#995)
11
+ - Makes `release-kit prepare` refuse, before it writes any file and in a dry run too, to release a publishable package that npm does not list or that it cannot look up, and prints `npm publish --access public` as the fix for each unlisted package. (#996)
12
+
13
+ ### 🐛 Bug fixes
14
+
15
+ - Fixes the issue that the `npm-auto-publish` kit's "published to npm" check reported a failed lookup, such as an unreachable registry or an authentication error, as an unpublished package and ignored `publishConfig.registry`. (#996)
16
+ - Fixes the issue that release-kit's changelog generation treated a floating major tag such as `v1` as a release and added a spurious release to the history, by counting a tag only when the rest of its name after the prefix is a complete SemVer version such as `1.0.0` or `1.0.0-beta.1`. (#1000)
17
+ - Fixes the issue that `release-kit show-tag-prefixes` counted every tag that starts with a workspace's prefix, including floating pointers such as `core-v1` and non-release tags such as `core-vnext`, rather than only the tags that release-kit counts as releases. (#1000)
18
+
5
19
  ## 14.0.0 — 2026-10-08
6
20
 
7
21
  ### 🎉 Features
package/README.md CHANGED
@@ -7,15 +7,19 @@ Version-bumping and changelog-generation toolkit for release workflows.
7
7
  Provides a self-contained CLI that auto-discovers workspaces from `pnpm-workspace.yaml`, parses conventional commits, determines version bumps, updates `package.json` files, and generates changelogs from the same commit history that decides each bump (with optional [editorial overrides](docs/editorial-overrides.md)).
8
8
 
9
9
  <!-- section:release-notes -->
10
- ## Release notes — v14.0.0 (2026-10-08)
10
+ ## Release notes — v14.1.0 (2026-10-11)
11
11
 
12
12
  ### 🎉 Features
13
13
 
14
- - 🚨 **Breaking:** Changes monorepo `--set-version` to take comma-separated `<workspace>@N.N.N` entries, which `prepare` accepts without `--only` and together with `--bump` and `--force`, and which release each named workspace's dependents. (#970)
14
+ - Stops `release-kit init` from scaffolding `publish.yaml` and `create-github-release.yaml` when every package is private, even with `--force`, and removes the provenance and trusted-publisher steps from the next steps that it prints. (#995)
15
+ - Stops `release-kit update-templates` from re-creating a deleted `publish.yaml` or `create-github-release.yaml` when every package is private, while it still refreshes either workflow that exists. (#995)
16
+ - Makes `release-kit prepare` refuse, before it writes any file and in a dry run too, to release a publishable package that npm does not list or that it cannot look up, and prints `npm publish --access public` as the fix for each unlisted package. (#996)
15
17
 
16
18
  ### 🐛 Bug fixes
17
19
 
18
- - 🚨 **Breaking:** Fixes the issue that `release-kit prepare --only` left each dependent of the named workspaces published against the old version range of its dependency, by also releasing every transitive dependent: a dependent with commits of its own at its own bump, and one without at a propagated patch bump. (#970)
20
+ - Fixes the issue that the `npm-auto-publish` kit's "published to npm" check reported a failed lookup, such as an unreachable registry or an authentication error, as an unpublished package and ignored `publishConfig.registry`. (#996)
21
+ - Fixes the issue that release-kit's changelog generation treated a floating major tag such as `v1` as a release and added a spurious release to the history, by counting a tag only when the rest of its name after the prefix is a complete SemVer version such as `1.0.0` or `1.0.0-beta.1`. (#1000)
22
+ - Fixes the issue that `release-kit show-tag-prefixes` counted every tag that starts with a workspace's prefix, including floating pointers such as `core-v1` and non-release tags such as `core-vnext`, rather than only the tags that release-kit counts as releases. (#1000)
19
23
  <!-- /section:release-notes -->
20
24
 
21
25
  ## Installation
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Throws when npm doesn't list, or can't confirm, a publishable package among the given workspaces.
3
+ *
4
+ * Each workspace path is repo-relative and is read from the current directory. A private package is not looked up,
5
+ * and an empty list doesn't call the registry at all.
6
+ */
7
+ export declare function assertReleasesOnNpm(workspacePaths: readonly string[]): void;
@@ -0,0 +1,56 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { isPublishableManifest } from "./isPublishableManifest.js";
4
+ import { lookUpNpmPackage } from "./npmRegistry.js";
5
+ import { isRecord } from "./typeGuards.js";
6
+ export function assertReleasesOnNpm(workspacePaths) {
7
+ const unpublished = [];
8
+ const unverifiable = [];
9
+ for (const workspacePath of workspacePaths) {
10
+ const candidate = buildNpmCandidate(workspacePath);
11
+ if (candidate === undefined) {
12
+ continue;
13
+ }
14
+ const lookup = lookUpNpmPackage(candidate.name, candidate.registry);
15
+ if (lookup.status === 'unpublished') {
16
+ unpublished.push(candidate);
17
+ }
18
+ else if (lookup.status === 'unverifiable') {
19
+ unverifiable.push({ candidate, detail: lookup.detail });
20
+ }
21
+ }
22
+ if (unpublished.length === 0 && unverifiable.length === 0) {
23
+ return;
24
+ }
25
+ throw new Error(describeRefusal(unpublished, unverifiable));
26
+ }
27
+ function buildNpmCandidate(workspacePath) {
28
+ const manifest = JSON.parse(readFileSync(join(workspacePath, 'package.json'), 'utf8'));
29
+ if (!isRecord(manifest) || !isPublishableManifest(manifest) || typeof manifest['name'] !== 'string') {
30
+ return undefined;
31
+ }
32
+ const publishConfig = manifest['publishConfig'];
33
+ const registry = isRecord(publishConfig) ? publishConfig['registry'] : undefined;
34
+ return {
35
+ name: manifest['name'],
36
+ workspacePath,
37
+ ...(typeof registry === 'string' && { registry }),
38
+ };
39
+ }
40
+ function describeRefusal(unpublished, unverifiable) {
41
+ const sections = ['Cannot prepare the release: npm must already have every package that the release publishes.'];
42
+ if (unpublished.length > 0) {
43
+ sections.push([
44
+ 'Not on npm yet:',
45
+ ...unpublished.map(({ name, workspacePath }) => ` - ${name}: run "npm publish --access public" from ${workspacePath}`),
46
+ 'Then register each one as a trusted publisher; "rdy run --from npm:@williamthorsen/release-kit npm-auto-publish" prints the command.',
47
+ ].join('\n'));
48
+ }
49
+ if (unverifiable.length > 0) {
50
+ sections.push([
51
+ 'Could not confirm on npm:',
52
+ ...unverifiable.map(({ candidate, detail }) => ` - ${candidate.name}: ${detail}`),
53
+ ].join('\n'));
54
+ }
55
+ return sections.join('\n\n');
56
+ }
@@ -108,7 +108,8 @@ function showInitHelp() {
108
108
  Usage: release-kit init [options]
109
109
 
110
110
  Initialize release-kit in the current repository.
111
- By default, scaffolds only the GitHub Actions workflow files.
111
+ By default, scaffolds only the GitHub Actions workflow files. When every
112
+ package is private, skips publish.yaml and create-github-release.yaml.
112
113
 
113
114
  Options:
114
115
  --with-config Also scaffold .config/release-kit.config.ts
@@ -124,7 +125,9 @@ Usage: release-kit update-templates [options]
124
125
  Update the GitHub Actions workflows that release-kit scaffolds to the templates
125
126
  of the installed version, showing what changed in each file. Creates a missing
126
127
  workflow that \`init\` would create, and refreshes sync-labels.yaml only when it
127
- exists. Keeps publish.yaml's provenance setting. Never touches config files.
128
+ exists. When every package is private, refreshes publish.yaml and
129
+ create-github-release.yaml only when they exist. Keeps publish.yaml's provenance
130
+ setting. Never touches config files.
128
131
 
129
132
  Options:
130
133
  --dry-run Preview changes without writing files
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
3
  import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
4
+ import { isPublishableManifest } from "./isPublishableManifest.js";
4
5
  import { isRecord } from "./typeGuards.js";
5
6
  export function deriveWorkspaceConfig(workspacePath) {
6
7
  const dir = basename(workspacePath);
@@ -17,8 +18,7 @@ export function deriveWorkspaceConfig(workspacePath) {
17
18
  throw new Error(`${packageJsonPath} is missing a 'name' field (required for tag derivation).`);
18
19
  }
19
20
  const unscopedName = stripNpmScope(name);
20
- const privateField = isRecord(parsed) ? parsed['private'] : undefined;
21
- const isPublishable = privateField === undefined || privateField === false;
21
+ const isPublishable = isPublishableManifest(parsed);
22
22
  return {
23
23
  dir,
24
24
  name,
@@ -22,7 +22,7 @@ export interface ReleaseWindow {
22
22
  export interface EnumerateReleaseWindowsOptions {
23
23
  /** Git pathspecs restricting the enumeration to commits that touch them. */
24
24
  paths?: readonly string[];
25
- /** Tag prefixes to match as a union; a matching tag name continues with a digit. */
25
+ /** Tag prefixes to match as a union; a matching tag name continues with a complete SemVer version. */
26
26
  tagPrefixes: readonly string[];
27
27
  /** The version that the unreleased window reports, such as the tag that the release being prepared will write. */
28
28
  unreleasedTag: string;
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { GIT_OUTPUT_LIMIT } from '@williamthorsen/nmr-core';
3
3
  import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
4
+ import { matchesTagPrefix } from "./matchesTagPrefix.js";
4
5
  const FIELD_SEPARATOR = '\u{1F}';
5
6
  const RECORD_SEPARATOR = '\u{1E}';
6
7
  export function enumerateReleaseWindows(options) {
@@ -46,9 +47,6 @@ function claimAncestors(boundaries, parentsByHash) {
46
47
  }
47
48
  return windowIndexByHash;
48
49
  }
49
- function matchesTagPrefix(tagName, tagPrefixes) {
50
- return tagPrefixes.some((prefix) => tagName.startsWith(prefix) && /^\d/.test(tagName.slice(prefix.length)));
51
- }
52
50
  function parseCommitRecord(record) {
53
51
  const [hash, subject, message] = record.replace(/^\n+/, '').split(FIELD_SEPARATOR);
54
52
  if (hash === undefined || subject === undefined || message === undefined || hash === '') {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Reports whether the repo at `root` contains a package that can be published: a discovered workspace, or the root
3
+ * package of a single-package repo, whose `package.json` is not private.
4
+ *
5
+ * Returns `true` whenever the answer cannot be determined (an empty workspace, a failed discovery, or an unreadable
6
+ * manifest), so that the scaffolding commands keep the publish workflows in every repo that might need them.
7
+ */
8
+ export declare function hasPublishablePackage(root?: string): boolean;
9
+ /** Formats the line that reports a workflow skipped because the repo does not contain any publishable package. */
10
+ export declare function formatPrivateWorkflowSkip(filePath: string): string;
@@ -0,0 +1,28 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { discoverWorkspaces } from "../discoverWorkspaces.js";
4
+ import { isPublishableManifest } from "../isPublishableManifest.js";
5
+ export function hasPublishablePackage(root = process.cwd()) {
6
+ let packageDirs;
7
+ try {
8
+ const workspace = discoverWorkspaces(root);
9
+ if (workspace.kind === 'empty')
10
+ return true;
11
+ packageDirs = workspace.kind === 'single-package' ? ['.'] : workspace.packageDirs;
12
+ }
13
+ catch {
14
+ return true;
15
+ }
16
+ return packageDirs.some((packageDir) => isPublishableAt(path.join(root, packageDir, 'package.json')));
17
+ }
18
+ export function formatPrivateWorkflowSkip(filePath) {
19
+ return `Skipping ${filePath}: every package is private`;
20
+ }
21
+ function isPublishableAt(manifestPath) {
22
+ try {
23
+ return isPublishableManifest(JSON.parse(readFileSync(manifestPath, 'utf8')));
24
+ }
25
+ catch {
26
+ return true;
27
+ }
28
+ }
@@ -1,8 +1,9 @@
1
- import { printError, printStep, printSuccess, reportWriteResult, } from '@williamthorsen/nmr-core';
1
+ import { printError, printSkip, printStep, printSuccess, reportWriteResult, } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
3
  import { hasPackageJson, isGitRepo, usesPnpm } from "./checks.js";
4
4
  import { detectRepoType } from "./detectRepoType.js";
5
- import { scaffoldFiles } from "./scaffold.js";
5
+ import { formatPrivateWorkflowSkip, hasPublishablePackage } from "./hasPublishablePackage.js";
6
+ import { PUBLISHING_WORKFLOW_PATHS, scaffoldFiles } from "./scaffold.js";
6
7
  export function checkEligibility(styles) {
7
8
  printStep('Checking eligibility');
8
9
  if (!runRequiredCheck('Git repository detected', isGitRepo(), styles))
@@ -37,10 +38,16 @@ export function initCommand({ dryRun, force, styles, withConfig }) {
37
38
  return 1;
38
39
  }
39
40
  printSuccess(`Detected: ${repoType}`, styles.stdout);
41
+ const publishable = hasPublishablePackage();
40
42
  printStep('Scaffolding files');
43
+ if (!publishable) {
44
+ for (const filePath of PUBLISHING_WORKFLOW_PATHS) {
45
+ printSkip(formatPrivateWorkflowSkip(filePath), styles.stdout);
46
+ }
47
+ }
41
48
  let results;
42
49
  try {
43
- results = scaffoldFiles({ repoType, dryRun, overwrite: force, withConfig });
50
+ results = scaffoldFiles({ repoType, dryRun, overwrite: force, publishable, withConfig });
44
51
  }
45
52
  catch (error) {
46
53
  printError(`Failed to scaffold files: ${describeError(error)}`, styles.stderr);
@@ -53,18 +60,21 @@ export function initCommand({ dryRun, force, styles, withConfig }) {
53
60
  return 1;
54
61
  }
55
62
  printStep('Next steps');
56
- const configHint = withConfig
57
- ? '1. (Optional) Customize .config/release-kit.config.ts.'
58
- : '1. (Optional) Run again with --with-config to scaffold config files.';
59
- console.info(`
60
- ${configHint}
61
- 2. If this is a private repo, remove provenance: true from .github/workflows/publish.yaml.
62
- 3. Test by running: npx @williamthorsen/release-kit prepare --dry-run
63
- 4. Commit the generated files.
64
- 5. Register each package as a trusted publisher on npmjs.com.
65
- `);
63
+ console.info(`\n${formatNextSteps({ publishable, withConfig })}\n`);
66
64
  return 0;
67
65
  }
66
+ function formatNextSteps({ publishable, withConfig }) {
67
+ const steps = [
68
+ withConfig
69
+ ? '(Optional) Customize .config/release-kit.config.ts.'
70
+ : '(Optional) Run again with --with-config to scaffold config files.',
71
+ ...(publishable ? ['If this is a private repo, remove provenance: true from .github/workflows/publish.yaml.'] : []),
72
+ 'Test by running: npx @williamthorsen/release-kit prepare --dry-run',
73
+ 'Commit the generated files.',
74
+ ...(publishable ? ['Register each package as a trusted publisher on npmjs.com.'] : []),
75
+ ];
76
+ return steps.map((step, index) => ` ${String(index + 1)}. ${step}`).join('\n');
77
+ }
68
78
  function runRequiredCheck(label, result, styles) {
69
79
  if (result.ok) {
70
80
  printSuccess(label, styles.stdout);
@@ -4,8 +4,13 @@ interface ScaffoldOptions {
4
4
  repoType: RepoType;
5
5
  dryRun: boolean;
6
6
  overwrite: boolean;
7
+ publishable: boolean;
7
8
  withConfig: boolean;
8
9
  }
10
+ export declare const CREATE_GITHUB_RELEASE_WORKFLOW_PATH = ".github/workflows/create-github-release.yaml";
11
+ export declare const PUBLISH_WORKFLOW_PATH = ".github/workflows/publish.yaml";
12
+ /** The workflows that act only on publishable packages, which a repo whose packages are all private does not get. */
13
+ export declare const PUBLISHING_WORKFLOW_PATHS: readonly [".github/workflows/create-github-release.yaml", ".github/workflows/publish.yaml"];
9
14
  /** Scaffolds release-kit files for the target repo, returning a result for each file attempted. */
10
- export declare function scaffoldFiles({ repoType, dryRun, overwrite, withConfig }: ScaffoldOptions): WriteResult[];
15
+ export declare function scaffoldFiles({ repoType, dryRun, overwrite, publishable, withConfig, }: ScaffoldOptions): WriteResult[];
11
16
  export {};
@@ -1,14 +1,17 @@
1
1
  import { writeFileWithCheck } from '@williamthorsen/nmr-core';
2
2
  import { createGithubReleaseWorkflow, publishWorkflow, releaseConfigScript, releaseWorkflow } from "./templates.js";
3
- export function scaffoldFiles({ repoType, dryRun, overwrite, withConfig }) {
4
- const results = [
5
- writeFileWithCheck('.github/workflows/create-github-release.yaml', createGithubReleaseWorkflow(repoType), {
3
+ export const CREATE_GITHUB_RELEASE_WORKFLOW_PATH = '.github/workflows/create-github-release.yaml';
4
+ export const PUBLISH_WORKFLOW_PATH = '.github/workflows/publish.yaml';
5
+ export const PUBLISHING_WORKFLOW_PATHS = [CREATE_GITHUB_RELEASE_WORKFLOW_PATH, PUBLISH_WORKFLOW_PATH];
6
+ export function scaffoldFiles({ repoType, dryRun, overwrite, publishable, withConfig, }) {
7
+ const results = [];
8
+ if (publishable) {
9
+ results.push(writeFileWithCheck(CREATE_GITHUB_RELEASE_WORKFLOW_PATH, createGithubReleaseWorkflow(repoType), {
6
10
  dryRun,
7
11
  overwrite,
8
- }),
9
- writeFileWithCheck('.github/workflows/publish.yaml', publishWorkflow(repoType), { dryRun, overwrite }),
10
- writeFileWithCheck('.github/workflows/release.yaml', releaseWorkflow(repoType), { dryRun, overwrite }),
11
- ];
12
+ }), writeFileWithCheck(PUBLISH_WORKFLOW_PATH, publishWorkflow(repoType), { dryRun, overwrite }));
13
+ }
14
+ results.push(writeFileWithCheck('.github/workflows/release.yaml', releaseWorkflow(repoType), { dryRun, overwrite }));
12
15
  if (withConfig) {
13
16
  results.push(writeFileWithCheck('.config/release-kit.config.ts', releaseConfigScript(repoType), { dryRun, overwrite }));
14
17
  }
@@ -7,7 +7,8 @@ interface UpdateTemplatesOptions {
7
7
  * Runs the `release-kit update-templates` command and returns the process exit code.
8
8
  *
9
9
  * Brings each workflow that release-kit scaffolds up to date with the installed templates. The opt-in
10
- * `sync-labels.yaml` is refreshed only when it exists, and config files are never read or written.
10
+ * `sync-labels.yaml` is refreshed only when it exists, as are the publishing workflows in a repo whose packages are
11
+ * all private. Config files are never read or written.
11
12
  */
12
13
  export declare function updateTemplatesCommand({ dryRun, styles }: UpdateTemplatesOptions): number;
13
14
  export {};
@@ -1,12 +1,13 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { printError, printStep, printSuccess, reportTemplateUpdate, updateManagedFile, } from '@williamthorsen/nmr-core';
2
+ import { printError, printSkip, printStep, printSuccess, reportTemplateUpdate, updateManagedFile, } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
4
  import { syncLabelsWorkflow } from "../sync-labels/templates.js";
5
5
  import { detectRepoType } from "./detectRepoType.js";
6
6
  import { hasProvenance } from "./hasProvenance.js";
7
+ import { formatPrivateWorkflowSkip, hasPublishablePackage } from "./hasPublishablePackage.js";
7
8
  import { checkEligibility } from "./initCommand.js";
9
+ import { CREATE_GITHUB_RELEASE_WORKFLOW_PATH, PUBLISH_WORKFLOW_PATH, PUBLISHING_WORKFLOW_PATHS } from "./scaffold.js";
8
10
  import { createGithubReleaseWorkflow, publishWorkflow, releaseWorkflow } from "./templates.js";
9
- const PUBLISH_WORKFLOW_PATH = '.github/workflows/publish.yaml';
10
11
  const SYNC_LABELS_WORKFLOW_PATH = '.github/workflows/sync-labels.yaml';
11
12
  export function updateTemplatesCommand({ dryRun, styles }) {
12
13
  if (dryRun) {
@@ -35,7 +36,11 @@ export function updateTemplatesCommand({ dryRun, styles }) {
35
36
  printStep('Updating workflows');
36
37
  let results;
37
38
  try {
38
- results = buildManagedFiles(repoType).map(([filePath, content]) => updateManagedFile(filePath, content, { dryRun }));
39
+ const { files, skipped } = buildManagedFiles(repoType, hasPublishablePackage());
40
+ for (const filePath of skipped) {
41
+ printSkip(formatPrivateWorkflowSkip(filePath), styles.stdout);
42
+ }
43
+ results = files.map(([filePath, content]) => updateManagedFile(filePath, content, { dryRun }));
39
44
  }
40
45
  catch (error) {
41
46
  printError(`Failed to render workflows: ${describeError(error)}`, styles.stderr);
@@ -46,16 +51,17 @@ export function updateTemplatesCommand({ dryRun, styles }) {
46
51
  }
47
52
  return results.some((result) => result.outcome === 'failed') ? 1 : 0;
48
53
  }
49
- function buildManagedFiles(repoType) {
54
+ function buildManagedFiles(repoType, publishable) {
55
+ const skipped = publishable ? [] : PUBLISHING_WORKFLOW_PATHS.filter((filePath) => !existsSync(filePath));
50
56
  const files = [
51
- ['.github/workflows/create-github-release.yaml', createGithubReleaseWorkflow(repoType)],
57
+ [CREATE_GITHUB_RELEASE_WORKFLOW_PATH, createGithubReleaseWorkflow(repoType)],
52
58
  [PUBLISH_WORKFLOW_PATH, publishWorkflow(repoType, { provenance: readProvenanceSetting() })],
53
59
  ['.github/workflows/release.yaml', releaseWorkflow(repoType)],
54
60
  ];
55
61
  if (existsSync(SYNC_LABELS_WORKFLOW_PATH)) {
56
62
  files.push([SYNC_LABELS_WORKFLOW_PATH, syncLabelsWorkflow()]);
57
63
  }
58
- return files;
64
+ return { files: files.filter(([filePath]) => !skipped.includes(filePath)), skipped };
59
65
  }
60
66
  function readProvenanceSetting() {
61
67
  if (!existsSync(PUBLISH_WORKFLOW_PATH))
@@ -0,0 +1,2 @@
1
+ /** Reports whether a parsed `package.json` can be published: its `private` field is absent or `false`. */
2
+ export declare function isPublishableManifest(manifest: unknown): boolean;
@@ -0,0 +1,5 @@
1
+ import { isRecord } from "./typeGuards.js";
2
+ export function isPublishableManifest(manifest) {
3
+ const privateField = isRecord(manifest) ? manifest['private'] : undefined;
4
+ return privateField === undefined || privateField === false;
5
+ }
@@ -0,0 +1,2 @@
1
+ /** Checks whether a tag name is one of the prefixes followed by a complete SemVer version. */
2
+ export declare function matchesTagPrefix(tagName: string, tagPrefixes: readonly string[]): boolean;
@@ -0,0 +1,4 @@
1
+ const SEMVER_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
2
+ export function matchesTagPrefix(tagName, tagPrefixes) {
3
+ return tagPrefixes.some((prefix) => tagName.startsWith(prefix) && SEMVER_VERSION_PATTERN.test(tagName.slice(prefix.length)));
4
+ }
@@ -0,0 +1,35 @@
1
+ export declare const AUTH_ERROR_CODES: ReadonlySet<string>;
2
+ export declare const UNREACHABLE_ERROR_CODES: ReadonlySet<string>;
3
+ /**
4
+ * Classifies the outcome of `npm view <package> version --json`.
5
+ *
6
+ * Only `E404` means that the registry doesn't know the package; any other failure leaves the question open.
7
+ */
8
+ export declare function classifyNpmPackageLookup(result: NpmCommandResult): NpmPackageLookup;
9
+ /** Looks up whether a package exists on the npm registry, querying `registry` when one is given. */
10
+ export declare function lookUpNpmPackage(packageName: string, registry?: string): NpmPackageLookup;
11
+ export interface NpmCommandResult {
12
+ exitOk: boolean;
13
+ stdout: string;
14
+ }
15
+ export interface NpmErrorPayload {
16
+ code: string;
17
+ summary: string;
18
+ }
19
+ export type NpmPackageLookup = {
20
+ status: 'published';
21
+ } | {
22
+ status: 'unpublished';
23
+ } | {
24
+ status: 'unverifiable';
25
+ detail: string;
26
+ };
27
+ /** Reads npm's `--json` error envelope, which npm writes to stdout on both the success and failure paths. */
28
+ export declare function readNpmError(stdout: string): NpmErrorPayload | undefined;
29
+ /**
30
+ * Runs npm with the given arguments, capturing stdout whether or not the command exits zero.
31
+ *
32
+ * The arguments are expected to include `--json`: npm writes its machine-readable error envelope to stdout,
33
+ * which `execFileSync` attaches to the thrown error instead of returning it.
34
+ */
35
+ export declare function runNpmJson(args: readonly string[]): NpmCommandResult;
@@ -0,0 +1,58 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { isRecord } from "./typeGuards.js";
3
+ export const AUTH_ERROR_CODES = new Set(['E401', 'ENEEDAUTH']);
4
+ export const UNREACHABLE_ERROR_CODES = new Set([
5
+ 'EAI_AGAIN',
6
+ 'ECONNREFUSED',
7
+ 'ECONNRESET',
8
+ 'ENETUNREACH',
9
+ 'ENOTFOUND',
10
+ 'ERR_SOCKET_TIMEOUT',
11
+ 'ETIMEDOUT',
12
+ ]);
13
+ export function classifyNpmPackageLookup(result) {
14
+ if (result.exitOk) {
15
+ return { status: 'published' };
16
+ }
17
+ const error = readNpmError(result.stdout);
18
+ if (error === undefined) {
19
+ return { status: 'unverifiable', detail: 'The npm registry query failed without a readable error payload' };
20
+ }
21
+ if (error.code === 'E404') {
22
+ return { status: 'unpublished' };
23
+ }
24
+ if (AUTH_ERROR_CODES.has(error.code)) {
25
+ return { status: 'unverifiable', detail: `The npm registry rejected the session (${error.code})` };
26
+ }
27
+ if (UNREACHABLE_ERROR_CODES.has(error.code)) {
28
+ return { status: 'unverifiable', detail: `Cannot reach the npm registry (${error.code})` };
29
+ }
30
+ return { status: 'unverifiable', detail: `The npm registry query failed (${error.code}): ${error.summary}` };
31
+ }
32
+ export function lookUpNpmPackage(packageName, registry) {
33
+ const args = ['view', packageName, 'version', '--json', ...(registry === undefined ? [] : ['--registry', registry])];
34
+ return classifyNpmPackageLookup(runNpmJson(args));
35
+ }
36
+ export function readNpmError(stdout) {
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(stdout);
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ if (!isRecord(parsed) || !isRecord(parsed['error']) || typeof parsed['error']['code'] !== 'string') {
45
+ return undefined;
46
+ }
47
+ const { code, summary } = parsed['error'];
48
+ return { code, summary: typeof summary === 'string' ? summary : '' };
49
+ }
50
+ export function runNpmJson(args) {
51
+ try {
52
+ return { exitOk: true, stdout: execFileSync('npm', args, { encoding: 'utf8', stdio: 'pipe' }) };
53
+ }
54
+ catch (error) {
55
+ const stdout = isRecord(error) && typeof error['stdout'] === 'string' ? error['stdout'] : '';
56
+ return { exitOk: false, stdout };
57
+ }
58
+ }
@@ -2,6 +2,7 @@ import { execSync } from 'node:child_process';
2
2
  import { formatGlyphLine, parseArgsOrExit, reportError, } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
4
  import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
5
+ import { assertReleasesOnNpm } from "./assertReleasesOnNpm.js";
5
6
  import { configFlagSchema } from "./configFlagSchema.js";
6
7
  import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
7
8
  import { dim } from "./format.js";
@@ -116,7 +117,11 @@ function runSinglePackageMode(userConfig, options, only, setVersion, dryRun, sty
116
117
  process.exit(1);
117
118
  }
118
119
  const config = mergeSinglePackageConfig(userConfig);
119
- runAndReport(() => releasePrepare(config, { ...options, ...(setVersion !== undefined && { setVersion }) }), dryRun, style);
120
+ runAndReport(() => {
121
+ const plan = releasePrepare(config, { ...options, ...(setVersion !== undefined && { setVersion }) });
122
+ assertReleasesOnNpm(plan.workspaces.some((result) => result.status === 'released') ? ['.'] : []);
123
+ return plan;
124
+ }, dryRun, style);
120
125
  }
121
126
  function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion, dryRun, style) {
122
127
  let config;
@@ -148,7 +153,15 @@ function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion,
148
153
  ...(only !== undefined && { only }),
149
154
  ...(setVersion !== undefined && { setVersions: parseSetVersions(setVersion, knownNames) }),
150
155
  };
151
- runAndReport(() => releasePrepareMono(config, monorepoOptions), dryRun, style);
156
+ runAndReport(() => {
157
+ const plan = releasePrepareMono(config, monorepoOptions);
158
+ assertReleasesOnNpm(findReleasedWorkspacePaths(plan, config.workspaces));
159
+ return plan;
160
+ }, dryRun, style);
161
+ }
162
+ function findReleasedWorkspacePaths(plan, workspaces) {
163
+ const releasedDirs = new Set(plan.workspaces.flatMap((result) => result.status === 'released' && result.name !== undefined ? [result.name] : []));
164
+ return workspaces.filter((workspace) => releasedDirs.has(workspace.dir)).map((workspace) => workspace.workspacePath);
152
165
  }
153
166
  function parseSetVersions(value, knownNames) {
154
167
  const setVersions = new Map();
@@ -5,6 +5,7 @@ import { describeError } from '@williamthorsen/toolbelt.errors';
5
5
  import { deriveWorkspaceConfig } from "./deriveWorkspaceConfig.js";
6
6
  import { detectUndeclaredTagPrefixes } from "./detectUndeclaredTagPrefixes.js";
7
7
  import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
8
+ import { matchesTagPrefix } from "./matchesTagPrefix.js";
8
9
  export function previewTagPrefixes(config) {
9
10
  const workspace = discoverWorkspaces();
10
11
  if (workspace.kind === 'empty') {
@@ -62,7 +63,7 @@ function countTagsMatching(prefix) {
62
63
  maxBuffer: GIT_OUTPUT_LIMIT,
63
64
  stdio: ['pipe', 'pipe', 'pipe'],
64
65
  });
65
- return output.split('\n').filter((line) => line.trim() !== '').length;
66
+ return output.split('\n').filter((line) => matchesTagPrefix(line.trim(), [prefix])).length;
66
67
  }
67
68
  catch {
68
69
  return 0;
@@ -11,7 +11,7 @@ on:
11
11
  paths:
12
12
  - .github/labels.yaml
13
13
 
14
- # Preview on review: The check's log has a full computed diff, including deletions of labels not declared by the file.
14
+ # Preview on review: The check's log has the computed diff, and the check warns about each label not declared by the file.
15
15
  pull_request:
16
16
  paths:
17
17
  - .github/labels.yaml
@@ -90,7 +90,7 @@ Tag prefixes from distinct owners must not be identical or be a strict prefix of
90
90
  - A `retiredPackages[]` entry (one owner per entry).
91
91
  - The `project` block, when configured.
92
92
 
93
- Because release-kit matches a tag to a prefix when the tag name starts with the prefix and continues with a digit, a strict-prefix overlap between distinct owners can match the wrong owner's tags. For example, a project prefix of `v` collides with a workspace prefix of `v11y-check-v`, since `v11y-check-v1.0.0` starts with `v` followed by a digit. The rule rejects every strict-prefix overlap, including one such as `v` and `vue-helpers-v`, whose shorter prefix can't match any tag of the longer one.
93
+ Because release-kit matches a tag to a prefix when the tag name is the prefix followed by a complete SemVer version, a strict-prefix overlap between distinct owners can match the wrong owner's tags. For example, a retired prefix of `pkg-` collides with a workspace prefix of `pkg-1.0.0-v`, since `pkg-1.0.0-v1.2.3` is `pkg-` followed by the pre-release version `1.0.0-v1.2.3`. The rule rejects every strict-prefix overlap, including one such as `v` and `vue-helpers-v`, whose shorter prefix can't match any tag of the longer one.
94
94
 
95
95
  The rule is enforced at config load; the resulting error identifies both colliding declarations.
96
96
 
package/docs/labels.md CHANGED
@@ -52,7 +52,7 @@ labels: {
52
52
  },
53
53
  ```
54
54
 
55
- The sync workflow archives a marked label rather than deleting it. An archived label stays on the issues and pull requests that have it, but it cannot be added to new ones and does not appear in the label picker. Removing the mark unarchives the label on the next sync. Removing the entry instead deletes the label, as the sync deletes every label that `.github/labels.yaml` does not declare, and the label disappears from every issue and pull request that had it.
55
+ The sync workflow archives a marked label rather than deleting it. An archived label stays on the issues and pull requests that have it, but it cannot be added to new ones and does not appear in the label picker. Removing the mark unarchives the label on the next sync. Removing the entry leaves the label in place: The sync does not delete a label that `.github/labels.yaml` does not declare, and it reports each such label as a warning. To delete a label, run `gh label delete <name>`; the label disappears from every issue and pull request that had it.
56
56
 
57
57
  The mark is set only in the `labels` record, not in a preset. To retire a preset's label, replace it with an entry that restates its color and adds the mark.
58
58
 
@@ -68,15 +68,17 @@ The scaffolded workflow declares three triggers:
68
68
 
69
69
  Applying on merge closes the window in which a regenerated `.github/labels.yaml` remains unapplied. In that window, a later manual dispatch would apply an unreviewed label set.
70
70
 
71
- The `check` job runs the same sync in dry-run. Its **Label diff** log group lists every create, edit, rename, and deletion that the sync would perform, including deletions of labels that the file does not declare: the destructive edits that a diff of `.github/labels.yaml` alone cannot show, because a label created by hand or by another workflow is absent from the file both before and after. Read that log; the check reports success whether or not the diff is destructive, so a green check is not evidence that nothing will be deleted.
71
+ The `check` job runs the same sync in dry-run. Its **Label diff** log group lists every create, edit, and rename that the sync would perform.
72
72
 
73
73
  Its **Archive state** log group lists each label that the sync would archive or unarchive. Archiving runs after the rest of the sync, so a label that the file both adds and marks is created and then archived.
74
74
 
75
+ Both jobs end with an **Undeclared labels** log group, and they report each repository label that the file does not declare as a warning annotation, which appears on the run and on the pull request's check. A label created by hand, for example to put it on the ticket that will declare it, therefore survives every sync until the file declares it or someone deletes it.
76
+
75
77
  The two jobs are split because a job's permissions are fixed when the run is created and cannot vary by trigger. The split keeps a write-scoped token out of pull-request runs.
76
78
 
77
79
  The push trigger filters on the path alone; the `sync` job compares `github.ref_name` against the repository's default branch. The workflow therefore doesn't need a per-repo edit, whatever that branch is named, and a push touching `.github/labels.yaml` on any other branch produces a run whose jobs all skip.
78
80
 
79
- Manual dispatch is not a preview. It matches the `sync` job, so it applies the labels, deletions included; only the pull-request path runs in dry-run.
81
+ Manual dispatch is not a preview. It matches the `sync` job, so it applies the labels; only the pull-request path runs in dry-run.
80
82
 
81
83
  ### Dry-run checks on fork pull requests
82
84
 
package/docs/releasing.md CHANGED
@@ -15,13 +15,15 @@ Scaffolded files:
15
15
  - `.github/workflows/release.yaml`: Workflow that delegates to a reusable release workflow
16
16
  - `.config/release-kit.config.ts`: Starter config with commented-out customization examples (with `--with-config`)
17
17
 
18
+ When every workspace, or the single package, sets `private: true`, `init` skips `create-github-release.yaml` and `publish.yaml`, `--force` included, and reports each skip: `publish` and `create-github-release` skip private packages, so neither workflow would do anything. Its next steps then leave out the provenance and trusted-publisher steps.
19
+
18
20
  ## `release-kit update-templates`
19
21
 
20
22
  Updates the scaffolded workflows to the templates of the installed release-kit version, and prints what changed in each file. Run it after upgrading release-kit; `--dry-run` previews the changes without writing them.
21
23
 
22
24
  The workflows are managed files: release-kit renders them, and a change made by hand is overwritten on the next update. The config files are the repository's own, and the command never reads or writes them.
23
25
 
24
- - It creates a missing workflow that `init` would create.
26
+ - It creates a missing workflow that `init` would create. In a repo whose packages are all private, `init` does not create `create-github-release.yaml` or `publish.yaml`, so the command does not create either one when it is missing and reports the skip; it still refreshes either one that exists.
25
27
  - It refreshes `.github/workflows/sync-labels.yaml` only when the file exists, because `sync-labels init` scaffolds it on request.
26
28
  - It keeps `publish.yaml`'s provenance setting: When the file does not set `provenance: true`, as in a private repository, the updated file does not either.
27
29
 
@@ -57,6 +59,10 @@ release-kit prepare --force --bump=minor
57
59
  release-kit prepare --bump=minor
58
60
  ```
59
61
 
62
+ ### Packages that npm doesn't have yet
63
+
64
+ Before it writes any file, and in a dry run as well, `prepare` asks npm about each publishable package that the release includes, querying the registry that the package's `publishConfig.registry` names when it sets one. OIDC trusted publishing can't publish a package's first version, so `prepare` refuses to release a package that npm doesn't list, and names each one with its fix: Run `npm publish --access public` from the package's directory once, then register the package as a trusted publisher. The `npm-auto-publish` [readiness kit](readiness-checks.md) prints the `npm trust` command. `prepare` also refuses when it can't look a package up, such as when the registry is unreachable, and reports npm's error for that package.
65
+
60
66
  ### Previewing release notes with `--with-release-notes`
61
67
 
62
68
  `--with-release-notes` writes two versioned files per workspace after each workspace's `changelog.json` is produced:
@@ -126,6 +132,8 @@ The `init` command scaffolds a release workflow at `.github/workflows/release.ya
126
132
 
127
133
  For repos that need a self-contained workflow instead of the reusable one, the scaffolded file can be expanded. The key steps are: checkout with full history (`fetch-depth: 0`), run `release-kit prepare` with optional `--only`, `--bump`, and `--force` flags, check for changes, read tags from `tmp/.release-tags`, then commit, tag, and push.
128
134
 
135
+ The reusable `release`, `create-github-release`, and `sync-labels` workflows run on `ubuntu-latest` unless the repository's `CI_RUNS_ON` variable names another runner; `publish` always runs on `ubuntu-latest`. See [Runner selection](https://github.com/williamthorsen/node-monorepo-tools/blob/main/.github/workflows/README.md#runner-selection).
136
+
129
137
  ### Triggering a release
130
138
 
131
139
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/release-kit",
3
- "version": "14.0.0",
3
+ "version": "14.1.0",
4
4
  "description": "Version-bumping and changelog-generation toolkit for release workflows",
5
5
  "keywords": [
6
6
  "changelog",
@@ -52,7 +52,7 @@
52
52
  "dependencies": {
53
53
  "@williamthorsen/change-grammar": "0.2.0",
54
54
  "@williamthorsen/nmr-core": "0.13.2",
55
- "@williamthorsen/toolbelt.errors": "0.8.2",
55
+ "@williamthorsen/toolbelt.errors": "0.8.3",
56
56
  "json-stringify-pretty-compact": "4.0.0",
57
57
  "semver": "7.8.5",
58
58
  "yaml": "2.9.1",
@@ -60,9 +60,9 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/semver": "7.8.0",
63
- "@williamthorsen/toolbelt.filesystem": "0.9.2",
64
- "@williamthorsen/toolbelt.testing": "0.6.2",
65
- "@williamthorsen/toolbelt.vitest": "0.10.2",
63
+ "@williamthorsen/toolbelt.filesystem": "0.9.3",
64
+ "@williamthorsen/toolbelt.testing": "0.6.3",
65
+ "@williamthorsen/toolbelt.vitest": "0.10.3",
66
66
  "actionlint": "2.0.6",
67
67
  "esbuild": "0.28.2",
68
68
  "readyup": "0.40.0",