rylease 0.0.0-stage → 3.0.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/LICENSE +21 -0
- package/README.md +910 -2
- package/dist/cli.mjs +376 -0
- package/dist/index.cjs +5022 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1572 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +1571 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +4928 -0
- package/dist/index.mjs.map +1 -0
- package/dist/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/package.json +68 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,4928 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { access, glob, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, normalize, posix, relative, resolve, sep } from "node:path";
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { performance } from "node:perf_hooks";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { crc32, deflateRaw } from "node:zlib";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
//#region src/core/options.ts
|
|
14
|
+
/** Options that don't match a step's schema; the message lists every problem. */
|
|
15
|
+
var OptionsError = class extends Error {
|
|
16
|
+
issues;
|
|
17
|
+
constructor(issues) {
|
|
18
|
+
super(issues.join("\n"));
|
|
19
|
+
this.name = "OptionsError";
|
|
20
|
+
this.issues = issues;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Validates options against a shape, applying defaults. Unknown keys are
|
|
25
|
+
* rejected. Each problem is reported with `path` prepended, e.g.
|
|
26
|
+
* `conventional/determine-kind.types.docs: Invalid option: ...`.
|
|
27
|
+
*/
|
|
28
|
+
function parseOptions(shape, value, path) {
|
|
29
|
+
const result = z.strictObject(shape).safeParse(value ?? {});
|
|
30
|
+
if (result.success) return result.data;
|
|
31
|
+
throw new OptionsError(result.error.issues.map((issue) => `${joinPath(path, issue.path)}: ${issue.message}`));
|
|
32
|
+
}
|
|
33
|
+
function joinPath(path, keys = []) {
|
|
34
|
+
return keys.reduce((text, key) => typeof key === "number" ? `${text}[${key}]` : /^[\w-]+$/.test(String(key)) ? `${text}.${String(key)}` : `${text}[${JSON.stringify(String(key))}]`, path);
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/core/factory.ts
|
|
38
|
+
/** Makes a factory that validates its options on every call. */
|
|
39
|
+
function makeFactory(kind, definition) {
|
|
40
|
+
const shape = definition.options ?? {};
|
|
41
|
+
const factory = (options) => definition.create(parseOptions(shape, options, definition.name));
|
|
42
|
+
return Object.defineProperties(factory, {
|
|
43
|
+
kind: {
|
|
44
|
+
value: kind,
|
|
45
|
+
enumerable: true
|
|
46
|
+
},
|
|
47
|
+
name: {
|
|
48
|
+
value: definition.name,
|
|
49
|
+
enumerable: true
|
|
50
|
+
},
|
|
51
|
+
description: {
|
|
52
|
+
value: definition.description,
|
|
53
|
+
enumerable: true
|
|
54
|
+
},
|
|
55
|
+
options: {
|
|
56
|
+
value: shape,
|
|
57
|
+
enumerable: true
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Whether a value is a factory, of any kind or of the given one. */
|
|
62
|
+
function isFactory(value, kind) {
|
|
63
|
+
if (typeof value !== "function") return false;
|
|
64
|
+
const factory = value;
|
|
65
|
+
return typeof factory.kind === "string" && (kind === void 0 || factory.kind === kind) && typeof factory.name === "string";
|
|
66
|
+
}
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/core/components.ts
|
|
69
|
+
/** Defines a source of components, with options as a zod shape. */
|
|
70
|
+
function defineComponents(definition) {
|
|
71
|
+
return makeFactory("components", definition);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Checks components and fills in their defaults, ordered so that each comes
|
|
75
|
+
* after the components it depends on.
|
|
76
|
+
*/
|
|
77
|
+
function resolveComponents(components, defaults) {
|
|
78
|
+
const issues = [];
|
|
79
|
+
const names = /* @__PURE__ */ new Set();
|
|
80
|
+
for (const [index, component] of components.entries()) {
|
|
81
|
+
if (typeof component.name !== "string" || component.name === "") issues.push(`components[${index}].name: expected a name`);
|
|
82
|
+
else if (names.has(component.name)) issues.push(`components[${index}].name: '${component.name}' is listed twice`);
|
|
83
|
+
names.add(component.name);
|
|
84
|
+
}
|
|
85
|
+
const paths = components.map((component, index) => {
|
|
86
|
+
const path = normalize(component.path ?? ".").replaceAll(sep, "/");
|
|
87
|
+
if (isAbsolute(path) || path === ".." || path.startsWith("../")) issues.push(`components[${index}].path: '${component.path}' must be inside the project`);
|
|
88
|
+
return path.replace(/\/$/, "");
|
|
89
|
+
});
|
|
90
|
+
for (const [index, component] of components.entries()) for (const dependency of component.dependsOn ?? []) if (!names.has(dependency)) issues.push(`components[${index}].dependsOn: '${dependency}' isn't a component`);
|
|
91
|
+
const several = components.length > 1;
|
|
92
|
+
const tags = components.map((component) => {
|
|
93
|
+
return (component.tag ?? defaults.tag ?? (several ? "{name}@{version}" : "v{version}")).replaceAll("{name}", component.name);
|
|
94
|
+
});
|
|
95
|
+
for (const [index, tag] of tags.entries()) {
|
|
96
|
+
const at = components[index]?.tag === void 0 ? "tag" : `components[${index}].tag`;
|
|
97
|
+
const unknown = [...tag.matchAll(/\{(\w+)\}/g)].map(([, name]) => name).filter((name) => name !== "version");
|
|
98
|
+
if (!tag.includes("{version}")) issues.push(`${at}: '${tag}' has no {version} placeholder`);
|
|
99
|
+
else if (unknown.length > 0) issues.push(`${at}: unknown placeholder {${unknown[0]}} in '${tag}'; use {version} or {name}`);
|
|
100
|
+
}
|
|
101
|
+
const seen = /* @__PURE__ */ new Map();
|
|
102
|
+
for (const [index, tag] of tags.entries()) {
|
|
103
|
+
const other = seen.get(tag);
|
|
104
|
+
const name = components[index]?.name ?? "";
|
|
105
|
+
if (other !== void 0) issues.push(`components: '${other}' and '${name}' share the tag '${tag}'; add {name} to it`);
|
|
106
|
+
seen.set(tag, name);
|
|
107
|
+
}
|
|
108
|
+
if (issues.length > 0) throw new OptionsError(issues);
|
|
109
|
+
return dependencyOrder(components.map((component, index) => {
|
|
110
|
+
const path = paths[index] ?? ".";
|
|
111
|
+
const nested = paths.filter((other, i) => i !== index && isInside(other, path));
|
|
112
|
+
const whole = path === "." && nested.length === 0;
|
|
113
|
+
return {
|
|
114
|
+
info: {
|
|
115
|
+
name: component.name,
|
|
116
|
+
path,
|
|
117
|
+
dependsOn: [...component.dependsOn ?? []],
|
|
118
|
+
pathspec: whole ? [] : [path, ...nested.map((other) => `:(exclude)${other}`)]
|
|
119
|
+
},
|
|
120
|
+
scheme: component.scheme ?? defaults.scheme,
|
|
121
|
+
history: component.history ?? defaults.history,
|
|
122
|
+
tag: tags[index] ?? ""
|
|
123
|
+
};
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
/** Whether `path` is strictly inside `parent`, both relative to the project. */
|
|
127
|
+
function isInside(path, parent) {
|
|
128
|
+
if (path === parent) return false;
|
|
129
|
+
if (parent === ".") return path !== ".";
|
|
130
|
+
const between = relative(parent, path);
|
|
131
|
+
return between !== "" && !between.startsWith("..") && !isAbsolute(between);
|
|
132
|
+
}
|
|
133
|
+
/** Dependencies first, otherwise in the order given. */
|
|
134
|
+
function dependencyOrder(components) {
|
|
135
|
+
const ordered = [];
|
|
136
|
+
const done = /* @__PURE__ */ new Set();
|
|
137
|
+
let remaining = components;
|
|
138
|
+
while (remaining.length > 0) {
|
|
139
|
+
const ready = remaining.filter(({ info }) => info.dependsOn.every((name) => done.has(name)));
|
|
140
|
+
if (ready.length === 0) throw new OptionsError([`components: ${remaining.map(({ info }) => info.name).join(", ")} depend on each other in a cycle`]);
|
|
141
|
+
for (const component of ready) {
|
|
142
|
+
ordered.push(component);
|
|
143
|
+
done.add(component.info.name);
|
|
144
|
+
}
|
|
145
|
+
remaining = remaining.filter((component) => !ready.includes(component));
|
|
146
|
+
}
|
|
147
|
+
return ordered;
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/utils/files.ts
|
|
151
|
+
/**
|
|
152
|
+
* A path relative to a component's directory: where it is, and how it's
|
|
153
|
+
* written relative to the project, e.g. in `files` and reasons.
|
|
154
|
+
*/
|
|
155
|
+
function inComponent(env, component, path) {
|
|
156
|
+
return {
|
|
157
|
+
file: resolve(env.cwd, component.path, path),
|
|
158
|
+
relative: posix.normalize(posix.join(component.path, path.replaceAll("\\", "/")))
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Resolves globs, relative to `cwd` unless absolute, to the absolute paths of
|
|
163
|
+
* the files they match, sorted; a directory stands for every file in it.
|
|
164
|
+
* Every pattern must match something, so a typo fails instead of uploading
|
|
165
|
+
* nothing.
|
|
166
|
+
*/
|
|
167
|
+
async function resolveFiles(cwd, patterns) {
|
|
168
|
+
const files = /* @__PURE__ */ new Set();
|
|
169
|
+
for (const pattern of patterns) {
|
|
170
|
+
const normalized = pattern.replaceAll("\\", "/");
|
|
171
|
+
let matched = false;
|
|
172
|
+
for await (const match of glob(normalized, { cwd })) {
|
|
173
|
+
const path = isAbsolute(match) ? match : resolve(cwd, match);
|
|
174
|
+
for (const file of await filesIn(path)) {
|
|
175
|
+
files.add(file);
|
|
176
|
+
matched = true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!matched) throw new Error(`no files match '${pattern}'`);
|
|
180
|
+
}
|
|
181
|
+
return [...files].sort();
|
|
182
|
+
}
|
|
183
|
+
async function filesIn(path) {
|
|
184
|
+
if (!(await stat(path)).isDirectory()) return [path];
|
|
185
|
+
return (await readdir(path, {
|
|
186
|
+
recursive: true,
|
|
187
|
+
withFileTypes: true
|
|
188
|
+
})).filter((entry) => entry.isFile()).map((entry) => join(entry.parentPath, entry.name));
|
|
189
|
+
}
|
|
190
|
+
/** `1.2 MB`, `340 kB`, `12 B`. */
|
|
191
|
+
function formatSize(bytes) {
|
|
192
|
+
const units = [
|
|
193
|
+
"B",
|
|
194
|
+
"kB",
|
|
195
|
+
"MB",
|
|
196
|
+
"GB"
|
|
197
|
+
];
|
|
198
|
+
let size = bytes;
|
|
199
|
+
let unit = 0;
|
|
200
|
+
while (size >= 1e3 && unit < units.length - 1) {
|
|
201
|
+
size /= 1e3;
|
|
202
|
+
unit++;
|
|
203
|
+
}
|
|
204
|
+
return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`;
|
|
205
|
+
}
|
|
206
|
+
async function loadJson(path) {
|
|
207
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/components/npm/read.ts
|
|
211
|
+
/**
|
|
212
|
+
* The `packages:` list of a pnpm-workspace.yaml, in block style; negated
|
|
213
|
+
* patterns are left out.
|
|
214
|
+
*/
|
|
215
|
+
function pnpmPackages(text) {
|
|
216
|
+
const patterns = [];
|
|
217
|
+
let inPackages = false;
|
|
218
|
+
for (const line of text.split(/\r?\n/)) {
|
|
219
|
+
if (/^packages:\s*$/.test(line)) {
|
|
220
|
+
inPackages = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!inPackages) continue;
|
|
224
|
+
const item = /^\s+-\s+(.+?)\s*$/.exec(line);
|
|
225
|
+
if (item?.[1]) {
|
|
226
|
+
const pattern = /^(['"])(.*)\1$/.exec(item[1])?.[2] ?? item[1].replace(/\s+#.*$/, "");
|
|
227
|
+
if (!pattern.startsWith("!")) patterns.push(pattern);
|
|
228
|
+
} else if (/^\S/.test(line)) inPackages = false;
|
|
229
|
+
}
|
|
230
|
+
return patterns;
|
|
231
|
+
}
|
|
232
|
+
/** Globs of the workspace's packages, from pnpm-workspace.yaml or package.json. */
|
|
233
|
+
async function getPackages(cwd) {
|
|
234
|
+
const pnpm = await readFile(join(cwd, "pnpm-workspace.yaml"), "utf8").catch(() => void 0);
|
|
235
|
+
if (pnpm !== void 0) {
|
|
236
|
+
const patterns = pnpmPackages(pnpm);
|
|
237
|
+
if (patterns.length > 0) return patterns;
|
|
238
|
+
}
|
|
239
|
+
const workspaces = (await loadJson(join(cwd, "package.json")).catch(() => ({}))).workspaces;
|
|
240
|
+
const list = Array.isArray(workspaces) ? workspaces : workspaces?.packages;
|
|
241
|
+
if (!Array.isArray(list) || list.length === 0) throw new Error("no workspaces: list them in package.json `workspaces`, or in pnpm-workspace.yaml `packages`");
|
|
242
|
+
return list.filter((item) => typeof item === "string");
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/components/npm/workspaces.ts
|
|
246
|
+
const DEPENDENCY_FIELDS$1 = [
|
|
247
|
+
"dependencies",
|
|
248
|
+
"devDependencies",
|
|
249
|
+
"peerDependencies",
|
|
250
|
+
"optionalDependencies"
|
|
251
|
+
];
|
|
252
|
+
/**
|
|
253
|
+
* The packages of an npm, Yarn or pnpm workspace, as components named after
|
|
254
|
+
* them, each depending on the workspace packages it lists as dependencies.
|
|
255
|
+
*/
|
|
256
|
+
const npmWorkspaces = defineComponents({
|
|
257
|
+
name: "npm/workspaces",
|
|
258
|
+
description: "Each package of the npm, Yarn or pnpm workspace, named after it and depending on the workspace packages it depends on.",
|
|
259
|
+
options: {
|
|
260
|
+
private: z.boolean().default(false).describe("Include packages marked `private`."),
|
|
261
|
+
exclude: z.array(z.string()).default([]).describe("Packages to leave out, by name.")
|
|
262
|
+
},
|
|
263
|
+
create: (options) => ({
|
|
264
|
+
name: "npm/workspaces",
|
|
265
|
+
resolve: async (env) => {
|
|
266
|
+
const patterns = await getPackages(env.cwd);
|
|
267
|
+
const found = [];
|
|
268
|
+
for (const pattern of patterns) for await (const match of glob(`${pattern.replace(/\/$/, "")}/package.json`, { cwd: env.cwd })) {
|
|
269
|
+
const path = match.replaceAll("\\", "/").replace(/\/?package\.json$/, "") || ".";
|
|
270
|
+
if (path.includes("node_modules")) continue;
|
|
271
|
+
const json = JSON.parse(await readFile(join(env.cwd, match), "utf8"));
|
|
272
|
+
if (typeof json.name !== "string") continue;
|
|
273
|
+
if (json.private === true && !options.private) continue;
|
|
274
|
+
if (options.exclude.includes(json.name)) continue;
|
|
275
|
+
if (!found.some((other) => other.path === path)) found.push({
|
|
276
|
+
name: json.name,
|
|
277
|
+
path,
|
|
278
|
+
json
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (found.length === 0) throw new Error(`no workspace packages in ${env.cwd}`);
|
|
282
|
+
found.sort((a, b) => a.path.localeCompare(b.path));
|
|
283
|
+
const names = new Set(found.map(({ name }) => name));
|
|
284
|
+
return found.map(({ name, path, json }) => {
|
|
285
|
+
const dependsOn = /* @__PURE__ */ new Set();
|
|
286
|
+
for (const field of DEPENDENCY_FIELDS$1) {
|
|
287
|
+
const deps = json[field];
|
|
288
|
+
if (typeof deps !== "object" || deps === null) continue;
|
|
289
|
+
for (const dep of Object.keys(deps)) if (names.has(dep) && dep !== name) dependsOn.add(dep);
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
name,
|
|
293
|
+
path,
|
|
294
|
+
dependsOn: [...dependsOn]
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
});
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region src/components/index.ts
|
|
302
|
+
var components_exports = /* @__PURE__ */ __exportAll({ npmWorkspaces: () => npmWorkspaces });
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/utils/text.ts
|
|
305
|
+
function plural(count, noun, nouns = `${noun}s`) {
|
|
306
|
+
return `${count} ${count === 1 ? noun : nouns}`;
|
|
307
|
+
}
|
|
308
|
+
/** `a`, `a and b`, `a, b and c`. */
|
|
309
|
+
function joinList(parts) {
|
|
310
|
+
return parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}` : parts[0] ?? "";
|
|
311
|
+
}
|
|
312
|
+
//#endregion
|
|
313
|
+
//#region src/utils/git.ts
|
|
314
|
+
/** Runs `git` in `cwd` and resolves with its trimmed stdout. */
|
|
315
|
+
function git(cwd, ...args) {
|
|
316
|
+
return new Promise((resolve, reject) => {
|
|
317
|
+
execFile("git", args, {
|
|
318
|
+
cwd,
|
|
319
|
+
maxBuffer: 67108864
|
|
320
|
+
}, (error, stdout, stderr) => {
|
|
321
|
+
if (error) {
|
|
322
|
+
const detail = stderr.trim() || error.message;
|
|
323
|
+
reject(/* @__PURE__ */ new Error(`git ${args.join(" ")} failed: ${detail}`));
|
|
324
|
+
} else resolve(stdout.trim());
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
/** Like {@link git}, but resolves with `undefined` instead of rejecting. */
|
|
329
|
+
async function tryGit(cwd, ...args) {
|
|
330
|
+
try {
|
|
331
|
+
return await git(cwd, ...args);
|
|
332
|
+
} catch {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** `GITHUB_REPOSITORY`, or `owner/repo` from the `origin` remote. */
|
|
337
|
+
async function detectRepo(cwd) {
|
|
338
|
+
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
|
|
339
|
+
const url = await tryGit(cwd, "remote", "get-url", "origin");
|
|
340
|
+
return url ? parseGitHubRepo(url) : void 0;
|
|
341
|
+
}
|
|
342
|
+
/** `git@github.com:o/r.git`, `https://github.com/o/r` and similar → `o/r`. */
|
|
343
|
+
function parseGitHubRepo(url) {
|
|
344
|
+
return /github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(url.trim())?.[1];
|
|
345
|
+
}
|
|
346
|
+
/** Rejects shallow clones, whose history would silently miss commits. */
|
|
347
|
+
async function assertFullHistory(cwd) {
|
|
348
|
+
if (await git(cwd, "rev-parse", "--is-shallow-repository") === "true") throw new Error("the repository is a shallow clone; fetch full history (e.g. `fetch-depth: 0`)");
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* `git log` of the commits since `ref` (or all of them), touching a
|
|
352
|
+
* component's pathspec. A component that's the whole project still only
|
|
353
|
+
* counts the commits of the working directory, when that's a subdirectory.
|
|
354
|
+
*/
|
|
355
|
+
async function logSince(cwd, options) {
|
|
356
|
+
const range = options.ref ? `${options.ref}..HEAD` : "HEAD";
|
|
357
|
+
let pathspec = options.pathspec;
|
|
358
|
+
if (pathspec.length === 0) {
|
|
359
|
+
if (await git(cwd, "rev-parse", "--show-prefix")) pathspec = ["."];
|
|
360
|
+
}
|
|
361
|
+
return git(cwd, "log", `--format=${options.format}`, range, ...pathspec.length > 0 ? ["--", ...pathspec] : []);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* The oldest commit of the latest run of commits in which `file` had
|
|
365
|
+
* `version`, as `read` finds it in the file's content at each commit; that is,
|
|
366
|
+
* the commit that set the version. `undefined` outside a git repository, or
|
|
367
|
+
* when no commit has it.
|
|
368
|
+
*/
|
|
369
|
+
async function findVersionCommit(cwd, file, version, read) {
|
|
370
|
+
const log = await tryGit(cwd, "log", "--format=%H", "--", file);
|
|
371
|
+
if (log === void 0) return void 0;
|
|
372
|
+
await assertFullHistory(cwd);
|
|
373
|
+
let found;
|
|
374
|
+
for (const sha of log.split("\n").filter(Boolean)) {
|
|
375
|
+
const content = await tryGit(cwd, "show", `${sha}:./${file}`);
|
|
376
|
+
if (content === void 0 || read(content) !== version) break;
|
|
377
|
+
found = sha;
|
|
378
|
+
}
|
|
379
|
+
return found;
|
|
380
|
+
}
|
|
381
|
+
const BOT_IDENTITY = [
|
|
382
|
+
"-c",
|
|
383
|
+
"user.name=github-actions[bot]",
|
|
384
|
+
"-c",
|
|
385
|
+
"user.email=41898282+github-actions[bot]@users.noreply.github.com"
|
|
386
|
+
];
|
|
387
|
+
/**
|
|
388
|
+
* Arguments that set the CI's identity when none is set, for commands that
|
|
389
|
+
* commit or tag: the bot's in GitHub Actions, the user who started the
|
|
390
|
+
* pipeline's in GitLab CI.
|
|
391
|
+
*/
|
|
392
|
+
async function identity(cwd) {
|
|
393
|
+
if (await tryGit(cwd, "config", "user.email")) return [];
|
|
394
|
+
if (process.env.GITHUB_ACTIONS === "true") return BOT_IDENTITY;
|
|
395
|
+
const { GITLAB_CI, GITLAB_USER_NAME, GITLAB_USER_EMAIL } = process.env;
|
|
396
|
+
if (GITLAB_CI === "true" && GITLAB_USER_NAME && GITLAB_USER_EMAIL) return [
|
|
397
|
+
"-c",
|
|
398
|
+
`user.name=${GITLAB_USER_NAME}`,
|
|
399
|
+
"-c",
|
|
400
|
+
`user.email=${GITLAB_USER_EMAIL}`
|
|
401
|
+
];
|
|
402
|
+
return [];
|
|
403
|
+
}
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region src/utils/version.ts
|
|
406
|
+
const PATTERN$1 = /^v?(\d+(?:\.\d+)*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
|
|
407
|
+
/** Parses a version, or returns `undefined` if it isn't one, e.g. `next`. */
|
|
408
|
+
function parseVersion(version) {
|
|
409
|
+
const match = PATTERN$1.exec(version.trim());
|
|
410
|
+
if (!match) return void 0;
|
|
411
|
+
const [, release = "", prerelease, build] = match;
|
|
412
|
+
return {
|
|
413
|
+
release: release.split("."),
|
|
414
|
+
...prerelease !== void 0 && { prerelease },
|
|
415
|
+
...build !== void 0 && { build }
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Orders versions by precedence: number by number, a missing number counting
|
|
420
|
+
* as 0 (`1.2` = `1.2.0`), then by pre-release as in semver. Build metadata is
|
|
421
|
+
* ignored.
|
|
422
|
+
*/
|
|
423
|
+
function compareVersions(a, b) {
|
|
424
|
+
for (let i = 0; i < Math.max(a.release.length, b.release.length); i++) {
|
|
425
|
+
const order = compareNumbers(a.release[i] ?? "0", b.release[i] ?? "0");
|
|
426
|
+
if (order !== 0) return order;
|
|
427
|
+
}
|
|
428
|
+
return comparePrerelease(a.prerelease, b.prerelease);
|
|
429
|
+
}
|
|
430
|
+
/** The index of the first number that differs, or `undefined` if none does. */
|
|
431
|
+
function firstDifference(a, b) {
|
|
432
|
+
for (let i = 0; i < Math.max(a.release.length, b.release.length); i++) if (compareNumbers(a.release[i] ?? "0", b.release[i] ?? "0") !== 0) return i;
|
|
433
|
+
}
|
|
434
|
+
/** Compares digit strings by value, however long, ignoring leading zeros. */
|
|
435
|
+
function compareNumbers(a, b) {
|
|
436
|
+
const x = BigInt(a);
|
|
437
|
+
const y = BigInt(b);
|
|
438
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
439
|
+
}
|
|
440
|
+
function comparePrerelease(a, b) {
|
|
441
|
+
if (a === b) return 0;
|
|
442
|
+
if (a === void 0) return 1;
|
|
443
|
+
if (b === void 0) return -1;
|
|
444
|
+
const left = a.split(".");
|
|
445
|
+
const right = b.split(".");
|
|
446
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
447
|
+
const x = left[i];
|
|
448
|
+
const y = right[i];
|
|
449
|
+
if (x === void 0) return -1;
|
|
450
|
+
if (y === void 0) return 1;
|
|
451
|
+
const order = /^\d+$/.test(x) && /^\d+$/.test(y) ? compareNumbers(x, y) : x < y ? -1 : x > y ? 1 : 0;
|
|
452
|
+
if (order !== 0) return order;
|
|
453
|
+
}
|
|
454
|
+
return 0;
|
|
455
|
+
}
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/utils/template.ts
|
|
458
|
+
const PLACEHOLDER = /\{(\w+)\}/g;
|
|
459
|
+
/** Replaces `{name}` placeholders, e.g. `v{version}` → `v1.2.3`. */
|
|
460
|
+
function fill(template, values) {
|
|
461
|
+
return template.replace(PLACEHOLDER, (_, name) => {
|
|
462
|
+
const value = values[name];
|
|
463
|
+
if (value === void 0) {
|
|
464
|
+
const known = Object.keys(values).map((key) => `{${key}}`);
|
|
465
|
+
throw new Error(`unknown placeholder {${name}} in '${template}'; use ${known.join(" or ")}`);
|
|
466
|
+
}
|
|
467
|
+
return value;
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Matches text produced by `fill(template, { [name]: ... })`, capturing the
|
|
472
|
+
* value of `name`, e.g. `v{version}` → `/^v(.+)$/`.
|
|
473
|
+
*/
|
|
474
|
+
function templatePattern(template, name) {
|
|
475
|
+
let found = false;
|
|
476
|
+
const source = template.split(PLACEHOLDER).map((part, index) => {
|
|
477
|
+
if (index % 2 === 0) return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
478
|
+
if (part !== name) throw new Error(`unknown placeholder {${part}} in '${template}'`);
|
|
479
|
+
found = true;
|
|
480
|
+
return "(.+)";
|
|
481
|
+
}).join("");
|
|
482
|
+
if (!found) throw new Error(`'${template}' has no {${name}} placeholder`);
|
|
483
|
+
return new RegExp(`^${source}$`);
|
|
484
|
+
}
|
|
485
|
+
const VERSION_PLACEHOLDERS = "`{version}`, `{tag}` and `{name}` (the component's) are filled in, and the scheme's own, e.g. `{major}`, `{minor}` and `{patch}`";
|
|
486
|
+
/**
|
|
487
|
+
* Generic placeholders of a version, in any scheme, under the scheme's own:
|
|
488
|
+
* `2026.01.3` gives `{major}` 2026, `{minor}` 01 and `{patch}` 3. A part the
|
|
489
|
+
* version doesn't have is left out, so using it fails.
|
|
490
|
+
*/
|
|
491
|
+
function versionValues(version) {
|
|
492
|
+
const [major, minor, patch] = parseVersion(version)?.release ?? [];
|
|
493
|
+
return Object.fromEntries(Object.entries({
|
|
494
|
+
version,
|
|
495
|
+
major,
|
|
496
|
+
minor,
|
|
497
|
+
patch
|
|
498
|
+
}).filter((entry) => entry[1] !== void 0));
|
|
499
|
+
}
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/core/channels.ts
|
|
502
|
+
const DEFAULT_FORMATS = {
|
|
503
|
+
prerelease: "{version}-{channel}.{n}",
|
|
504
|
+
build: "{version}+{channel}.{n}"
|
|
505
|
+
};
|
|
506
|
+
/** Checks channels, so a typo fails before anything runs. */
|
|
507
|
+
function checkChannels(channels) {
|
|
508
|
+
const issues = [];
|
|
509
|
+
for (const [name, channel] of Object.entries(channels)) {
|
|
510
|
+
const at = `channels.${name}`;
|
|
511
|
+
if (!/^[0-9A-Za-z-]+$/.test(name)) issues.push(`${at}: names may only have letters, digits and '-'`);
|
|
512
|
+
if (channel.type !== void 0 && channel.type !== "prerelease" && channel.type !== "build") issues.push(`${at}.type: expected prerelease or build`);
|
|
513
|
+
const format = channel.format;
|
|
514
|
+
if (format !== void 0) {
|
|
515
|
+
for (const name of ["{version}", "{n}"]) if (!format.includes(name)) issues.push(`${at}.format: '${format}' has no ${name} placeholder`);
|
|
516
|
+
const unknown = [...format.matchAll(/\{(\w+)\}/g)].map(([, name]) => name).find((name) => ![
|
|
517
|
+
"version",
|
|
518
|
+
"n",
|
|
519
|
+
"channel"
|
|
520
|
+
].includes(name ?? ""));
|
|
521
|
+
if (unknown) issues.push(`${at}.format: unknown placeholder {${unknown}}; use {version}, {n} or {channel}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (issues.length > 0) throw new OptionsError(issues);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* The channel a run releases on: the one asked for, or the first whose
|
|
528
|
+
* branches match; none means a stable release.
|
|
529
|
+
*/
|
|
530
|
+
function selectChannel(channels, branch, asked) {
|
|
531
|
+
if (asked !== void 0 && asked !== "") {
|
|
532
|
+
const channel = channels[asked];
|
|
533
|
+
if (!channel) {
|
|
534
|
+
const known = Object.keys(channels);
|
|
535
|
+
throw new OptionsError([`channel: '${asked}' isn't a channel${known.length > 0 ? `; use ${known.join(", ")}` : "; define it in channels"}`]);
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
name: asked,
|
|
539
|
+
type: channel.type ?? "prerelease"
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
for (const [name, channel] of Object.entries(channels)) if (channel.branches?.some((pattern) => matchesGlob(branch, pattern))) return {
|
|
543
|
+
name,
|
|
544
|
+
type: channel.type ?? "prerelease"
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/** Every channel, with its format for the scheme. */
|
|
548
|
+
function resolveChannels(channels, scheme) {
|
|
549
|
+
return Object.entries(channels).map(([name, channel]) => {
|
|
550
|
+
const type = channel.type ?? "prerelease";
|
|
551
|
+
return {
|
|
552
|
+
name,
|
|
553
|
+
type,
|
|
554
|
+
format: channel.format ?? scheme.formats?.[type] ?? DEFAULT_FORMATS[type]
|
|
555
|
+
};
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Classifies a version: on one of the channels (`2.0.0-next.3`), stable in
|
|
560
|
+
* the scheme (`2.0.0`), or neither.
|
|
561
|
+
*/
|
|
562
|
+
function classify$1(version, scheme, channels) {
|
|
563
|
+
for (const channel of channels) {
|
|
564
|
+
const match = channelPattern(channel).exec(version);
|
|
565
|
+
const target = match?.groups?.version;
|
|
566
|
+
const n = match?.groups?.n;
|
|
567
|
+
if (target !== void 0 && n !== void 0 && scheme.matches(target)) return {
|
|
568
|
+
type: channel.type,
|
|
569
|
+
channel: channel.name,
|
|
570
|
+
target,
|
|
571
|
+
n: Number(n)
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
return scheme.matches(version) ? {
|
|
575
|
+
type: "stable",
|
|
576
|
+
target: version
|
|
577
|
+
} : { type: "foreign" };
|
|
578
|
+
}
|
|
579
|
+
/** A version on a channel. */
|
|
580
|
+
function channelVersion(channel, target, n) {
|
|
581
|
+
return fill(channel.format, {
|
|
582
|
+
version: target,
|
|
583
|
+
channel: channel.name,
|
|
584
|
+
n: String(n)
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
const RANK$4 = {
|
|
588
|
+
prerelease: -1,
|
|
589
|
+
stable: 0,
|
|
590
|
+
build: 1
|
|
591
|
+
};
|
|
592
|
+
/**
|
|
593
|
+
* Orders classified versions: by target in the scheme, then a prerelease
|
|
594
|
+
* before the stable version before a build, then by counter.
|
|
595
|
+
*/
|
|
596
|
+
function compareClassified(a, b, scheme) {
|
|
597
|
+
if (a.type === "foreign" || b.type === "foreign") return 0;
|
|
598
|
+
const order = scheme.compare(a.target, b.target);
|
|
599
|
+
if (order !== 0) return order;
|
|
600
|
+
if (a.type !== b.type) return RANK$4[a.type] - RANK$4[b.type];
|
|
601
|
+
return a.type === "stable" || b.type === "stable" ? 0 : a.n - b.n;
|
|
602
|
+
}
|
|
603
|
+
function channelPattern(channel) {
|
|
604
|
+
const source = channel.format.split(/(\{\w+\})/).map((part) => {
|
|
605
|
+
if (part === "{version}") return "(?<version>.+?)";
|
|
606
|
+
if (part === "{n}") return "(?<n>\\d+)";
|
|
607
|
+
return (part === "{channel}" ? channel.name : part).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
608
|
+
}).join("");
|
|
609
|
+
return new RegExp(`^${source}$`);
|
|
610
|
+
}
|
|
611
|
+
/** `*` matches within a path segment, `**` across them. */
|
|
612
|
+
function matchesGlob(text, pattern) {
|
|
613
|
+
const source = pattern.split(/(\*\*|\*)/).map((part) => part === "**" ? ".*" : part === "*" ? "[^/]*" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("");
|
|
614
|
+
return new RegExp(`^${source}$`).test(text);
|
|
615
|
+
}
|
|
616
|
+
//#endregion
|
|
617
|
+
//#region src/core/facts.ts
|
|
618
|
+
/** The facts about `changes`, which hold only as long as the changes do. */
|
|
619
|
+
const CHANGE_FIELDS = [
|
|
620
|
+
"title",
|
|
621
|
+
"description",
|
|
622
|
+
"labels",
|
|
623
|
+
"link"
|
|
624
|
+
];
|
|
625
|
+
function isChangeFact(fact) {
|
|
626
|
+
return fact.startsWith("changes.");
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Throws unless every change has what the facts promise, e.g. `labels` for
|
|
630
|
+
* `changes.labels`. Facts that aren't about changes aren't checked here.
|
|
631
|
+
*/
|
|
632
|
+
function checkChanges(changes, facts) {
|
|
633
|
+
for (const fact of facts) {
|
|
634
|
+
const field = CHANGE_FIELDS.find((name) => fact === `changes.${name}`);
|
|
635
|
+
if (field === void 0) continue;
|
|
636
|
+
const index = changes.findIndex((change) => change[field] === void 0);
|
|
637
|
+
if (index !== -1) throw new TypeError(`invalid output: it provides ${fact}, but change ${index} (${changes[index]?.reason}) has no '${field}'`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/** Steps that provide each fact, by name, as they're defined; for error messages. */
|
|
641
|
+
const providers = /* @__PURE__ */ new Map();
|
|
642
|
+
function registerProvider(name, facts) {
|
|
643
|
+
for (const fact of facts) {
|
|
644
|
+
const names = providers.get(fact) ?? /* @__PURE__ */ new Set();
|
|
645
|
+
names.add(name);
|
|
646
|
+
providers.set(fact, names);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/** The defined steps that provide a fact, e.g. to suggest one. */
|
|
650
|
+
function providersOf(fact) {
|
|
651
|
+
return [...providers.get(fact) ?? []];
|
|
652
|
+
}
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region src/core/reporter.ts
|
|
655
|
+
const ANSI = {
|
|
656
|
+
bold: [1, 22],
|
|
657
|
+
dim: [2, 22],
|
|
658
|
+
red: [31, 39],
|
|
659
|
+
green: [32, 39],
|
|
660
|
+
yellow: [33, 39],
|
|
661
|
+
magenta: [35, 39],
|
|
662
|
+
cyan: [36, 39]
|
|
663
|
+
};
|
|
664
|
+
/** Aligned, colored output: one row per step with phase, name, duration and reason. */
|
|
665
|
+
function prettyReporter(options = {}) {
|
|
666
|
+
const write = options.write ?? ((text) => void process.stdout.write(text));
|
|
667
|
+
const color = options.color ?? (process.env.FORCE_COLOR !== void 0 || process.stdout.isTTY === true && process.env.NO_COLOR === void 0);
|
|
668
|
+
const paint = (style, text) => color ? `\x1b[${ANSI[style][0]}m${text}\x1b[${ANSI[style][1]}m` : text;
|
|
669
|
+
let phaseWidth = 0;
|
|
670
|
+
let nameWidth = 0;
|
|
671
|
+
let componentWidth = 0;
|
|
672
|
+
let dryRun = false;
|
|
673
|
+
return {
|
|
674
|
+
start(env, steps, components) {
|
|
675
|
+
dryRun = env.dryRun;
|
|
676
|
+
phaseWidth = Math.max(0, ...steps.map((step) => step.phase.length));
|
|
677
|
+
nameWidth = Math.max(0, ...steps.map((step) => step.name.length));
|
|
678
|
+
componentWidth = components.length > 1 ? Math.max(...components.map((component) => component.name.length)) : 0;
|
|
679
|
+
const mode = env.dryRun ? paint("yellow", "dry run") : "release";
|
|
680
|
+
write(`\n${paint("bold", "rylease")} ${mode} ${paint("dim", "·")} ${paint("cyan", env.branch)} ${paint("dim", `· ${env.cwd}`)}\n\n`);
|
|
681
|
+
},
|
|
682
|
+
step(record) {
|
|
683
|
+
const icon = {
|
|
684
|
+
ok: paint("green", "✔"),
|
|
685
|
+
skipped: paint("dim", "○"),
|
|
686
|
+
failed: paint("red", "✖")
|
|
687
|
+
}[record.status];
|
|
688
|
+
const phase = paint("magenta", record.phase.padEnd(phaseWidth));
|
|
689
|
+
const name = paint("bold", record.name.padEnd(nameWidth));
|
|
690
|
+
const component = componentWidth > 0 ? `${paint("cyan", (record.component ?? "*").padEnd(componentWidth))} ` : "";
|
|
691
|
+
const duration = paint("dim", (record.status === "skipped" ? "-" : formatDuration(record.durationMs)).padStart(7));
|
|
692
|
+
const reason = record.status === "ok" ? record.reason : paint(record.status === "failed" ? "red" : "dim", record.reason);
|
|
693
|
+
write(` ${icon} ${component}${phase} ${name} ${duration} ${reason}\n`);
|
|
694
|
+
},
|
|
695
|
+
finish(report) {
|
|
696
|
+
const line = (component) => {
|
|
697
|
+
const from = component.previousVersion ?? "nothing";
|
|
698
|
+
const action = component.status === "pending" ? paint("yellow", dryRun ? "would be proposed" : "proposed") : dryRun ? paint("yellow", "would release") : "released";
|
|
699
|
+
if (component.status !== "released" && component.status !== "pending") return `${paint("bold", from)} ${paint("dim", "·")} nothing to release`;
|
|
700
|
+
const to = paint("green", paint("bold", component.version ?? "?"));
|
|
701
|
+
return from === component.version ? `${to} ${paint("dim", "·")} ${action}` : `${paint("bold", from)} → ${to} ${paint("dim", `(${component.kind})`)} ${paint("dim", "·")} ${action}`;
|
|
702
|
+
};
|
|
703
|
+
let summary;
|
|
704
|
+
if (report.status === "failed") summary = paint("red", "release failed");
|
|
705
|
+
else if (report.components.length === 1 && report.components[0]) summary = line(report.components[0]);
|
|
706
|
+
else {
|
|
707
|
+
const width = Math.max(...report.components.map((component) => component.name.length));
|
|
708
|
+
summary = report.components.map((component) => `${paint("cyan", component.name.padEnd(width))} ${line(component)}`).join("\n ");
|
|
709
|
+
}
|
|
710
|
+
const deferred = report.deferred === void 0 ? "" : `\n ${paint("dim", report.deferred)}`;
|
|
711
|
+
write(`\n ${summary}${deferred}\n\n`);
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
function defaultReporter() {
|
|
716
|
+
return prettyReporter();
|
|
717
|
+
}
|
|
718
|
+
function formatDuration(ms) {
|
|
719
|
+
if (ms < 1) return "<1ms";
|
|
720
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
721
|
+
return `${(ms / 1e3).toFixed(2)}s`;
|
|
722
|
+
}
|
|
723
|
+
//#endregion
|
|
724
|
+
//#region src/core/types.ts
|
|
725
|
+
/** Every phase, in the order the engine runs its steps. */
|
|
726
|
+
const PHASES = [
|
|
727
|
+
"read-changes",
|
|
728
|
+
"determine-kind",
|
|
729
|
+
"determine-notes",
|
|
730
|
+
"write",
|
|
731
|
+
"stage",
|
|
732
|
+
"release"
|
|
733
|
+
];
|
|
734
|
+
/**
|
|
735
|
+
* How much a release changes, whatever the version scheme: the scheme decides
|
|
736
|
+
* what each kind bumps, e.g. semver's `MAJOR.minor.patch`.
|
|
737
|
+
*/
|
|
738
|
+
const KINDS = [
|
|
739
|
+
"breaking",
|
|
740
|
+
"feature",
|
|
741
|
+
"fix",
|
|
742
|
+
"none"
|
|
743
|
+
];
|
|
744
|
+
//#endregion
|
|
745
|
+
//#region src/core/engine.ts
|
|
746
|
+
var ReleaseError = class extends Error {
|
|
747
|
+
report;
|
|
748
|
+
constructor(message, report, options) {
|
|
749
|
+
super(message, options);
|
|
750
|
+
this.name = "ReleaseError";
|
|
751
|
+
this.report = report;
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
function phaseIndex(phase) {
|
|
755
|
+
return PHASES.indexOf(phase);
|
|
756
|
+
}
|
|
757
|
+
const KIND_PHASE = phaseIndex("determine-kind");
|
|
758
|
+
/** Phases that must have at least one step for a release to be possible. */
|
|
759
|
+
const REQUIRED_PHASES = PHASES.filter((phase) => phase !== "write" && phase !== "stage" && phase !== "release");
|
|
760
|
+
/** What each phase produces, where later phases read it. */
|
|
761
|
+
const PHASE_VALUES = {
|
|
762
|
+
"read-changes": "changes",
|
|
763
|
+
"determine-kind": "kind",
|
|
764
|
+
"determine-notes": "notes",
|
|
765
|
+
write: "releases",
|
|
766
|
+
stage: "releases",
|
|
767
|
+
release: "releases"
|
|
768
|
+
};
|
|
769
|
+
const CHANGE_FACTS = [
|
|
770
|
+
"changes.title",
|
|
771
|
+
"changes.description",
|
|
772
|
+
"changes.labels",
|
|
773
|
+
"changes.link"
|
|
774
|
+
];
|
|
775
|
+
/** Stable sort of steps by phase; steps of the same phase keep their order. */
|
|
776
|
+
function sortSteps(steps) {
|
|
777
|
+
return [...steps].sort((a, b) => phaseIndex(a.phase) - phaseIndex(b.phase));
|
|
778
|
+
}
|
|
779
|
+
async function runRelease(setup, options = {}) {
|
|
780
|
+
const cwd = options.cwd ?? process.cwd();
|
|
781
|
+
const env = {
|
|
782
|
+
cwd,
|
|
783
|
+
branch: options.branch ?? await detectBranch(cwd),
|
|
784
|
+
dryRun: options.dryRun ?? false
|
|
785
|
+
};
|
|
786
|
+
const reporter = options.reporter === void 0 ? defaultReporter() : options.reporter;
|
|
787
|
+
const given = options.release === void 0 ? [] : [options.release].flat();
|
|
788
|
+
const ordered = sortSteps(setup.steps);
|
|
789
|
+
const records = [];
|
|
790
|
+
let states = [];
|
|
791
|
+
let deferred;
|
|
792
|
+
const report = (status, error) => {
|
|
793
|
+
const components = states.map((state) => ({
|
|
794
|
+
name: state.component.info.name,
|
|
795
|
+
path: state.component.info.path,
|
|
796
|
+
status: state.kind === "none" || !state.live ? "unchanged" : status === "failed" ? "failed" : status,
|
|
797
|
+
dependencies: state.dependencies,
|
|
798
|
+
releases: state.releases,
|
|
799
|
+
...definedOnly({
|
|
800
|
+
previousVersion: state.previousVersion,
|
|
801
|
+
previousRef: state.previousRef,
|
|
802
|
+
previousTag: state.previousTag,
|
|
803
|
+
kind: state.kind,
|
|
804
|
+
version: state.version,
|
|
805
|
+
tag: state.tag,
|
|
806
|
+
notes: state.notes
|
|
807
|
+
})
|
|
808
|
+
}));
|
|
809
|
+
const only = states.length === 1 ? states[0] : void 0;
|
|
810
|
+
return {
|
|
811
|
+
env,
|
|
812
|
+
status,
|
|
813
|
+
records,
|
|
814
|
+
components,
|
|
815
|
+
releases: only?.releases ?? [],
|
|
816
|
+
...definedOnly({
|
|
817
|
+
previousVersion: only?.previousVersion,
|
|
818
|
+
previousRef: only?.previousRef,
|
|
819
|
+
kind: only?.kind,
|
|
820
|
+
version: only?.version,
|
|
821
|
+
notes: only?.notes,
|
|
822
|
+
deferred,
|
|
823
|
+
error
|
|
824
|
+
})
|
|
825
|
+
};
|
|
826
|
+
};
|
|
827
|
+
const record = (entry) => {
|
|
828
|
+
records.push(entry);
|
|
829
|
+
reporter?.step?.(entry);
|
|
830
|
+
};
|
|
831
|
+
const fail = (error, message = errorMessage(error)) => {
|
|
832
|
+
const result = report("failed", error);
|
|
833
|
+
reporter?.finish?.(result);
|
|
834
|
+
throw new ReleaseError(message, result, { cause: error });
|
|
835
|
+
};
|
|
836
|
+
let resolved;
|
|
837
|
+
try {
|
|
838
|
+
const channels = setup.channels ?? {};
|
|
839
|
+
checkChannels(channels);
|
|
840
|
+
const channel = selectChannel(channels, env.branch, options.channel);
|
|
841
|
+
if (channel) env.channel = channel;
|
|
842
|
+
resolved = await resolveSetup(setup, env);
|
|
843
|
+
} catch (error) {
|
|
844
|
+
reporter?.start?.(env, ordered, []);
|
|
845
|
+
return fail(error);
|
|
846
|
+
}
|
|
847
|
+
reporter?.start?.(env, [...resolved.flatMap((component) => [...historiesOf(component).map(({ name }) => ({
|
|
848
|
+
phase: "read-version",
|
|
849
|
+
name
|
|
850
|
+
})), {
|
|
851
|
+
phase: "determine-version",
|
|
852
|
+
name: component.scheme.name
|
|
853
|
+
}]), ...ordered], resolved.map(({ info }) => info));
|
|
854
|
+
states = resolved.map((component) => newState(component, setup.channels ?? {}, env));
|
|
855
|
+
const missing = given.length > 0 ? [] : REQUIRED_PHASES.filter((phase) => !ordered.some((step) => step.phase === phase));
|
|
856
|
+
if (missing.length > 0) fail(/* @__PURE__ */ new Error(`no step provided for: ${missing.join(", ")}`));
|
|
857
|
+
if (given.length > 0) {
|
|
858
|
+
for (const state of states) state.live = false;
|
|
859
|
+
for (const release of given) {
|
|
860
|
+
const target = givenTarget(states, release.component);
|
|
861
|
+
if (typeof target === "string") fail(new Error(target));
|
|
862
|
+
const state = target;
|
|
863
|
+
setGiven(state, release, `releasing the given ${release.version}`);
|
|
864
|
+
if (release.previousVersion !== void 0) state.facts.add("previousVersion");
|
|
865
|
+
}
|
|
866
|
+
} else {
|
|
867
|
+
for (const state of states) {
|
|
868
|
+
const start = performance.now();
|
|
869
|
+
const { scheme } = state.component;
|
|
870
|
+
try {
|
|
871
|
+
const found = await findPrevious(state, env);
|
|
872
|
+
const previous = found.found;
|
|
873
|
+
const kind = previous?.version === void 0 ? void 0 : classify$1(previous.version, scheme, state.channels);
|
|
874
|
+
const foreign = kind?.type === "foreign";
|
|
875
|
+
Object.assign(state, {
|
|
876
|
+
previousVersion: foreign ? void 0 : previous?.version,
|
|
877
|
+
previousRef: previous?.ref,
|
|
878
|
+
previousTag: previous?.tag,
|
|
879
|
+
stable: previous?.stable?.version ?? (kind?.type === "stable" ? previous?.version : void 0)
|
|
880
|
+
});
|
|
881
|
+
if (state.previousVersion !== void 0) state.facts.add("previousVersion");
|
|
882
|
+
record({
|
|
883
|
+
phase: "read-version",
|
|
884
|
+
name: found.name,
|
|
885
|
+
component: state.component.info.name,
|
|
886
|
+
status: "ok",
|
|
887
|
+
durationMs: performance.now() - start,
|
|
888
|
+
reason: previous ? `${previous.reason}${foreign ? `; ${previous.version} isn't ${scheme.name}, so this release is its first` : ""}` : "no previous release; every change counts",
|
|
889
|
+
output: previous ?? null
|
|
890
|
+
});
|
|
891
|
+
if (previous?.pending) {
|
|
892
|
+
const pending = previous.pending;
|
|
893
|
+
setGiven(state, {
|
|
894
|
+
version: pending.version,
|
|
895
|
+
...definedOnly({
|
|
896
|
+
notes: pending.notes,
|
|
897
|
+
kind: pending.kind,
|
|
898
|
+
previousVersion: pending.previousVersion
|
|
899
|
+
})
|
|
900
|
+
}, `releasing ${pending.version}, ${pending.reason}`);
|
|
901
|
+
}
|
|
902
|
+
} catch (error) {
|
|
903
|
+
record({
|
|
904
|
+
phase: "read-version",
|
|
905
|
+
name: historiesOf(state.component).map(({ name }) => name).join(", "),
|
|
906
|
+
component: state.component.info.name,
|
|
907
|
+
status: "failed",
|
|
908
|
+
durationMs: performance.now() - start,
|
|
909
|
+
reason: errorMessage(error),
|
|
910
|
+
error
|
|
911
|
+
});
|
|
912
|
+
fail(error);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (states.some((state) => state.given !== void 0)) for (const state of states.filter((state) => state.given === void 0)) state.live = false;
|
|
916
|
+
}
|
|
917
|
+
const publishing = states.find((state) => state.given !== void 0)?.given;
|
|
918
|
+
const problems = preflight(ordered, states, publishing !== void 0);
|
|
919
|
+
if (problems.length > 0) fail(new Error(problems.join("\n")));
|
|
920
|
+
let bumped = publishing !== void 0;
|
|
921
|
+
if (bumped) setDependencies(states);
|
|
922
|
+
const skipRest = (from, reason) => {
|
|
923
|
+
for (const step of ordered.slice(from)) record({
|
|
924
|
+
phase: step.phase,
|
|
925
|
+
name: step.name,
|
|
926
|
+
status: "skipped",
|
|
927
|
+
durationMs: 0,
|
|
928
|
+
reason
|
|
929
|
+
});
|
|
930
|
+
};
|
|
931
|
+
const finish = (status) => {
|
|
932
|
+
const result = report(status);
|
|
933
|
+
reporter?.finish?.(result);
|
|
934
|
+
return result;
|
|
935
|
+
};
|
|
936
|
+
for (const [index, step] of ordered.entries()) {
|
|
937
|
+
if (publishing !== void 0 && step.phase !== "release") {
|
|
938
|
+
record({
|
|
939
|
+
phase: step.phase,
|
|
940
|
+
name: step.name,
|
|
941
|
+
status: "skipped",
|
|
942
|
+
durationMs: 0,
|
|
943
|
+
reason: publishing
|
|
944
|
+
});
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
if (!bumped && phaseIndex(step.phase) > KIND_PHASE) {
|
|
948
|
+
bumped = true;
|
|
949
|
+
await bump(states, env, record, fail);
|
|
950
|
+
setDependencies(states);
|
|
951
|
+
}
|
|
952
|
+
const live = states.filter((state) => state.live);
|
|
953
|
+
if (live.length === 0) {
|
|
954
|
+
skipRest(index, "no release-worthy changes");
|
|
955
|
+
return finish("unchanged");
|
|
956
|
+
}
|
|
957
|
+
if (deferred !== void 0 && step.phase === "release") {
|
|
958
|
+
skipRest(index, deferred);
|
|
959
|
+
return finish("pending");
|
|
960
|
+
}
|
|
961
|
+
const targets = step.scope === "all" ? [void 0] : live;
|
|
962
|
+
for (const target of targets) {
|
|
963
|
+
const start = performance.now();
|
|
964
|
+
const group = target ? [target] : live;
|
|
965
|
+
const component = target?.component.info.name;
|
|
966
|
+
try {
|
|
967
|
+
for (const state of group) assertEarlierPhases(state, step.phase);
|
|
968
|
+
const input = target ? {
|
|
969
|
+
...values(target),
|
|
970
|
+
...shared(env, setup.context, records)
|
|
971
|
+
} : {
|
|
972
|
+
components: live.map(values),
|
|
973
|
+
...shared(env, setup.context, records)
|
|
974
|
+
};
|
|
975
|
+
const condition = step.if;
|
|
976
|
+
if (condition && !condition(input)) {
|
|
977
|
+
const source = condition.source;
|
|
978
|
+
record({
|
|
979
|
+
phase: step.phase,
|
|
980
|
+
name: step.name,
|
|
981
|
+
...component !== void 0 && { component },
|
|
982
|
+
status: "skipped",
|
|
983
|
+
durationMs: performance.now() - start,
|
|
984
|
+
reason: source ? `\`${source}\` is false` : "its condition is false"
|
|
985
|
+
});
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
for (const state of group) assertNeeds(step, state);
|
|
989
|
+
const output = await step.run(input);
|
|
990
|
+
const applied = apply(step, group, target === void 0, output);
|
|
991
|
+
deferred ??= applied.defer;
|
|
992
|
+
record({
|
|
993
|
+
phase: step.phase,
|
|
994
|
+
name: step.name,
|
|
995
|
+
...component !== void 0 && { component },
|
|
996
|
+
status: "ok",
|
|
997
|
+
durationMs: performance.now() - start,
|
|
998
|
+
reason: applied.reason,
|
|
999
|
+
output
|
|
1000
|
+
});
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
const message = errorMessage(error);
|
|
1003
|
+
record({
|
|
1004
|
+
phase: step.phase,
|
|
1005
|
+
name: step.name,
|
|
1006
|
+
...component !== void 0 && { component },
|
|
1007
|
+
status: "failed",
|
|
1008
|
+
durationMs: performance.now() - start,
|
|
1009
|
+
reason: message,
|
|
1010
|
+
error
|
|
1011
|
+
});
|
|
1012
|
+
skipRest(index + 1, `'${step.phase}' step '${step.name}' failed`);
|
|
1013
|
+
fail(error, `${step.phase} step '${step.name}'${component !== void 0 && states.length > 1 ? ` (${component})` : ""} failed: ${message}`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (!bumped) {
|
|
1018
|
+
await bump(states, env, record, fail);
|
|
1019
|
+
setDependencies(states);
|
|
1020
|
+
}
|
|
1021
|
+
if (!states.some((state) => state.live)) return finish("unchanged");
|
|
1022
|
+
return finish(deferred !== void 0 ? "pending" : "released");
|
|
1023
|
+
}
|
|
1024
|
+
async function resolveSetup(setup, env) {
|
|
1025
|
+
const source = setup.components;
|
|
1026
|
+
const components = source === void 0 ? [{ name: "root" }] : "resolve" in source ? await source.resolve(env) : source;
|
|
1027
|
+
const { history } = setup;
|
|
1028
|
+
return resolveComponents(components, {
|
|
1029
|
+
scheme: setup.scheme,
|
|
1030
|
+
history: Array.isArray(history) ? chain(history) : history,
|
|
1031
|
+
tag: setup.tag
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
/** Histories tried in turn; the first that finds a release wins. */
|
|
1035
|
+
function chain(histories) {
|
|
1036
|
+
const [only] = histories;
|
|
1037
|
+
if (histories.length === 1 && only) return only;
|
|
1038
|
+
return Object.assign({
|
|
1039
|
+
name: histories.map(({ name }) => name).join(", "),
|
|
1040
|
+
find: async () => void 0
|
|
1041
|
+
}, { chain: histories });
|
|
1042
|
+
}
|
|
1043
|
+
function historiesOf(component) {
|
|
1044
|
+
const history = component.history;
|
|
1045
|
+
return history.chain ?? [history];
|
|
1046
|
+
}
|
|
1047
|
+
/** What a history is asked, for a component. */
|
|
1048
|
+
function historyInput(state, env) {
|
|
1049
|
+
return {
|
|
1050
|
+
env,
|
|
1051
|
+
component: state.component.info,
|
|
1052
|
+
scheme: state.component.scheme,
|
|
1053
|
+
tag: state.component.tag,
|
|
1054
|
+
channel: state.channel,
|
|
1055
|
+
channels: state.channels
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
async function findPrevious(state, env) {
|
|
1059
|
+
const histories = historiesOf(state.component);
|
|
1060
|
+
for (const history of histories) {
|
|
1061
|
+
const found = await history.find(historyInput(state, env));
|
|
1062
|
+
if (found) return {
|
|
1063
|
+
name: history.name,
|
|
1064
|
+
found
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
return {
|
|
1068
|
+
name: histories.map(({ name }) => name).join(", "),
|
|
1069
|
+
found: void 0
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
function newState(component, channels, env) {
|
|
1073
|
+
const resolved = resolveChannels(channels, component.scheme);
|
|
1074
|
+
return {
|
|
1075
|
+
component,
|
|
1076
|
+
previousVersion: void 0,
|
|
1077
|
+
previousRef: void 0,
|
|
1078
|
+
previousTag: void 0,
|
|
1079
|
+
changes: void 0,
|
|
1080
|
+
kind: void 0,
|
|
1081
|
+
exact: void 0,
|
|
1082
|
+
version: void 0,
|
|
1083
|
+
tag: void 0,
|
|
1084
|
+
placeholders: void 0,
|
|
1085
|
+
notes: void 0,
|
|
1086
|
+
dependencies: [],
|
|
1087
|
+
releases: [],
|
|
1088
|
+
facts: new Set(component.scheme.traits ?? []),
|
|
1089
|
+
live: true,
|
|
1090
|
+
stable: void 0,
|
|
1091
|
+
channel: resolved.find(({ name }) => name === env.channel?.name),
|
|
1092
|
+
channels: resolved,
|
|
1093
|
+
given: void 0
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
/** Makes a component release a version worked out before, e.g. by a job. */
|
|
1097
|
+
function setGiven(state, release, reason) {
|
|
1098
|
+
const previousVersion = release.previousVersion ?? release.version;
|
|
1099
|
+
state.live = true;
|
|
1100
|
+
state.given = reason;
|
|
1101
|
+
state.previousVersion = previousVersion;
|
|
1102
|
+
state.changes = [];
|
|
1103
|
+
state.kind = release.kind ?? difference(previousVersion, release.version);
|
|
1104
|
+
state.notes = release.notes ?? "";
|
|
1105
|
+
const kind = classify$1(release.version, state.component.scheme, state.channels);
|
|
1106
|
+
setVersion(state, release.version, kind.type === "foreign" ? release.version : kind.target);
|
|
1107
|
+
for (const fact of CHANGE_FACTS) state.facts.add(fact);
|
|
1108
|
+
}
|
|
1109
|
+
/** Each live component's dependencies released along with it. */
|
|
1110
|
+
function setDependencies(states) {
|
|
1111
|
+
for (const state of states.filter((state) => state.live)) state.dependencies = state.component.info.dependsOn.flatMap((name) => {
|
|
1112
|
+
const other = states.find((s) => s.component.info.name === name);
|
|
1113
|
+
return other?.live && other.version !== void 0 && other.kind && other.kind !== "none" ? [{
|
|
1114
|
+
name,
|
|
1115
|
+
version: other.version,
|
|
1116
|
+
...definedOnly({ previousVersion: other.previousVersion }),
|
|
1117
|
+
kind: other.kind
|
|
1118
|
+
}] : [];
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
function givenTarget(states, name) {
|
|
1122
|
+
if (name === void 0) {
|
|
1123
|
+
const [only] = states;
|
|
1124
|
+
return states.length === 1 && only ? only : `there are several components (${names(states)}); say which the given release is for`;
|
|
1125
|
+
}
|
|
1126
|
+
return states.find((state) => state.component.info.name === name) ?? `'${name}' isn't a component; use ${names(states)}`;
|
|
1127
|
+
}
|
|
1128
|
+
function names(states) {
|
|
1129
|
+
return states.map((state) => state.component.info.name).join(", ");
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Sets the version, and what follows from it: the tag and placeholders. On a
|
|
1133
|
+
* channel, the scheme's placeholders are its target's.
|
|
1134
|
+
*/
|
|
1135
|
+
function setVersion(state, version, target = version) {
|
|
1136
|
+
const { component } = state;
|
|
1137
|
+
const tag = fill(component.tag, { version });
|
|
1138
|
+
state.version = version;
|
|
1139
|
+
state.tag = tag;
|
|
1140
|
+
state.placeholders = {
|
|
1141
|
+
...versionValues(target),
|
|
1142
|
+
...component.scheme.matches(target) ? component.scheme.fields?.(target) : {},
|
|
1143
|
+
version,
|
|
1144
|
+
tag,
|
|
1145
|
+
name: component.info.name,
|
|
1146
|
+
...state.channel && { channel: state.channel.name }
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* After the last `determine-kind` step: components with nothing to release
|
|
1151
|
+
* drop out, and the others get their next version from their scheme, on
|
|
1152
|
+
* their channel if there is one.
|
|
1153
|
+
*/
|
|
1154
|
+
async function bump(states, env, record, fail) {
|
|
1155
|
+
for (const state of states.filter((state) => state.live)) {
|
|
1156
|
+
const start = performance.now();
|
|
1157
|
+
const { scheme, info } = state.component;
|
|
1158
|
+
const base = {
|
|
1159
|
+
phase: "determine-version",
|
|
1160
|
+
name: scheme.name,
|
|
1161
|
+
component: info.name
|
|
1162
|
+
};
|
|
1163
|
+
if (state.kind === void 0) {
|
|
1164
|
+
const error = /* @__PURE__ */ new Error("no 'determine-kind' step ran, so it has no kind");
|
|
1165
|
+
record({
|
|
1166
|
+
...base,
|
|
1167
|
+
status: "failed",
|
|
1168
|
+
durationMs: 0,
|
|
1169
|
+
reason: error.message,
|
|
1170
|
+
error
|
|
1171
|
+
});
|
|
1172
|
+
fail(error);
|
|
1173
|
+
}
|
|
1174
|
+
if (state.kind === "none") {
|
|
1175
|
+
state.live = false;
|
|
1176
|
+
record({
|
|
1177
|
+
...base,
|
|
1178
|
+
status: "skipped",
|
|
1179
|
+
durationMs: 0,
|
|
1180
|
+
reason: "no release-worthy changes"
|
|
1181
|
+
});
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
const kind = state.kind;
|
|
1185
|
+
const previous = state.previousVersion;
|
|
1186
|
+
try {
|
|
1187
|
+
const next = await nextVersion(state, kind, env);
|
|
1188
|
+
const { version, target } = next;
|
|
1189
|
+
if (!scheme.matches(target)) throw new Error(`${target} isn't a ${scheme.name} version`);
|
|
1190
|
+
if (previous !== void 0) {
|
|
1191
|
+
if (compareClassified(classify$1(version, scheme, state.channels), classify$1(previous, scheme, state.channels), scheme) <= 0) throw new Error(`${version} wouldn't come after ${previous}`);
|
|
1192
|
+
}
|
|
1193
|
+
setVersion(state, version, target);
|
|
1194
|
+
const exists = historiesOf(state.component).find((h) => h.exists);
|
|
1195
|
+
if (state.tag !== void 0 && await exists?.exists?.({
|
|
1196
|
+
...historyInput(state, env),
|
|
1197
|
+
releaseTag: state.tag
|
|
1198
|
+
})) throw new Error(`${state.tag} already exists; it was released from another branch`);
|
|
1199
|
+
record({
|
|
1200
|
+
...base,
|
|
1201
|
+
status: "ok",
|
|
1202
|
+
durationMs: performance.now() - start,
|
|
1203
|
+
reason: previous === void 0 ? `${version}: '${kind}', ${next.how}` : `${version}: '${kind}' from ${previous}, ${next.how}`,
|
|
1204
|
+
output: {
|
|
1205
|
+
version,
|
|
1206
|
+
tag: state.tag
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
record({
|
|
1211
|
+
...base,
|
|
1212
|
+
status: "failed",
|
|
1213
|
+
durationMs: performance.now() - start,
|
|
1214
|
+
reason: errorMessage(error),
|
|
1215
|
+
error
|
|
1216
|
+
});
|
|
1217
|
+
fail(error);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* The next version, and the scheme's version it stands for: on a
|
|
1223
|
+
* prerelease channel, the next stable version's next prerelease (keeping a
|
|
1224
|
+
* higher target that its channel already leads up to); on a build channel,
|
|
1225
|
+
* the stable version's next build.
|
|
1226
|
+
*/
|
|
1227
|
+
async function nextVersion(state, kind, env) {
|
|
1228
|
+
const { scheme } = state.component;
|
|
1229
|
+
const channel = state.channel;
|
|
1230
|
+
let target;
|
|
1231
|
+
let how;
|
|
1232
|
+
if (channel?.type === "build") {
|
|
1233
|
+
if (state.stable === void 0) throw new Error(`a build on '${channel.name}' needs a previous release to number builds of`);
|
|
1234
|
+
target = state.stable;
|
|
1235
|
+
how = `build of ${target}`;
|
|
1236
|
+
} else if (state.exact !== void 0) {
|
|
1237
|
+
target = state.exact;
|
|
1238
|
+
how = "as determined";
|
|
1239
|
+
} else {
|
|
1240
|
+
const bumped = scheme.bump({
|
|
1241
|
+
...state.stable !== void 0 && { previous: state.stable },
|
|
1242
|
+
kind,
|
|
1243
|
+
branch: env.branch,
|
|
1244
|
+
date: /* @__PURE__ */ new Date()
|
|
1245
|
+
});
|
|
1246
|
+
target = bumped.version;
|
|
1247
|
+
how = bumped.reason;
|
|
1248
|
+
}
|
|
1249
|
+
if (!channel) return {
|
|
1250
|
+
version: target,
|
|
1251
|
+
target,
|
|
1252
|
+
how
|
|
1253
|
+
};
|
|
1254
|
+
const previous = state.previousVersion === void 0 ? void 0 : classify$1(state.previousVersion, scheme, state.channels);
|
|
1255
|
+
const onChannel = previous !== void 0 && previous.type !== "stable" && previous.type !== "foreign" && previous.channel === channel.name ? previous : void 0;
|
|
1256
|
+
if (channel.type === "prerelease" && onChannel && scheme.compare(onChannel.target, target) > 0) {
|
|
1257
|
+
target = onChannel.target;
|
|
1258
|
+
how = `continues ${target}`;
|
|
1259
|
+
}
|
|
1260
|
+
const counted = await historiesOf(state.component).find((h) => h.count)?.count?.({
|
|
1261
|
+
...historyInput(state, env),
|
|
1262
|
+
target
|
|
1263
|
+
}) ?? 0;
|
|
1264
|
+
const last = onChannel && scheme.compare(onChannel.target, target) === 0 ? onChannel.n : 0;
|
|
1265
|
+
const n = Math.max(counted, last) + 1;
|
|
1266
|
+
return {
|
|
1267
|
+
version: channelVersion(channel, target, n),
|
|
1268
|
+
target,
|
|
1269
|
+
how: `${how}, ${channel.name} ${n}`
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Checks, before anything runs, that every step's needs are met by the
|
|
1274
|
+
* scheme and the steps before it, naming steps that would meet them.
|
|
1275
|
+
*/
|
|
1276
|
+
function preflight(steps, states, given) {
|
|
1277
|
+
const problems = /* @__PURE__ */ new Set();
|
|
1278
|
+
for (const state of states) {
|
|
1279
|
+
const facts = new Set(state.facts);
|
|
1280
|
+
let source;
|
|
1281
|
+
for (const step of steps) {
|
|
1282
|
+
if (given && step.phase !== "release") continue;
|
|
1283
|
+
for (const fact of step.needs ?? []) {
|
|
1284
|
+
if (facts.has(fact)) continue;
|
|
1285
|
+
const by = isChangeFact(fact) ? source === void 0 ? "no step" : source : fact === "previousVersion" ? void 0 : `the scheme ${state.component.scheme.name}`;
|
|
1286
|
+
const providers = providersOf(fact).filter((name) => name !== source);
|
|
1287
|
+
const prefix = states.length > 1 ? `${state.component.info.name}: ` : "";
|
|
1288
|
+
problems.add(by === void 0 ? `${prefix}${step.name} needs a previous ${state.component.scheme.name} version, and there's none` : `${prefix}${step.name} needs ${fact}, which ${by} doesn't provide${providers.length > 0 ? `; ${joinOr(providers)} ${providers.length > 1 ? "do" : "does"}` : ""}`);
|
|
1289
|
+
}
|
|
1290
|
+
if (step.phase === "read-changes" && !step.if) {
|
|
1291
|
+
for (const fact of [...facts]) if (isChangeFact(fact)) facts.delete(fact);
|
|
1292
|
+
source = step.name;
|
|
1293
|
+
}
|
|
1294
|
+
for (const fact of step.provides ?? []) facts.add(fact);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return [...problems];
|
|
1298
|
+
}
|
|
1299
|
+
function joinOr(items) {
|
|
1300
|
+
return items.length <= 1 ? items[0] ?? "" : `${items.slice(0, -1).join(", ")} or ${items.at(-1)}`;
|
|
1301
|
+
}
|
|
1302
|
+
function assertNeeds(step, state) {
|
|
1303
|
+
const missing = (step.needs ?? []).filter((fact) => !state.facts.has(fact));
|
|
1304
|
+
if (missing.length > 0) throw new Error(`it needs ${missing.join(", ")}, which no step that ran provides`);
|
|
1305
|
+
}
|
|
1306
|
+
function assertEarlierPhases(state, before) {
|
|
1307
|
+
for (const phase of PHASES.slice(0, phaseIndex(before))) if (state[PHASE_VALUES[phase]] === void 0) throw new Error(`no '${phase}' step ran, so it has no ${PHASE_VALUES[phase]}`);
|
|
1308
|
+
}
|
|
1309
|
+
/** What a step receives for a component. */
|
|
1310
|
+
function values(state) {
|
|
1311
|
+
return {
|
|
1312
|
+
component: state.component.info,
|
|
1313
|
+
...definedOnly({
|
|
1314
|
+
previousVersion: state.previousVersion,
|
|
1315
|
+
previousRef: state.previousRef,
|
|
1316
|
+
previousTag: state.previousTag,
|
|
1317
|
+
changes: state.changes,
|
|
1318
|
+
kind: state.kind,
|
|
1319
|
+
version: state.version,
|
|
1320
|
+
tag: state.tag,
|
|
1321
|
+
placeholders: state.placeholders,
|
|
1322
|
+
notes: state.notes
|
|
1323
|
+
}),
|
|
1324
|
+
...state.version !== void 0 && { dependencies: [...state.dependencies] },
|
|
1325
|
+
releases: [...state.releases]
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
function shared(env, context, records) {
|
|
1329
|
+
return {
|
|
1330
|
+
env: { ...env },
|
|
1331
|
+
context,
|
|
1332
|
+
history: [...records]
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
/** Checks a step's output and applies it; resolves with its reason. */
|
|
1336
|
+
function apply(step, group, all, value) {
|
|
1337
|
+
const output = checkObject(value);
|
|
1338
|
+
if (step.phase === "write" || step.phase === "stage" || step.phase === "release") {
|
|
1339
|
+
checkRelease(output);
|
|
1340
|
+
if (step.phase !== "release" && output.defer !== void 0) {
|
|
1341
|
+
if (typeof output.defer !== "string" || output.defer.trim() === "") throw new TypeError("invalid output: 'defer' must be a non-empty string when present");
|
|
1342
|
+
}
|
|
1343
|
+
const result = {
|
|
1344
|
+
name: step.name,
|
|
1345
|
+
output: output.output,
|
|
1346
|
+
files: output.files ?? []
|
|
1347
|
+
};
|
|
1348
|
+
for (const state of group) {
|
|
1349
|
+
state.releases.push(result);
|
|
1350
|
+
for (const fact of step.provides ?? []) state.facts.add(fact);
|
|
1351
|
+
}
|
|
1352
|
+
return {
|
|
1353
|
+
reason: output.reason,
|
|
1354
|
+
...typeof output.defer === "string" && { defer: output.defer }
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
if (!all) {
|
|
1358
|
+
const [state] = group;
|
|
1359
|
+
if (state) applyTo(step, state, output);
|
|
1360
|
+
return { reason: output.reason };
|
|
1361
|
+
}
|
|
1362
|
+
const results = output.results;
|
|
1363
|
+
if (typeof results !== "object" || results === null || Array.isArray(results)) throw new TypeError("invalid output: 'results' must map components to their results");
|
|
1364
|
+
for (const name of Object.keys(results)) if (!group.some((state) => state.component.info.name === name)) throw new TypeError(`invalid output: '${name}' isn't a component being released`);
|
|
1365
|
+
for (const state of group) {
|
|
1366
|
+
const own = results[state.component.info.name];
|
|
1367
|
+
if (own !== void 0) applyTo(step, state, checkObject(own));
|
|
1368
|
+
}
|
|
1369
|
+
return { reason: output.reason };
|
|
1370
|
+
}
|
|
1371
|
+
function applyTo(step, state, output) {
|
|
1372
|
+
switch (step.phase) {
|
|
1373
|
+
case "read-changes": {
|
|
1374
|
+
const changes = parseChanges(output);
|
|
1375
|
+
checkChanges(changes, step.provides ?? []);
|
|
1376
|
+
state.changes = changes;
|
|
1377
|
+
for (const fact of [...state.facts]) if (isChangeFact(fact)) state.facts.delete(fact);
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
case "determine-kind":
|
|
1381
|
+
if (!KINDS.includes(output.kind)) throw new TypeError(`invalid output: 'kind' must be one of ${KINDS.join(", ")}`);
|
|
1382
|
+
if (output.version !== void 0 && (typeof output.version !== "string" || output.version === "")) throw new TypeError("invalid output: 'version' must be a non-empty string when present");
|
|
1383
|
+
state.kind = output.kind;
|
|
1384
|
+
state.exact = output.version;
|
|
1385
|
+
break;
|
|
1386
|
+
case "determine-notes":
|
|
1387
|
+
if (typeof output.notes !== "string") throw new TypeError("invalid output: 'notes' must be a string");
|
|
1388
|
+
state.notes = output.notes;
|
|
1389
|
+
}
|
|
1390
|
+
for (const fact of step.provides ?? []) state.facts.add(fact);
|
|
1391
|
+
}
|
|
1392
|
+
/** Guards against untyped (e.g. plain JS) steps returning malformed output. */
|
|
1393
|
+
function checkObject(value) {
|
|
1394
|
+
if (typeof value !== "object" || value === null) throw new TypeError("invalid output: expected an object");
|
|
1395
|
+
const output = value;
|
|
1396
|
+
if (typeof output.reason !== "string" || output.reason.trim() === "") throw new TypeError("invalid output: 'reason' must be a non-empty string");
|
|
1397
|
+
return output;
|
|
1398
|
+
}
|
|
1399
|
+
function parseChanges(output) {
|
|
1400
|
+
const changes = output.changes;
|
|
1401
|
+
if (!Array.isArray(changes) || !changes.every((change) => typeof change === "object" && change !== null && typeof change.reason === "string")) throw new TypeError("invalid output: 'changes' must be an array of changes, each with a 'reason'");
|
|
1402
|
+
if (!changes.every(({ labels }) => labels === void 0 || Array.isArray(labels) && labels.every((label) => typeof label === "string"))) throw new TypeError("invalid output: a change's 'labels' must be an array of strings when present");
|
|
1403
|
+
return changes;
|
|
1404
|
+
}
|
|
1405
|
+
function checkRelease(output) {
|
|
1406
|
+
if (!("output" in output)) throw new TypeError("invalid output: 'output' is required (use null for none)");
|
|
1407
|
+
if (output.files !== void 0 && !(Array.isArray(output.files) && output.files.every((file) => typeof file === "string"))) throw new TypeError("invalid output: 'files' must be an array of paths when present");
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* `breaking` or `feature` when the first or second number differs;
|
|
1411
|
+
* otherwise, or when either isn't a version, `fix`.
|
|
1412
|
+
*/
|
|
1413
|
+
function difference(from, to) {
|
|
1414
|
+
const a = parseVersion(from);
|
|
1415
|
+
const b = parseVersion(to);
|
|
1416
|
+
const index = a && b ? firstDifference(a, b) : void 0;
|
|
1417
|
+
return index === 0 ? "breaking" : index === 1 ? "feature" : "fix";
|
|
1418
|
+
}
|
|
1419
|
+
async function detectBranch(cwd) {
|
|
1420
|
+
const fromCi = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_REF_NAME;
|
|
1421
|
+
if (fromCi) return fromCi;
|
|
1422
|
+
return await tryGit(cwd, "rev-parse", "--abbrev-ref", "HEAD") ?? "unknown";
|
|
1423
|
+
}
|
|
1424
|
+
function errorMessage(error) {
|
|
1425
|
+
return error instanceof Error ? error.message : String(error);
|
|
1426
|
+
}
|
|
1427
|
+
/** Drops `undefined` properties, for `exactOptionalPropertyTypes`. */
|
|
1428
|
+
function definedOnly(object) {
|
|
1429
|
+
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== void 0));
|
|
1430
|
+
}
|
|
1431
|
+
//#endregion
|
|
1432
|
+
//#region src/core/define.ts
|
|
1433
|
+
/**
|
|
1434
|
+
* Defines a release pipeline. Pass a custom context type to type the `context`
|
|
1435
|
+
* every step receives: `defineRelease<{ slackTs?: string }>({ context: {}, steps })`.
|
|
1436
|
+
* Its `scheme` and `history` are its own, or else the preset's its steps come from.
|
|
1437
|
+
*/
|
|
1438
|
+
function defineRelease(config) {
|
|
1439
|
+
const issues = [];
|
|
1440
|
+
const scheme = config.scheme ?? fromPreset(config.steps, "scheme", issues);
|
|
1441
|
+
const history = config.history ?? fromPreset(config.steps, "history", issues);
|
|
1442
|
+
if (!scheme || !history) throw new OptionsError(issues);
|
|
1443
|
+
return {
|
|
1444
|
+
steps: config.steps,
|
|
1445
|
+
scheme,
|
|
1446
|
+
history,
|
|
1447
|
+
run: (options) => runRelease({
|
|
1448
|
+
steps: config.steps,
|
|
1449
|
+
context: config.context,
|
|
1450
|
+
scheme,
|
|
1451
|
+
tag: config.tag,
|
|
1452
|
+
history,
|
|
1453
|
+
components: config.components,
|
|
1454
|
+
channels: config.channels
|
|
1455
|
+
}, options)
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
const EXAMPLES = {
|
|
1459
|
+
scheme: "semver",
|
|
1460
|
+
history: "git/tags"
|
|
1461
|
+
};
|
|
1462
|
+
/** The scheme or history provided by the one preset `steps` come from that has it. */
|
|
1463
|
+
function fromPreset(steps, key, issues) {
|
|
1464
|
+
const provided = /* @__PURE__ */ new Map();
|
|
1465
|
+
for (const { preset } of steps) if (preset?.[key] && !provided.has(preset.name)) provided.set(preset.name, preset[key]);
|
|
1466
|
+
const [only, ...others] = provided.values();
|
|
1467
|
+
if (only && others.length === 0) return only;
|
|
1468
|
+
issues.push(provided.size === 0 ? `${key}: expected one, e.g. ${EXAMPLES[key]}, or a preset that provides one` : `${key}: ${joinList([...provided.keys()])} each provide one; set it`);
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Defines a reusable step whose options are declared as a zod shape. The
|
|
1472
|
+
* result is a factory: call it with options to get a step. Its options are
|
|
1473
|
+
* validated on every call, so they're just as safe when they come from a
|
|
1474
|
+
* workflow file.
|
|
1475
|
+
*
|
|
1476
|
+
* ```ts
|
|
1477
|
+
* export const header = defineStep({
|
|
1478
|
+
* phase: "determine-notes",
|
|
1479
|
+
* name: "header",
|
|
1480
|
+
* options: { text: z.string().default("Released by rylease") },
|
|
1481
|
+
* run: ({ notes, options }) => ({ notes: `> ${options.text}\n\n${notes}`, reason: "added header" }),
|
|
1482
|
+
* });
|
|
1483
|
+
* header({ text: "Hi" });
|
|
1484
|
+
* ```
|
|
1485
|
+
*/
|
|
1486
|
+
function defineStep(definition) {
|
|
1487
|
+
const shape = definition.options ?? {};
|
|
1488
|
+
const scope = definition.scope ?? "each";
|
|
1489
|
+
const declared = definition.needs ?? [];
|
|
1490
|
+
const needs = typeof declared === "function" ? [] : declared;
|
|
1491
|
+
const provides = definition.provides ?? [];
|
|
1492
|
+
for (const key of ["phase", "name"]) if (!(key in definition)) throw new OptionsError([`${definition.name}: ${key} is required`]);
|
|
1493
|
+
registerProvider(definition.name, provides);
|
|
1494
|
+
const factory = (options) => {
|
|
1495
|
+
const parsed = parseOptions(shape, options, definition.name);
|
|
1496
|
+
const run = definition.run;
|
|
1497
|
+
const needs = typeof declared === "function" ? declared(parsed) : declared;
|
|
1498
|
+
return {
|
|
1499
|
+
phase: typeof definition.phase === "function" ? definition.phase(parsed) : definition.phase,
|
|
1500
|
+
name: definition.name,
|
|
1501
|
+
...scope === "all" && { scope },
|
|
1502
|
+
...needs.length > 0 && { needs },
|
|
1503
|
+
...provides.length > 0 && { provides },
|
|
1504
|
+
run: (input) => run({
|
|
1505
|
+
...input,
|
|
1506
|
+
options: parsed
|
|
1507
|
+
})
|
|
1508
|
+
};
|
|
1509
|
+
};
|
|
1510
|
+
return Object.defineProperties(factory, {
|
|
1511
|
+
kind: {
|
|
1512
|
+
value: "step",
|
|
1513
|
+
enumerable: true
|
|
1514
|
+
},
|
|
1515
|
+
phase: {
|
|
1516
|
+
value: definition.phase,
|
|
1517
|
+
enumerable: true
|
|
1518
|
+
},
|
|
1519
|
+
name: {
|
|
1520
|
+
value: definition.name,
|
|
1521
|
+
enumerable: true
|
|
1522
|
+
},
|
|
1523
|
+
description: {
|
|
1524
|
+
value: definition.description,
|
|
1525
|
+
enumerable: true
|
|
1526
|
+
},
|
|
1527
|
+
scope: {
|
|
1528
|
+
value: scope,
|
|
1529
|
+
enumerable: true
|
|
1530
|
+
},
|
|
1531
|
+
needs: {
|
|
1532
|
+
value: needs,
|
|
1533
|
+
enumerable: true
|
|
1534
|
+
},
|
|
1535
|
+
provides: {
|
|
1536
|
+
value: provides,
|
|
1537
|
+
enumerable: true
|
|
1538
|
+
},
|
|
1539
|
+
options: {
|
|
1540
|
+
value: shape,
|
|
1541
|
+
enumerable: true
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Bundles step factories under one name. Calling the preset creates every
|
|
1547
|
+
* step, passing each its options by name:
|
|
1548
|
+
* `conventional({ "github/create-release": false, "conventional/determine-kind": { fallback: "none" } })`.
|
|
1549
|
+
* `defaults` sets the preset's own options per step, under the caller's;
|
|
1550
|
+
* `scheme` and `history` make the release's, unless it sets its own, e.g.
|
|
1551
|
+
* `history: gitTags` for a preset that tags its releases.
|
|
1552
|
+
*/
|
|
1553
|
+
function definePreset(definition) {
|
|
1554
|
+
const names = definition.steps.map((step) => step.name);
|
|
1555
|
+
const defaults = definition.defaults ?? {};
|
|
1556
|
+
const preset = (options = {}) => {
|
|
1557
|
+
const unknown = Object.keys(options).filter((key) => !names.includes(key));
|
|
1558
|
+
if (unknown.length > 0) throw new OptionsError(unknown.map((key) => `${definition.name}: '${key}' isn't one of its steps (${names.join(", ")})`));
|
|
1559
|
+
const made = {
|
|
1560
|
+
name: definition.name,
|
|
1561
|
+
...definition.scheme && { scheme: definition.scheme() },
|
|
1562
|
+
...definition.history && { history: definition.history() }
|
|
1563
|
+
};
|
|
1564
|
+
return definition.steps.flatMap((factory) => {
|
|
1565
|
+
const value = options[factory.name];
|
|
1566
|
+
if (value === false) return [];
|
|
1567
|
+
const base = defaults[factory.name];
|
|
1568
|
+
const merged = base && (value == null || typeof value === "object") ? {
|
|
1569
|
+
...base,
|
|
1570
|
+
...value
|
|
1571
|
+
} : value;
|
|
1572
|
+
try {
|
|
1573
|
+
return [{
|
|
1574
|
+
...factory(merged),
|
|
1575
|
+
preset: made
|
|
1576
|
+
}];
|
|
1577
|
+
} catch (error) {
|
|
1578
|
+
throw error instanceof OptionsError ? new OptionsError(error.issues.map((issue) => `${definition.name}.${issue}`)) : error;
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1581
|
+
};
|
|
1582
|
+
return Object.defineProperties(preset, {
|
|
1583
|
+
kind: {
|
|
1584
|
+
value: "preset",
|
|
1585
|
+
enumerable: true
|
|
1586
|
+
},
|
|
1587
|
+
name: {
|
|
1588
|
+
value: definition.name,
|
|
1589
|
+
enumerable: true
|
|
1590
|
+
},
|
|
1591
|
+
description: {
|
|
1592
|
+
value: definition.description,
|
|
1593
|
+
enumerable: true
|
|
1594
|
+
},
|
|
1595
|
+
steps: {
|
|
1596
|
+
value: definition.steps,
|
|
1597
|
+
enumerable: true
|
|
1598
|
+
},
|
|
1599
|
+
defaults: {
|
|
1600
|
+
value: definition.defaults ?? {},
|
|
1601
|
+
enumerable: true
|
|
1602
|
+
},
|
|
1603
|
+
scheme: {
|
|
1604
|
+
value: definition.scheme,
|
|
1605
|
+
enumerable: true
|
|
1606
|
+
},
|
|
1607
|
+
history: {
|
|
1608
|
+
value: definition.history,
|
|
1609
|
+
enumerable: true
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Makes a step run only when `condition` holds, on top of any condition it
|
|
1615
|
+
* already has: `when(steps.npmPublish(), ({ env }) => !env.dryRun)`.
|
|
1616
|
+
*/
|
|
1617
|
+
function when(step, condition) {
|
|
1618
|
+
const own = step.if;
|
|
1619
|
+
const source = condition.source ?? "its condition";
|
|
1620
|
+
const combined = own ? Object.assign((input) => condition(input) && own(input), { source: `${source} && ${own.source ?? "its own condition"}` }) : condition;
|
|
1621
|
+
return {
|
|
1622
|
+
...step,
|
|
1623
|
+
if: combined
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
//#endregion
|
|
1627
|
+
//#region src/core/expression.ts
|
|
1628
|
+
const NAMES = [
|
|
1629
|
+
"dryRun",
|
|
1630
|
+
"branch",
|
|
1631
|
+
"cwd",
|
|
1632
|
+
"channel",
|
|
1633
|
+
"prerelease",
|
|
1634
|
+
"component",
|
|
1635
|
+
"components",
|
|
1636
|
+
"previousVersion",
|
|
1637
|
+
"previousRef",
|
|
1638
|
+
"previousTag",
|
|
1639
|
+
"changes",
|
|
1640
|
+
"kind",
|
|
1641
|
+
"version",
|
|
1642
|
+
"tag",
|
|
1643
|
+
"notes",
|
|
1644
|
+
"dependencies",
|
|
1645
|
+
"steps"
|
|
1646
|
+
];
|
|
1647
|
+
const VALUES = [
|
|
1648
|
+
"previousVersion",
|
|
1649
|
+
"previousRef",
|
|
1650
|
+
"previousTag",
|
|
1651
|
+
"changes",
|
|
1652
|
+
"kind",
|
|
1653
|
+
"version",
|
|
1654
|
+
"tag",
|
|
1655
|
+
"notes",
|
|
1656
|
+
"dependencies"
|
|
1657
|
+
];
|
|
1658
|
+
/** Values of a component, as expressions read them. */
|
|
1659
|
+
function valuesOf(results) {
|
|
1660
|
+
const component = results.component;
|
|
1661
|
+
return {
|
|
1662
|
+
...component && { component: {
|
|
1663
|
+
name: component.name,
|
|
1664
|
+
path: component.path
|
|
1665
|
+
} },
|
|
1666
|
+
...Object.fromEntries(VALUES.flatMap((name) => results[name] === void 0 ? [] : [[name, results[name]]]))
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
/** What an expression reads, from what a step receives: for one component, or all. */
|
|
1670
|
+
function expressionContext(input) {
|
|
1671
|
+
const fields = input;
|
|
1672
|
+
const own = fields.component;
|
|
1673
|
+
const all = fields.components;
|
|
1674
|
+
return {
|
|
1675
|
+
dryRun: fields.env.dryRun,
|
|
1676
|
+
branch: fields.env.branch,
|
|
1677
|
+
cwd: fields.env.cwd,
|
|
1678
|
+
channel: fields.env.channel?.name ?? null,
|
|
1679
|
+
prerelease: fields.env.channel?.type === "prerelease",
|
|
1680
|
+
...all ? {
|
|
1681
|
+
components: all.map(valuesOf),
|
|
1682
|
+
...all.length === 1 && all[0] ? valuesOf(all[0]) : {}
|
|
1683
|
+
} : valuesOf(fields),
|
|
1684
|
+
steps: Object.fromEntries(fields.history.filter((record) => record.phase !== "read-version" && record.phase !== "determine-version" && (record.component === void 0 || own === void 0 || record.component === own.name)).map((record) => [record.name, {
|
|
1685
|
+
phase: record.phase,
|
|
1686
|
+
status: record.status,
|
|
1687
|
+
reason: record.reason,
|
|
1688
|
+
output: record.phase === "release" ? record.output?.output : record.output
|
|
1689
|
+
}]))
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
var ExpressionError = class extends Error {
|
|
1693
|
+
name = "ExpressionError";
|
|
1694
|
+
};
|
|
1695
|
+
const FUNCTIONS = {
|
|
1696
|
+
contains: (search, item) => Array.isArray(search) ? search.includes(item) : String(search ?? "").includes(String(item ?? "")),
|
|
1697
|
+
startsWith: (text, prefix) => String(text ?? "").startsWith(String(prefix ?? "")),
|
|
1698
|
+
endsWith: (text, suffix) => String(text ?? "").endsWith(String(suffix ?? ""))
|
|
1699
|
+
};
|
|
1700
|
+
const TOKEN = /\s*(?:(\d+(?:\.\d+)?)|'((?:[^']|'')*)'|([A-Za-z_][\w-]*)|(==|!=|<=|>=|&&|\|\||[!<>().[\],]))/y;
|
|
1701
|
+
function tokenize(source) {
|
|
1702
|
+
const tokens = [];
|
|
1703
|
+
TOKEN.lastIndex = 0;
|
|
1704
|
+
while (source.slice(TOKEN.lastIndex).trim() !== "") {
|
|
1705
|
+
const at = TOKEN.lastIndex;
|
|
1706
|
+
const match = TOKEN.exec(source);
|
|
1707
|
+
if (!match) {
|
|
1708
|
+
const rest = source.slice(at).trimStart();
|
|
1709
|
+
const hint = rest[0] === "/" ? "; read names with '/' as steps['git/commit']" : "";
|
|
1710
|
+
throw new ExpressionError(`unexpected '${rest[0]}' at ${source.length - rest.length + 1}${hint}`);
|
|
1711
|
+
}
|
|
1712
|
+
const [, number, string, name, op] = match;
|
|
1713
|
+
if (number !== void 0) tokens.push({
|
|
1714
|
+
kind: "number",
|
|
1715
|
+
value: Number(number)
|
|
1716
|
+
});
|
|
1717
|
+
else if (string !== void 0) tokens.push({
|
|
1718
|
+
kind: "string",
|
|
1719
|
+
value: string.replaceAll("''", "'")
|
|
1720
|
+
});
|
|
1721
|
+
else if (name !== void 0) tokens.push({
|
|
1722
|
+
kind: "name",
|
|
1723
|
+
value: name
|
|
1724
|
+
});
|
|
1725
|
+
else tokens.push({
|
|
1726
|
+
kind: "op",
|
|
1727
|
+
value: op ?? ""
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
return tokens;
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Compiles an `if:` expression, in the style of GitHub's: literals (`true`,
|
|
1734
|
+
* `'text'`, `1`, `null`), names from {@link ExpressionContext} with `.x` and
|
|
1735
|
+
* `['x']` access, `!`, `==`, `!=`, `<`, `<=`, `>`, `>=`, `&&`, `||`,
|
|
1736
|
+
* parentheses, and `contains`, `startsWith` and `endsWith`.
|
|
1737
|
+
*/
|
|
1738
|
+
function compileExpression(source) {
|
|
1739
|
+
const tokens = tokenize(source);
|
|
1740
|
+
let position = 0;
|
|
1741
|
+
const peek = () => tokens[position];
|
|
1742
|
+
const isOp = (value) => {
|
|
1743
|
+
const token = peek();
|
|
1744
|
+
return token?.kind === "op" && token.value === value;
|
|
1745
|
+
};
|
|
1746
|
+
const expect = (value) => {
|
|
1747
|
+
if (!isOp(value)) throw unexpected();
|
|
1748
|
+
position++;
|
|
1749
|
+
};
|
|
1750
|
+
const unexpected = () => {
|
|
1751
|
+
const token = peek();
|
|
1752
|
+
return new ExpressionError(token ? `unexpected '${token.value}'` : "unexpected end");
|
|
1753
|
+
};
|
|
1754
|
+
const binary = (next, operators) => {
|
|
1755
|
+
return () => {
|
|
1756
|
+
let left = next();
|
|
1757
|
+
for (;;) {
|
|
1758
|
+
const token = peek();
|
|
1759
|
+
const apply = token?.kind === "op" ? operators[token.value] : void 0;
|
|
1760
|
+
if (!apply) return left;
|
|
1761
|
+
position++;
|
|
1762
|
+
const right = next();
|
|
1763
|
+
const l = left;
|
|
1764
|
+
left = (context) => apply(l(context), () => right(context));
|
|
1765
|
+
}
|
|
1766
|
+
};
|
|
1767
|
+
};
|
|
1768
|
+
const compare = (test) => (a, b) => {
|
|
1769
|
+
const right = b();
|
|
1770
|
+
return (typeof a === "number" || typeof a === "string") && typeof right === typeof a && test(a, right);
|
|
1771
|
+
};
|
|
1772
|
+
const primary = () => {
|
|
1773
|
+
const token = peek();
|
|
1774
|
+
if (!token) throw unexpected();
|
|
1775
|
+
position++;
|
|
1776
|
+
if (token.kind === "number" || token.kind === "string") return () => token.value;
|
|
1777
|
+
if (token.kind === "op") {
|
|
1778
|
+
if (token.value === "!") {
|
|
1779
|
+
const operand = primary();
|
|
1780
|
+
return (context) => !operand(context);
|
|
1781
|
+
}
|
|
1782
|
+
if (token.value === "(") {
|
|
1783
|
+
const inner = or();
|
|
1784
|
+
expect(")");
|
|
1785
|
+
return inner;
|
|
1786
|
+
}
|
|
1787
|
+
position--;
|
|
1788
|
+
throw unexpected();
|
|
1789
|
+
}
|
|
1790
|
+
const literals = {
|
|
1791
|
+
true: true,
|
|
1792
|
+
false: false,
|
|
1793
|
+
null: null
|
|
1794
|
+
};
|
|
1795
|
+
if (token.value in literals) return () => literals[token.value];
|
|
1796
|
+
if (isOp("(")) {
|
|
1797
|
+
const fn = FUNCTIONS[token.value];
|
|
1798
|
+
if (!fn) throw new ExpressionError(`unknown function '${token.value}'; use ${Object.keys(FUNCTIONS).join(", ")}`);
|
|
1799
|
+
position++;
|
|
1800
|
+
const args = [];
|
|
1801
|
+
if (!isOp(")")) {
|
|
1802
|
+
args.push(or());
|
|
1803
|
+
while (isOp(",")) {
|
|
1804
|
+
position++;
|
|
1805
|
+
args.push(or());
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
expect(")");
|
|
1809
|
+
return (context) => fn(...args.map((arg) => arg(context)));
|
|
1810
|
+
}
|
|
1811
|
+
if (!NAMES.includes(token.value)) throw new ExpressionError(`unknown name '${token.value}'; use ${NAMES.join(", ")}`);
|
|
1812
|
+
const name = token.value;
|
|
1813
|
+
let node = (context) => context[name];
|
|
1814
|
+
for (;;) if (isOp(".")) {
|
|
1815
|
+
position++;
|
|
1816
|
+
const key = peek();
|
|
1817
|
+
if (key?.kind !== "name") throw unexpected();
|
|
1818
|
+
position++;
|
|
1819
|
+
const object = node;
|
|
1820
|
+
node = (context) => property(object(context), key.value);
|
|
1821
|
+
} else if (isOp("[")) {
|
|
1822
|
+
position++;
|
|
1823
|
+
const key = or();
|
|
1824
|
+
expect("]");
|
|
1825
|
+
const object = node;
|
|
1826
|
+
node = (context) => property(object(context), key(context));
|
|
1827
|
+
} else return node;
|
|
1828
|
+
};
|
|
1829
|
+
const or = binary(binary(binary(binary(primary, {
|
|
1830
|
+
"<": compare((a, b) => a < b),
|
|
1831
|
+
"<=": compare((a, b) => a <= b),
|
|
1832
|
+
">": compare((a, b) => a > b),
|
|
1833
|
+
">=": compare((a, b) => a >= b)
|
|
1834
|
+
}), {
|
|
1835
|
+
"==": (a, b) => (a ?? null) === (b() ?? null),
|
|
1836
|
+
"!=": (a, b) => (a ?? null) !== (b() ?? null)
|
|
1837
|
+
}), { "&&": (a, b) => a ? b() : a }), { "||": (a, b) => a || b() });
|
|
1838
|
+
if (tokens.length === 0) throw new ExpressionError("empty expression");
|
|
1839
|
+
const root = or();
|
|
1840
|
+
if (position < tokens.length) throw unexpected();
|
|
1841
|
+
return root;
|
|
1842
|
+
}
|
|
1843
|
+
function property(object, key) {
|
|
1844
|
+
if (typeof object !== "object" || object === null) return void 0;
|
|
1845
|
+
const name = String(key);
|
|
1846
|
+
return Object.hasOwn(object, name) ? object[name] : void 0;
|
|
1847
|
+
}
|
|
1848
|
+
//#endregion
|
|
1849
|
+
//#region src/core/history.ts
|
|
1850
|
+
/** Defines where previous releases are found, with options as a zod shape. */
|
|
1851
|
+
function defineHistory(definition) {
|
|
1852
|
+
return makeFactory("history", definition);
|
|
1853
|
+
}
|
|
1854
|
+
//#endregion
|
|
1855
|
+
//#region src/steps/npm/package-json.ts
|
|
1856
|
+
function versionOf(json, source) {
|
|
1857
|
+
if (typeof json.version !== "string" || json.version === "") throw new Error(`${source} has no 'version'`);
|
|
1858
|
+
return json.version;
|
|
1859
|
+
}
|
|
1860
|
+
const DEPENDENCY_FIELDS = [
|
|
1861
|
+
"dependencies",
|
|
1862
|
+
"devDependencies",
|
|
1863
|
+
"peerDependencies",
|
|
1864
|
+
"optionalDependencies"
|
|
1865
|
+
];
|
|
1866
|
+
/**
|
|
1867
|
+
* A package.json's text with a new `version`, and with dependencies on the
|
|
1868
|
+
* given packages pointed at theirs, keeping each range's operator (`^`, `~`,
|
|
1869
|
+
* `>=` or none). `workspace:`, `file:` and ranges like `1.x` are left for the
|
|
1870
|
+
* package manager. Its indentation and trailing newline are kept.
|
|
1871
|
+
*/
|
|
1872
|
+
function setPackageJsonVersion(text, version, packages) {
|
|
1873
|
+
const json = JSON.parse(text);
|
|
1874
|
+
json.version = version;
|
|
1875
|
+
const changed = [];
|
|
1876
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
1877
|
+
const deps = json[field];
|
|
1878
|
+
if (typeof deps !== "object" || deps === null) continue;
|
|
1879
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
1880
|
+
const next = packages.get(name);
|
|
1881
|
+
if (next === void 0 || typeof range !== "string") continue;
|
|
1882
|
+
const updated = withOperator(range, next);
|
|
1883
|
+
if (updated === void 0 || updated === range) continue;
|
|
1884
|
+
deps[name] = updated;
|
|
1885
|
+
changed.push(`${name}@${updated}`);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
const indent = /^[ \t]+(?=")/m.exec(text)?.[0] ?? " ";
|
|
1889
|
+
const newline = text.endsWith("\n") ? "\n" : "";
|
|
1890
|
+
return {
|
|
1891
|
+
text: JSON.stringify(json, null, indent) + newline,
|
|
1892
|
+
dependencies: [...new Set(changed)]
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* A range pointed at a new version, keeping its operator, or `undefined`
|
|
1897
|
+
* when it isn't a plain range on one version.
|
|
1898
|
+
*/
|
|
1899
|
+
function withOperator(range, version) {
|
|
1900
|
+
const match = /^(\^|~|>=|=)?v?\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.+-]+)?$/.exec(range.trim());
|
|
1901
|
+
return match ? `${match[1] ?? ""}${version}` : void 0;
|
|
1902
|
+
}
|
|
1903
|
+
//#endregion
|
|
1904
|
+
//#region src/steps/fs/formats.ts
|
|
1905
|
+
const FORMAT_NAMES = [
|
|
1906
|
+
"plain",
|
|
1907
|
+
"npm",
|
|
1908
|
+
"cargo",
|
|
1909
|
+
"python",
|
|
1910
|
+
"helm",
|
|
1911
|
+
"gradle",
|
|
1912
|
+
"maven",
|
|
1913
|
+
"pattern"
|
|
1914
|
+
];
|
|
1915
|
+
/** The format by name; `pattern` needs its template, e.g. `VERSION = "{version}"`. */
|
|
1916
|
+
function versionFormat(name, pattern) {
|
|
1917
|
+
switch (name) {
|
|
1918
|
+
case "plain": return plain;
|
|
1919
|
+
case "npm": return npm;
|
|
1920
|
+
case "cargo": return cargo;
|
|
1921
|
+
case "python": return python;
|
|
1922
|
+
case "helm": return helm;
|
|
1923
|
+
case "gradle": return gradle;
|
|
1924
|
+
case "maven": return maven;
|
|
1925
|
+
case "pattern":
|
|
1926
|
+
if (pattern === void 0) throw new Error("the pattern format needs a `pattern`");
|
|
1927
|
+
return fromPattern(pattern);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
const unchanged = (text) => ({
|
|
1931
|
+
text,
|
|
1932
|
+
dependencies: []
|
|
1933
|
+
});
|
|
1934
|
+
const plain = {
|
|
1935
|
+
name: "plain",
|
|
1936
|
+
path: "VERSION",
|
|
1937
|
+
read: (text) => text.trim() || void 0,
|
|
1938
|
+
write: (text, version) => unchanged(`${version}${text === "" || text.endsWith("\n") ? "\n" : ""}`)
|
|
1939
|
+
};
|
|
1940
|
+
const npm = {
|
|
1941
|
+
name: "npm",
|
|
1942
|
+
path: "package.json",
|
|
1943
|
+
needs: ["version.semver"],
|
|
1944
|
+
read: (text) => {
|
|
1945
|
+
const json = parseJson(text);
|
|
1946
|
+
return typeof json?.version === "string" && json.version !== "" ? json.version : void 0;
|
|
1947
|
+
},
|
|
1948
|
+
write: setPackageJsonVersion,
|
|
1949
|
+
packageName: (text) => {
|
|
1950
|
+
const name = parseJson(text)?.name;
|
|
1951
|
+
return typeof name === "string" ? name : void 0;
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
function parseJson(text) {
|
|
1955
|
+
try {
|
|
1956
|
+
const json = JSON.parse(text);
|
|
1957
|
+
return typeof json === "object" && json !== null ? json : void 0;
|
|
1958
|
+
} catch {
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
/** `key = "value"` in the first of the TOML tables that has it. */
|
|
1963
|
+
function tomlKey(tables, key) {
|
|
1964
|
+
const find = (text) => {
|
|
1965
|
+
const lines = text.split("\n");
|
|
1966
|
+
for (const table of tables) {
|
|
1967
|
+
let inside = false;
|
|
1968
|
+
for (const [index, line] of lines.entries()) {
|
|
1969
|
+
const header = /^\s*\[([^[\]]+)\]\s*(#.*)?$/.exec(line);
|
|
1970
|
+
if (header) {
|
|
1971
|
+
inside = header[1]?.trim() === table;
|
|
1972
|
+
continue;
|
|
1973
|
+
}
|
|
1974
|
+
if (/^\s*\[\[/.test(line)) {
|
|
1975
|
+
inside = false;
|
|
1976
|
+
continue;
|
|
1977
|
+
}
|
|
1978
|
+
const match = new RegExp(`^(\\s*${key}\\s*=\\s*)(["'])([^"']*)\\2(.*)$`).exec(line);
|
|
1979
|
+
if (inside && match) return {
|
|
1980
|
+
lines,
|
|
1981
|
+
index,
|
|
1982
|
+
match
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
};
|
|
1987
|
+
return {
|
|
1988
|
+
read: (text) => find(text)?.match[3],
|
|
1989
|
+
write: (text, value) => {
|
|
1990
|
+
const found = find(text);
|
|
1991
|
+
if (!found) return void 0;
|
|
1992
|
+
const [, before, quote, , after] = found.match;
|
|
1993
|
+
found.lines[found.index] = `${before}${quote}${value}${quote}${after}`;
|
|
1994
|
+
return found.lines.join("\n");
|
|
1995
|
+
}
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
const CARGO_TABLES = [
|
|
1999
|
+
"dependencies",
|
|
2000
|
+
"dev-dependencies",
|
|
2001
|
+
"build-dependencies",
|
|
2002
|
+
"workspace.dependencies"
|
|
2003
|
+
];
|
|
2004
|
+
const cargoVersion = tomlKey(["package", "workspace.package"], "version");
|
|
2005
|
+
const cargoName = tomlKey(["package"], "name");
|
|
2006
|
+
const cargo = {
|
|
2007
|
+
name: "cargo",
|
|
2008
|
+
path: "Cargo.toml",
|
|
2009
|
+
needs: ["version.semver"],
|
|
2010
|
+
read: cargoVersion.read,
|
|
2011
|
+
write: (text, version, packages) => {
|
|
2012
|
+
const written = cargoVersion.write(text, version);
|
|
2013
|
+
if (written === void 0) throw new Error("it has no [package] version");
|
|
2014
|
+
const dependencies = [];
|
|
2015
|
+
let table = "";
|
|
2016
|
+
return {
|
|
2017
|
+
text: written.split("\n").map((line) => {
|
|
2018
|
+
const header = /^\s*\[([^[\]]+)\]/.exec(line);
|
|
2019
|
+
if (header) table = header[1]?.trim() ?? "";
|
|
2020
|
+
if (!CARGO_TABLES.some((name) => table.endsWith(name))) return line;
|
|
2021
|
+
const match = /^(\s*"?([\w-]+)"?\s*=\s*\{[^}]*\bversion\s*=\s*")([^"]*)(".*)$/.exec(line);
|
|
2022
|
+
const next = match?.[2] ? packages.get(match[2]) : void 0;
|
|
2023
|
+
const range = match?.[3];
|
|
2024
|
+
if (!match || next === void 0 || range === void 0) return line;
|
|
2025
|
+
const updated = withOperator(range, next);
|
|
2026
|
+
if (updated === void 0 || updated === range) return line;
|
|
2027
|
+
dependencies.push(`${match[2]}@${updated}`);
|
|
2028
|
+
return `${match[1]}${updated}${match[4]}`;
|
|
2029
|
+
}).join("\n"),
|
|
2030
|
+
dependencies
|
|
2031
|
+
};
|
|
2032
|
+
},
|
|
2033
|
+
packageName: cargoName.read
|
|
2034
|
+
};
|
|
2035
|
+
const pythonVersion = tomlKey(["project", "tool.poetry"], "version");
|
|
2036
|
+
const python = {
|
|
2037
|
+
name: "python",
|
|
2038
|
+
path: "pyproject.toml",
|
|
2039
|
+
read: pythonVersion.read,
|
|
2040
|
+
write: (text, version) => {
|
|
2041
|
+
const written = pythonVersion.write(text, version);
|
|
2042
|
+
if (written === void 0) throw new Error("it has no [project] or [tool.poetry] version");
|
|
2043
|
+
return unchanged(written);
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
2046
|
+
/** A top-level `key: value` line, e.g. in YAML or a properties file. */
|
|
2047
|
+
function lineKey(name, path, pattern) {
|
|
2048
|
+
return {
|
|
2049
|
+
name,
|
|
2050
|
+
path,
|
|
2051
|
+
read: (text) => pattern.exec(text)?.[3],
|
|
2052
|
+
write: (text, version) => {
|
|
2053
|
+
if (!pattern.test(text)) throw new Error(`it has no ${name} version`);
|
|
2054
|
+
return unchanged(text.replace(pattern, (_, before, quote, __, after) => `${before}${quote}${version}${quote}${after}`));
|
|
2055
|
+
}
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
const helm = lineKey("helm", "Chart.yaml", /^(version:\s*)(["']?)([^"'\s#]+)\2(.*)$/m);
|
|
2059
|
+
const gradle = lineKey("gradle", "gradle.properties", /^(\s*version\s*[=:]\s*)()([^\s#]+)()$/m);
|
|
2060
|
+
/** The project's own `<version>`: the one directly in `<project>`. */
|
|
2061
|
+
function mavenVersion(text) {
|
|
2062
|
+
const tags = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<(\/?)([\w.:-]+)[^>]*?(\/?)>/g;
|
|
2063
|
+
let depth = 0;
|
|
2064
|
+
let open;
|
|
2065
|
+
for (const match of text.matchAll(tags)) {
|
|
2066
|
+
const [whole, closing, name, selfClosing] = match;
|
|
2067
|
+
if (name === void 0 || selfClosing) continue;
|
|
2068
|
+
if (closing) {
|
|
2069
|
+
depth--;
|
|
2070
|
+
if (name === "version" && depth === 1 && open !== void 0) return {
|
|
2071
|
+
start: open,
|
|
2072
|
+
end: match.index
|
|
2073
|
+
};
|
|
2074
|
+
continue;
|
|
2075
|
+
}
|
|
2076
|
+
if (name === "version" && depth === 1) open = match.index + whole.length;
|
|
2077
|
+
depth++;
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
const maven = {
|
|
2081
|
+
name: "maven",
|
|
2082
|
+
path: "pom.xml",
|
|
2083
|
+
read: (text) => {
|
|
2084
|
+
const found = mavenVersion(text);
|
|
2085
|
+
return found ? text.slice(found.start, found.end).trim() : void 0;
|
|
2086
|
+
},
|
|
2087
|
+
write: (text, version) => {
|
|
2088
|
+
const found = mavenVersion(text);
|
|
2089
|
+
if (!found) throw new Error("it has no project <version>");
|
|
2090
|
+
return unchanged(`${text.slice(0, found.start)}${version}${text.slice(found.end)}`);
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
/**
|
|
2094
|
+
* Versions wherever a template matches, e.g. `__version__ = "{version}"`;
|
|
2095
|
+
* every match is written.
|
|
2096
|
+
*/
|
|
2097
|
+
function fromPattern(template) {
|
|
2098
|
+
if (!template.includes("{version}")) throw new Error(`the pattern '${template}' has no {version} placeholder`);
|
|
2099
|
+
const source = template.split("{version}").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("([^\\s\"'`,;<>]+)");
|
|
2100
|
+
const pattern = new RegExp(source, "g");
|
|
2101
|
+
return {
|
|
2102
|
+
name: "pattern",
|
|
2103
|
+
path: "",
|
|
2104
|
+
read: (text) => new RegExp(source).exec(text)?.[1],
|
|
2105
|
+
write: (text, version) => {
|
|
2106
|
+
if (!new RegExp(source).test(text)) throw new Error(`nothing matches '${template}'`);
|
|
2107
|
+
return unchanged(text.replace(pattern, () => template.replaceAll("{version}", version)));
|
|
2108
|
+
}
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
//#endregion
|
|
2112
|
+
//#region src/histories/fs/version-file.ts
|
|
2113
|
+
/**
|
|
2114
|
+
* Finds the previous release in the project's version file: the version in
|
|
2115
|
+
* it, released at the commit that set it. For projects that kept their
|
|
2116
|
+
* version in a file before tagging releases, like `package.json` or a
|
|
2117
|
+
* `VERSION` file.
|
|
2118
|
+
*/
|
|
2119
|
+
const fsVersionFile = defineHistory({
|
|
2120
|
+
name: "fs/version-file",
|
|
2121
|
+
description: "The version in the project's version file, in any format `fs/write-version` writes, released at the commit that set it.",
|
|
2122
|
+
options: {
|
|
2123
|
+
format: z.enum(FORMAT_NAMES).default("plain").describe("As for `fs/write-version`."),
|
|
2124
|
+
path: z.string().optional().describe("Relative to the component's directory; defaults to the format's file."),
|
|
2125
|
+
pattern: z.string().optional().describe("With the `pattern` format, where the version is.")
|
|
2126
|
+
},
|
|
2127
|
+
create: (options) => {
|
|
2128
|
+
const format = versionFormat(options.format, options.pattern);
|
|
2129
|
+
const path = options.path ?? format.path;
|
|
2130
|
+
if (path === "") throw new Error("the pattern format needs a `path`");
|
|
2131
|
+
return {
|
|
2132
|
+
name: "fs/version-file",
|
|
2133
|
+
find: async ({ env, component }) => {
|
|
2134
|
+
const dir = join(env.cwd, component.path);
|
|
2135
|
+
const shown = join(component.path, path).replaceAll("\\", "/");
|
|
2136
|
+
const version = format.read(await readFile(join(dir, path), "utf8"));
|
|
2137
|
+
if (version === void 0) throw new Error(`${shown} has no version`);
|
|
2138
|
+
const ref = await findVersionCommit(dir, path, version, format.read);
|
|
2139
|
+
return {
|
|
2140
|
+
version,
|
|
2141
|
+
...ref && { ref },
|
|
2142
|
+
reason: ref ? `${version} in ${shown}, set in ${ref.slice(0, 7)}` : `${version} in ${shown}, not in git history`
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
2147
|
+
});
|
|
2148
|
+
//#endregion
|
|
2149
|
+
//#region src/histories/git/git-tags.ts
|
|
2150
|
+
const FIELD = "";
|
|
2151
|
+
/**
|
|
2152
|
+
* Finds the previous release by its tag: the nearest on HEAD. Of the release
|
|
2153
|
+
* tags merged into HEAD, only those that no other one comes after count, so
|
|
2154
|
+
* a `1.x` branch finds its own latest release, not `main`'s; of those, the
|
|
2155
|
+
* highest version in the scheme wins. When the scheme changed since, the
|
|
2156
|
+
* nearest release still marks where changes start, but there's no previous
|
|
2157
|
+
* version to bump: the release is the scheme's first.
|
|
2158
|
+
*
|
|
2159
|
+
* A stable release only counts stable releases. On a channel, its own
|
|
2160
|
+
* releases count too, and the nearest stable release is what's bumped.
|
|
2161
|
+
*/
|
|
2162
|
+
const gitTags = defineHistory({
|
|
2163
|
+
name: "git/tags",
|
|
2164
|
+
description: "The nearest release tag on HEAD: the highest version among the tags no other release tag comes after. On a channel, its own releases count too.",
|
|
2165
|
+
create: () => ({
|
|
2166
|
+
name: "git/tags",
|
|
2167
|
+
find: async (input) => {
|
|
2168
|
+
const cwd = input.env.cwd;
|
|
2169
|
+
if (await tryGit(cwd, "rev-parse", "--verify", "--quiet", "HEAD") === void 0) return;
|
|
2170
|
+
await assertFullHistory(cwd);
|
|
2171
|
+
const tags = await listTags(input, true);
|
|
2172
|
+
const channel = input.channel?.name;
|
|
2173
|
+
const counts = (tag) => tag.kind.type === "stable" || tag.kind.type === "foreign" || tag.kind.channel === channel;
|
|
2174
|
+
const best = await nearest(cwd, tags.filter(counts), input);
|
|
2175
|
+
if (!best) return void 0;
|
|
2176
|
+
const at = `${best.name} at ${best.commit.slice(0, 7)}`;
|
|
2177
|
+
if (best.kind.type === "foreign") return {
|
|
2178
|
+
ref: best.commit,
|
|
2179
|
+
tag: best.name,
|
|
2180
|
+
reason: `${at}, nearest on HEAD; not ${input.scheme.name}, so this release is its first`
|
|
2181
|
+
};
|
|
2182
|
+
const stable = best.kind.type === "stable" ? void 0 : await nearest(cwd, tags.filter((tag) => tag.kind.type === "stable"), input);
|
|
2183
|
+
return {
|
|
2184
|
+
version: best.version,
|
|
2185
|
+
ref: best.commit,
|
|
2186
|
+
tag: best.name,
|
|
2187
|
+
reason: `${at}, nearest on HEAD${stable ? `; ${stable.version} before it` : ""}`,
|
|
2188
|
+
...stable && { stable: {
|
|
2189
|
+
version: stable.version,
|
|
2190
|
+
ref: stable.commit,
|
|
2191
|
+
tag: stable.name
|
|
2192
|
+
} }
|
|
2193
|
+
};
|
|
2194
|
+
},
|
|
2195
|
+
count: async (input) => {
|
|
2196
|
+
const channel = input.channel?.name;
|
|
2197
|
+
let highest = 0;
|
|
2198
|
+
for (const { kind } of await listTags(input, false)) if (kind.type !== "stable" && kind.type !== "foreign" && kind.channel === channel && input.scheme.compare(kind.target, input.target) === 0) highest = Math.max(highest, kind.n);
|
|
2199
|
+
return highest;
|
|
2200
|
+
},
|
|
2201
|
+
exists: async ({ env, releaseTag }) => await tryGit(env.cwd, "rev-parse", "--verify", "--quiet", `refs/tags/${releaseTag}`) !== void 0
|
|
2202
|
+
})
|
|
2203
|
+
});
|
|
2204
|
+
/** Release tags, of HEAD or of the whole repository, classified. */
|
|
2205
|
+
async function listTags(input, merged) {
|
|
2206
|
+
const pattern = templatePattern(input.tag, "version");
|
|
2207
|
+
const listing = await git(input.env.cwd, "for-each-ref", ...merged ? ["--merged=HEAD"] : [], `--format=%(refname:strip=2)${FIELD}%(objectname)${FIELD}%(*objectname)${FIELD}%(creatordate:unix)`, "refs/tags");
|
|
2208
|
+
const tags = [];
|
|
2209
|
+
for (const line of listing.split("\n").filter(Boolean)) {
|
|
2210
|
+
const [name = "", object = "", peeled = "", date = "0"] = line.split(FIELD);
|
|
2211
|
+
const version = pattern.exec(name)?.[1];
|
|
2212
|
+
if (version === void 0) continue;
|
|
2213
|
+
const kind = classify$1(version, input.scheme, input.channels);
|
|
2214
|
+
if (kind.type === "foreign" && parseVersion(version) === void 0) continue;
|
|
2215
|
+
tags.push({
|
|
2216
|
+
name,
|
|
2217
|
+
version,
|
|
2218
|
+
commit: peeled || object,
|
|
2219
|
+
date: Number(date),
|
|
2220
|
+
kind
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
return tags;
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* The nearest of the tags: of those no other one comes after, the highest
|
|
2227
|
+
* in the scheme, or failing that, the latest of another scheme.
|
|
2228
|
+
*/
|
|
2229
|
+
async function nearest(cwd, tags, input) {
|
|
2230
|
+
if (tags.length === 0) return void 0;
|
|
2231
|
+
const commits = [...new Set(tags.map((tag) => tag.commit))];
|
|
2232
|
+
const frontier = new Set(commits.length === 1 ? commits : (await git(cwd, "merge-base", "--independent", ...commits)).split("\n"));
|
|
2233
|
+
const near = tags.filter((tag) => frontier.has(tag.commit));
|
|
2234
|
+
const ours = near.filter((tag) => tag.kind.type !== "foreign");
|
|
2235
|
+
return ours.length > 0 ? ours.reduce((a, b) => compareClassified(b.kind, a.kind, input.scheme) > 0 ? b : a) : near.reduce((a, b) => b.date > a.date ? b : a);
|
|
2236
|
+
}
|
|
2237
|
+
//#endregion
|
|
2238
|
+
//#region src/utils/retry.ts
|
|
2239
|
+
/** Waits; replaceable in tests. */
|
|
2240
|
+
const clock = { sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) };
|
|
2241
|
+
/** Calls `run` until it resolves, waiting longer before each retry. */
|
|
2242
|
+
async function retry(run, options) {
|
|
2243
|
+
const delayMs = options.delayMs ?? ((retry) => retry * 1e3);
|
|
2244
|
+
for (let attempt = 1;; attempt++) try {
|
|
2245
|
+
return await run(attempt);
|
|
2246
|
+
} catch (error) {
|
|
2247
|
+
if (attempt >= options.attempts || !(options.retryIf?.(error) ?? true)) throw error;
|
|
2248
|
+
await clock.sleep(delayMs(attempt));
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Polls `check` until it resolves with a value, e.g. until something another
|
|
2253
|
+
* job created becomes visible; resolves with `undefined` if it never does.
|
|
2254
|
+
*/
|
|
2255
|
+
async function waitFor(check, options) {
|
|
2256
|
+
try {
|
|
2257
|
+
return await retry(async () => {
|
|
2258
|
+
const value = await check();
|
|
2259
|
+
if (value === void 0) throw new NotYet();
|
|
2260
|
+
return value;
|
|
2261
|
+
}, {
|
|
2262
|
+
...options,
|
|
2263
|
+
retryIf: (error) => error instanceof NotYet
|
|
2264
|
+
});
|
|
2265
|
+
} catch (error) {
|
|
2266
|
+
if (error instanceof NotYet) return void 0;
|
|
2267
|
+
throw error;
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
var NotYet = class extends Error {};
|
|
2271
|
+
//#endregion
|
|
2272
|
+
//#region src/steps/github/api.ts
|
|
2273
|
+
/** The token option, `GITHUB_TOKEN` or `GH_TOKEN`. */
|
|
2274
|
+
function githubToken(token) {
|
|
2275
|
+
const found = token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
2276
|
+
if (!found) throw new Error("no token; set GITHUB_TOKEN or GH_TOKEN");
|
|
2277
|
+
return found;
|
|
2278
|
+
}
|
|
2279
|
+
var GitHubError = class extends Error {
|
|
2280
|
+
status;
|
|
2281
|
+
constructor(status, message) {
|
|
2282
|
+
super(message);
|
|
2283
|
+
this.name = "GitHubError";
|
|
2284
|
+
this.status = status;
|
|
2285
|
+
}
|
|
2286
|
+
};
|
|
2287
|
+
/** Statuses worth another try: rate limits and server errors. */
|
|
2288
|
+
function isTransient(error) {
|
|
2289
|
+
if (error instanceof GitHubError) return error.status === 429 || error.status >= 500;
|
|
2290
|
+
return error instanceof TypeError;
|
|
2291
|
+
}
|
|
2292
|
+
/** A GitHub REST client for one repository that retries transient failures. */
|
|
2293
|
+
function githubClient(repo, token) {
|
|
2294
|
+
const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com";
|
|
2295
|
+
return {
|
|
2296
|
+
repo,
|
|
2297
|
+
request: (method, path, options = {}) => retry(async () => {
|
|
2298
|
+
const url = /^https?:/.test(path) ? path : `${apiUrl}/repos/${repo}${path}`;
|
|
2299
|
+
const response = await fetch(url, {
|
|
2300
|
+
method,
|
|
2301
|
+
headers: {
|
|
2302
|
+
accept: "application/vnd.github+json",
|
|
2303
|
+
authorization: `Bearer ${token}`,
|
|
2304
|
+
"x-github-api-version": "2022-11-28",
|
|
2305
|
+
...options.body !== void 0 && { "content-type": options.contentType ?? "application/json" }
|
|
2306
|
+
},
|
|
2307
|
+
...options.body !== void 0 && { body: options.contentType === void 0 ? JSON.stringify(options.body) : options.body }
|
|
2308
|
+
});
|
|
2309
|
+
const text = await response.text();
|
|
2310
|
+
if (!response.ok && !options.allow?.includes(response.status)) {
|
|
2311
|
+
const wait = Number(response.headers.get("retry-after"));
|
|
2312
|
+
if (response.status === 429 && wait > 0) await clock.sleep(Math.min(wait, 60) * 1e3);
|
|
2313
|
+
throw new GitHubError(response.status, `GitHub responded ${response.status} to ${method} ${url}: ${text}`);
|
|
2314
|
+
}
|
|
2315
|
+
return {
|
|
2316
|
+
status: response.status,
|
|
2317
|
+
data: text ? JSON.parse(text) : null
|
|
2318
|
+
};
|
|
2319
|
+
}, {
|
|
2320
|
+
attempts: options.attempts ?? 5,
|
|
2321
|
+
retryIf: isTransient
|
|
2322
|
+
})
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
//#endregion
|
|
2326
|
+
//#region src/steps/github/release.ts
|
|
2327
|
+
/** The `repo` option, or the repository being released. */
|
|
2328
|
+
async function releaseRepo(cwd, repo) {
|
|
2329
|
+
const found = repo ?? await detectRepo(cwd);
|
|
2330
|
+
if (!found) throw new Error("couldn't determine the GitHub repository; set `repo` or GITHUB_REPOSITORY");
|
|
2331
|
+
return found;
|
|
2332
|
+
}
|
|
2333
|
+
/** Options of the steps that create a release, or find one. */
|
|
2334
|
+
const releaseOptions = {
|
|
2335
|
+
title: z.string().default("{tag}").describe("`{tag}`, `{version}`, `{name}` and the scheme's placeholders are filled in."),
|
|
2336
|
+
draft: z.boolean().default(false),
|
|
2337
|
+
prerelease: z.union([z.boolean(), z.literal("auto")]).default("auto").describe("`auto` marks releases on a prerelease channel."),
|
|
2338
|
+
repo: z.string().optional().describe("`owner/repo`; defaults to `GITHUB_REPOSITORY` or the `origin` remote."),
|
|
2339
|
+
token: z.string().optional().describe("Defaults to `GITHUB_TOKEN` or `GH_TOKEN`.")
|
|
2340
|
+
};
|
|
2341
|
+
/** The release of a tag, drafts included, or `undefined`. */
|
|
2342
|
+
async function findRelease(client, tag) {
|
|
2343
|
+
const { status, data } = await client.request("GET", `/releases/tags/${encodeURIComponent(tag)}`, { allow: [404] });
|
|
2344
|
+
if (status !== 404) return data;
|
|
2345
|
+
const { data: releases } = await client.request("GET", "/releases?per_page=100");
|
|
2346
|
+
return releases.find((release) => release.tag_name === tag);
|
|
2347
|
+
}
|
|
2348
|
+
/**
|
|
2349
|
+
* Finds the release of a tag, or creates it. Other jobs may be doing the same
|
|
2350
|
+
* at once: when one of them wins, this waits until its release is visible and
|
|
2351
|
+
* uses that.
|
|
2352
|
+
*/
|
|
2353
|
+
async function ensureRelease(client, release) {
|
|
2354
|
+
const existing = await findRelease(client, release.tag);
|
|
2355
|
+
if (existing) return {
|
|
2356
|
+
release: existing,
|
|
2357
|
+
created: false
|
|
2358
|
+
};
|
|
2359
|
+
try {
|
|
2360
|
+
const { data } = await client.request("POST", "/releases", { body: {
|
|
2361
|
+
tag_name: release.tag,
|
|
2362
|
+
target_commitish: release.target,
|
|
2363
|
+
name: release.title,
|
|
2364
|
+
body: release.notes,
|
|
2365
|
+
draft: release.draft,
|
|
2366
|
+
prerelease: release.prerelease
|
|
2367
|
+
} });
|
|
2368
|
+
return {
|
|
2369
|
+
release: data,
|
|
2370
|
+
created: true
|
|
2371
|
+
};
|
|
2372
|
+
} catch (error) {
|
|
2373
|
+
if (!(error instanceof GitHubError && error.status === 422)) throw error;
|
|
2374
|
+
const created = await waitFor(() => findRelease(client, release.tag), { attempts: 5 });
|
|
2375
|
+
if (!created) throw error;
|
|
2376
|
+
return {
|
|
2377
|
+
release: created,
|
|
2378
|
+
created: false
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
/** The name GitHub stores an asset under, e.g. `My App.zip` → `My.App.zip`. */
|
|
2383
|
+
function assetName(file) {
|
|
2384
|
+
return file.replace(/[^\w.+@~-]/g, ".");
|
|
2385
|
+
}
|
|
2386
|
+
async function listAssets(client, release) {
|
|
2387
|
+
const assets = [];
|
|
2388
|
+
for (let page = 1;; page++) {
|
|
2389
|
+
const { data } = await client.request("GET", `/releases/${release.id}/assets?per_page=100&page=${page}`);
|
|
2390
|
+
assets.push(...data);
|
|
2391
|
+
if (data.length < 100) return assets;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Uploads a file as an asset of the release, retrying failed uploads. An
|
|
2396
|
+
* asset of the same name is replaced if `clobber` is set, or if it's what's
|
|
2397
|
+
* left of an unfinished upload.
|
|
2398
|
+
*/
|
|
2399
|
+
async function uploadAsset(client, release, asset, clobber) {
|
|
2400
|
+
const name = assetName(asset.name);
|
|
2401
|
+
return retry(async () => {
|
|
2402
|
+
const existing = (await listAssets(client, release)).find((other) => other.name === name);
|
|
2403
|
+
if (existing) {
|
|
2404
|
+
if (existing.state === "uploaded" && !clobber) throw new Error(`release ${release.tag_name} already has ${name}; set clobber to replace it`);
|
|
2405
|
+
await client.request("DELETE", `/releases/assets/${existing.id}`, { allow: [404] });
|
|
2406
|
+
}
|
|
2407
|
+
const url = new URL(release.upload_url.replace(/\{.*\}$/, ""));
|
|
2408
|
+
url.searchParams.set("name", asset.name);
|
|
2409
|
+
const { data } = await client.request("POST", url.href, {
|
|
2410
|
+
body: asset.content,
|
|
2411
|
+
contentType: "application/octet-stream",
|
|
2412
|
+
attempts: 1
|
|
2413
|
+
});
|
|
2414
|
+
return data;
|
|
2415
|
+
}, {
|
|
2416
|
+
attempts: 5,
|
|
2417
|
+
retryIf: isTransient
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
/** Whether a release is a prerelease, by the `prerelease` option. */
|
|
2421
|
+
function isPrerelease(option, env) {
|
|
2422
|
+
return option === "auto" ? env.channel?.type === "prerelease" : option;
|
|
2423
|
+
}
|
|
2424
|
+
//#endregion
|
|
2425
|
+
//#region src/steps/github/release-pr.ts
|
|
2426
|
+
/** The branch prefix of release pull requests, and their label until released. */
|
|
2427
|
+
const RELEASE_BRANCH = "rylease/";
|
|
2428
|
+
const PENDING_LABEL = "rylease: pending";
|
|
2429
|
+
const MARKER = /<!-- rylease (\{[\s\S]*?\}) -->\s*$/;
|
|
2430
|
+
/** A pull request's body: each release's notes, then the manifest, hidden. */
|
|
2431
|
+
function releaseBody(manifest) {
|
|
2432
|
+
return `${Object.values(manifest.components).map(({ tag, notes }) => `## ${tag}\n\n${notes.trim() || "_No notes._"}`).join("\n\n")}\n\n<!-- rylease ${JSON.stringify(manifest)} -->\n`;
|
|
2433
|
+
}
|
|
2434
|
+
/** The manifest in a pull request's body, if it has a valid one. */
|
|
2435
|
+
function readManifest(body) {
|
|
2436
|
+
const json = MARKER.exec(body ?? "")?.[1];
|
|
2437
|
+
if (json === void 0) return void 0;
|
|
2438
|
+
try {
|
|
2439
|
+
const manifest = JSON.parse(json);
|
|
2440
|
+
return typeof manifest.components === "object" ? manifest : void 0;
|
|
2441
|
+
} catch {
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
//#endregion
|
|
2446
|
+
//#region src/histories/github/release-pr.ts
|
|
2447
|
+
/**
|
|
2448
|
+
* Finds a release that a merged release pull request proposed: when HEAD is
|
|
2449
|
+
* its merge, the release steps run for the versions it proposed, with its
|
|
2450
|
+
* notes. Components it doesn't name, or whose tag exists already, are found
|
|
2451
|
+
* by their tags, as with `git/tags`.
|
|
2452
|
+
*/
|
|
2453
|
+
const githubReleasePr = defineHistory({
|
|
2454
|
+
name: "github/release-pr",
|
|
2455
|
+
description: "A release proposed by a release pull request from `github/open-release-pr`, once it's merged; otherwise the nearest release tag on HEAD, as `git/tags`.",
|
|
2456
|
+
options: {
|
|
2457
|
+
label: z.string().default(PENDING_LABEL).describe("The label of release pull requests not yet released."),
|
|
2458
|
+
repo: z.string().optional().describe("`owner/repo`; defaults to `GITHUB_REPOSITORY` or the `origin` remote."),
|
|
2459
|
+
token: z.string().optional().describe("Defaults to `GITHUB_TOKEN` or `GH_TOKEN`.")
|
|
2460
|
+
},
|
|
2461
|
+
create: (options) => {
|
|
2462
|
+
const tags = gitTags();
|
|
2463
|
+
let merged;
|
|
2464
|
+
const find = async (cwd) => {
|
|
2465
|
+
const head = await tryGit(cwd, "rev-parse", "HEAD");
|
|
2466
|
+
if (head === void 0) return void 0;
|
|
2467
|
+
const { data } = await githubClient(await releaseRepo(cwd, options.repo), githubToken(options.token)).request("GET", `/commits/${head}/pulls`);
|
|
2468
|
+
for (const pull of data) {
|
|
2469
|
+
const manifest = readManifest(pull.body);
|
|
2470
|
+
if (pull.merged_at && pull.head.ref.startsWith("rylease/") && pull.labels.some((label) => label.name === options.label) && manifest) return {
|
|
2471
|
+
number: pull.number,
|
|
2472
|
+
manifest
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
return {
|
|
2477
|
+
name: "github/release-pr",
|
|
2478
|
+
find: async (input) => {
|
|
2479
|
+
merged ??= find(input.env.cwd);
|
|
2480
|
+
const pull = await merged;
|
|
2481
|
+
const entry = pull?.manifest.components[input.component.name];
|
|
2482
|
+
const released = entry === void 0 || await tryGit(input.env.cwd, "rev-parse", "--verify", "--quiet", `refs/tags/${entry.tag}`) !== void 0;
|
|
2483
|
+
if (!pull || !entry || released) return tags.find(input);
|
|
2484
|
+
const head = await git(input.env.cwd, "rev-parse", "HEAD");
|
|
2485
|
+
return {
|
|
2486
|
+
...entry.previousVersion !== void 0 && { version: entry.previousVersion },
|
|
2487
|
+
reason: `#${pull.number} merged at ${head.slice(0, 7)}`,
|
|
2488
|
+
pending: {
|
|
2489
|
+
version: entry.version,
|
|
2490
|
+
notes: entry.notes,
|
|
2491
|
+
kind: entry.kind === "none" ? "fix" : entry.kind,
|
|
2492
|
+
...entry.previousVersion !== void 0 && { previousVersion: entry.previousVersion },
|
|
2493
|
+
reason: `proposed in #${pull.number}`
|
|
2494
|
+
}
|
|
2495
|
+
};
|
|
2496
|
+
},
|
|
2497
|
+
count: (input) => tags.count?.(input) ?? Promise.resolve(0),
|
|
2498
|
+
exists: (input) => tags.exists?.(input) ?? Promise.resolve(false)
|
|
2499
|
+
};
|
|
2500
|
+
}
|
|
2501
|
+
});
|
|
2502
|
+
//#endregion
|
|
2503
|
+
//#region src/histories/index.ts
|
|
2504
|
+
var histories_exports = /* @__PURE__ */ __exportAll({
|
|
2505
|
+
fsVersionFile: () => fsVersionFile,
|
|
2506
|
+
gitTags: () => gitTags,
|
|
2507
|
+
githubReleasePr: () => githubReleasePr
|
|
2508
|
+
});
|
|
2509
|
+
//#endregion
|
|
2510
|
+
//#region src/steps/channels.ts
|
|
2511
|
+
/** The `channels` option of steps that a channel's releases skip by default. */
|
|
2512
|
+
const channelsOption = z.boolean().default(false).describe("Also run for releases on a channel, e.g. prereleases; by default only stable releases do.");
|
|
2513
|
+
/**
|
|
2514
|
+
* For a step that leaves channel releases to the stable release, e.g. so a
|
|
2515
|
+
* prerelease doesn't take the changelog's entries: its output when it's
|
|
2516
|
+
* skipped, or `undefined` when it runs.
|
|
2517
|
+
*/
|
|
2518
|
+
function skipOnChannel(env, channels, what) {
|
|
2519
|
+
if (!env.channel || channels) return void 0;
|
|
2520
|
+
return {
|
|
2521
|
+
output: null,
|
|
2522
|
+
reason: `left ${what} for the stable release, as this is on ${env.channel.name}`
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
//#endregion
|
|
2526
|
+
//#region src/steps/change-files/parse.ts
|
|
2527
|
+
/**
|
|
2528
|
+
* Parses a change file:
|
|
2529
|
+
*
|
|
2530
|
+
* ```md
|
|
2531
|
+
* ---
|
|
2532
|
+
* "@x/core": feature
|
|
2533
|
+
* cli: fix
|
|
2534
|
+
* ---
|
|
2535
|
+
* Add dark mode.
|
|
2536
|
+
*
|
|
2537
|
+
* More about it.
|
|
2538
|
+
* ```
|
|
2539
|
+
*/
|
|
2540
|
+
function parseChangeFile(text, path) {
|
|
2541
|
+
const lines = text.replace(/^/, "").split(/\r?\n/);
|
|
2542
|
+
if (lines[0]?.trim() !== "---") throw new Error(`${path} must start with '---', then kinds by component`);
|
|
2543
|
+
const end = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
|
|
2544
|
+
if (end === -1) throw new Error(`${path} has no closing '---'`);
|
|
2545
|
+
const kinds = {};
|
|
2546
|
+
for (const [i, line] of lines.slice(1, end).entries()) {
|
|
2547
|
+
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
2548
|
+
const match = /^\s*(?:"([^"]+)"|'([^']+)'|([^:\s]+))\s*:\s*(\S+)\s*$/.exec(line);
|
|
2549
|
+
const name = match?.[1] ?? match?.[2] ?? match?.[3];
|
|
2550
|
+
const kind = match?.[4];
|
|
2551
|
+
if (name === void 0 || kind === void 0) throw new Error(`${path} line ${i + 2}: expected 'component: kind'`);
|
|
2552
|
+
if (!KINDS.includes(kind)) throw new Error(`${path} line ${i + 2}: '${kind}' isn't a kind; use ${KINDS.join(", ")}`);
|
|
2553
|
+
kinds[name] = kind;
|
|
2554
|
+
}
|
|
2555
|
+
const [title = "", ...rest] = lines.slice(end + 1).join("\n").trim().split("\n");
|
|
2556
|
+
if (title.trim() === "") throw new Error(`${path} says nothing after its kinds`);
|
|
2557
|
+
const description = rest.join("\n").trim();
|
|
2558
|
+
return {
|
|
2559
|
+
kinds,
|
|
2560
|
+
title: title.trim(),
|
|
2561
|
+
...description && { description }
|
|
2562
|
+
};
|
|
2563
|
+
}
|
|
2564
|
+
//#endregion
|
|
2565
|
+
//#region src/steps/change-files/files.ts
|
|
2566
|
+
const directoryOption = z.string().default(".changes").describe("Where change files are kept, relative to the project.");
|
|
2567
|
+
/** Every change file in the directory, parsed, by its path in the project. */
|
|
2568
|
+
async function readChangeFiles(cwd, directory) {
|
|
2569
|
+
const names = await readdir(join(cwd, directory)).catch((error) => {
|
|
2570
|
+
if (error.code === "ENOENT") return [];
|
|
2571
|
+
throw error;
|
|
2572
|
+
});
|
|
2573
|
+
const files = [];
|
|
2574
|
+
for (const name of names.filter((name) => name.endsWith(".md")).sort()) {
|
|
2575
|
+
if (name.toLowerCase() === "readme.md") continue;
|
|
2576
|
+
const path = posix.join(directory.replaceAll("\\", "/"), name);
|
|
2577
|
+
files.push({
|
|
2578
|
+
path,
|
|
2579
|
+
file: parseChangeFile(await readFile(join(cwd, path), "utf8"), path)
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
return files;
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* The kind a change file gives a component: its own, or `kind` when the
|
|
2586
|
+
* component is the only one.
|
|
2587
|
+
*/
|
|
2588
|
+
function kindFor(file, component, only) {
|
|
2589
|
+
return file.kinds[component] ?? (only ? file.kinds.kind : void 0);
|
|
2590
|
+
}
|
|
2591
|
+
//#endregion
|
|
2592
|
+
//#region src/steps/change-files/clean.ts
|
|
2593
|
+
/**
|
|
2594
|
+
* Deletes the change files a release consumed, those naming a component
|
|
2595
|
+
* that's released, and reports them for `git/commit` to commit their
|
|
2596
|
+
* deletion. Releases on a channel leave them for the stable release.
|
|
2597
|
+
*/
|
|
2598
|
+
const changeFilesClean = defineStep({
|
|
2599
|
+
phase: "write",
|
|
2600
|
+
name: "change-files/clean",
|
|
2601
|
+
scope: "all",
|
|
2602
|
+
description: "Deletes the change files of the components being released, for `git/commit` to commit; releases on a channel leave them for the stable release.",
|
|
2603
|
+
options: {
|
|
2604
|
+
directory: directoryOption,
|
|
2605
|
+
channels: channelsOption
|
|
2606
|
+
},
|
|
2607
|
+
run: async ({ env, components, options }) => {
|
|
2608
|
+
const skipped = skipOnChannel(env, options.channels, "the change files");
|
|
2609
|
+
if (skipped) return skipped;
|
|
2610
|
+
const only = components.length === 1 && components[0]?.component.path === ".";
|
|
2611
|
+
const released = components.map(({ component }) => component.name);
|
|
2612
|
+
const files = (await readChangeFiles(env.cwd, options.directory)).filter(({ file }) => released.some((name) => kindFor(file, name, only) !== void 0)).map(({ path }) => path);
|
|
2613
|
+
if (files.length === 0) return {
|
|
2614
|
+
output: { deleted: [] },
|
|
2615
|
+
reason: "no change files to delete"
|
|
2616
|
+
};
|
|
2617
|
+
if (!env.dryRun) for (const path of files) await rm(join(env.cwd, path));
|
|
2618
|
+
return {
|
|
2619
|
+
output: { deleted: env.dryRun ? [] : files },
|
|
2620
|
+
files,
|
|
2621
|
+
reason: `${env.dryRun ? "would delete" : "deleted"} ${files.length > 3 ? plural(files.length, "change file") : joinList(files)}`
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
});
|
|
2625
|
+
//#endregion
|
|
2626
|
+
//#region src/steps/change-files/read.ts
|
|
2627
|
+
/**
|
|
2628
|
+
* Collects the change files in `.changes/`: each says what changed, and
|
|
2629
|
+
* how much for each component it names. A change's label is its kind, for
|
|
2630
|
+
* `labels/determine-kind` and `labels/determine-notes`.
|
|
2631
|
+
*/
|
|
2632
|
+
const changeFilesRead = defineStep({
|
|
2633
|
+
phase: "read-changes",
|
|
2634
|
+
name: "change-files/read",
|
|
2635
|
+
scope: "all",
|
|
2636
|
+
provides: ["changes.title", "changes.labels"],
|
|
2637
|
+
description: "Collects the change files in `.changes/`, each naming the components it changes and how much, e.g. `core: feature`; each change is labeled with its kind.",
|
|
2638
|
+
options: { directory: directoryOption },
|
|
2639
|
+
run: async ({ env, components, options }) => {
|
|
2640
|
+
const files = await readChangeFiles(env.cwd, options.directory);
|
|
2641
|
+
const names = new Set(components.map(({ component }) => component.name));
|
|
2642
|
+
const only = components.length === 1 && components[0]?.component.path === ".";
|
|
2643
|
+
for (const { path, file } of files) for (const name of Object.keys(file.kinds)) {
|
|
2644
|
+
if (names.has(name) || only && name === "kind") continue;
|
|
2645
|
+
throw new Error(`${path} names '${name}', which isn't a component; use ${only ? "kind" : [...names].join(", ")}`);
|
|
2646
|
+
}
|
|
2647
|
+
const results = {};
|
|
2648
|
+
for (const { component } of components) {
|
|
2649
|
+
const changes = files.flatMap(({ path, file }) => {
|
|
2650
|
+
const kind = kindFor(file, component.name, only);
|
|
2651
|
+
return kind === void 0 ? [] : [{
|
|
2652
|
+
title: file.title,
|
|
2653
|
+
...file.description && { description: file.description },
|
|
2654
|
+
labels: [kind],
|
|
2655
|
+
reason: path
|
|
2656
|
+
}];
|
|
2657
|
+
});
|
|
2658
|
+
results[component.name] = {
|
|
2659
|
+
changes,
|
|
2660
|
+
reason: `${plural(changes.length, "change file")} in ${options.directory}`
|
|
2661
|
+
};
|
|
2662
|
+
}
|
|
2663
|
+
return {
|
|
2664
|
+
results,
|
|
2665
|
+
reason: `${plural(files.length, "change file")} in ${options.directory}`
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
});
|
|
2669
|
+
//#endregion
|
|
2670
|
+
//#region src/steps/components/dependents.ts
|
|
2671
|
+
const RANK$3 = {
|
|
2672
|
+
none: 0,
|
|
2673
|
+
fix: 1,
|
|
2674
|
+
feature: 2,
|
|
2675
|
+
breaking: 3
|
|
2676
|
+
};
|
|
2677
|
+
/**
|
|
2678
|
+
* Releases the components that depend on a released component, so their
|
|
2679
|
+
* releases pick up the new version, e.g. with `npm/write-version` updating
|
|
2680
|
+
* their dependency on it. Dependents of dependents follow.
|
|
2681
|
+
*/
|
|
2682
|
+
const componentsDependents = defineStep({
|
|
2683
|
+
phase: "determine-kind",
|
|
2684
|
+
name: "components/dependents",
|
|
2685
|
+
scope: "all",
|
|
2686
|
+
description: "Releases each component that depends on a released one, as at least a `fix`; list it after the steps that determine kinds.",
|
|
2687
|
+
options: {
|
|
2688
|
+
kind: z.enum([
|
|
2689
|
+
"breaking",
|
|
2690
|
+
"feature",
|
|
2691
|
+
"fix"
|
|
2692
|
+
]).default("fix").describe("The least kind of a dependent's release."),
|
|
2693
|
+
on: z.array(z.enum([
|
|
2694
|
+
"breaking",
|
|
2695
|
+
"feature",
|
|
2696
|
+
"fix"
|
|
2697
|
+
])).default([
|
|
2698
|
+
"breaking",
|
|
2699
|
+
"feature",
|
|
2700
|
+
"fix"
|
|
2701
|
+
]).describe("The kinds of a dependency's release that release its dependents.")
|
|
2702
|
+
},
|
|
2703
|
+
run: ({ components, options }) => {
|
|
2704
|
+
const kinds = new Map(components.map(({ component, kind }) => [component.name, kind ?? "none"]));
|
|
2705
|
+
const results = {};
|
|
2706
|
+
for (const { component } of components) {
|
|
2707
|
+
const released = component.dependsOn.filter((name) => {
|
|
2708
|
+
const kind = kinds.get(name) ?? "none";
|
|
2709
|
+
return kind !== "none" && options.on.includes(kind);
|
|
2710
|
+
});
|
|
2711
|
+
const current = kinds.get(component.name) ?? "none";
|
|
2712
|
+
if (released.length === 0 || RANK$3[current] >= RANK$3[options.kind]) continue;
|
|
2713
|
+
kinds.set(component.name, options.kind);
|
|
2714
|
+
results[component.name] = {
|
|
2715
|
+
kind: options.kind,
|
|
2716
|
+
reason: `depends on ${joinList(released)}, released too`
|
|
2717
|
+
};
|
|
2718
|
+
}
|
|
2719
|
+
const raised = Object.keys(results);
|
|
2720
|
+
return {
|
|
2721
|
+
results,
|
|
2722
|
+
reason: raised.length > 0 ? `${plural(raised.length, "dependent")} released: ${joinList(raised)}` : "no dependents of released components"
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
2725
|
+
});
|
|
2726
|
+
//#endregion
|
|
2727
|
+
//#region src/steps/conventional/classify.ts
|
|
2728
|
+
/** A conventional commit header, e.g. `feat(api)!: drop node 18`. */
|
|
2729
|
+
const HEADER = /^([a-z][\w-]*)(?:\(([^()]*)\))?(!)?: (.+)$/;
|
|
2730
|
+
const BREAKING = /\bBREAKING(?:[ -]CHANGES?)?\b/;
|
|
2731
|
+
/**
|
|
2732
|
+
* Splits a commit message into conventional sections. Every line that looks
|
|
2733
|
+
* like a conventional header starts a new section, so squashed messages with
|
|
2734
|
+
* several entries yield several sections. Types must start lowercase, so
|
|
2735
|
+
* footers like `Refs: #12` aren't mistaken for headers. Text before the first
|
|
2736
|
+
* header (e.g. a merge commit's subject) is ignored.
|
|
2737
|
+
*/
|
|
2738
|
+
function parseSections(message) {
|
|
2739
|
+
const sections = [];
|
|
2740
|
+
for (const line of message.split(/\r?\n/)) if (HEADER.test(line.trim())) sections.push({
|
|
2741
|
+
title: line.trim(),
|
|
2742
|
+
lines: []
|
|
2743
|
+
});
|
|
2744
|
+
else sections.at(-1)?.lines.push(line);
|
|
2745
|
+
return sections.map(({ title, lines }) => {
|
|
2746
|
+
const description = lines.join("\n").trim();
|
|
2747
|
+
return description ? {
|
|
2748
|
+
title,
|
|
2749
|
+
description
|
|
2750
|
+
} : { title };
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
function parseTitle(title) {
|
|
2754
|
+
const match = HEADER.exec(title.trim());
|
|
2755
|
+
if (!match) return void 0;
|
|
2756
|
+
const [, type = "", scope, bang, subject = ""] = match;
|
|
2757
|
+
return {
|
|
2758
|
+
type,
|
|
2759
|
+
...scope ? { scope } : {},
|
|
2760
|
+
breaking: bang === "!",
|
|
2761
|
+
subject
|
|
2762
|
+
};
|
|
2763
|
+
}
|
|
2764
|
+
/** Whether a text mentions a breaking change (`BREAKING CHANGE:`, `BREAKING-CHANGE`, ...). */
|
|
2765
|
+
function mentionsBreaking(text) {
|
|
2766
|
+
return text !== void 0 && BREAKING.test(text);
|
|
2767
|
+
}
|
|
2768
|
+
/** `!` or `BREAKING` → breaking; otherwise by the rules (by default `feat` → feature, the rest → fix). */
|
|
2769
|
+
function classify(change, rules = {
|
|
2770
|
+
types: { feat: "feature" },
|
|
2771
|
+
fallback: "fix"
|
|
2772
|
+
}) {
|
|
2773
|
+
const source = `(${change.reason})`;
|
|
2774
|
+
const title = change.title === void 0 ? void 0 : parseTitle(change.title);
|
|
2775
|
+
const prefix = title ? `${title.type}${title.scope ? `(${title.scope})` : ""}${title.breaking ? "!" : ""}` : void 0;
|
|
2776
|
+
if (title?.breaking) return {
|
|
2777
|
+
kind: "breaking",
|
|
2778
|
+
reason: `'${prefix}' in title ${source}`
|
|
2779
|
+
};
|
|
2780
|
+
if (mentionsBreaking(change.title)) return {
|
|
2781
|
+
kind: "breaking",
|
|
2782
|
+
reason: `'BREAKING' in title ${source}`
|
|
2783
|
+
};
|
|
2784
|
+
if (mentionsBreaking(change.description)) return {
|
|
2785
|
+
kind: "breaking",
|
|
2786
|
+
reason: `'BREAKING' in description ${source}`
|
|
2787
|
+
};
|
|
2788
|
+
if (!title) return {
|
|
2789
|
+
kind: rules.fallback,
|
|
2790
|
+
reason: `untyped change ${source}`
|
|
2791
|
+
};
|
|
2792
|
+
return {
|
|
2793
|
+
kind: typeKey(title, rules.types) ?? rules.fallback,
|
|
2794
|
+
reason: `'${prefix}' in title ${source}`
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
/** Looks up `type(scope)`, then `type`, in a map keyed by either. */
|
|
2798
|
+
function typeKey(title, map) {
|
|
2799
|
+
return (title.scope ? map[`${title.type}(${title.scope})`] : void 0) ?? map[title.type];
|
|
2800
|
+
}
|
|
2801
|
+
//#endregion
|
|
2802
|
+
//#region src/steps/conventional/determine-kind.ts
|
|
2803
|
+
const RANK$2 = {
|
|
2804
|
+
none: 0,
|
|
2805
|
+
fix: 1,
|
|
2806
|
+
feature: 2,
|
|
2807
|
+
breaking: 3
|
|
2808
|
+
};
|
|
2809
|
+
/**
|
|
2810
|
+
* `!` or `BREAKING` → breaking; otherwise each change's kind comes from its
|
|
2811
|
+
* conventional type, and the highest kind wins.
|
|
2812
|
+
*/
|
|
2813
|
+
const conventionalDetermineKind = defineStep({
|
|
2814
|
+
phase: "determine-kind",
|
|
2815
|
+
name: "conventional/determine-kind",
|
|
2816
|
+
description: "`!` or `BREAKING` → breaking; otherwise by conventional type (`feat` → feature, anything else → fix). The highest kind wins.",
|
|
2817
|
+
options: {
|
|
2818
|
+
types: z.record(z.string(), z.enum(KINDS)).default({}).describe("Kinds by `type` or `type(scope)`, on top of `feat: feature`, e.g. `{ docs: none, chore(deps): fix }`."),
|
|
2819
|
+
fallback: z.enum(KINDS).default("fix").describe("The kind of any other change, untyped ones included.")
|
|
2820
|
+
},
|
|
2821
|
+
run: ({ changes, options }) => {
|
|
2822
|
+
const rules = {
|
|
2823
|
+
types: {
|
|
2824
|
+
feat: "feature",
|
|
2825
|
+
...options.types
|
|
2826
|
+
},
|
|
2827
|
+
fallback: options.fallback
|
|
2828
|
+
};
|
|
2829
|
+
let best;
|
|
2830
|
+
for (const change of changes) {
|
|
2831
|
+
const candidate = classify(change, rules);
|
|
2832
|
+
if (!best || RANK$2[candidate.kind] > RANK$2[best.kind]) best = candidate;
|
|
2833
|
+
if (best.kind === "breaking") break;
|
|
2834
|
+
}
|
|
2835
|
+
return best ?? {
|
|
2836
|
+
kind: "none",
|
|
2837
|
+
reason: "no changes"
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
});
|
|
2841
|
+
//#endregion
|
|
2842
|
+
//#region src/steps/notes.ts
|
|
2843
|
+
/** The `dependencies` option of the steps that build notes. */
|
|
2844
|
+
const dependenciesOption = z.union([z.string(), z.literal(false)]).default("Dependencies").describe("The last section, for the components it depends on that were released along with it; `false` leaves it out.");
|
|
2845
|
+
/** A section listing released dependencies, e.g. `- core 1.2.0 → 2.0.0`. */
|
|
2846
|
+
function dependencySection(title, dependencies) {
|
|
2847
|
+
if (title === false || dependencies.length === 0) return [];
|
|
2848
|
+
return [{
|
|
2849
|
+
title,
|
|
2850
|
+
entries: dependencies.map(({ name, version, previousVersion }) => previousVersion === void 0 ? `- ${name} ${version}` : `- ${name} ${previousVersion} → ${version}`)
|
|
2851
|
+
}];
|
|
2852
|
+
}
|
|
2853
|
+
/** Notes from the sections that have entries, and their reason. */
|
|
2854
|
+
function joinSections(kind, sections) {
|
|
2855
|
+
const filled = sections.filter(({ entries }) => entries.length > 0);
|
|
2856
|
+
return {
|
|
2857
|
+
notes: filled.map(({ title, entries }) => `### ${title}\n\n${entries.join("\n")}`).join("\n\n"),
|
|
2858
|
+
reason: `${kind} release; ${filled.map(({ title, entries }) => `${title.toLowerCase()}: ${entries.length}`).join(", ") || "no entries"}`
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
//#endregion
|
|
2862
|
+
//#region src/steps/conventional/determine-notes.ts
|
|
2863
|
+
/**
|
|
2864
|
+
* A `### Title` section for breaking changes, then one per group of
|
|
2865
|
+
* conventional types, then the rest.
|
|
2866
|
+
*/
|
|
2867
|
+
const conventionalBuildNotes = defineStep({
|
|
2868
|
+
phase: "determine-notes",
|
|
2869
|
+
name: "conventional/determine-notes",
|
|
2870
|
+
description: "`### Breaking Changes`, `### Features`, `### Bug Fixes`, `### Performance Improvements` and `### Reverts` sections, then `### Other` for everything else.",
|
|
2871
|
+
options: {
|
|
2872
|
+
sections: z.array(z.strictObject({
|
|
2873
|
+
title: z.string(),
|
|
2874
|
+
types: z.array(z.string()).describe("`type` or `type(scope)`")
|
|
2875
|
+
})).default([
|
|
2876
|
+
{
|
|
2877
|
+
title: "Features",
|
|
2878
|
+
types: ["feat"]
|
|
2879
|
+
},
|
|
2880
|
+
{
|
|
2881
|
+
title: "Bug Fixes",
|
|
2882
|
+
types: ["fix"]
|
|
2883
|
+
},
|
|
2884
|
+
{
|
|
2885
|
+
title: "Performance Improvements",
|
|
2886
|
+
types: ["perf"]
|
|
2887
|
+
},
|
|
2888
|
+
{
|
|
2889
|
+
title: "Reverts",
|
|
2890
|
+
types: ["revert"]
|
|
2891
|
+
}
|
|
2892
|
+
]).describe("Sections in order; a change goes in the first that lists its type."),
|
|
2893
|
+
breaking: z.union([z.string(), z.literal(false)]).default("Breaking Changes").describe("The first section, for breaking changes of any type; `false` marks them **(breaking)** in their own section instead."),
|
|
2894
|
+
other: z.union([z.string(), z.literal(false)]).default("Other").describe("The section for remaining changes; `false` leaves them out."),
|
|
2895
|
+
links: z.boolean().default(true).describe("Link each change to its source, e.g. its commit."),
|
|
2896
|
+
dependencies: dependenciesOption
|
|
2897
|
+
},
|
|
2898
|
+
run: ({ changes, kind, dependencies, options }) => {
|
|
2899
|
+
const section = (title, types = []) => ({
|
|
2900
|
+
title,
|
|
2901
|
+
types,
|
|
2902
|
+
entries: []
|
|
2903
|
+
});
|
|
2904
|
+
const breaking = options.breaking === false ? void 0 : section(options.breaking);
|
|
2905
|
+
const other = options.other === false ? void 0 : section(options.other);
|
|
2906
|
+
const typed = options.sections.map(({ title, types }) => section(title, types));
|
|
2907
|
+
for (const change of changes) {
|
|
2908
|
+
const title = change.title ? parseTitle(change.title) : void 0;
|
|
2909
|
+
const isBreaking = title?.breaking || mentionsBreaking(change.title) || mentionsBreaking(change.description);
|
|
2910
|
+
const target = (isBreaking ? breaking : void 0) ?? (title && typed.find((s) => matches(title, s.types))) ?? other;
|
|
2911
|
+
if (!target) continue;
|
|
2912
|
+
const entry = format$1(change, title, {
|
|
2913
|
+
marker: isBreaking && target !== breaking,
|
|
2914
|
+
link: options.links
|
|
2915
|
+
});
|
|
2916
|
+
if (entry !== void 0) target.entries.push(entry);
|
|
2917
|
+
}
|
|
2918
|
+
return joinSections(kind, [...[
|
|
2919
|
+
breaking,
|
|
2920
|
+
...typed,
|
|
2921
|
+
other
|
|
2922
|
+
].filter((s) => s !== void 0), ...dependencySection(options.dependencies, dependencies)]);
|
|
2923
|
+
}
|
|
2924
|
+
});
|
|
2925
|
+
function matches(title, types) {
|
|
2926
|
+
return types.includes(title.type) || title.scope !== void 0 && types.includes(`${title.type}(${title.scope})`);
|
|
2927
|
+
}
|
|
2928
|
+
function format$1(change, parsed, style) {
|
|
2929
|
+
const text = parsed ? `${parsed.scope ? `**${parsed.scope}:** ` : ""}${parsed.subject}` : (change.title ?? change.description?.split("\n")[0])?.trim();
|
|
2930
|
+
if (!text) return void 0;
|
|
2931
|
+
return `- ${text}${style.marker ? " **(breaking)**" : ""}${style.link && change.link ? ` ([${change.link.label}](${change.link.url}))` : ""}`;
|
|
2932
|
+
}
|
|
2933
|
+
//#endregion
|
|
2934
|
+
//#region src/steps/conventional/read-commits.ts
|
|
2935
|
+
const COMMIT_SEPARATOR = "";
|
|
2936
|
+
const FIELD_SEPARATOR = "";
|
|
2937
|
+
/**
|
|
2938
|
+
* Collects every conventional section (`feat: ...`, `fix(x)!: ...`) in the
|
|
2939
|
+
* commits since the previous release, or in all history without one. Only
|
|
2940
|
+
* commits touching the component count; for the whole project, when the
|
|
2941
|
+
* working directory is a subdirectory of the repository, only those touching
|
|
2942
|
+
* it. Each change links to its commit when the repository is known: on
|
|
2943
|
+
* GitHub, or in GitLab CI, on GitLab.
|
|
2944
|
+
*/
|
|
2945
|
+
const conventionalReadCommits = defineStep({
|
|
2946
|
+
phase: "read-changes",
|
|
2947
|
+
name: "conventional/read-commits",
|
|
2948
|
+
description: "Collects each conventional section (`feat:`, `fix(x)!:` …) of every commit touching the component since the previous release, or in all history without one.",
|
|
2949
|
+
provides: ["changes.title"],
|
|
2950
|
+
run: async ({ env, component, previousRef }) => {
|
|
2951
|
+
const cwd = env.cwd;
|
|
2952
|
+
await assertFullHistory(cwd);
|
|
2953
|
+
const log = await logSince(cwd, {
|
|
2954
|
+
ref: previousRef,
|
|
2955
|
+
pathspec: component.pathspec,
|
|
2956
|
+
format: `%H${FIELD_SEPARATOR}%B${COMMIT_SEPARATOR}`
|
|
2957
|
+
});
|
|
2958
|
+
const commitUrl = await commitUrlOf(cwd);
|
|
2959
|
+
const changes = [];
|
|
2960
|
+
let commits = 0;
|
|
2961
|
+
for (const entry of log.split(COMMIT_SEPARATOR)) {
|
|
2962
|
+
const [sha, message = ""] = entry.trim().split(FIELD_SEPARATOR);
|
|
2963
|
+
if (!sha) continue;
|
|
2964
|
+
commits++;
|
|
2965
|
+
const short = sha.slice(0, 7);
|
|
2966
|
+
const link = commitUrl ? {
|
|
2967
|
+
label: short,
|
|
2968
|
+
url: commitUrl(sha)
|
|
2969
|
+
} : void 0;
|
|
2970
|
+
for (const section of parseSections(message)) changes.push({
|
|
2971
|
+
...section,
|
|
2972
|
+
reason: `commit ${short}`,
|
|
2973
|
+
...link && { link }
|
|
2974
|
+
});
|
|
2975
|
+
}
|
|
2976
|
+
const since = previousRef ? `since ${previousRef.slice(0, 7)}` : "in all history";
|
|
2977
|
+
return {
|
|
2978
|
+
changes,
|
|
2979
|
+
reason: `${plural(changes.length, "change")} in ${plural(commits, "commit")} ${since}`
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
});
|
|
2983
|
+
/** Links to commits on GitHub, or in GitLab CI on GitLab, when the repository is known. */
|
|
2984
|
+
async function commitUrlOf(cwd) {
|
|
2985
|
+
const repo = await detectRepo(cwd);
|
|
2986
|
+
if (repo) {
|
|
2987
|
+
const server = process.env.GITHUB_SERVER_URL || "https://github.com";
|
|
2988
|
+
return (sha) => `${server}/${repo}/commit/${sha}`;
|
|
2989
|
+
}
|
|
2990
|
+
const project = process.env.CI_PROJECT_URL;
|
|
2991
|
+
return project ? (sha) => `${project}/-/commit/${sha}` : void 0;
|
|
2992
|
+
}
|
|
2993
|
+
//#endregion
|
|
2994
|
+
//#region src/utils/exec.ts
|
|
2995
|
+
/** Runs a command and resolves with its trimmed stdout; fails with its stderr. */
|
|
2996
|
+
function exec(command, args, options) {
|
|
2997
|
+
return new Promise((resolve, reject) => {
|
|
2998
|
+
execFile(command, args, {
|
|
2999
|
+
...options,
|
|
3000
|
+
maxBuffer: 67108864
|
|
3001
|
+
}, (error, stdout, stderr) => {
|
|
3002
|
+
if (error) {
|
|
3003
|
+
const detail = stderr.trim() || error.message;
|
|
3004
|
+
reject(/* @__PURE__ */ new Error(`${command} ${args.join(" ")} failed: ${detail}`));
|
|
3005
|
+
} else resolve(stdout.trim());
|
|
3006
|
+
});
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
//#endregion
|
|
3010
|
+
//#region src/steps/exec/run.ts
|
|
3011
|
+
/**
|
|
3012
|
+
* Runs a step's command for a component, or in a dry run its `dryRun`
|
|
3013
|
+
* command if it has one; resolves with what it ran, and its output.
|
|
3014
|
+
*/
|
|
3015
|
+
async function runCommand(input, options) {
|
|
3016
|
+
const { env, component, placeholders } = input;
|
|
3017
|
+
const command = env.dryRun && options.dryRun !== void 0 ? options.dryRun : options.command;
|
|
3018
|
+
const argv = typeof command === "string" ? [
|
|
3019
|
+
"sh",
|
|
3020
|
+
"-c",
|
|
3021
|
+
fill(command, placeholders)
|
|
3022
|
+
] : command.map((part) => fill(part, placeholders));
|
|
3023
|
+
const shown = typeof command === "string" ? argv[2] ?? "" : argv.join(" ");
|
|
3024
|
+
if (env.dryRun && options.dryRun === void 0) return {
|
|
3025
|
+
shown,
|
|
3026
|
+
stdout: null
|
|
3027
|
+
};
|
|
3028
|
+
const [program = "", ...args] = argv;
|
|
3029
|
+
return {
|
|
3030
|
+
shown,
|
|
3031
|
+
stdout: await exec(program, args, {
|
|
3032
|
+
cwd: inComponent(env, component, options.cwd).file,
|
|
3033
|
+
env: {
|
|
3034
|
+
...process.env,
|
|
3035
|
+
...Object.fromEntries(Object.entries(options.env).map(([key, value]) => [key, fill(value, placeholders)]))
|
|
3036
|
+
}
|
|
3037
|
+
})
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
/** The last lines of a command's output, for its reason. */
|
|
3041
|
+
function tail(stdout, lines = 1) {
|
|
3042
|
+
return stdout.split("\n").filter((line) => line.trim()).slice(-lines).join(" ").slice(0, 200);
|
|
3043
|
+
}
|
|
3044
|
+
//#endregion
|
|
3045
|
+
//#region src/steps/exec/command.ts
|
|
3046
|
+
/**
|
|
3047
|
+
* Runs a command for each component in the phase it's given: in `write`,
|
|
3048
|
+
* e.g. `cargo update --workspace` to update a lockfile; in `release`, e.g.
|
|
3049
|
+
* `cargo publish` or `docker push app:{version}`.
|
|
3050
|
+
*/
|
|
3051
|
+
const execCommand = defineStep({
|
|
3052
|
+
phase: (options) => options.phase,
|
|
3053
|
+
name: "exec/command",
|
|
3054
|
+
description: "Runs a command for each component in its `phase`: `write` e.g. to update a lockfile, listing what it changes as `files`, or `release` e.g. `cargo publish` or `docker push app:{version}`.",
|
|
3055
|
+
options: {
|
|
3056
|
+
phase: z.enum([
|
|
3057
|
+
"write",
|
|
3058
|
+
"stage",
|
|
3059
|
+
"release"
|
|
3060
|
+
]).describe("The phase it runs in."),
|
|
3061
|
+
command: z.union([z.string(), z.array(z.string()).min(1)]).describe("`{placeholders}` are filled in. Text runs in `sh -c`; a list runs as it is, without a shell."),
|
|
3062
|
+
dryRun: z.union([z.string(), z.array(z.string()).min(1)]).optional().describe("Runs instead in a dry run, e.g. `cargo publish --dry-run`; without it, a dry run only says what it would run."),
|
|
3063
|
+
cwd: z.string().default(".").describe("Where it runs, relative to the component's directory."),
|
|
3064
|
+
env: z.record(z.string(), z.string()).default({}).describe("More environment variables, with `{placeholders}` filled in."),
|
|
3065
|
+
files: z.array(z.string()).default([]).describe("What it changes, relative to the component's directory, for `git/commit`.")
|
|
3066
|
+
},
|
|
3067
|
+
run: async (input) => {
|
|
3068
|
+
const { env, component, options } = input;
|
|
3069
|
+
const files = options.files.map((file) => inComponent(env, component, file).relative);
|
|
3070
|
+
const { shown, stdout } = await runCommand(input, options);
|
|
3071
|
+
if (stdout === null) return {
|
|
3072
|
+
output: {
|
|
3073
|
+
command: shown,
|
|
3074
|
+
stdout: null
|
|
3075
|
+
},
|
|
3076
|
+
files,
|
|
3077
|
+
reason: `would run ${shown}`
|
|
3078
|
+
};
|
|
3079
|
+
const last = tail(stdout);
|
|
3080
|
+
return {
|
|
3081
|
+
output: {
|
|
3082
|
+
command: shown,
|
|
3083
|
+
stdout
|
|
3084
|
+
},
|
|
3085
|
+
files,
|
|
3086
|
+
reason: `ran ${shown}${last ? `: ${last}` : ""}`
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3089
|
+
});
|
|
3090
|
+
//#endregion
|
|
3091
|
+
//#region src/steps/fs/write-changes.ts
|
|
3092
|
+
/** Writes release notes to a file, creating it if needed. */
|
|
3093
|
+
const fsWriteChanges = defineStep({
|
|
3094
|
+
phase: "write",
|
|
3095
|
+
name: "fs/write-changes",
|
|
3096
|
+
description: "Writes a heading and the notes to a file.",
|
|
3097
|
+
options: {
|
|
3098
|
+
path: z.string().default("CHANGELOG.md").describe("Relative to the component's directory."),
|
|
3099
|
+
heading: z.string().default("## {tag} ({date})").describe(`${VERSION_PLACEHOLDERS}, and \`{date}\` (YYYY-MM-DD).`),
|
|
3100
|
+
mode: z.enum([
|
|
3101
|
+
"prepend",
|
|
3102
|
+
"append",
|
|
3103
|
+
"replace"
|
|
3104
|
+
]).default("prepend"),
|
|
3105
|
+
channels: channelsOption
|
|
3106
|
+
},
|
|
3107
|
+
run: async ({ env, component, placeholders, notes, options }) => {
|
|
3108
|
+
const { heading, mode } = options;
|
|
3109
|
+
const { file, relative: path } = inComponent(env, component, options.path);
|
|
3110
|
+
const skipped = skipOnChannel(env, options.channels, path);
|
|
3111
|
+
if (skipped) return skipped;
|
|
3112
|
+
const existing = await readFile(file, "utf8").catch((error) => {
|
|
3113
|
+
if (error.code === "ENOENT") return void 0;
|
|
3114
|
+
throw error;
|
|
3115
|
+
});
|
|
3116
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3117
|
+
const entry = `${fill(heading, {
|
|
3118
|
+
...placeholders,
|
|
3119
|
+
date
|
|
3120
|
+
})}\n\n${notes}`.trimEnd();
|
|
3121
|
+
const action = existing === void 0 ? "create" : mode;
|
|
3122
|
+
if (env.dryRun) return {
|
|
3123
|
+
output: {
|
|
3124
|
+
path,
|
|
3125
|
+
entry,
|
|
3126
|
+
written: false
|
|
3127
|
+
},
|
|
3128
|
+
files: [path],
|
|
3129
|
+
reason: `would ${action} ${path}`
|
|
3130
|
+
};
|
|
3131
|
+
let content = [entry];
|
|
3132
|
+
if (mode !== "replace") content = [
|
|
3133
|
+
...mode === "prepend" ? [entry] : [],
|
|
3134
|
+
...existing?.trim() ? [existing.trim()] : [],
|
|
3135
|
+
...mode === "append" ? [entry] : []
|
|
3136
|
+
];
|
|
3137
|
+
await writeFile(file, `${content.join("\n\n")}\n`);
|
|
3138
|
+
return {
|
|
3139
|
+
output: {
|
|
3140
|
+
path,
|
|
3141
|
+
entry,
|
|
3142
|
+
written: true
|
|
3143
|
+
},
|
|
3144
|
+
files: [path],
|
|
3145
|
+
reason: `${existing === void 0 ? "created" : `${mode}${mode.endsWith("e") ? "d" : "ed"}`} ${path}`
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
});
|
|
3149
|
+
//#endregion
|
|
3150
|
+
//#region src/core/scheme.ts
|
|
3151
|
+
/**
|
|
3152
|
+
* Defines a version scheme whose options are declared as a zod shape. The
|
|
3153
|
+
* result is a factory, like a step's: `calver({ format: "YY.MINOR.MICRO" })`.
|
|
3154
|
+
*/
|
|
3155
|
+
function defineScheme(definition) {
|
|
3156
|
+
return makeFactory("scheme", definition);
|
|
3157
|
+
}
|
|
3158
|
+
//#endregion
|
|
3159
|
+
//#region src/schemes/semver.ts
|
|
3160
|
+
const PATTERN = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
|
|
3161
|
+
/** Whether a version is valid semver: `MAJOR.MINOR.PATCH`, without leading zeros. */
|
|
3162
|
+
function isSemver(version) {
|
|
3163
|
+
return PATTERN.test(version.trim());
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* Whether a version is a stable semantic version: one without a pre-release
|
|
3167
|
+
* part, which the scheme leaves to channels.
|
|
3168
|
+
*/
|
|
3169
|
+
function isStable(version) {
|
|
3170
|
+
const match = PATTERN.exec(version.trim());
|
|
3171
|
+
return match !== null && match[4] === void 0;
|
|
3172
|
+
}
|
|
3173
|
+
function parseSemver(version) {
|
|
3174
|
+
const match = PATTERN.exec(version.trim());
|
|
3175
|
+
if (!match) throw new Error(`'${version}' is not a valid semantic version`);
|
|
3176
|
+
const [, major, minor, patch, prerelease, build] = match;
|
|
3177
|
+
return {
|
|
3178
|
+
major: Number(major),
|
|
3179
|
+
minor: Number(minor),
|
|
3180
|
+
patch: Number(patch),
|
|
3181
|
+
...prerelease !== void 0 && { prerelease },
|
|
3182
|
+
...build !== void 0 && { build }
|
|
3183
|
+
};
|
|
3184
|
+
}
|
|
3185
|
+
function formatSemver(version) {
|
|
3186
|
+
let text = `${version.major}.${version.minor}.${version.patch}`;
|
|
3187
|
+
if (version.prerelease !== void 0) text += `-${version.prerelease}`;
|
|
3188
|
+
if (version.build !== void 0) text += `+${version.build}`;
|
|
3189
|
+
return text;
|
|
3190
|
+
}
|
|
3191
|
+
/** Bumps a version: `breaking` → major, `feature` → minor, `fix` → patch. */
|
|
3192
|
+
function bumpSemver(version, kind) {
|
|
3193
|
+
const { major, minor, patch } = version;
|
|
3194
|
+
switch (kind) {
|
|
3195
|
+
case "breaking": return {
|
|
3196
|
+
major: major + 1,
|
|
3197
|
+
minor: 0,
|
|
3198
|
+
patch: 0
|
|
3199
|
+
};
|
|
3200
|
+
case "feature": return {
|
|
3201
|
+
major,
|
|
3202
|
+
minor: minor + 1,
|
|
3203
|
+
patch: 0
|
|
3204
|
+
};
|
|
3205
|
+
case "fix": return {
|
|
3206
|
+
major,
|
|
3207
|
+
minor,
|
|
3208
|
+
patch: patch + 1
|
|
3209
|
+
};
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
const POSITION = {
|
|
3213
|
+
breaking: "major",
|
|
3214
|
+
feature: "minor",
|
|
3215
|
+
fix: "patch"
|
|
3216
|
+
};
|
|
3217
|
+
/**
|
|
3218
|
+
* `MAJOR.minor.patch`: breaking changes bump the major number, features the
|
|
3219
|
+
* minor and fixes the patch. Before 1.0.0, bumps shift down a level by
|
|
3220
|
+
* default (`0.MAJOR.minorOrPatch`).
|
|
3221
|
+
*/
|
|
3222
|
+
const semver = defineScheme({
|
|
3223
|
+
name: "semver",
|
|
3224
|
+
description: "`MAJOR.minor.patch` by the kind: breaking, feature or fix; `0.MAJOR.minorOrPatch` before 1.0.0.",
|
|
3225
|
+
options: {
|
|
3226
|
+
shiftPreMajor: z.boolean().default(true).describe("Before 1.0.0, bump breaking changes as minor and features as patch."),
|
|
3227
|
+
initial: z.string().default("0.1.0").refine(isSemver, "must be a semantic version, e.g. 1.0.0").describe("The first version, when there's no previous one.")
|
|
3228
|
+
},
|
|
3229
|
+
create: (options) => ({
|
|
3230
|
+
name: "semver",
|
|
3231
|
+
traits: ["version.semver"],
|
|
3232
|
+
matches: isStable,
|
|
3233
|
+
compare: (a, b) => compareVersions(parseVersion(a), parseVersion(b)),
|
|
3234
|
+
bump: ({ previous, kind }) => {
|
|
3235
|
+
if (previous === void 0) return {
|
|
3236
|
+
version: options.initial,
|
|
3237
|
+
reason: `first release`
|
|
3238
|
+
};
|
|
3239
|
+
const parsed = parseSemver(previous);
|
|
3240
|
+
const shifted = parsed.major === 0 && options.shiftPreMajor ? kind === "breaking" ? "feature" : "fix" : kind;
|
|
3241
|
+
return {
|
|
3242
|
+
version: formatSemver(bumpSemver(parsed, shifted)),
|
|
3243
|
+
reason: shifted === kind ? `bumps ${POSITION[kind]}` : `bumps ${POSITION[shifted]} (pre-1.0)`
|
|
3244
|
+
};
|
|
3245
|
+
},
|
|
3246
|
+
fields: (version) => {
|
|
3247
|
+
const { major, minor, patch, prerelease } = parseSemver(version);
|
|
3248
|
+
return {
|
|
3249
|
+
major: String(major),
|
|
3250
|
+
minor: String(minor),
|
|
3251
|
+
patch: String(patch),
|
|
3252
|
+
...prerelease !== void 0 && { prerelease }
|
|
3253
|
+
};
|
|
3254
|
+
}
|
|
3255
|
+
})
|
|
3256
|
+
});
|
|
3257
|
+
//#endregion
|
|
3258
|
+
//#region src/steps/fs/write-version.ts
|
|
3259
|
+
/**
|
|
3260
|
+
* Writes each component's new version to its version file, in the project's
|
|
3261
|
+
* own format, keeping everything else as it was. With the `npm` and `cargo`
|
|
3262
|
+
* formats, dependencies on the other components released with it are
|
|
3263
|
+
* pointed at their new versions too, keeping each range's operator.
|
|
3264
|
+
*/
|
|
3265
|
+
const fsWriteVersion = defineStep({
|
|
3266
|
+
phase: "write",
|
|
3267
|
+
name: "fs/write-version",
|
|
3268
|
+
scope: "all",
|
|
3269
|
+
description: "Writes each component's new version to its version file: `VERSION`, `package.json`, `Cargo.toml`, `pyproject.toml`, `Chart.yaml`, `gradle.properties`, `pom.xml`, or any file by pattern. With `npm` and `cargo`, it also updates dependencies on the other components released with it, and needs `version.semver`.",
|
|
3270
|
+
options: {
|
|
3271
|
+
format: z.enum(FORMAT_NAMES).default("plain").describe("`plain` (the whole file), `npm`, `cargo`, `python`, `helm`, `gradle`, `maven`, or `pattern`."),
|
|
3272
|
+
path: z.union([z.string(), z.array(z.string()).min(1)]).optional().describe("Relative to each component's directory; globs, or a list, write several, e.g. `packages/*/package.json` for packages sharing a version. Defaults to the format's file."),
|
|
3273
|
+
pattern: z.string().optional().describe("With the `pattern` format, where the version is, e.g. `__version__ = \"{version}\"`.")
|
|
3274
|
+
},
|
|
3275
|
+
needs: (options) => versionFormat(options.format, options.pattern ?? "{version}").needs ?? [],
|
|
3276
|
+
run: async ({ env, components, options }) => {
|
|
3277
|
+
const format = versionFormat(options.format, options.pattern);
|
|
3278
|
+
const patterns = [options.path ?? format.path].flat();
|
|
3279
|
+
if (patterns.includes("")) throw new Error("the pattern format needs a `path`");
|
|
3280
|
+
if (format.needs?.includes("version.semver")) {
|
|
3281
|
+
for (const { version } of components) if (!isSemver(version)) throw new Error(`${format.name} needs a semantic version, and '${version}' isn't one`);
|
|
3282
|
+
}
|
|
3283
|
+
const files = (await Promise.all(components.map(async ({ component, version }) => {
|
|
3284
|
+
const dir = inComponent(env, component, ".").file;
|
|
3285
|
+
const found = options.format === "plain" ? patterns.map((pattern) => inComponent(env, component, pattern).file) : await resolveFiles(dir, patterns);
|
|
3286
|
+
return Promise.all(found.map(async (file) => ({
|
|
3287
|
+
file,
|
|
3288
|
+
version,
|
|
3289
|
+
text: await readFile(file, "utf8").catch((error) => {
|
|
3290
|
+
if (error.code === "ENOENT") return "";
|
|
3291
|
+
throw error;
|
|
3292
|
+
}),
|
|
3293
|
+
relative: posix.normalize(relative(env.cwd, file).replaceAll("\\", "/"))
|
|
3294
|
+
})));
|
|
3295
|
+
}))).flat();
|
|
3296
|
+
const packages = /* @__PURE__ */ new Map();
|
|
3297
|
+
for (const { text, version } of files) {
|
|
3298
|
+
const name = format.packageName?.(text);
|
|
3299
|
+
if (name !== void 0) packages.set(name, version);
|
|
3300
|
+
}
|
|
3301
|
+
const written = [];
|
|
3302
|
+
for (const { file, version, text, relative: path } of files) {
|
|
3303
|
+
if (text === "" && options.format !== "plain") throw new Error(`${path} doesn't exist`);
|
|
3304
|
+
let result;
|
|
3305
|
+
try {
|
|
3306
|
+
result = format.write(text, version, packages);
|
|
3307
|
+
} catch (error) {
|
|
3308
|
+
throw new Error(`can't write ${version} to ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3309
|
+
}
|
|
3310
|
+
if (!env.dryRun) await writeFile(file, result.text);
|
|
3311
|
+
written.push({
|
|
3312
|
+
path,
|
|
3313
|
+
version,
|
|
3314
|
+
dependencies: result.dependencies
|
|
3315
|
+
});
|
|
3316
|
+
}
|
|
3317
|
+
const described = joinList(written.map(({ path, version, dependencies }) => `${version} to ${path}${dependencies.length > 0 ? ` (and ${joinList(dependencies)})` : ""}`));
|
|
3318
|
+
return {
|
|
3319
|
+
output: { written: env.dryRun ? [] : written },
|
|
3320
|
+
files: written.map(({ path }) => path),
|
|
3321
|
+
reason: `${env.dryRun ? "would write" : "wrote"} ${described}`
|
|
3322
|
+
};
|
|
3323
|
+
}
|
|
3324
|
+
});
|
|
3325
|
+
//#endregion
|
|
3326
|
+
//#region src/steps/git/placeholders.ts
|
|
3327
|
+
/**
|
|
3328
|
+
* Placeholders for what's released as a whole: `{tags}`, every release's
|
|
3329
|
+
* tag, and with one component, its own placeholders.
|
|
3330
|
+
*/
|
|
3331
|
+
function releasedPlaceholders(components) {
|
|
3332
|
+
const [only] = components;
|
|
3333
|
+
return {
|
|
3334
|
+
...components.length === 1 && only ? only.placeholders : {},
|
|
3335
|
+
tags: components.map(({ tag }) => tag).join(", ")
|
|
3336
|
+
};
|
|
3337
|
+
}
|
|
3338
|
+
//#endregion
|
|
3339
|
+
//#region src/steps/git/commit.ts
|
|
3340
|
+
/**
|
|
3341
|
+
* Commits the files that earlier `release` steps changed, for every
|
|
3342
|
+
* component, in one commit. Without this the version bump never lands.
|
|
3343
|
+
* Pushing is off by default, so `git/create-tag` can push the commit and its
|
|
3344
|
+
* tags together.
|
|
3345
|
+
*/
|
|
3346
|
+
const gitCommit = defineStep({
|
|
3347
|
+
phase: "stage",
|
|
3348
|
+
name: "git/commit",
|
|
3349
|
+
scope: "all",
|
|
3350
|
+
description: "Commits the files earlier release steps changed, for every component, in one commit.",
|
|
3351
|
+
options: {
|
|
3352
|
+
files: z.array(z.string()).default([]).describe("More files to commit, relative to the working directory."),
|
|
3353
|
+
message: z.string().default("chore(release): {tags}").describe("The commit message; `{tags}` is filled in with every release's tag, and with one component, its placeholders too, e.g. `{version}`."),
|
|
3354
|
+
push: z.union([z.string(), z.literal(false)]).default(false).describe("The remote to push the branch to; `false` leaves it to a later step, e.g. `git/create-tag`, so the commit and tag land together.")
|
|
3355
|
+
},
|
|
3356
|
+
run: async ({ env, components, options }) => {
|
|
3357
|
+
const remote = options.push;
|
|
3358
|
+
const message = fill(options.message, releasedPlaceholders(components));
|
|
3359
|
+
const files = [.../* @__PURE__ */ new Set([...components.flatMap(({ releases }) => releases.flatMap((release) => release.files)), ...options.files])];
|
|
3360
|
+
if (files.length === 0) throw new Error("no files to commit; no earlier step reported any");
|
|
3361
|
+
const pushing = remote ? ` and push to ${remote}` : "";
|
|
3362
|
+
if (env.dryRun) return {
|
|
3363
|
+
output: {
|
|
3364
|
+
commit: null,
|
|
3365
|
+
pushed: false
|
|
3366
|
+
},
|
|
3367
|
+
reason: `would commit ${files.join(", ")}${pushing}`
|
|
3368
|
+
};
|
|
3369
|
+
const cwd = env.cwd;
|
|
3370
|
+
await git(cwd, "add", "--all", "--", ...files);
|
|
3371
|
+
if (await git(cwd, "diff", "--cached", "--name-only") === "") throw new Error("nothing to commit");
|
|
3372
|
+
await git(cwd, ...await identity(cwd), "commit", "-m", message);
|
|
3373
|
+
const commit = await git(cwd, "rev-parse", "HEAD");
|
|
3374
|
+
if (remote) await git(cwd, "push", remote, "HEAD");
|
|
3375
|
+
return {
|
|
3376
|
+
output: {
|
|
3377
|
+
commit,
|
|
3378
|
+
pushed: remote !== false
|
|
3379
|
+
},
|
|
3380
|
+
reason: `committed ${commit.slice(0, 7)}${remote ? ` and pushed to ${remote}` : ""}`
|
|
3381
|
+
};
|
|
3382
|
+
}
|
|
3383
|
+
});
|
|
3384
|
+
//#endregion
|
|
3385
|
+
//#region src/steps/git/create-tag.ts
|
|
3386
|
+
/**
|
|
3387
|
+
* Tags HEAD with every component's release, moves any floating tags to it,
|
|
3388
|
+
* and pushes them along with the current branch in one atomic push, so a
|
|
3389
|
+
* commit from `git/commit` and its tags land together or not at all.
|
|
3390
|
+
*/
|
|
3391
|
+
const gitCreateTag = defineStep({
|
|
3392
|
+
phase: "release",
|
|
3393
|
+
name: "git/create-tag",
|
|
3394
|
+
scope: "all",
|
|
3395
|
+
description: "Tags HEAD with each release's tag and pushes them, along with the branch, in one atomic push.",
|
|
3396
|
+
options: {
|
|
3397
|
+
message: z.string().default("chore(release): {tag}").describe(`The tag message; ${VERSION_PLACEHOLDERS}.`),
|
|
3398
|
+
move: z.array(z.string()).default([]).describe("Tags to create or move to each release, e.g. `v{major}`; force-pushed. Those with `{channel}` only move on a channel, e.g. to `next`, and the others only on stable releases."),
|
|
3399
|
+
push: z.union([z.string(), z.literal(false)]).default(false).describe("The remote to push the tags and the current branch to; `false` skips pushing.")
|
|
3400
|
+
},
|
|
3401
|
+
run: async ({ env, components, options }) => {
|
|
3402
|
+
const remote = options.push;
|
|
3403
|
+
const tags = components.map(({ tag, placeholders }) => ({
|
|
3404
|
+
tag,
|
|
3405
|
+
message: fill(options.message, placeholders)
|
|
3406
|
+
}));
|
|
3407
|
+
const moved = [...new Set(components.flatMap(({ placeholders }) => options.move.filter((name) => name.includes("{channel}") === (env.channel !== void 0)).map((name) => fill(name, placeholders))))];
|
|
3408
|
+
const names = joinList(tags.map(({ tag }) => tag));
|
|
3409
|
+
if (env.dryRun) return {
|
|
3410
|
+
output: {
|
|
3411
|
+
tags: tags.map(({ tag }) => tag),
|
|
3412
|
+
moved,
|
|
3413
|
+
pushed: false
|
|
3414
|
+
},
|
|
3415
|
+
reason: `would ${joinList([
|
|
3416
|
+
`tag ${names}`,
|
|
3417
|
+
...moved.length ? [`move ${moved.join(", ")}`] : [],
|
|
3418
|
+
...remote ? [`push to ${remote}`] : []
|
|
3419
|
+
])}`
|
|
3420
|
+
};
|
|
3421
|
+
const cwd = env.cwd;
|
|
3422
|
+
const head = await git(cwd, "rev-parse", "HEAD");
|
|
3423
|
+
const who = await identity(cwd);
|
|
3424
|
+
for (const { tag, message } of tags) await git(cwd, ...who, "tag", "-a", tag, "-m", message);
|
|
3425
|
+
for (const name of moved) await git(cwd, "tag", "-f", name, "HEAD");
|
|
3426
|
+
if (remote) await git(cwd, "push", "--atomic", remote, ...await tryGit(cwd, "symbolic-ref", "-q", "HEAD") ? ["HEAD"] : [], ...tags.map(({ tag }) => `refs/tags/${tag}`), ...moved.map((name) => `+refs/tags/${name}`));
|
|
3427
|
+
return {
|
|
3428
|
+
output: {
|
|
3429
|
+
tags: tags.map(({ tag }) => tag),
|
|
3430
|
+
moved,
|
|
3431
|
+
pushed: remote !== false
|
|
3432
|
+
},
|
|
3433
|
+
reason: joinList([
|
|
3434
|
+
`tagged ${names} at ${head.slice(0, 7)}`,
|
|
3435
|
+
...moved.length ? [`moved ${moved.join(", ")}`] : [],
|
|
3436
|
+
...remote ? [`pushed to ${remote}`] : []
|
|
3437
|
+
])
|
|
3438
|
+
};
|
|
3439
|
+
}
|
|
3440
|
+
});
|
|
3441
|
+
//#endregion
|
|
3442
|
+
//#region src/steps/github/create-release.ts
|
|
3443
|
+
/**
|
|
3444
|
+
* Creates a GitHub release for the version, with the notes as its body, or
|
|
3445
|
+
* finds the one another job already created.
|
|
3446
|
+
*/
|
|
3447
|
+
const githubCreateRelease = defineStep({
|
|
3448
|
+
phase: "release",
|
|
3449
|
+
name: "github/create-release",
|
|
3450
|
+
description: "Creates a GitHub release of the tag with the notes, unless it already has one; the tag is created at HEAD if it doesn't exist. Needs a token with `contents: write`.",
|
|
3451
|
+
options: releaseOptions,
|
|
3452
|
+
run: async ({ env, tag, placeholders, notes, options }) => {
|
|
3453
|
+
const title = fill(options.title, placeholders);
|
|
3454
|
+
const repo = await releaseRepo(env.cwd, options.repo);
|
|
3455
|
+
if (env.dryRun) return {
|
|
3456
|
+
output: {
|
|
3457
|
+
repo,
|
|
3458
|
+
tag,
|
|
3459
|
+
url: null
|
|
3460
|
+
},
|
|
3461
|
+
reason: `would create release ${tag} on ${repo}`
|
|
3462
|
+
};
|
|
3463
|
+
const { release, created } = await ensureRelease(githubClient(repo, githubToken(options.token)), {
|
|
3464
|
+
tag,
|
|
3465
|
+
target: await git(env.cwd, "rev-parse", "HEAD"),
|
|
3466
|
+
title,
|
|
3467
|
+
notes,
|
|
3468
|
+
draft: options.draft,
|
|
3469
|
+
prerelease: isPrerelease(options.prerelease, env)
|
|
3470
|
+
});
|
|
3471
|
+
return {
|
|
3472
|
+
output: {
|
|
3473
|
+
repo,
|
|
3474
|
+
tag,
|
|
3475
|
+
id: release.id,
|
|
3476
|
+
url: release.html_url
|
|
3477
|
+
},
|
|
3478
|
+
reason: `${created ? "created" : "found existing"} release ${tag}: ${release.html_url}`
|
|
3479
|
+
};
|
|
3480
|
+
}
|
|
3481
|
+
});
|
|
3482
|
+
//#endregion
|
|
3483
|
+
//#region src/steps/github/open-release-pr.ts
|
|
3484
|
+
/**
|
|
3485
|
+
* Proposes the release as a pull request instead of releasing it: commits
|
|
3486
|
+
* what earlier `write` steps changed to a release branch, and opens a pull
|
|
3487
|
+
* request for it, or updates the one that's open. The release steps wait
|
|
3488
|
+
* until it merges; then the `github/release-pr` history finds it, and they
|
|
3489
|
+
* run for what it proposed.
|
|
3490
|
+
*/
|
|
3491
|
+
const githubOpenReleasePr = defineStep({
|
|
3492
|
+
phase: "stage",
|
|
3493
|
+
name: "github/open-release-pr",
|
|
3494
|
+
scope: "all",
|
|
3495
|
+
description: "Commits the prepared release to a branch and opens a pull request for it, or updates the open one; the release waits until it merges. Use with the `github/release-pr` history. List it after the `write` steps, in place of `git/commit`.",
|
|
3496
|
+
options: {
|
|
3497
|
+
branch: z.string().default(`${RELEASE_BRANCH}{branch}`).describe("The release branch, force-pushed; `{branch}` is the branch being released."),
|
|
3498
|
+
title: z.string().default("chore(release): {tags}").describe("The commit message and title; `{tags}` lists every release's tag."),
|
|
3499
|
+
labels: z.array(z.string()).default([PENDING_LABEL]).describe("Labels for the pull request; `github/release-pr` looks for the first."),
|
|
3500
|
+
remote: z.string().default("origin").describe("Where to push the branch."),
|
|
3501
|
+
repo: z.string().optional().describe("`owner/repo`; defaults to `GITHUB_REPOSITORY` or the `origin` remote."),
|
|
3502
|
+
token: z.string().optional().describe("Defaults to `GITHUB_TOKEN` or `GH_TOKEN`; needs `pull-requests: write`.")
|
|
3503
|
+
},
|
|
3504
|
+
run: async ({ env, components, options }) => {
|
|
3505
|
+
const tags = components.map(({ tag }) => tag).join(", ");
|
|
3506
|
+
const branch = fill(options.branch, { branch: env.branch });
|
|
3507
|
+
const title = fill(options.title, { tags });
|
|
3508
|
+
const files = [...new Set(components.flatMap(({ releases }) => releases.flatMap((release) => release.files)))];
|
|
3509
|
+
const manifest = { components: Object.fromEntries(components.map((component) => [component.component.name, {
|
|
3510
|
+
version: component.version,
|
|
3511
|
+
tag: component.tag,
|
|
3512
|
+
kind: component.kind,
|
|
3513
|
+
...component.previousVersion !== void 0 && { previousVersion: component.previousVersion },
|
|
3514
|
+
notes: component.notes
|
|
3515
|
+
}])) };
|
|
3516
|
+
const repo = await releaseRepo(env.cwd, options.repo);
|
|
3517
|
+
if (env.dryRun) return {
|
|
3518
|
+
output: {
|
|
3519
|
+
branch,
|
|
3520
|
+
number: null,
|
|
3521
|
+
url: null
|
|
3522
|
+
},
|
|
3523
|
+
files,
|
|
3524
|
+
reason: `would push ${files.length > 0 ? joinList(files) : "nothing new"} to ${branch} and propose ${tags} on ${repo}`,
|
|
3525
|
+
defer: `released when the pull request for ${tags} merges`
|
|
3526
|
+
};
|
|
3527
|
+
const client = githubClient(repo, githubToken(options.token));
|
|
3528
|
+
const cwd = env.cwd;
|
|
3529
|
+
const back = await tryGit(cwd, "symbolic-ref", "-q", "--short", "HEAD") ?? await git(cwd, "rev-parse", "HEAD");
|
|
3530
|
+
await git(cwd, "checkout", "-q", "--detach");
|
|
3531
|
+
try {
|
|
3532
|
+
if (files.length > 0) await git(cwd, "add", "--all", "--", ...files);
|
|
3533
|
+
await git(cwd, ...await identity(cwd), "commit", "-q", "--allow-empty", "-m", title);
|
|
3534
|
+
await git(cwd, "push", "--force", options.remote, `HEAD:refs/heads/${branch}`);
|
|
3535
|
+
} finally {
|
|
3536
|
+
await git(cwd, "checkout", "-q", "--force", back);
|
|
3537
|
+
}
|
|
3538
|
+
const owner = repo.split("/")[0];
|
|
3539
|
+
const { data: open } = await client.request("GET", `/pulls?state=open&head=${encodeURIComponent(`${owner}:${branch}`)}`);
|
|
3540
|
+
const body = releaseBody(manifest);
|
|
3541
|
+
let pull;
|
|
3542
|
+
const [existing] = open;
|
|
3543
|
+
if (existing) pull = (await client.request("PATCH", `/pulls/${existing.number}`, { body: {
|
|
3544
|
+
title,
|
|
3545
|
+
body
|
|
3546
|
+
} })).data;
|
|
3547
|
+
else pull = (await client.request("POST", "/pulls", { body: {
|
|
3548
|
+
title,
|
|
3549
|
+
body,
|
|
3550
|
+
head: branch,
|
|
3551
|
+
base: env.branch
|
|
3552
|
+
} })).data;
|
|
3553
|
+
if (options.labels.length > 0) await client.request("POST", `/issues/${pull.number}/labels`, { body: { labels: options.labels } });
|
|
3554
|
+
return {
|
|
3555
|
+
output: {
|
|
3556
|
+
branch,
|
|
3557
|
+
number: pull.number,
|
|
3558
|
+
url: pull.html_url
|
|
3559
|
+
},
|
|
3560
|
+
files,
|
|
3561
|
+
reason: `${existing ? "updated" : "opened"} #${pull.number} for ${tags}, with ${plural(files.length, "file")}: ${pull.html_url}`,
|
|
3562
|
+
defer: `released when #${pull.number} merges`
|
|
3563
|
+
};
|
|
3564
|
+
}
|
|
3565
|
+
});
|
|
3566
|
+
//#endregion
|
|
3567
|
+
//#region src/steps/github/read-pull-requests.ts
|
|
3568
|
+
/** Commits looked up at once. */
|
|
3569
|
+
const CONCURRENCY = 8;
|
|
3570
|
+
/**
|
|
3571
|
+
* Collects the pull requests merged into the branch through the commits
|
|
3572
|
+
* touching the component since the previous release, or in all history
|
|
3573
|
+
* without one, each with its labels. Commits that weren't merged through a
|
|
3574
|
+
* pull request aren't counted.
|
|
3575
|
+
*/
|
|
3576
|
+
const githubReadPullRequests = defineStep({
|
|
3577
|
+
phase: "read-changes",
|
|
3578
|
+
name: "github/read-pull-requests",
|
|
3579
|
+
description: "Collects the pull requests merged into the branch by the commits touching the component since the previous release, with their labels. Needs a token that can read pull requests.",
|
|
3580
|
+
provides: [
|
|
3581
|
+
"changes.title",
|
|
3582
|
+
"changes.labels",
|
|
3583
|
+
"changes.link"
|
|
3584
|
+
],
|
|
3585
|
+
options: {
|
|
3586
|
+
repo: z.string().optional().describe("`owner/repo`; defaults to `GITHUB_REPOSITORY` or the `origin` remote."),
|
|
3587
|
+
token: z.string().optional().describe("Defaults to `GITHUB_TOKEN` or `GH_TOKEN`.")
|
|
3588
|
+
},
|
|
3589
|
+
run: async ({ env, component, previousRef, options }) => {
|
|
3590
|
+
const cwd = env.cwd;
|
|
3591
|
+
await assertFullHistory(cwd);
|
|
3592
|
+
const shas = (await logSince(cwd, {
|
|
3593
|
+
ref: previousRef,
|
|
3594
|
+
pathspec: component.pathspec,
|
|
3595
|
+
format: "%H"
|
|
3596
|
+
})).split("\n").filter(Boolean);
|
|
3597
|
+
const client = githubClient(await releaseRepo(cwd, options.repo), githubToken(options.token));
|
|
3598
|
+
const found = /* @__PURE__ */ new Map();
|
|
3599
|
+
for (let i = 0; i < shas.length; i += CONCURRENCY) {
|
|
3600
|
+
const batch = await Promise.all(shas.slice(i, i + CONCURRENCY).map(async (sha) => {
|
|
3601
|
+
const { data } = await client.request("GET", `/commits/${sha}/pulls`);
|
|
3602
|
+
return data;
|
|
3603
|
+
}));
|
|
3604
|
+
for (const pull of batch.flat()) if (pull.merged_at && pull.base.ref === env.branch) found.set(pull.number, pull);
|
|
3605
|
+
}
|
|
3606
|
+
const changes = [...found.values()].map((pull) => ({
|
|
3607
|
+
title: pull.title,
|
|
3608
|
+
...pull.body?.trim() && { description: pull.body.trim() },
|
|
3609
|
+
reason: `PR #${pull.number}`,
|
|
3610
|
+
link: {
|
|
3611
|
+
label: `#${pull.number}`,
|
|
3612
|
+
url: pull.html_url
|
|
3613
|
+
},
|
|
3614
|
+
labels: pull.labels.map((label) => label.name)
|
|
3615
|
+
}));
|
|
3616
|
+
const since = previousRef ? `since ${previousRef.slice(0, 7)}` : "in all history";
|
|
3617
|
+
return {
|
|
3618
|
+
changes,
|
|
3619
|
+
reason: `${plural(changes.length, "pull request")} in ${plural(shas.length, "commit")} ${since}`
|
|
3620
|
+
};
|
|
3621
|
+
}
|
|
3622
|
+
});
|
|
3623
|
+
//#endregion
|
|
3624
|
+
//#region src/utils/zip.ts
|
|
3625
|
+
const deflate = promisify(deflateRaw);
|
|
3626
|
+
const LIMIT = 4294967295;
|
|
3627
|
+
/**
|
|
3628
|
+
* Zips files, deflated, into one buffer. Without ZIP64, the archive and each
|
|
3629
|
+
* file must stay under 4 GiB.
|
|
3630
|
+
*/
|
|
3631
|
+
async function zip(entries) {
|
|
3632
|
+
const parts = [];
|
|
3633
|
+
const central = [];
|
|
3634
|
+
let offset = 0;
|
|
3635
|
+
for (const entry of entries) {
|
|
3636
|
+
const name = Buffer.from(entry.name.replaceAll("\\", "/"));
|
|
3637
|
+
const data = await deflate(entry.content);
|
|
3638
|
+
if (entry.content.length >= LIMIT || offset + data.length >= LIMIT) throw new Error(`'${entry.name}' is too large to zip (4 GiB at most)`);
|
|
3639
|
+
const crc = crc32(entry.content);
|
|
3640
|
+
const fields = (header, at) => {
|
|
3641
|
+
header.writeUInt16LE(20, at);
|
|
3642
|
+
header.writeUInt16LE(2048, at + 2);
|
|
3643
|
+
header.writeUInt16LE(8, at + 4);
|
|
3644
|
+
header.writeUInt16LE(0, at + 6);
|
|
3645
|
+
header.writeUInt16LE(33, at + 8);
|
|
3646
|
+
header.writeUInt32LE(crc, at + 10);
|
|
3647
|
+
header.writeUInt32LE(data.length, at + 14);
|
|
3648
|
+
header.writeUInt32LE(entry.content.length, at + 18);
|
|
3649
|
+
header.writeUInt16LE(name.length, at + 22);
|
|
3650
|
+
header.writeUInt16LE(0, at + 24);
|
|
3651
|
+
};
|
|
3652
|
+
const local = Buffer.alloc(30);
|
|
3653
|
+
local.writeUInt32LE(67324752, 0);
|
|
3654
|
+
fields(local, 4);
|
|
3655
|
+
const record = Buffer.alloc(46);
|
|
3656
|
+
record.writeUInt32LE(33639248, 0);
|
|
3657
|
+
record.writeUInt16LE(20, 4);
|
|
3658
|
+
fields(record, 6);
|
|
3659
|
+
record.writeUInt32LE(offset, 42);
|
|
3660
|
+
central.push(record, name);
|
|
3661
|
+
parts.push(local, name, data);
|
|
3662
|
+
offset += local.length + name.length + data.length;
|
|
3663
|
+
}
|
|
3664
|
+
const directory = Buffer.concat(central);
|
|
3665
|
+
const end = Buffer.alloc(22);
|
|
3666
|
+
end.writeUInt32LE(101010256, 0);
|
|
3667
|
+
end.writeUInt16LE(entries.length, 8);
|
|
3668
|
+
end.writeUInt16LE(entries.length, 10);
|
|
3669
|
+
end.writeUInt32LE(directory.length, 12);
|
|
3670
|
+
end.writeUInt32LE(offset, 16);
|
|
3671
|
+
return Buffer.concat([
|
|
3672
|
+
...parts,
|
|
3673
|
+
directory,
|
|
3674
|
+
end
|
|
3675
|
+
]);
|
|
3676
|
+
}
|
|
3677
|
+
//#endregion
|
|
3678
|
+
//#region src/steps/github/artifact.ts
|
|
3679
|
+
const SERVICE = "twirp/github.actions.results.api.v1.ArtifactService";
|
|
3680
|
+
/** Whether this is a job that can upload workflow artifacts. */
|
|
3681
|
+
function canUploadArtifacts() {
|
|
3682
|
+
return Boolean(process.env.ACTIONS_RESULTS_URL && process.env.ACTIONS_RUNTIME_TOKEN);
|
|
3683
|
+
}
|
|
3684
|
+
/** The job's artifact service, from what the runner gives JavaScript actions. */
|
|
3685
|
+
function service() {
|
|
3686
|
+
const url = process.env.ACTIONS_RESULTS_URL;
|
|
3687
|
+
const token = process.env.ACTIONS_RUNTIME_TOKEN;
|
|
3688
|
+
if (!url || !token) throw new Error("no artifact service; workflow artifacts can only be uploaded by the rylease action in a GitHub Actions job");
|
|
3689
|
+
const [, run, job] = (JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString()).scp?.split(" ").find((scope) => scope.startsWith("Actions.Results:")))?.split(":") ?? [];
|
|
3690
|
+
if (!run || !job) throw new Error("the runtime token names no job");
|
|
3691
|
+
return {
|
|
3692
|
+
ids: {
|
|
3693
|
+
workflow_run_backend_id: run,
|
|
3694
|
+
workflow_job_run_backend_id: job
|
|
3695
|
+
},
|
|
3696
|
+
call: async (method, body) => {
|
|
3697
|
+
const response = await fetch(`${new URL(url).origin}/${SERVICE}/${method}`, {
|
|
3698
|
+
method: "POST",
|
|
3699
|
+
headers: {
|
|
3700
|
+
authorization: `Bearer ${token}`,
|
|
3701
|
+
"content-type": "application/json"
|
|
3702
|
+
},
|
|
3703
|
+
body: JSON.stringify(body)
|
|
3704
|
+
});
|
|
3705
|
+
const text = await response.text();
|
|
3706
|
+
if (!response.ok) throw new Error(`artifact service responded ${response.status} to ${method}: ${text}`);
|
|
3707
|
+
return JSON.parse(text);
|
|
3708
|
+
}
|
|
3709
|
+
};
|
|
3710
|
+
}
|
|
3711
|
+
/** Uploads a zip as a workflow artifact; resolves with its id. */
|
|
3712
|
+
async function uploadArtifact(name, content, retentionDays) {
|
|
3713
|
+
const { ids, call } = service();
|
|
3714
|
+
const created = await call("CreateArtifact", {
|
|
3715
|
+
...ids,
|
|
3716
|
+
name,
|
|
3717
|
+
version: 4,
|
|
3718
|
+
...retentionDays !== void 0 && { expires_at: new Date(Date.now() + retentionDays * 864e5).toISOString() }
|
|
3719
|
+
});
|
|
3720
|
+
if (!created.ok) throw new Error(`couldn't create artifact '${name}'`);
|
|
3721
|
+
await retry(async () => {
|
|
3722
|
+
const response = await fetch(created.signed_upload_url, {
|
|
3723
|
+
method: "PUT",
|
|
3724
|
+
headers: {
|
|
3725
|
+
"x-ms-blob-type": "BlockBlob",
|
|
3726
|
+
"x-ms-version": "2020-10-02",
|
|
3727
|
+
"content-type": "application/zip"
|
|
3728
|
+
},
|
|
3729
|
+
body: content
|
|
3730
|
+
});
|
|
3731
|
+
if (!response.ok) throw new Error(`artifact storage responded ${response.status}: ${await response.text()}`);
|
|
3732
|
+
}, { attempts: 5 });
|
|
3733
|
+
const finalized = await call("FinalizeArtifact", {
|
|
3734
|
+
...ids,
|
|
3735
|
+
name,
|
|
3736
|
+
size: String(content.length),
|
|
3737
|
+
hash: `sha256:${createHash("sha256").update(content).digest("hex")}`
|
|
3738
|
+
});
|
|
3739
|
+
if (!finalized.ok) throw new Error(`couldn't finalize artifact '${name}'`);
|
|
3740
|
+
return finalized.artifact_id;
|
|
3741
|
+
}
|
|
3742
|
+
/**
|
|
3743
|
+
* Zips files as a workflow artifact, with paths relative to their closest
|
|
3744
|
+
* common directory, as `actions/upload-artifact` does.
|
|
3745
|
+
*/
|
|
3746
|
+
async function uploadFiles(name, files, retentionDays) {
|
|
3747
|
+
const root = files.map(dirname).reduce(commonDirectory);
|
|
3748
|
+
const entries = await Promise.all(files.map(async (file) => ({
|
|
3749
|
+
name: relative(root, file).split(sep).join("/"),
|
|
3750
|
+
content: await readFile(file)
|
|
3751
|
+
})));
|
|
3752
|
+
const sizes = await Promise.all(files.map(async (file) => (await stat(file)).size));
|
|
3753
|
+
return {
|
|
3754
|
+
id: await uploadArtifact(name, await zip(entries), retentionDays),
|
|
3755
|
+
files: entries.map((entry) => entry.name),
|
|
3756
|
+
size: sizes.reduce((sum, bytes) => sum + bytes, 0)
|
|
3757
|
+
};
|
|
3758
|
+
}
|
|
3759
|
+
function commonDirectory(a, b) {
|
|
3760
|
+
const left = a.split(sep);
|
|
3761
|
+
const right = b.split(sep);
|
|
3762
|
+
let length = 0;
|
|
3763
|
+
while (length < left.length && left[length] === right[length]) length++;
|
|
3764
|
+
return left.slice(0, length).join(sep) || sep;
|
|
3765
|
+
}
|
|
3766
|
+
//#endregion
|
|
3767
|
+
//#region src/steps/github/upload-artifact.ts
|
|
3768
|
+
/**
|
|
3769
|
+
* Keeps files as an artifact of the workflow run; with `dryRunArtifact`, in
|
|
3770
|
+
* dry runs too, e.g. to keep what a dry run built.
|
|
3771
|
+
*/
|
|
3772
|
+
const githubUploadArtifact = defineStep({
|
|
3773
|
+
phase: "release",
|
|
3774
|
+
name: "github/upload-artifact",
|
|
3775
|
+
description: "Uploads files as a workflow artifact. Only works in the GitHub Action.",
|
|
3776
|
+
options: {
|
|
3777
|
+
name: z.string().describe("The artifact's name, unique in the run; `{version}`, `{tag}` and the other placeholders are filled in."),
|
|
3778
|
+
files: z.array(z.string()).min(1).describe("Paths, globs or directories, relative to the component's directory unless absolute; each must match."),
|
|
3779
|
+
dryRunArtifact: z.boolean().default(false).describe("Upload in dry runs too."),
|
|
3780
|
+
retentionDays: z.number().int().min(1).optional().describe("Defaults to the repository's setting.")
|
|
3781
|
+
},
|
|
3782
|
+
run: async ({ env, component, placeholders, options }) => {
|
|
3783
|
+
const name = fill(options.name, placeholders);
|
|
3784
|
+
const files = await resolveFiles(inComponent(env, component, ".").file, options.files);
|
|
3785
|
+
const listed = `${plural(files.length, "file")} as artifact ${name}`;
|
|
3786
|
+
if (env.dryRun && !(options.dryRunArtifact && canUploadArtifacts())) return {
|
|
3787
|
+
output: {
|
|
3788
|
+
name,
|
|
3789
|
+
id: null,
|
|
3790
|
+
files: []
|
|
3791
|
+
},
|
|
3792
|
+
reason: `would upload ${listed}${options.dryRunArtifact ? " (outside GitHub Actions)" : ""}`
|
|
3793
|
+
};
|
|
3794
|
+
const uploaded = await uploadFiles(name, files, options.retentionDays);
|
|
3795
|
+
return {
|
|
3796
|
+
output: {
|
|
3797
|
+
name,
|
|
3798
|
+
id: uploaded.id,
|
|
3799
|
+
files: uploaded.files
|
|
3800
|
+
},
|
|
3801
|
+
reason: `uploaded ${plural(files.length, "file")} (${formatSize(uploaded.size)}) as artifact ${name}`
|
|
3802
|
+
};
|
|
3803
|
+
}
|
|
3804
|
+
});
|
|
3805
|
+
//#endregion
|
|
3806
|
+
//#region src/steps/github/upload-release-assets.ts
|
|
3807
|
+
/**
|
|
3808
|
+
* Uploads files to the version's GitHub release, creating the release if no
|
|
3809
|
+
* other step or job has yet, so that jobs building different files can each
|
|
3810
|
+
* upload their own.
|
|
3811
|
+
*/
|
|
3812
|
+
const githubUploadReleaseAssets = defineStep({
|
|
3813
|
+
phase: "release",
|
|
3814
|
+
name: "github/upload-release-assets",
|
|
3815
|
+
description: "Uploads files to the GitHub release, creating it (with the notes) if no other job has yet.",
|
|
3816
|
+
options: {
|
|
3817
|
+
files: z.array(z.string()).min(1).describe("Paths or globs, relative to the component's directory unless absolute; each must match."),
|
|
3818
|
+
clobber: z.boolean().default(true).describe("Replace assets of the same name."),
|
|
3819
|
+
dryRunArtifact: z.boolean().default(false).describe("In dry runs, keep the files as a workflow artifact instead; only in the GitHub Action."),
|
|
3820
|
+
artifactName: z.string().optional().describe("Unique in the run; `{version}`, `{tag}` and the other placeholders are filled in. Defaults to the asset's name, or for several, a name made from theirs."),
|
|
3821
|
+
...releaseOptions
|
|
3822
|
+
},
|
|
3823
|
+
run: async ({ env, component, tag, placeholders, notes, options }) => {
|
|
3824
|
+
const title = fill(options.title, placeholders);
|
|
3825
|
+
const repo = await releaseRepo(env.cwd, options.repo);
|
|
3826
|
+
const files = await resolveFiles(inComponent(env, component, ".").file, options.files);
|
|
3827
|
+
const sizes = await Promise.all(files.map(async (file) => (await stat(file)).size));
|
|
3828
|
+
const listed = files.map((file, index) => `${assetName(basename(file))} (${formatSize(sizes[index] ?? 0)})`);
|
|
3829
|
+
if (env.dryRun) {
|
|
3830
|
+
const reason = `would upload ${joinList(listed)} to release ${tag} on ${repo}`;
|
|
3831
|
+
if (!options.dryRunArtifact) return {
|
|
3832
|
+
output: {
|
|
3833
|
+
repo,
|
|
3834
|
+
tag,
|
|
3835
|
+
url: null,
|
|
3836
|
+
assets: []
|
|
3837
|
+
},
|
|
3838
|
+
reason
|
|
3839
|
+
};
|
|
3840
|
+
const artifact = artifactNameOf(files, options.artifactName, placeholders);
|
|
3841
|
+
if (!canUploadArtifacts()) return {
|
|
3842
|
+
output: {
|
|
3843
|
+
repo,
|
|
3844
|
+
tag,
|
|
3845
|
+
url: null,
|
|
3846
|
+
assets: [],
|
|
3847
|
+
artifact: null
|
|
3848
|
+
},
|
|
3849
|
+
reason: `${reason} (no artifact ${artifact} outside GitHub Actions)`
|
|
3850
|
+
};
|
|
3851
|
+
const { id } = await uploadFiles(artifact, files);
|
|
3852
|
+
return {
|
|
3853
|
+
output: {
|
|
3854
|
+
repo,
|
|
3855
|
+
tag,
|
|
3856
|
+
url: null,
|
|
3857
|
+
assets: [],
|
|
3858
|
+
artifact: {
|
|
3859
|
+
name: artifact,
|
|
3860
|
+
id
|
|
3861
|
+
}
|
|
3862
|
+
},
|
|
3863
|
+
reason: `${reason}; kept them as artifact ${artifact}`
|
|
3864
|
+
};
|
|
3865
|
+
}
|
|
3866
|
+
const client = githubClient(repo, githubToken(options.token));
|
|
3867
|
+
const { release, created } = await ensureRelease(client, {
|
|
3868
|
+
tag,
|
|
3869
|
+
target: await git(env.cwd, "rev-parse", "HEAD"),
|
|
3870
|
+
title,
|
|
3871
|
+
notes,
|
|
3872
|
+
draft: options.draft,
|
|
3873
|
+
prerelease: isPrerelease(options.prerelease, env)
|
|
3874
|
+
});
|
|
3875
|
+
const assets = [];
|
|
3876
|
+
for (const file of files) {
|
|
3877
|
+
const asset = await uploadAsset(client, release, {
|
|
3878
|
+
name: basename(file),
|
|
3879
|
+
content: await readFile(file)
|
|
3880
|
+
}, options.clobber);
|
|
3881
|
+
assets.push({
|
|
3882
|
+
name: asset.name,
|
|
3883
|
+
size: asset.size,
|
|
3884
|
+
url: asset.browser_download_url
|
|
3885
|
+
});
|
|
3886
|
+
}
|
|
3887
|
+
return {
|
|
3888
|
+
output: {
|
|
3889
|
+
repo,
|
|
3890
|
+
tag,
|
|
3891
|
+
url: release.html_url,
|
|
3892
|
+
assets
|
|
3893
|
+
},
|
|
3894
|
+
reason: `uploaded ${joinList(listed)} to ${created ? "new" : "existing"} release ${tag}: ${release.html_url}`
|
|
3895
|
+
};
|
|
3896
|
+
}
|
|
3897
|
+
});
|
|
3898
|
+
function artifactNameOf(files, name, placeholders) {
|
|
3899
|
+
if (name !== void 0) return fill(name, placeholders);
|
|
3900
|
+
const assets = files.map((file) => assetName(basename(file)));
|
|
3901
|
+
if (assets.length === 1 && assets[0]) return assets[0];
|
|
3902
|
+
return `release-assets-${createHash("sha256").update(assets.join("\n")).digest("hex").slice(0, 8)}`;
|
|
3903
|
+
}
|
|
3904
|
+
//#endregion
|
|
3905
|
+
//#region src/steps/gitlab/create-release.ts
|
|
3906
|
+
var GitLabError = class extends Error {
|
|
3907
|
+
status;
|
|
3908
|
+
constructor(status, message) {
|
|
3909
|
+
super(message);
|
|
3910
|
+
this.name = "GitLabError";
|
|
3911
|
+
this.status = status;
|
|
3912
|
+
}
|
|
3913
|
+
};
|
|
3914
|
+
/**
|
|
3915
|
+
* Creates a GitLab release for the version, with the notes as its
|
|
3916
|
+
* description, or finds the one another job already created. In GitLab CI,
|
|
3917
|
+
* the project, API and job token come from the job's environment.
|
|
3918
|
+
*/
|
|
3919
|
+
const gitlabCreateRelease = defineStep({
|
|
3920
|
+
phase: "release",
|
|
3921
|
+
name: "gitlab/create-release",
|
|
3922
|
+
description: "Creates a GitLab release of the tag with the notes, unless it already has one; the tag is created at HEAD if it doesn't exist.",
|
|
3923
|
+
options: {
|
|
3924
|
+
title: z.string().default("{tag}").describe("`{tag}`, `{version}`, `{name}` and the scheme's placeholders are filled in."),
|
|
3925
|
+
project: z.string().optional().describe("The project's ID or path, e.g. `group/project`; defaults to `CI_PROJECT_ID`."),
|
|
3926
|
+
token: z.string().optional().describe("Defaults to `GITLAB_TOKEN`, then the job's `CI_JOB_TOKEN`.")
|
|
3927
|
+
},
|
|
3928
|
+
run: async ({ env, tag, placeholders, notes, options }) => {
|
|
3929
|
+
const title = fill(options.title, placeholders);
|
|
3930
|
+
const project = options.project ?? process.env.CI_PROJECT_ID;
|
|
3931
|
+
if (!project) throw new Error("couldn't determine the GitLab project; set `project` or CI_PROJECT_ID");
|
|
3932
|
+
if (env.dryRun) return {
|
|
3933
|
+
output: {
|
|
3934
|
+
project,
|
|
3935
|
+
tag,
|
|
3936
|
+
url: null
|
|
3937
|
+
},
|
|
3938
|
+
reason: `would create release ${tag} on ${project}`
|
|
3939
|
+
};
|
|
3940
|
+
const request = gitlabClient(project, options.token);
|
|
3941
|
+
let created = true;
|
|
3942
|
+
let release;
|
|
3943
|
+
try {
|
|
3944
|
+
release = await request("POST", "/releases", {
|
|
3945
|
+
tag_name: tag,
|
|
3946
|
+
ref: await git(env.cwd, "rev-parse", "HEAD"),
|
|
3947
|
+
name: title,
|
|
3948
|
+
description: notes
|
|
3949
|
+
});
|
|
3950
|
+
} catch (error) {
|
|
3951
|
+
if (!(error instanceof GitLabError && error.status === 409)) throw error;
|
|
3952
|
+
created = false;
|
|
3953
|
+
release = await request("GET", `/releases/${encodeURIComponent(tag)}`);
|
|
3954
|
+
}
|
|
3955
|
+
const url = release._links.self;
|
|
3956
|
+
return {
|
|
3957
|
+
output: {
|
|
3958
|
+
project,
|
|
3959
|
+
tag,
|
|
3960
|
+
url
|
|
3961
|
+
},
|
|
3962
|
+
reason: `${created ? "created" : "found existing"} release ${tag}: ${url}`
|
|
3963
|
+
};
|
|
3964
|
+
}
|
|
3965
|
+
});
|
|
3966
|
+
/** Requests to a project's REST API, retrying rate limits and server errors. */
|
|
3967
|
+
function gitlabClient(project, token) {
|
|
3968
|
+
const api = process.env.CI_API_V4_URL || "https://gitlab.com/api/v4";
|
|
3969
|
+
const auth = token ?? process.env.GITLAB_TOKEN;
|
|
3970
|
+
const header = auth ? { "private-token": auth } : process.env.CI_JOB_TOKEN ? { "job-token": process.env.CI_JOB_TOKEN } : void 0;
|
|
3971
|
+
if (!header) throw new Error("no token; set GITLAB_TOKEN, or run in GitLab CI");
|
|
3972
|
+
return (method, path, body) => retry(async () => {
|
|
3973
|
+
const url = `${api}/projects/${encodeURIComponent(project)}${path}`;
|
|
3974
|
+
const response = await fetch(url, {
|
|
3975
|
+
method,
|
|
3976
|
+
headers: {
|
|
3977
|
+
...header,
|
|
3978
|
+
...body !== void 0 && { "content-type": "application/json" }
|
|
3979
|
+
},
|
|
3980
|
+
...body !== void 0 && { body: JSON.stringify(body) }
|
|
3981
|
+
});
|
|
3982
|
+
const text = await response.text();
|
|
3983
|
+
if (!response.ok) throw new GitLabError(response.status, `GitLab responded ${response.status} to ${method} ${url}: ${text}`);
|
|
3984
|
+
return JSON.parse(text);
|
|
3985
|
+
}, {
|
|
3986
|
+
attempts: 5,
|
|
3987
|
+
retryIf: (error) => error instanceof GitLabError ? error.status === 429 || error.status >= 500 : error instanceof TypeError
|
|
3988
|
+
});
|
|
3989
|
+
}
|
|
3990
|
+
//#endregion
|
|
3991
|
+
//#region src/steps/keep-a-changelog/changelog.ts
|
|
3992
|
+
/** The `## [Unreleased]` heading of a Keep a Changelog file. */
|
|
3993
|
+
const UNRELEASED = /^##\s+\[?unreleased\]?\s*$/i;
|
|
3994
|
+
/** Finds the `## [Unreleased]` section of a changelog's lines. */
|
|
3995
|
+
function findUnreleased(lines, path) {
|
|
3996
|
+
const start = lines.findIndex((line) => UNRELEASED.test(line));
|
|
3997
|
+
if (start === -1) throw new Error(`${path} has no '## [Unreleased]' section`);
|
|
3998
|
+
const next = lines.findIndex((line, i) => i > start && /^##\s/.test(line));
|
|
3999
|
+
return {
|
|
4000
|
+
start,
|
|
4001
|
+
end: next === -1 ? lines.length : next
|
|
4002
|
+
};
|
|
4003
|
+
}
|
|
4004
|
+
//#endregion
|
|
4005
|
+
//#region src/steps/keep-a-changelog/read-unreleased.ts
|
|
4006
|
+
/**
|
|
4007
|
+
* Collects the entries of the `## [Unreleased]` section of a changelog kept as
|
|
4008
|
+
* on keepachangelog.com: each top-level bullet is a change, labeled with its
|
|
4009
|
+
* `### Added`, `### Fixed` … heading, and the lines under it its description.
|
|
4010
|
+
*/
|
|
4011
|
+
const keepAChangelogReadUnreleased = defineStep({
|
|
4012
|
+
phase: "read-changes",
|
|
4013
|
+
name: "keep-a-changelog/read-unreleased",
|
|
4014
|
+
description: "Collects each entry under `## [Unreleased]` in a Keep a Changelog file, labeled with its `### Added`, `### Fixed` … heading.",
|
|
4015
|
+
provides: ["changes.title", "changes.labels"],
|
|
4016
|
+
options: { path: z.string().default("CHANGELOG.md").describe("Relative to the component's directory.") },
|
|
4017
|
+
run: async ({ env, component, options }) => {
|
|
4018
|
+
const { file, relative: path } = inComponent(env, component, options.path);
|
|
4019
|
+
const lines = (await readFile(file, "utf8")).split(/\r?\n/);
|
|
4020
|
+
const { start, end } = findUnreleased(lines, path);
|
|
4021
|
+
const changes = [];
|
|
4022
|
+
let category;
|
|
4023
|
+
let current;
|
|
4024
|
+
const finish = () => {
|
|
4025
|
+
const description = current?.body.join("\n").trim();
|
|
4026
|
+
if (current && description) current.change.description = description;
|
|
4027
|
+
current = void 0;
|
|
4028
|
+
};
|
|
4029
|
+
for (let i = start + 1; i < end; i++) {
|
|
4030
|
+
const line = lines[i] ?? "";
|
|
4031
|
+
const heading = /^###\s+(.+?)\s*$/.exec(line);
|
|
4032
|
+
const bullet = /^[-*+]\s+(.*)$/.exec(line);
|
|
4033
|
+
if (heading) {
|
|
4034
|
+
finish();
|
|
4035
|
+
category = heading[1];
|
|
4036
|
+
} else if (bullet) {
|
|
4037
|
+
finish();
|
|
4038
|
+
current = {
|
|
4039
|
+
change: {
|
|
4040
|
+
title: (bullet[1] ?? "").trim(),
|
|
4041
|
+
reason: `${path} line ${i + 1}`,
|
|
4042
|
+
labels: category ? [category] : []
|
|
4043
|
+
},
|
|
4044
|
+
body: []
|
|
4045
|
+
};
|
|
4046
|
+
changes.push(current.change);
|
|
4047
|
+
} else if (current && (line.trim() === "" || /^\s/.test(line))) current.body.push(line.replace(/^ {1,2}|^\t/, ""));
|
|
4048
|
+
else finish();
|
|
4049
|
+
}
|
|
4050
|
+
finish();
|
|
4051
|
+
return {
|
|
4052
|
+
changes,
|
|
4053
|
+
reason: `${plural(changes.length, "entry", "entries")} under ${lines[start]?.trim()} in ${path}`
|
|
4054
|
+
};
|
|
4055
|
+
}
|
|
4056
|
+
});
|
|
4057
|
+
//#endregion
|
|
4058
|
+
//#region src/steps/keep-a-changelog/write-release.ts
|
|
4059
|
+
/** A `[unreleased]: <base>/compare/<tag>...HEAD` link definition. */
|
|
4060
|
+
const UNRELEASED_LINK = /^\[unreleased\]:\s*(\S+)\/compare\/(\S+?)\.\.\.HEAD\s*$/i;
|
|
4061
|
+
/**
|
|
4062
|
+
* Releases the `## [Unreleased]` section of a Keep a Changelog file: its
|
|
4063
|
+
* entries move under a heading for the version, and an empty
|
|
4064
|
+
* `## [Unreleased]` takes its place. A `[unreleased]: …/compare/v1.0.0...HEAD`
|
|
4065
|
+
* link moves on to the new tag, and the version gets its own compare link.
|
|
4066
|
+
*/
|
|
4067
|
+
const keepAChangelogWriteRelease = defineStep({
|
|
4068
|
+
phase: "write",
|
|
4069
|
+
name: "keep-a-changelog/write-release",
|
|
4070
|
+
description: "Moves the entries under `## [Unreleased]` to a heading for the version, and updates the compare links, if there are any.",
|
|
4071
|
+
options: {
|
|
4072
|
+
path: z.string().default("CHANGELOG.md").describe("Relative to the component's directory."),
|
|
4073
|
+
heading: z.string().default("## [{version}] - {date}").describe(`${VERSION_PLACEHOLDERS}, and \`{date}\` (YYYY-MM-DD).`),
|
|
4074
|
+
channels: channelsOption
|
|
4075
|
+
},
|
|
4076
|
+
run: async ({ env, component, version, tag, placeholders, options }) => {
|
|
4077
|
+
const { file, relative: path } = inComponent(env, component, options.path);
|
|
4078
|
+
const skipped = skipOnChannel(env, options.channels, path);
|
|
4079
|
+
if (skipped) return skipped;
|
|
4080
|
+
const text = await readFile(file, "utf8");
|
|
4081
|
+
const lines = text.split(/\r?\n/);
|
|
4082
|
+
const { start } = findUnreleased(lines, path);
|
|
4083
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
4084
|
+
const heading = fill(options.heading, {
|
|
4085
|
+
...placeholders,
|
|
4086
|
+
date
|
|
4087
|
+
});
|
|
4088
|
+
lines.splice(start + 1, 0, "", heading);
|
|
4089
|
+
const link = lines.findIndex((line) => UNRELEASED_LINK.test(line));
|
|
4090
|
+
const [, base, previous] = UNRELEASED_LINK.exec(lines[link] ?? "") ?? [];
|
|
4091
|
+
if (base !== void 0 && previous !== void 0) lines.splice(link, 1, `[unreleased]: ${base}/compare/${tag}...HEAD`, `[${version}]: ${base}/compare/${previous}...${tag}`);
|
|
4092
|
+
const updated = `${heading}${base ? " and its compare link" : ""}`;
|
|
4093
|
+
if (env.dryRun) return {
|
|
4094
|
+
output: {
|
|
4095
|
+
path,
|
|
4096
|
+
heading,
|
|
4097
|
+
written: false
|
|
4098
|
+
},
|
|
4099
|
+
files: [path],
|
|
4100
|
+
reason: `would add ${updated} to ${path}`
|
|
4101
|
+
};
|
|
4102
|
+
await writeFile(file, lines.join(text.includes("\r\n") ? "\r\n" : "\n"));
|
|
4103
|
+
return {
|
|
4104
|
+
output: {
|
|
4105
|
+
path,
|
|
4106
|
+
heading,
|
|
4107
|
+
written: true
|
|
4108
|
+
},
|
|
4109
|
+
files: [path],
|
|
4110
|
+
reason: `added ${updated} to ${path}`
|
|
4111
|
+
};
|
|
4112
|
+
}
|
|
4113
|
+
});
|
|
4114
|
+
//#endregion
|
|
4115
|
+
//#region src/steps/labels/labels.ts
|
|
4116
|
+
/** Labels compare case-insensitively, as GitHub's do. */
|
|
4117
|
+
function hasLabel(change, labels) {
|
|
4118
|
+
const wanted = labels.map((label) => label.toLowerCase());
|
|
4119
|
+
return (change.labels ?? []).some((label) => wanted.includes(label.toLowerCase()));
|
|
4120
|
+
}
|
|
4121
|
+
/** Looks a label up in a map keyed by labels, ignoring case. */
|
|
4122
|
+
function lookup(map, label) {
|
|
4123
|
+
const key = Object.keys(map).find((other) => other.toLowerCase() === label.toLowerCase());
|
|
4124
|
+
return key === void 0 ? void 0 : map[key];
|
|
4125
|
+
}
|
|
4126
|
+
//#endregion
|
|
4127
|
+
//#region src/steps/labels/determine-kind.ts
|
|
4128
|
+
const RANK$1 = {
|
|
4129
|
+
none: 0,
|
|
4130
|
+
fix: 1,
|
|
4131
|
+
feature: 2,
|
|
4132
|
+
breaking: 3
|
|
4133
|
+
};
|
|
4134
|
+
/**
|
|
4135
|
+
* Each change's kind comes from its labels, e.g. a pull request's: the
|
|
4136
|
+
* highest of them, or `fallback` when none is mapped. The highest kind wins.
|
|
4137
|
+
*/
|
|
4138
|
+
const labelsDetermineKind = defineStep({
|
|
4139
|
+
phase: "determine-kind",
|
|
4140
|
+
name: "labels/determine-kind",
|
|
4141
|
+
description: "By each change's labels, e.g. a pull request's (`breaking` → breaking, `enhancement` → feature, anything else → fix). The highest kind wins.",
|
|
4142
|
+
needs: ["changes.labels"],
|
|
4143
|
+
options: {
|
|
4144
|
+
kinds: z.record(z.string(), z.enum(KINDS)).default({
|
|
4145
|
+
breaking: "breaking",
|
|
4146
|
+
"semver:major": "breaking",
|
|
4147
|
+
feature: "feature",
|
|
4148
|
+
enhancement: "feature",
|
|
4149
|
+
"semver:minor": "feature"
|
|
4150
|
+
}).describe("Kinds by label, ignoring case."),
|
|
4151
|
+
fallback: z.enum(KINDS).default("fix").describe("The kind of a change without a label in `kinds`.")
|
|
4152
|
+
},
|
|
4153
|
+
run: ({ changes, options }) => {
|
|
4154
|
+
let best;
|
|
4155
|
+
for (const change of changes) {
|
|
4156
|
+
let candidate = {
|
|
4157
|
+
kind: options.fallback,
|
|
4158
|
+
reason: `no label in kinds (${change.reason})`
|
|
4159
|
+
};
|
|
4160
|
+
for (const label of change.labels ?? []) {
|
|
4161
|
+
const kind = lookup(options.kinds, label);
|
|
4162
|
+
if (kind !== void 0 && RANK$1[kind] > RANK$1[candidate.kind]) candidate = {
|
|
4163
|
+
kind,
|
|
4164
|
+
reason: `'${label}' label (${change.reason})`
|
|
4165
|
+
};
|
|
4166
|
+
}
|
|
4167
|
+
if (!best || RANK$1[candidate.kind] > RANK$1[best.kind]) best = candidate;
|
|
4168
|
+
if (best.kind === "breaking") break;
|
|
4169
|
+
}
|
|
4170
|
+
return best ?? {
|
|
4171
|
+
kind: "none",
|
|
4172
|
+
reason: "no changes"
|
|
4173
|
+
};
|
|
4174
|
+
}
|
|
4175
|
+
});
|
|
4176
|
+
//#endregion
|
|
4177
|
+
//#region src/steps/labels/determine-notes.ts
|
|
4178
|
+
/**
|
|
4179
|
+
* A `### Title` section per group of labels, like GitHub's own release notes
|
|
4180
|
+
* categories, then the rest.
|
|
4181
|
+
*/
|
|
4182
|
+
const labelsBuildNotes = defineStep({
|
|
4183
|
+
phase: "determine-notes",
|
|
4184
|
+
name: "labels/determine-notes",
|
|
4185
|
+
description: "A `### Title` section per group of labels, e.g. `### Bug Fixes` for `bug`, then `### Other` for everything else.",
|
|
4186
|
+
needs: ["changes.labels"],
|
|
4187
|
+
options: {
|
|
4188
|
+
sections: z.array(z.strictObject({
|
|
4189
|
+
title: z.string(),
|
|
4190
|
+
labels: z.array(z.string())
|
|
4191
|
+
})).default([
|
|
4192
|
+
{
|
|
4193
|
+
title: "Breaking Changes",
|
|
4194
|
+
labels: ["breaking", "semver:major"]
|
|
4195
|
+
},
|
|
4196
|
+
{
|
|
4197
|
+
title: "Features",
|
|
4198
|
+
labels: ["feature", "enhancement"]
|
|
4199
|
+
},
|
|
4200
|
+
{
|
|
4201
|
+
title: "Bug Fixes",
|
|
4202
|
+
labels: ["bug", "fix"]
|
|
4203
|
+
}
|
|
4204
|
+
]).describe("Sections in order; a change goes in the first with one of its labels, ignoring case."),
|
|
4205
|
+
other: z.union([z.string(), z.literal(false)]).default("Other").describe("The section for remaining changes; `false` leaves them out."),
|
|
4206
|
+
exclude: z.array(z.string()).default(["skip-changelog"]).describe("Changes with any of these labels are left out."),
|
|
4207
|
+
descriptions: z.boolean().default(false).describe("Write each change's description, indented, under it."),
|
|
4208
|
+
links: z.boolean().default(true).describe("Link each change to its source, e.g. its pull request."),
|
|
4209
|
+
dependencies: dependenciesOption
|
|
4210
|
+
},
|
|
4211
|
+
run: ({ changes, kind, dependencies, options }) => {
|
|
4212
|
+
const sections = [...options.sections, ...options.other === false ? [] : [{ title: options.other }]].map((section) => ({
|
|
4213
|
+
...section,
|
|
4214
|
+
entries: []
|
|
4215
|
+
}));
|
|
4216
|
+
for (const change of changes) {
|
|
4217
|
+
if (hasLabel(change, options.exclude)) continue;
|
|
4218
|
+
const target = sections.find((section) => !("labels" in section) || hasLabel(change, section.labels));
|
|
4219
|
+
const entry = format(change, options);
|
|
4220
|
+
if (target && entry !== void 0) target.entries.push(entry);
|
|
4221
|
+
}
|
|
4222
|
+
return joinSections(kind, [...sections, ...dependencySection(options.dependencies, dependencies)]);
|
|
4223
|
+
}
|
|
4224
|
+
});
|
|
4225
|
+
function format(change, style) {
|
|
4226
|
+
const lines = (change.title ?? change.description ?? "").trim().split("\n");
|
|
4227
|
+
const text = lines[0]?.trim();
|
|
4228
|
+
if (!text) return void 0;
|
|
4229
|
+
const link = style.links && change.link ? ` ([${change.link.label}](${change.link.url}))` : "";
|
|
4230
|
+
const details = (change.title === void 0 ? lines.slice(1) : [change.description ?? ""]).join("\n").trim();
|
|
4231
|
+
return `- ${text}${link}${style.descriptions && details ? `\n${details.split("\n").map((line) => line.trim() ? ` ${line}` : "").join("\n")}` : ""}`;
|
|
4232
|
+
}
|
|
4233
|
+
//#endregion
|
|
4234
|
+
//#region src/steps/npm/publish.ts
|
|
4235
|
+
const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
|
|
4236
|
+
/**
|
|
4237
|
+
* Publishes the package to npm, after earlier steps wrote the new version to
|
|
4238
|
+
* `package.json`. Lifecycle scripts like `prepublishOnly` run as usual.
|
|
4239
|
+
*/
|
|
4240
|
+
const npmPublish = defineStep({
|
|
4241
|
+
phase: "release",
|
|
4242
|
+
name: "npm/publish",
|
|
4243
|
+
description: "Publishes the component's package to the npm registry with `npm` or `pnpm publish`; components are published dependencies first.",
|
|
4244
|
+
needs: ["version.semver"],
|
|
4245
|
+
options: {
|
|
4246
|
+
path: z.string().default(".").describe("The package's directory, relative to the component's."),
|
|
4247
|
+
client: z.enum(["npm", "pnpm"]).optional().describe("Defaults to the `packageManager` in package.json, then `pnpm` if there's a `pnpm-lock.yaml`, then `npm`."),
|
|
4248
|
+
tag: z.string().optional().describe("The dist-tag; defaults to the channel's name on a channel, then `publishConfig.tag`, or `latest`."),
|
|
4249
|
+
access: z.enum(["public", "restricted"]).optional().describe("Defaults to `publishConfig.access`."),
|
|
4250
|
+
provenance: z.boolean().default(false).describe("Publishes with a provenance statement; needs `id-token: write`."),
|
|
4251
|
+
registry: z.string().optional().describe("Defaults to `publishConfig.registry`, or the npm registry."),
|
|
4252
|
+
token: z.string().optional().describe("Defaults to `NPM_TOKEN` or `NODE_AUTH_TOKEN`; without one, the client's own auth is used, e.g. trusted publishing.")
|
|
4253
|
+
},
|
|
4254
|
+
run: async ({ env, component, version, options }) => {
|
|
4255
|
+
const { file: dir, relative } = inComponent(env, component, options.path);
|
|
4256
|
+
const manifest = posix.join(relative, "package.json");
|
|
4257
|
+
const json = await loadJson(join(dir, "package.json"));
|
|
4258
|
+
const name = typeof json.name === "string" ? json.name : void 0;
|
|
4259
|
+
if (!name) throw new Error(`${manifest} has no 'name'`);
|
|
4260
|
+
if (json.private === true) throw new Error(`${manifest} is private; npm won't publish it`);
|
|
4261
|
+
const publishConfig = json.publishConfig ?? {};
|
|
4262
|
+
const registry = options.registry ?? (typeof publishConfig.registry === "string" ? publishConfig.registry : DEFAULT_REGISTRY);
|
|
4263
|
+
const client = options.client ?? await detectClient(dir, json);
|
|
4264
|
+
const tag = options.tag ?? env.channel?.name ?? (typeof publishConfig.tag === "string" ? publishConfig.tag : "latest");
|
|
4265
|
+
const target = `${name}@${version} to ${registry} as ${tag}`;
|
|
4266
|
+
const output = {
|
|
4267
|
+
name,
|
|
4268
|
+
version,
|
|
4269
|
+
client,
|
|
4270
|
+
tag,
|
|
4271
|
+
registry
|
|
4272
|
+
};
|
|
4273
|
+
if (env.dryRun) return {
|
|
4274
|
+
output: {
|
|
4275
|
+
...output,
|
|
4276
|
+
published: false
|
|
4277
|
+
},
|
|
4278
|
+
reason: `would ${client} publish ${target}`
|
|
4279
|
+
};
|
|
4280
|
+
const current = versionOf(json, manifest);
|
|
4281
|
+
if (current !== version) throw new Error(`${manifest} is at ${current}, not ${version}; write it first (npm/write-version)`);
|
|
4282
|
+
const args = [
|
|
4283
|
+
"publish",
|
|
4284
|
+
"--registry",
|
|
4285
|
+
registry,
|
|
4286
|
+
"--tag",
|
|
4287
|
+
tag
|
|
4288
|
+
];
|
|
4289
|
+
if (options.access) args.push("--access", options.access);
|
|
4290
|
+
if (options.provenance) args.push("--provenance");
|
|
4291
|
+
if (client === "pnpm") args.push("--no-git-checks");
|
|
4292
|
+
await withToken(options.token ?? process.env.NPM_TOKEN ?? process.env.NODE_AUTH_TOKEN, registry, (env) => exec(client, args, {
|
|
4293
|
+
cwd: dir,
|
|
4294
|
+
env
|
|
4295
|
+
}));
|
|
4296
|
+
return {
|
|
4297
|
+
output: {
|
|
4298
|
+
...output,
|
|
4299
|
+
published: true
|
|
4300
|
+
},
|
|
4301
|
+
reason: `published ${target}`
|
|
4302
|
+
};
|
|
4303
|
+
}
|
|
4304
|
+
});
|
|
4305
|
+
async function detectClient(dir, json) {
|
|
4306
|
+
if (typeof json.packageManager === "string") return json.packageManager.startsWith("pnpm@") ? "pnpm" : "npm";
|
|
4307
|
+
for (let current = dir;; current = dirname(current)) {
|
|
4308
|
+
if (await exists(join(current, "pnpm-lock.yaml"))) return "pnpm";
|
|
4309
|
+
if (await exists(join(current, "package-lock.json"))) return "npm";
|
|
4310
|
+
if (dirname(current) === current) return "npm";
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4313
|
+
function exists(path) {
|
|
4314
|
+
return access(path).then(() => true, () => false);
|
|
4315
|
+
}
|
|
4316
|
+
/**
|
|
4317
|
+
* Runs `run` with an environment that authenticates to the registry with the
|
|
4318
|
+
* token, if there is one: a copy of the user's npmrc with the token's key
|
|
4319
|
+
* added, which both npm and pnpm read. The token itself stays in the
|
|
4320
|
+
* environment, never on disk.
|
|
4321
|
+
*/
|
|
4322
|
+
async function withToken(token, registry, run) {
|
|
4323
|
+
if (!token) return run(process.env);
|
|
4324
|
+
const userconfig = process.env.NPM_CONFIG_USERCONFIG ?? process.env.npm_config_userconfig ?? join(homedir(), ".npmrc");
|
|
4325
|
+
const existing = await readFile(userconfig, "utf8").catch(() => "");
|
|
4326
|
+
const dir = await mkdtemp(join(tmpdir(), "rylease-npmrc-"));
|
|
4327
|
+
try {
|
|
4328
|
+
const npmrc = join(dir, ".npmrc");
|
|
4329
|
+
await writeFile(npmrc, `${existing.trimEnd()}\n${authKey(registry)}=\${RYLEASE_NPM_TOKEN}\n`.trimStart());
|
|
4330
|
+
const { npm_config_userconfig: _, ...env } = process.env;
|
|
4331
|
+
return await run({
|
|
4332
|
+
...env,
|
|
4333
|
+
NPM_CONFIG_USERCONFIG: npmrc,
|
|
4334
|
+
RYLEASE_NPM_TOKEN: token
|
|
4335
|
+
});
|
|
4336
|
+
} finally {
|
|
4337
|
+
await rm(dir, {
|
|
4338
|
+
recursive: true,
|
|
4339
|
+
force: true
|
|
4340
|
+
});
|
|
4341
|
+
}
|
|
4342
|
+
}
|
|
4343
|
+
/** The npmrc key of a registry's token, e.g. `//registry.npmjs.org/:_authToken`. */
|
|
4344
|
+
function authKey(registry) {
|
|
4345
|
+
const url = new URL(registry);
|
|
4346
|
+
const path = url.pathname.endsWith("/") ? url.pathname : `${url.pathname}/`;
|
|
4347
|
+
return `//${url.host}${path}:_authToken`;
|
|
4348
|
+
}
|
|
4349
|
+
//#endregion
|
|
4350
|
+
//#region src/steps/index.ts
|
|
4351
|
+
var steps_exports = /* @__PURE__ */ __exportAll({
|
|
4352
|
+
changeFilesClean: () => changeFilesClean,
|
|
4353
|
+
changeFilesRead: () => changeFilesRead,
|
|
4354
|
+
componentsDependents: () => componentsDependents,
|
|
4355
|
+
conventionalBuildNotes: () => conventionalBuildNotes,
|
|
4356
|
+
conventionalDetermineKind: () => conventionalDetermineKind,
|
|
4357
|
+
conventionalReadCommits: () => conventionalReadCommits,
|
|
4358
|
+
execCommand: () => execCommand,
|
|
4359
|
+
fsWriteChanges: () => fsWriteChanges,
|
|
4360
|
+
fsWriteVersion: () => fsWriteVersion,
|
|
4361
|
+
gitCommit: () => gitCommit,
|
|
4362
|
+
gitCreateTag: () => gitCreateTag,
|
|
4363
|
+
githubCreateRelease: () => githubCreateRelease,
|
|
4364
|
+
githubOpenReleasePr: () => githubOpenReleasePr,
|
|
4365
|
+
githubReadPullRequests: () => githubReadPullRequests,
|
|
4366
|
+
githubUploadArtifact: () => githubUploadArtifact,
|
|
4367
|
+
githubUploadReleaseAssets: () => githubUploadReleaseAssets,
|
|
4368
|
+
gitlabCreateRelease: () => gitlabCreateRelease,
|
|
4369
|
+
keepAChangelogReadUnreleased: () => keepAChangelogReadUnreleased,
|
|
4370
|
+
keepAChangelogWriteRelease: () => keepAChangelogWriteRelease,
|
|
4371
|
+
labelsBuildNotes: () => labelsBuildNotes,
|
|
4372
|
+
labelsDetermineKind: () => labelsDetermineKind,
|
|
4373
|
+
npmPublish: () => npmPublish
|
|
4374
|
+
});
|
|
4375
|
+
//#endregion
|
|
4376
|
+
//#region src/presets/change-files.ts
|
|
4377
|
+
/**
|
|
4378
|
+
* Change files in `.changes/` → notes by kind → the changelog written and
|
|
4379
|
+
* the change files deleted, committed and tagged → GitHub release. Each
|
|
4380
|
+
* file names the components it changes and how much, so a monorepo's
|
|
4381
|
+
* releases don't depend on commit messages. Add files with `rylease add`.
|
|
4382
|
+
* Releases are found by their tags; set the release's `scheme`.
|
|
4383
|
+
*/
|
|
4384
|
+
const changeFiles = definePreset({
|
|
4385
|
+
name: "change-files",
|
|
4386
|
+
description: "Change files in `.changes/` → changelog written and change files deleted, committed, git tag and GitHub release.",
|
|
4387
|
+
steps: [
|
|
4388
|
+
changeFilesRead,
|
|
4389
|
+
labelsDetermineKind,
|
|
4390
|
+
labelsBuildNotes,
|
|
4391
|
+
changeFilesClean,
|
|
4392
|
+
fsWriteChanges,
|
|
4393
|
+
gitCommit,
|
|
4394
|
+
gitCreateTag,
|
|
4395
|
+
githubCreateRelease
|
|
4396
|
+
],
|
|
4397
|
+
defaults: {
|
|
4398
|
+
"labels/determine-kind": { kinds: {
|
|
4399
|
+
breaking: "breaking",
|
|
4400
|
+
feature: "feature",
|
|
4401
|
+
fix: "fix",
|
|
4402
|
+
none: "none"
|
|
4403
|
+
} },
|
|
4404
|
+
"labels/determine-notes": {
|
|
4405
|
+
sections: [
|
|
4406
|
+
{
|
|
4407
|
+
title: "Breaking Changes",
|
|
4408
|
+
labels: ["breaking"]
|
|
4409
|
+
},
|
|
4410
|
+
{
|
|
4411
|
+
title: "Features",
|
|
4412
|
+
labels: ["feature"]
|
|
4413
|
+
},
|
|
4414
|
+
{
|
|
4415
|
+
title: "Fixes",
|
|
4416
|
+
labels: ["fix"]
|
|
4417
|
+
}
|
|
4418
|
+
],
|
|
4419
|
+
other: false,
|
|
4420
|
+
exclude: ["none"],
|
|
4421
|
+
descriptions: true
|
|
4422
|
+
},
|
|
4423
|
+
"git/create-tag": { push: "origin" }
|
|
4424
|
+
},
|
|
4425
|
+
history: gitTags
|
|
4426
|
+
});
|
|
4427
|
+
//#endregion
|
|
4428
|
+
//#region src/schemes/calver.ts
|
|
4429
|
+
/** The date tokens of calver.org, by what they're made from. */
|
|
4430
|
+
const DATES = {
|
|
4431
|
+
YYYY: (date) => String(date.year),
|
|
4432
|
+
YY: (date) => String(date.year - 2e3),
|
|
4433
|
+
"0Y": (date) => String(date.year - 2e3).padStart(2, "0"),
|
|
4434
|
+
MM: (date) => String(date.month),
|
|
4435
|
+
"0M": (date) => String(date.month).padStart(2, "0"),
|
|
4436
|
+
WW: (date) => String(date.week),
|
|
4437
|
+
"0W": (date) => String(date.week).padStart(2, "0"),
|
|
4438
|
+
DD: (date) => String(date.day),
|
|
4439
|
+
"0D": (date) => String(date.day).padStart(2, "0")
|
|
4440
|
+
};
|
|
4441
|
+
/** Counters, from the kind each one is bumped for. */
|
|
4442
|
+
const COUNTERS = {
|
|
4443
|
+
MAJOR: "breaking",
|
|
4444
|
+
MINOR: "feature",
|
|
4445
|
+
MICRO: "fix"
|
|
4446
|
+
};
|
|
4447
|
+
const RANK = {
|
|
4448
|
+
fix: 0,
|
|
4449
|
+
feature: 1,
|
|
4450
|
+
breaking: 2
|
|
4451
|
+
};
|
|
4452
|
+
const TOKENS = [...Object.keys(DATES), ...Object.keys(COUNTERS)];
|
|
4453
|
+
/**
|
|
4454
|
+
* Calendar versioning, as on calver.org: the format's date tokens come from
|
|
4455
|
+
* the release date, and its counters (`MAJOR`, `MINOR`, `MICRO`) number the
|
|
4456
|
+
* releases within the same period. A counter is bumped by the kind it stands
|
|
4457
|
+
* for (`breaking`, `feature`, `fix`), or by a higher kind when the format has
|
|
4458
|
+
* no counter for that; the counters after it start again from 0, as do all of
|
|
4459
|
+
* them in a new period.
|
|
4460
|
+
*/
|
|
4461
|
+
const calver = defineScheme({
|
|
4462
|
+
name: "calver",
|
|
4463
|
+
description: "Dates the version, as on calver.org, e.g. `YYYY.MM.MICRO` → `2026.10.0`; a counter numbers releases in the same period, bumped by the kind.",
|
|
4464
|
+
options: {
|
|
4465
|
+
format: z.string().default("YYYY.MM.MICRO").refine((format) => format.split(".").every((token) => TOKENS.includes(token)) && new Set(format.split(".")).size === format.split(".").length, `must be distinct tokens joined by '.', of ${TOKENS.join(", ")}`).refine((format) => format.split(".").some((token) => token in DATES), "must have a date token, e.g. YYYY").describe("Tokens joined by `.`: `YYYY`, `YY`, `0Y`, `MM`, `0M`, `WW`, `0W`, `DD`, `0D` (ISO 8601 weeks, with the ISO year), and the counters `MAJOR`, `MINOR`, `MICRO`. Zero-padded tokens (`0M` …) aren't valid semver, which npm needs."),
|
|
4466
|
+
timezone: z.string().default("UTC").refine(isTimezone, "must be an IANA time zone, e.g. Europe/Berlin").describe("Where the release date is taken, e.g. `America/New_York`.")
|
|
4467
|
+
},
|
|
4468
|
+
create: (options) => {
|
|
4469
|
+
const format = options.format.split(".");
|
|
4470
|
+
const matches = (version) => {
|
|
4471
|
+
const parts = version.trim().split(".");
|
|
4472
|
+
return parts.length === format.length && parts.every((part, i) => SHAPES[format[i]].test(part));
|
|
4473
|
+
};
|
|
4474
|
+
const padded = format.some((token) => token.startsWith("0"));
|
|
4475
|
+
return {
|
|
4476
|
+
name: `calver ${options.format}`,
|
|
4477
|
+
traits: padded ? [] : ["version.semver"],
|
|
4478
|
+
matches,
|
|
4479
|
+
compare: (a, b) => compareVersions(parseVersion(a), parseVersion(b)),
|
|
4480
|
+
bump: ({ previous, kind, date: now }) => {
|
|
4481
|
+
const date = dateParts(now, options.timezone, format);
|
|
4482
|
+
const before = previous?.split(".").map(Number);
|
|
4483
|
+
const samePeriod = before !== void 0 && format.every((token, i) => !(token in DATES) || Number(DATES[token](date)) === before[i]);
|
|
4484
|
+
const counters = format.filter((token) => Object.hasOwn(COUNTERS, token));
|
|
4485
|
+
const bumped = samePeriod ? bumpedCounter(counters, kind) : void 0;
|
|
4486
|
+
if (samePeriod && bumped === void 0) throw new Error(`${previous} is already this period's release; add a counter (e.g. MICRO) to '${options.format}' to release more often`);
|
|
4487
|
+
return {
|
|
4488
|
+
version: format.map((token, i) => {
|
|
4489
|
+
if (token in DATES) return DATES[token](date);
|
|
4490
|
+
if (!samePeriod || bumped === void 0) return "0";
|
|
4491
|
+
const index = format.indexOf(bumped);
|
|
4492
|
+
if (i < index) return String(before?.[i]);
|
|
4493
|
+
return i === index ? String((before?.[i] ?? 0) + 1) : "0";
|
|
4494
|
+
}).join("."),
|
|
4495
|
+
reason: previous === void 0 ? "first release" : samePeriod ? `bumps ${bumped}` : "new period"
|
|
4496
|
+
};
|
|
4497
|
+
},
|
|
4498
|
+
fields: (version) => Object.fromEntries(format.map((token, i) => [token, version.split(".")[i] ?? ""]))
|
|
4499
|
+
};
|
|
4500
|
+
}
|
|
4501
|
+
});
|
|
4502
|
+
/** What each token looks like in a version. */
|
|
4503
|
+
const SHAPES = {
|
|
4504
|
+
YYYY: /^[1-9]\d{3}$/,
|
|
4505
|
+
YY: /^(0|[1-9]\d*)$/,
|
|
4506
|
+
"0Y": /^\d{2,}$/,
|
|
4507
|
+
MM: /^([1-9]|1[0-2])$/,
|
|
4508
|
+
"0M": /^(0[1-9]|1[0-2])$/,
|
|
4509
|
+
WW: /^([1-9]|[1-4]\d|5[0-3])$/,
|
|
4510
|
+
"0W": /^(0[1-9]|[1-4]\d|5[0-3])$/,
|
|
4511
|
+
DD: /^([1-9]|[12]\d|3[01])$/,
|
|
4512
|
+
"0D": /^(0[1-9]|[12]\d|3[01])$/,
|
|
4513
|
+
MAJOR: /^(0|[1-9]\d*)$/,
|
|
4514
|
+
MINOR: /^(0|[1-9]\d*)$/,
|
|
4515
|
+
MICRO: /^(0|[1-9]\d*)$/
|
|
4516
|
+
};
|
|
4517
|
+
/**
|
|
4518
|
+
* The counter a kind bumps: its own, or the nearest one below it, or else the
|
|
4519
|
+
* lowest the format has (e.g. `MINOR` alone takes fixes too).
|
|
4520
|
+
*/
|
|
4521
|
+
function bumpedCounter(counters, kind) {
|
|
4522
|
+
const ranked = [...counters].sort((a, b) => RANK[COUNTERS[b]] - RANK[COUNTERS[a]]);
|
|
4523
|
+
return ranked.find((counter) => RANK[COUNTERS[counter]] <= RANK[kind]) ?? ranked.at(-1);
|
|
4524
|
+
}
|
|
4525
|
+
function dateParts(now, timezone, format) {
|
|
4526
|
+
const fields = Object.fromEntries(new Intl.DateTimeFormat("en-US", {
|
|
4527
|
+
timeZone: timezone,
|
|
4528
|
+
year: "numeric",
|
|
4529
|
+
month: "numeric",
|
|
4530
|
+
day: "numeric"
|
|
4531
|
+
}).formatToParts(now).map(({ type, value }) => [type, Number(value)]));
|
|
4532
|
+
const iso = isoWeek(fields.year, fields.month, fields.day);
|
|
4533
|
+
const weekly = format.includes("WW") || format.includes("0W");
|
|
4534
|
+
return {
|
|
4535
|
+
...fields,
|
|
4536
|
+
year: weekly ? iso.year : fields.year,
|
|
4537
|
+
week: iso.week
|
|
4538
|
+
};
|
|
4539
|
+
}
|
|
4540
|
+
/** The ISO 8601 week of a date, and the year that week belongs to. */
|
|
4541
|
+
function isoWeek(year, month, day) {
|
|
4542
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
4543
|
+
date.setUTCDate(date.getUTCDate() + 3 - weekday(date));
|
|
4544
|
+
const weekYear = date.getUTCFullYear();
|
|
4545
|
+
const first = new Date(Date.UTC(weekYear, 0, 4));
|
|
4546
|
+
first.setUTCDate(first.getUTCDate() + 3 - weekday(first));
|
|
4547
|
+
const weeks = (date.getTime() - first.getTime()) / 6048e5;
|
|
4548
|
+
return {
|
|
4549
|
+
year: weekYear,
|
|
4550
|
+
week: 1 + Math.round(weeks)
|
|
4551
|
+
};
|
|
4552
|
+
}
|
|
4553
|
+
/** Monday is 0. */
|
|
4554
|
+
function weekday(date) {
|
|
4555
|
+
return (date.getUTCDay() + 6) % 7;
|
|
4556
|
+
}
|
|
4557
|
+
function isTimezone(timezone) {
|
|
4558
|
+
try {
|
|
4559
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
|
|
4560
|
+
return true;
|
|
4561
|
+
} catch {
|
|
4562
|
+
return false;
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
//#endregion
|
|
4566
|
+
//#region src/schemes/index.ts
|
|
4567
|
+
var schemes_exports = /* @__PURE__ */ __exportAll({
|
|
4568
|
+
bumpSemver: () => bumpSemver,
|
|
4569
|
+
calver: () => calver,
|
|
4570
|
+
formatSemver: () => formatSemver,
|
|
4571
|
+
isSemver: () => isSemver,
|
|
4572
|
+
parseSemver: () => parseSemver,
|
|
4573
|
+
semver: () => semver
|
|
4574
|
+
});
|
|
4575
|
+
//#endregion
|
|
4576
|
+
//#region src/presets/conventional.ts
|
|
4577
|
+
/**
|
|
4578
|
+
* Conventional commits since the previous release → git tag → GitHub
|
|
4579
|
+
* release, versioned by semver and found by its tags. Nothing specific to a
|
|
4580
|
+
* language or ecosystem; add steps like `fs/write-version`,
|
|
4581
|
+
* `fs/write-changes` and `git/commit` for that. The tag is pushed, with the
|
|
4582
|
+
* branch, to `origin`.
|
|
4583
|
+
*/
|
|
4584
|
+
const conventional = definePreset({
|
|
4585
|
+
name: "conventional",
|
|
4586
|
+
description: "Conventional commits → notes → git tag and GitHub release.",
|
|
4587
|
+
steps: [
|
|
4588
|
+
conventionalReadCommits,
|
|
4589
|
+
conventionalDetermineKind,
|
|
4590
|
+
conventionalBuildNotes,
|
|
4591
|
+
gitCreateTag,
|
|
4592
|
+
githubCreateRelease
|
|
4593
|
+
],
|
|
4594
|
+
defaults: { "git/create-tag": { push: "origin" } },
|
|
4595
|
+
scheme: semver,
|
|
4596
|
+
history: gitTags
|
|
4597
|
+
});
|
|
4598
|
+
//#endregion
|
|
4599
|
+
//#region src/presets/conventional-pr.ts
|
|
4600
|
+
/**
|
|
4601
|
+
* Conventional commits → the changelog written and proposed in a release
|
|
4602
|
+
* pull request, kept up to date with each push; merging it tags the release
|
|
4603
|
+
* and creates its GitHub release. Versioned by semver; its history,
|
|
4604
|
+
* `github/release-pr`, is how the run on the merge finds what the pull
|
|
4605
|
+
* request proposed.
|
|
4606
|
+
*/
|
|
4607
|
+
const conventionalPr = definePreset({
|
|
4608
|
+
name: "conventional-pr",
|
|
4609
|
+
description: "Conventional commits → changelog proposed in a release pull request; merging it tags the release and creates its GitHub release.",
|
|
4610
|
+
steps: [
|
|
4611
|
+
conventionalReadCommits,
|
|
4612
|
+
conventionalDetermineKind,
|
|
4613
|
+
conventionalBuildNotes,
|
|
4614
|
+
fsWriteChanges,
|
|
4615
|
+
githubOpenReleasePr,
|
|
4616
|
+
gitCreateTag,
|
|
4617
|
+
githubCreateRelease
|
|
4618
|
+
],
|
|
4619
|
+
defaults: { "git/create-tag": { push: "origin" } },
|
|
4620
|
+
scheme: semver,
|
|
4621
|
+
history: githubReleasePr
|
|
4622
|
+
});
|
|
4623
|
+
/**
|
|
4624
|
+
* `## [Unreleased]` in CHANGELOG.md → the changelog released, committed and
|
|
4625
|
+
* tagged → GitHub release. Each entry's kind comes from its section:
|
|
4626
|
+
* `Removed` is breaking, `Added`, `Changed` and `Deprecated` features, and
|
|
4627
|
+
* the rest fixes. Releases are found by their tags; set the release's
|
|
4628
|
+
* `scheme`, e.g. `semver()`, or `calver()` to date them.
|
|
4629
|
+
*/
|
|
4630
|
+
const keepAChangelog = definePreset({
|
|
4631
|
+
name: "keep-a-changelog",
|
|
4632
|
+
description: "`## [Unreleased]` in CHANGELOG.md → changelog committed, git tag and GitHub release.",
|
|
4633
|
+
steps: [
|
|
4634
|
+
keepAChangelogReadUnreleased,
|
|
4635
|
+
labelsDetermineKind,
|
|
4636
|
+
labelsBuildNotes,
|
|
4637
|
+
keepAChangelogWriteRelease,
|
|
4638
|
+
gitCommit,
|
|
4639
|
+
gitCreateTag,
|
|
4640
|
+
githubCreateRelease
|
|
4641
|
+
],
|
|
4642
|
+
defaults: {
|
|
4643
|
+
"labels/determine-kind": { kinds: {
|
|
4644
|
+
Removed: "breaking",
|
|
4645
|
+
Added: "feature",
|
|
4646
|
+
Changed: "feature",
|
|
4647
|
+
Deprecated: "feature"
|
|
4648
|
+
} },
|
|
4649
|
+
"labels/determine-notes": {
|
|
4650
|
+
sections: [
|
|
4651
|
+
"Added",
|
|
4652
|
+
"Changed",
|
|
4653
|
+
"Deprecated",
|
|
4654
|
+
"Removed",
|
|
4655
|
+
"Fixed",
|
|
4656
|
+
"Security"
|
|
4657
|
+
].map((title) => ({
|
|
4658
|
+
title,
|
|
4659
|
+
labels: [title]
|
|
4660
|
+
})),
|
|
4661
|
+
descriptions: true
|
|
4662
|
+
},
|
|
4663
|
+
"git/create-tag": { push: "origin" }
|
|
4664
|
+
},
|
|
4665
|
+
history: gitTags
|
|
4666
|
+
});
|
|
4667
|
+
//#endregion
|
|
4668
|
+
//#region src/presets/index.ts
|
|
4669
|
+
var presets_exports = /* @__PURE__ */ __exportAll({
|
|
4670
|
+
changeFiles: () => changeFiles,
|
|
4671
|
+
conventional: () => conventional,
|
|
4672
|
+
conventionalPr: () => conventionalPr,
|
|
4673
|
+
keepAChangelog: () => keepAChangelog
|
|
4674
|
+
});
|
|
4675
|
+
//#endregion
|
|
4676
|
+
//#region src/config/workflow.ts
|
|
4677
|
+
const builtins = new Map([
|
|
4678
|
+
...Object.values(steps_exports),
|
|
4679
|
+
...Object.values(presets_exports),
|
|
4680
|
+
...Object.values(schemes_exports),
|
|
4681
|
+
...Object.values(histories_exports),
|
|
4682
|
+
...Object.values(components_exports)
|
|
4683
|
+
].filter((item) => isFactory(item)).map((item) => [item.name, item]));
|
|
4684
|
+
const CHANNELS = z.record(z.string(), z.strictObject({
|
|
4685
|
+
branches: z.array(z.string()).optional(),
|
|
4686
|
+
type: z.enum(["prerelease", "build"]).optional(),
|
|
4687
|
+
format: z.string().optional()
|
|
4688
|
+
}));
|
|
4689
|
+
/**
|
|
4690
|
+
* Turns a workflow's inputs into a release: its `steps`, and how versions are
|
|
4691
|
+
* made (`scheme`), tagged (`tag`), found (`history`), and of what
|
|
4692
|
+
* (`components`). Each of those but `tag` names what it `uses` and passes
|
|
4693
|
+
* it options `with`, or is just a name:
|
|
4694
|
+
*
|
|
4695
|
+
* ```yaml
|
|
4696
|
+
* scheme: { uses: calver, with: { format: YY.MINOR.MICRO } }
|
|
4697
|
+
* components: npm/workspaces
|
|
4698
|
+
* ```
|
|
4699
|
+
*/
|
|
4700
|
+
async function resolveWorkflow(input, options) {
|
|
4701
|
+
const scheme = input.scheme == null ? void 0 : await create(input.scheme, "scheme", "scheme", options.cwd);
|
|
4702
|
+
const history = input.history == null ? void 0 : await Promise.all((Array.isArray(input.history) ? input.history : [input.history]).map((entry, index) => create(entry, "history", Array.isArray(input.history) ? `history[${index}]` : "history", options.cwd)));
|
|
4703
|
+
if (input.tag != null && typeof input.tag !== "string") throw new OptionsError(["tag: expected a template, e.g. v{version}"]);
|
|
4704
|
+
return {
|
|
4705
|
+
steps: await resolveWorkflowSteps(input.steps, options),
|
|
4706
|
+
...scheme && { scheme },
|
|
4707
|
+
...typeof input.tag === "string" && input.tag !== "" && { tag: input.tag },
|
|
4708
|
+
...history && { history },
|
|
4709
|
+
...input.components != null && { components: await resolveComponentsInput(input.components, options.cwd) },
|
|
4710
|
+
...input.channels != null && { channels: parseChannels(input.channels) }
|
|
4711
|
+
};
|
|
4712
|
+
}
|
|
4713
|
+
function parseChannels(value) {
|
|
4714
|
+
const result = CHANNELS.safeParse(value);
|
|
4715
|
+
if (result.success) return result.data;
|
|
4716
|
+
throw new OptionsError(result.error.issues.map((issue) => `${joinPath("channels", issue.path)}: ${issue.message}`));
|
|
4717
|
+
}
|
|
4718
|
+
async function resolveComponentsInput(value, cwd) {
|
|
4719
|
+
if (!Array.isArray(value)) return create(value, "components", "components", cwd);
|
|
4720
|
+
const keys = [
|
|
4721
|
+
"name",
|
|
4722
|
+
"path",
|
|
4723
|
+
"dependsOn",
|
|
4724
|
+
"tag",
|
|
4725
|
+
"scheme",
|
|
4726
|
+
"history"
|
|
4727
|
+
];
|
|
4728
|
+
const components = [];
|
|
4729
|
+
for (const [index, entry] of value.entries()) {
|
|
4730
|
+
const at = `components[${index}]`;
|
|
4731
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new OptionsError([`${at}: expected a map with 'name' and 'path'`]);
|
|
4732
|
+
const fields = entry;
|
|
4733
|
+
const unknown = Object.keys(fields).filter((key) => !keys.includes(key));
|
|
4734
|
+
if (unknown.length > 0) throw new OptionsError(unknown.map((key) => `${at}.${key}: unknown key; use ${keys.join(", ")}`));
|
|
4735
|
+
const text = (key) => {
|
|
4736
|
+
const field = fields[key];
|
|
4737
|
+
if (field != null && typeof field !== "string") throw new OptionsError([`${at}.${key}: expected text`]);
|
|
4738
|
+
return field ?? void 0;
|
|
4739
|
+
};
|
|
4740
|
+
const dependsOn = fields.dependsOn;
|
|
4741
|
+
if (dependsOn != null && !(Array.isArray(dependsOn) && dependsOn.every((item) => typeof item === "string"))) throw new OptionsError([`${at}.dependsOn: expected a list of component names`]);
|
|
4742
|
+
const name = text("name");
|
|
4743
|
+
const path = text("path");
|
|
4744
|
+
const tag = text("tag");
|
|
4745
|
+
components.push({
|
|
4746
|
+
name: name ?? "",
|
|
4747
|
+
...path !== void 0 && { path },
|
|
4748
|
+
...tag !== void 0 && { tag },
|
|
4749
|
+
...dependsOn != null && { dependsOn },
|
|
4750
|
+
...fields.scheme != null && { scheme: await create(fields.scheme, "scheme", `${at}.scheme`, cwd) },
|
|
4751
|
+
...fields.history != null && { history: await create(fields.history, "history", `${at}.history`, cwd) }
|
|
4752
|
+
});
|
|
4753
|
+
}
|
|
4754
|
+
return components;
|
|
4755
|
+
}
|
|
4756
|
+
/** Creates a scheme, history or component source from `name` or `{ uses, with }`. */
|
|
4757
|
+
async function create(value, kind, at, cwd) {
|
|
4758
|
+
const entry = typeof value === "string" ? {
|
|
4759
|
+
uses: value,
|
|
4760
|
+
with: void 0
|
|
4761
|
+
} : value;
|
|
4762
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new OptionsError([`${at}: expected a name, or a map with 'uses' and 'with'`]);
|
|
4763
|
+
const fields = entry;
|
|
4764
|
+
const unknown = Object.keys(fields).filter((key) => key !== "uses" && key !== "with");
|
|
4765
|
+
if (unknown.length > 0) throw new OptionsError(unknown.map((key) => `${at}.${key}: unknown key; use uses, with`));
|
|
4766
|
+
if (typeof fields.uses !== "string" || fields.uses === "") throw new OptionsError([`${at}.uses: expected the name of a ${kind === "components" ? "component source" : kind}`]);
|
|
4767
|
+
const target = await load(fields.uses, `${at}.uses`, cwd);
|
|
4768
|
+
if (target.kind !== kind) throw new OptionsError([`${at}.uses: '${fields.uses}' is a ${target.kind}, not a ${kind === "components" ? "component source" : kind}`]);
|
|
4769
|
+
try {
|
|
4770
|
+
return target(fields.with ?? void 0);
|
|
4771
|
+
} catch (error) {
|
|
4772
|
+
if (!(error instanceof OptionsError)) throw error;
|
|
4773
|
+
throw new OptionsError(error.issues.map((issue) => `${at}.with${issue.startsWith(target.name) ? issue.slice(target.name.length) : `: ${issue}`}`));
|
|
4774
|
+
}
|
|
4775
|
+
}
|
|
4776
|
+
/**
|
|
4777
|
+
* Turns workflow `steps` (already parsed from YAML) into steps. Each entry
|
|
4778
|
+
* names what it `uses`, passes it options `with`, and may run only `if` an
|
|
4779
|
+
* expression holds:
|
|
4780
|
+
*
|
|
4781
|
+
* ```yaml
|
|
4782
|
+
* - uses: conventional # a bundled preset or step
|
|
4783
|
+
* with:
|
|
4784
|
+
* github/create-release: false
|
|
4785
|
+
* - uses: ./release/announce.ts # a file's default export
|
|
4786
|
+
* if: "!dryRun && kind != 'patch'"
|
|
4787
|
+
* with:
|
|
4788
|
+
* channel: "#releases"
|
|
4789
|
+
* - uses: some-package#slackNotes # a package's named export
|
|
4790
|
+
* ```
|
|
4791
|
+
*/
|
|
4792
|
+
async function resolveWorkflowSteps(entries, options) {
|
|
4793
|
+
if (!Array.isArray(entries)) throw new OptionsError(["steps: expected a list of steps"]);
|
|
4794
|
+
const parsed = entries.map((entry, index) => parseEntry(entry, index));
|
|
4795
|
+
const steps = [];
|
|
4796
|
+
const fromPreset = /* @__PURE__ */ new Map();
|
|
4797
|
+
const explicit = [];
|
|
4798
|
+
for (const { uses, with: value, condition, at } of parsed) {
|
|
4799
|
+
const target = await load(uses, `${at}.uses`, options.cwd);
|
|
4800
|
+
if (target.kind !== "step" && target.kind !== "preset") throw new OptionsError([`${at}.uses: '${uses}' is a ${target.kind}, not a step; set it as \`${target.kind}\` instead`]);
|
|
4801
|
+
let created;
|
|
4802
|
+
try {
|
|
4803
|
+
if (isPreset(target)) {
|
|
4804
|
+
created = target(value);
|
|
4805
|
+
for (const step of created) fromPreset.set(step.name, target.name);
|
|
4806
|
+
} else {
|
|
4807
|
+
created = [target(value)];
|
|
4808
|
+
explicit.push({
|
|
4809
|
+
name: target.name,
|
|
4810
|
+
at
|
|
4811
|
+
});
|
|
4812
|
+
}
|
|
4813
|
+
} catch (error) {
|
|
4814
|
+
if (!(error instanceof OptionsError)) throw error;
|
|
4815
|
+
throw new OptionsError(error.issues.map((issue) => `${at}.with${issue.startsWith(target.name) ? issue.slice(target.name.length) : `: ${issue}`}`));
|
|
4816
|
+
}
|
|
4817
|
+
steps.push(...created.map((step) => condition ? when(step, condition) : step));
|
|
4818
|
+
}
|
|
4819
|
+
const twice = explicit.flatMap(({ name, at }) => {
|
|
4820
|
+
const preset = fromPreset.get(name);
|
|
4821
|
+
return preset ? [`${at}.uses: '${name}' is already in '${preset}'; configure it there (with: { ${name}: ... }), or set it to false there to replace it`] : [];
|
|
4822
|
+
});
|
|
4823
|
+
if (twice.length > 0) throw new OptionsError(twice);
|
|
4824
|
+
return steps;
|
|
4825
|
+
}
|
|
4826
|
+
const ENTRY_KEYS = [
|
|
4827
|
+
"uses",
|
|
4828
|
+
"with",
|
|
4829
|
+
"if"
|
|
4830
|
+
];
|
|
4831
|
+
function parseEntry(entry, index) {
|
|
4832
|
+
const at = `steps[${index}]`;
|
|
4833
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new OptionsError([`${at}: expected a map with 'uses', and optionally 'with' and 'if'`]);
|
|
4834
|
+
const fields = entry;
|
|
4835
|
+
const unknown = Object.keys(fields).filter((key) => !ENTRY_KEYS.includes(key));
|
|
4836
|
+
if (unknown.length > 0) throw new OptionsError(unknown.map((key) => `${at}.${key}: unknown key; use ${ENTRY_KEYS.join(", ")}`));
|
|
4837
|
+
if (typeof fields.uses !== "string" || fields.uses === "") throw new OptionsError([`${at}.uses: expected the name of a step or preset`]);
|
|
4838
|
+
if (fields.with != null && (typeof fields.with !== "object" || Array.isArray(fields.with))) throw new OptionsError([`${at}.with: expected a map of options`]);
|
|
4839
|
+
const condition = parseCondition(fields.if, `${at}.if`);
|
|
4840
|
+
return {
|
|
4841
|
+
uses: fields.uses,
|
|
4842
|
+
with: fields.with ?? void 0,
|
|
4843
|
+
...condition && { condition },
|
|
4844
|
+
at
|
|
4845
|
+
};
|
|
4846
|
+
}
|
|
4847
|
+
/**
|
|
4848
|
+
* `if:` is a rylease expression, or a boolean, e.g. what GitHub made of
|
|
4849
|
+
* `${{ ... }}` before rylease ran.
|
|
4850
|
+
*/
|
|
4851
|
+
function parseCondition(value, at) {
|
|
4852
|
+
if (value === void 0 || value === null) return void 0;
|
|
4853
|
+
if (typeof value === "boolean") return Object.assign(() => value, { source: String(value) });
|
|
4854
|
+
if (typeof value !== "string") throw new OptionsError([`${at}: expected an expression or a boolean`]);
|
|
4855
|
+
try {
|
|
4856
|
+
const evaluate = compileExpression(value);
|
|
4857
|
+
return Object.assign((input) => Boolean(evaluate(expressionContext(input))), { source: value });
|
|
4858
|
+
} catch (error) {
|
|
4859
|
+
if (!(error instanceof ExpressionError)) throw error;
|
|
4860
|
+
throw new OptionsError([`${at}: ${error.message} in \`${value}\``]);
|
|
4861
|
+
}
|
|
4862
|
+
}
|
|
4863
|
+
async function load(name, path, cwd) {
|
|
4864
|
+
const builtin = builtins.get(name);
|
|
4865
|
+
if (builtin) return builtin;
|
|
4866
|
+
const [specifier = name, exportName = "default"] = name.split("#");
|
|
4867
|
+
const isPath = /^\.{0,2}\//.test(specifier);
|
|
4868
|
+
let url;
|
|
4869
|
+
try {
|
|
4870
|
+
url = isPath ? pathToFileURL(resolve(cwd, specifier)).href : pathToFileURL(createRequire(join(cwd, "package.json")).resolve(specifier)).href;
|
|
4871
|
+
} catch {
|
|
4872
|
+
throw new OptionsError([`${path}: unknown step ${name}; use one of ${[...builtins.keys()].join(", ")}, a path like ./steps/x.ts, or an installed package`]);
|
|
4873
|
+
}
|
|
4874
|
+
const target = (await import(url))[exportName];
|
|
4875
|
+
if (isFactory(target)) return target;
|
|
4876
|
+
throw new OptionsError([`${path}: export '${exportName}' of ${specifier} isn't made with defineStep, definePreset, defineScheme, defineHistory or defineComponents`]);
|
|
4877
|
+
}
|
|
4878
|
+
function isPreset(value) {
|
|
4879
|
+
return value.kind === "preset";
|
|
4880
|
+
}
|
|
4881
|
+
//#endregion
|
|
4882
|
+
//#region src/core/summary.ts
|
|
4883
|
+
const ICONS = {
|
|
4884
|
+
ok: "✅",
|
|
4885
|
+
skipped: "⚪",
|
|
4886
|
+
failed: "❌"
|
|
4887
|
+
};
|
|
4888
|
+
/**
|
|
4889
|
+
* The report as Markdown, for GitHub's job summary: what happened, a row per
|
|
4890
|
+
* step, and the release notes.
|
|
4891
|
+
*/
|
|
4892
|
+
function markdownSummary(report) {
|
|
4893
|
+
const dryRun = report.env.dryRun;
|
|
4894
|
+
const several = report.components.length > 1;
|
|
4895
|
+
const describe = (component) => {
|
|
4896
|
+
const from = component.previousVersion ?? "nothing";
|
|
4897
|
+
const to = component.version ?? "?";
|
|
4898
|
+
if (component.status !== "released" && component.status !== "pending") return `**${from}** · nothing to release`;
|
|
4899
|
+
return `${from === to ? `**${to}**` : `**${from}** → **${to}** (${component.kind})`} · ${component.status === "pending" ? dryRun ? "would be proposed" : "proposed" : dryRun ? "would release" : "released"}`;
|
|
4900
|
+
};
|
|
4901
|
+
const [only] = report.components;
|
|
4902
|
+
const headline = report.status === "failed" ? "release failed" : !several && only ? describe(only) : report.status === "released" ? dryRun ? "would release" : "released" : report.status === "pending" ? dryRun ? "would be proposed" : "proposed" : "nothing to release";
|
|
4903
|
+
const lines = [
|
|
4904
|
+
`### ${report.status === "failed" ? "❌ " : ""}rylease${dryRun ? " dry run" : ""} · ${headline}`,
|
|
4905
|
+
"",
|
|
4906
|
+
`On \`${report.env.branch}\`${report.env.channel ? `, on the \`${report.env.channel.name}\` channel` : ""}${dryRun ? ", without side effects" : ""}.${report.deferred ? ` ${report.deferred[0]?.toUpperCase()}${report.deferred.slice(1)}.` : ""}`,
|
|
4907
|
+
...several && report.status !== "failed" ? ["", ...report.components.map((component) => `- \`${component.name}\`: ${describe(component)}`)] : [],
|
|
4908
|
+
"",
|
|
4909
|
+
several ? "| | Component | Type | Step | Time | |" : "| | Type | Step | Time | |",
|
|
4910
|
+
several ? "|:-:|---|---|---|--:|---|" : "|:-:|---|---|--:|---|",
|
|
4911
|
+
...report.records.map((record) => `| ${ICONS[record.status]} |${several ? ` ${record.component ? `\`${record.component}\`` : "all"} |` : ""} \`${record.phase}\` | \`${record.name}\` | ${record.status === "skipped" ? "" : formatDuration(record.durationMs)} | ${cell(record.reason)} |`)
|
|
4912
|
+
];
|
|
4913
|
+
for (const component of report.components) {
|
|
4914
|
+
if (report.status === "failed" || report.status === "unchanged") continue;
|
|
4915
|
+
if (component.status !== "released" && component.status !== "pending") continue;
|
|
4916
|
+
if (!component.notes?.trim()) continue;
|
|
4917
|
+
lines.push("", `<details open><summary>Release notes${several ? ` of ${component.name} ${component.version}` : ""}</summary>`, "", component.notes.trim(), "", "</details>");
|
|
4918
|
+
}
|
|
4919
|
+
return `${lines.join("\n")}\n\n`;
|
|
4920
|
+
}
|
|
4921
|
+
/** Text that stays inside one table cell or list item. */
|
|
4922
|
+
function cell(text) {
|
|
4923
|
+
return text.replaceAll("|", "\\|").replaceAll("<", "<").replace(/\r?\n/g, "<br>");
|
|
4924
|
+
}
|
|
4925
|
+
//#endregion
|
|
4926
|
+
export { KINDS, OptionsError, PHASES, ReleaseError, compileExpression, components_exports as components, defaultReporter, defineComponents, defineHistory, definePreset, defineRelease, defineScheme, defineStep, errorMessage, expressionContext, formatDuration, git, histories_exports as histories, joinPath, markdownSummary, parseOptions, presets_exports as presets, prettyReporter, resolveComponents, resolveWorkflow, resolveWorkflowSteps, runRelease, schemes_exports as schemes, sortSteps, steps_exports as steps, tryGit, when, z };
|
|
4927
|
+
|
|
4928
|
+
//# sourceMappingURL=index.mjs.map
|