@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.
- package/.meta/changelog.json +48 -0
- package/.readyup/kits/default.js +2 -2
- package/.readyup/kits/npm-auto-publish.js +80 -40
- package/.readyup/manifest.json +17 -7
- package/CHANGELOG.md +14 -0
- package/README.md +7 -3
- package/dist/esm/assertReleasesOnNpm.d.ts +7 -0
- package/dist/esm/assertReleasesOnNpm.js +56 -0
- package/dist/esm/bin/release-kit.js +5 -2
- package/dist/esm/deriveWorkspaceConfig.js +2 -2
- package/dist/esm/enumerateReleaseWindows.d.ts +1 -1
- package/dist/esm/enumerateReleaseWindows.js +1 -3
- package/dist/esm/init/hasPublishablePackage.d.ts +10 -0
- package/dist/esm/init/hasPublishablePackage.js +28 -0
- package/dist/esm/init/initCommand.js +23 -13
- package/dist/esm/init/scaffold.d.ts +6 -1
- package/dist/esm/init/scaffold.js +10 -7
- package/dist/esm/init/updateTemplatesCommand.d.ts +2 -1
- package/dist/esm/init/updateTemplatesCommand.js +12 -6
- package/dist/esm/isPublishableManifest.d.ts +2 -0
- package/dist/esm/isPublishableManifest.js +5 -0
- package/dist/esm/matchesTagPrefix.d.ts +2 -0
- package/dist/esm/matchesTagPrefix.js +4 -0
- package/dist/esm/npmRegistry.d.ts +35 -0
- package/dist/esm/npmRegistry.js +58 -0
- package/dist/esm/prepareCommand.js +15 -2
- package/dist/esm/previewTagPrefixes.js +2 -1
- package/dist/esm/sync-labels/templates.js +1 -1
- package/docs/configuration.md +1 -1
- package/docs/labels.md +5 -3
- package/docs/releasing.md +9 -1
- package/package.json +5 -5
package/.meta/changelog.json
CHANGED
|
@@ -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",
|
package/.readyup/kits/default.js
CHANGED
|
@@ -98,7 +98,7 @@ function translateGlob(pattern) {
|
|
|
98
98
|
|
|
99
99
|
// .readyup/kits/default.ts
|
|
100
100
|
function getMinVersion() {
|
|
101
|
-
const picked = { "version": "14.
|
|
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 = "
|
|
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
|
-
//
|
|
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: () =>
|
|
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("
|
|
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(
|
|
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
|
|
378
|
-
|
|
379
|
-
|
|
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) =>
|
|
445
|
+
return parsed.filter((entry) => isRecord2(entry) && "type" in entry);
|
|
399
446
|
}
|
|
400
|
-
if (!
|
|
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,
|
package/.readyup/manifest.json
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"esbuildVersion": "0.28.2",
|
|
9
9
|
"inputs": [
|
|
10
10
|
{
|
|
11
|
-
"hash": "
|
|
11
|
+
"hash": "dee24ab4",
|
|
12
12
|
"kind": "module",
|
|
13
13
|
"path": "kits/default.ts"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
|
-
"hash": "
|
|
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": "
|
|
49
|
-
"targetHash": "
|
|
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": "
|
|
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": "
|
|
74
|
-
"targetHash": "
|
|
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.
|
|
10
|
+
## Release notes — v14.1.0 (2026-10-11)
|
|
11
11
|
|
|
12
12
|
### 🎉 Features
|
|
13
13
|
|
|
14
|
-
-
|
|
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
|
-
-
|
|
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.
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
[
|
|
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,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(() =>
|
|
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(() =>
|
|
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()
|
|
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
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
64
|
-
"@williamthorsen/toolbelt.testing": "0.6.
|
|
65
|
-
"@williamthorsen/toolbelt.vitest": "0.10.
|
|
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",
|