aronrepo 3.0.0 → 3.0.2

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.
@@ -0,0 +1,1688 @@
1
+ import { agentsBlockEnd, agentsBlockStart, commits, createReleasePathMatcher, defaultReleasePathExcludes, getAgentCommitPolicy, getBaseTag, getNextVersion, getReleaseChannel, getReleasePolicy, getReleaseTable, getReleaseType, legacyCommits, parseCommit, parseVersionTag, renderAgentsBlock, renderReleaseNotes, resolveCommitRelease, resolveRelease, toVersionTag } from "./index.js";
2
+ import semver from "semver";
3
+ import { execFileSync, spawn } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { globSync } from "tinyglobby";
8
+ import { load } from "js-yaml";
9
+ import os from "node:os";
10
+ import { gunzipSync } from "node:zlib";
11
+ // src/project.ts
12
+ /** Config files in lookup order. Node 22.18+ imports `.ts` files by stripping their types. */
13
+ const configFileNames = [
14
+ "aronrepo.config.js",
15
+ "aronrepo.config.mjs",
16
+ "aronrepo.config.cjs",
17
+ "aronrepo.config.ts",
18
+ "aronrepo.config.mts",
19
+ "aronrepo.config.cts"
20
+ ];
21
+ function toPosixPath(value) {
22
+ return value.split(path.sep).join("/");
23
+ }
24
+ /** Reads JSON like npm does, accepting a UTF-8 byte order mark, and names the file when it is invalid. */
25
+ function readJsonFile(file) {
26
+ const content = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
27
+ try {
28
+ return JSON.parse(content);
29
+ } catch (error) {
30
+ throw new Error(`${file} is not valid JSON: ${error.message}`, { cause: error });
31
+ }
32
+ }
33
+ const readJson$1 = readJsonFile;
34
+ function findConfigFile(cwd = process.cwd()) {
35
+ return configFileNames.map((name) => path.join(cwd, name)).find((file) => fs.existsSync(file));
36
+ }
37
+ /** Loads `aronrepo.config.js` from the repository root; returns `{}` when it is missing. */
38
+ async function loadConfig(cwd = process.cwd()) {
39
+ const file = findConfigFile(cwd);
40
+ if (!file) return {};
41
+ const stat = fs.statSync(file);
42
+ return (await import(`${pathToFileURL(file).href}?mtime=${stat.mtimeMs}&size=${stat.size}`)).default ?? {};
43
+ }
44
+ function readPNPMWorkspaces(cwd) {
45
+ const workspacePath = path.join(cwd, "pnpm-workspace.yaml");
46
+ if (!fs.existsSync(workspacePath)) return void 0;
47
+ const content = fs.readFileSync(workspacePath, "utf8");
48
+ if (!content.split("\n").some((line) => line.trim() && !line.trim().startsWith("#"))) return void 0;
49
+ return load(content)?.packages;
50
+ }
51
+ function readNPMWorkspaces(cwd) {
52
+ const packagePath = path.join(cwd, "package.json");
53
+ if (!fs.existsSync(packagePath)) return void 0;
54
+ const pkg = readJson$1(packagePath);
55
+ return Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages;
56
+ }
57
+ function readWorkspacePatterns(cwd = process.cwd()) {
58
+ return readPNPMWorkspaces(cwd) ?? readNPMWorkspaces(cwd) ?? [];
59
+ }
60
+ function queryWorkspaceDirs(patterns, cwd = process.cwd()) {
61
+ if (!patterns.length) return [];
62
+ return globSync(patterns.map((workspace) => `${workspace}/package.json`), {
63
+ cwd,
64
+ onlyFiles: true,
65
+ expandDirectories: false,
66
+ ignore: ["**/node_modules/**"]
67
+ }).map((workspacePackage) => toPosixPath(workspacePackage).replace(/\/?package\.json$/, "") || ".").sort();
68
+ }
69
+ function readWorkspacePackage(cwd, workspace) {
70
+ return readJson$1(path.join(cwd, workspace, "package.json"));
71
+ }
72
+ /**
73
+ * Directories of the packages that ship: public, non-private workspace packages,
74
+ * and the root package when it is published too. A root package without
75
+ * workspaces ships unless it is private; with workspaces it needs
76
+ * `publishConfig.access: "public"` like the others.
77
+ */
78
+ function queryReleaseDirs(cwd = process.cwd()) {
79
+ const dirs = queryPublicWorkspaceDirs(cwd);
80
+ const rootFile = path.join(cwd, "package.json");
81
+ if (dirs.includes(".") || !fs.existsSync(rootFile)) return dirs;
82
+ const root = readJson$1(rootFile);
83
+ const hasWorkspaces = readWorkspacePatterns(cwd).length > 0;
84
+ if (root.name && !root.private && (!hasWorkspaces || root.publishConfig?.access === "public")) return [".", ...dirs];
85
+ return dirs;
86
+ }
87
+ /** Workspace directories whose package is published to npm: public and not private. */
88
+ function queryPublicWorkspaceDirs(cwd = process.cwd()) {
89
+ return queryWorkspaceDirs(readWorkspacePatterns(cwd), cwd).filter((workspace) => {
90
+ const pkg = readWorkspacePackage(cwd, workspace);
91
+ return pkg.publishConfig?.access === "public" && !pkg.private;
92
+ });
93
+ }
94
+ /**
95
+ * Resolves the files whose changes can release a version.
96
+ *
97
+ * Configured paths win. Otherwise public workspace packages define them; a
98
+ * single-package repository with a non-private root package uses every path.
99
+ * Returns `undefined` when no published package is found, which turns the check off.
100
+ */
101
+ function resolveReleasePaths(cwd = process.cwd(), policy) {
102
+ if (policy?.paths === false) return void 0;
103
+ if (policy?.paths?.include) return {
104
+ include: policy.paths.include,
105
+ exclude: policy.paths.exclude ?? defaultReleasePathExcludes
106
+ };
107
+ const exclude = policy?.paths?.exclude ?? defaultReleasePathExcludes;
108
+ const dirs = queryReleaseDirs(cwd);
109
+ if (!dirs.length) return void 0;
110
+ return {
111
+ include: dirs.includes(".") ? ["**"] : dirs.map((dir) => `${dir}/**`),
112
+ exclude
113
+ };
114
+ }
115
+ const lockfiles = [
116
+ ["bun.lock", "bun"],
117
+ ["bun.lockb", "bun"],
118
+ ["pnpm-lock.yaml", "pnpm"],
119
+ ["pnpm-workspace.yaml", "pnpm"],
120
+ ["yarn.lock", "yarn"],
121
+ ["package-lock.json", "npm"]
122
+ ];
123
+ /** The package manager from `packageManager` in package.json, then from the lockfile; `npm` by default. */
124
+ function detectPackageManager(cwd = process.cwd()) {
125
+ const rootPackage = path.join(cwd, "package.json");
126
+ const declared = fs.existsSync(rootPackage) ? String(readJson$1(rootPackage).packageManager ?? "").split("@")[0] : "";
127
+ if (declared === "bun" || declared === "pnpm" || declared === "yarn" || declared === "npm") return declared;
128
+ return lockfiles.find(([file]) => fs.existsSync(path.join(cwd, file)))?.[1] ?? "npm";
129
+ }
130
+ /** How to run the aronrepo CLI with a package manager. */
131
+ function getCliCommand(packageManager) {
132
+ return {
133
+ bun: "bun x aronrepo",
134
+ pnpm: "pnpm exec aronrepo",
135
+ yarn: "yarn aronrepo",
136
+ npm: "npx aronrepo"
137
+ }[packageManager];
138
+ }
139
+ /**
140
+ * How to run the aronrepo CLI in a repository: its `aronrepo` script when it has
141
+ * one, for example a workspace that builds aronrepo itself, or the installed binary.
142
+ */
143
+ function getProjectCliCommand(cwd, packageManager = detectPackageManager(cwd)) {
144
+ const rootPackage = path.join(cwd, "package.json");
145
+ if ((fs.existsSync(rootPackage) ? readJson$1(rootPackage).scripts : void 0)?.aronrepo) return packageManager === "npm" ? "npm run aronrepo --" : `${packageManager} run aronrepo`;
146
+ return getCliCommand(packageManager);
147
+ }
148
+ /** Loads the config, release policy, and release paths of a repository. */
149
+ async function loadProjectPolicy(cwd = process.cwd(), overrides = {}) {
150
+ const config = await loadConfig(cwd);
151
+ const policy = {
152
+ ...getReleasePolicy(config),
153
+ ...overrides
154
+ };
155
+ const releasePaths = resolveReleasePaths(cwd, policy);
156
+ const configFile = findConfigFile(cwd);
157
+ const packageManager = detectPackageManager(cwd);
158
+ return {
159
+ cwd,
160
+ packageManager,
161
+ cli: getProjectCliCommand(cwd, packageManager),
162
+ config,
163
+ ...configFile ? { configFile } : {},
164
+ policy,
165
+ ...releasePaths ? { releasePaths } : {},
166
+ ...releasePaths ? { isReleasePath: createReleasePathMatcher(releasePaths) } : {}
167
+ };
168
+ }
169
+ function git$1(cwd, args) {
170
+ if (!fs.existsSync(cwd)) throw new Error(`${cwd} does not exist.`);
171
+ try {
172
+ return execFileSync("git", args, {
173
+ cwd,
174
+ encoding: "utf8",
175
+ maxBuffer: 67108864,
176
+ stdio: [
177
+ "ignore",
178
+ "pipe",
179
+ "pipe"
180
+ ]
181
+ });
182
+ } catch (error) {
183
+ const { stderr, code } = error;
184
+ if (code === "ENOENT") throw new Error("git is not installed or not on PATH.", { cause: error });
185
+ throw new Error(`\`git ${args.join(" ")}\` failed: ${stderr?.trim() || error.message}`, { cause: error });
186
+ }
187
+ }
188
+ /** The repository root that contains `cwd`, or `cwd` itself outside a git repository. */
189
+ function getRepositoryRoot(cwd = process.cwd()) {
190
+ try {
191
+ return git$1(cwd, ["rev-parse", "--show-toplevel"]).trim() || cwd;
192
+ } catch {
193
+ return cwd;
194
+ }
195
+ }
196
+ /** A git config value, or `undefined` when it is not set. */
197
+ function getGitConfig(cwd, key) {
198
+ try {
199
+ return git$1(cwd, [
200
+ "config",
201
+ "--get",
202
+ key
203
+ ]).trim() || void 0;
204
+ } catch {
205
+ return;
206
+ }
207
+ }
208
+ /**
209
+ * NUL-separated paths. `-z` keeps git from quoting paths with non-ASCII or
210
+ * special characters, such as `"docs/\\346\\226\\207.md"`, which would never
211
+ * match a release path.
212
+ */
213
+ function splitPaths(output) {
214
+ return output.split("\0").map((file) => file.replace(/^\n/, "")).filter(Boolean);
215
+ }
216
+ const nameOnly = [
217
+ "--name-only",
218
+ "-z",
219
+ "--no-renames"
220
+ ];
221
+ /** Files changed by one commit. Merge commits report no files. */
222
+ function getCommitFiles(cwd, hash) {
223
+ return splitPaths(git$1(cwd, [
224
+ "diff-tree",
225
+ "--no-commit-id",
226
+ ...nameOnly,
227
+ "-r",
228
+ "--root",
229
+ hash
230
+ ]));
231
+ }
232
+ function getStagedFiles(cwd = process.cwd()) {
233
+ return splitPaths(git$1(cwd, [
234
+ "diff",
235
+ "--cached",
236
+ ...nameOnly
237
+ ]));
238
+ }
239
+ /** Files changed between the merge base of `base` and `head`, like a pull request diff. */
240
+ function getDiffFiles(cwd, base, head) {
241
+ return splitPaths(git$1(cwd, [
242
+ "diff",
243
+ ...nameOnly,
244
+ `${base}...${head}`
245
+ ]));
246
+ }
247
+ /** Commits in `from..to`, oldest first, with their changed files, from one `git log`. Merge commits report no files. */
248
+ function getRangeCommits(cwd, from, to = "HEAD") {
249
+ const range = from ? `${from}..${to}` : to;
250
+ return git$1(cwd, [
251
+ "-c",
252
+ "log.showSignature=false",
253
+ "log",
254
+ "--reverse",
255
+ "--no-color",
256
+ ...nameOnly,
257
+ "--format=%x1e%H%x1f%B%x1f",
258
+ range,
259
+ "--"
260
+ ]).split("").filter((entry) => entry.includes("")).map((entry) => {
261
+ const hashEnd = entry.indexOf("");
262
+ const messageEnd = entry.indexOf("", hashEnd + 1);
263
+ return {
264
+ hash: entry.slice(0, hashEnd),
265
+ message: entry.slice(hashEnd + 1, messageEnd).trim(),
266
+ files: splitPaths(entry.slice(messageEnd + 1))
267
+ };
268
+ });
269
+ }
270
+ /**
271
+ * Latest `v*` version tag reachable from `ref`, or `undefined` when there is none.
272
+ * A tag on `ref` itself does not count, so `explain --to v1.1.0` explains v1.1.0.
273
+ */
274
+ function getLatestTag(cwd, ref = "HEAD") {
275
+ try {
276
+ return git$1(cwd, [
277
+ "describe",
278
+ "--tags",
279
+ "--abbrev=0",
280
+ "--match",
281
+ "v[0-9]*",
282
+ `${ref}^`
283
+ ]).trim() || void 0;
284
+ } catch {
285
+ return;
286
+ }
287
+ }
288
+ /** `v*` tags, optionally only those merged into `ref`; none when `ref` does not exist, such as the parent of a root commit. */
289
+ function getVersionTags(cwd, mergedInto) {
290
+ try {
291
+ return splitPaths(git$1(cwd, [
292
+ "tag",
293
+ "--list",
294
+ "v*",
295
+ ...mergedInto ? ["--merged", mergedInto] : []
296
+ ]).replace(/\n/g, "\0"));
297
+ } catch {
298
+ return [];
299
+ }
300
+ }
301
+ /** The checked-out branch, or `undefined` on a detached HEAD. */
302
+ function getCurrentBranch(cwd) {
303
+ const branch = git$1(cwd, [
304
+ "rev-parse",
305
+ "--abbrev-ref",
306
+ "HEAD"
307
+ ]).trim();
308
+ return branch === "HEAD" ? void 0 : branch;
309
+ }
310
+ /** Resolves the release of a commit range with the repository's policy and release paths. */
311
+ async function explainRange(cwd, from, to = "HEAD", overrides = {}) {
312
+ const project = await loadProjectPolicy(cwd, overrides);
313
+ const commits = getRangeCommits(cwd, from, to);
314
+ return {
315
+ ...project,
316
+ from,
317
+ to,
318
+ ...resolveRelease(commits, project.policy, project.isReleasePath ? { isReleasePath: project.isReleasePath } : {})
319
+ };
320
+ }
321
+ // src/release.ts
322
+ const officialRegistry = "https://registry.npmjs.org/";
323
+ /** npm dist-tag that holds a release until every package of it is published. */
324
+ const stagingDistTag = "aronrepo-staging";
325
+ /** GitHub rejects release bodies over 125,000 characters. */
326
+ const releaseBodyLimit = 12e4;
327
+ /** Quotes an argument for `cmd.exe`, which runs `.cmd` shims such as `npm.cmd` on Windows. */
328
+ function quoteWindowsArg(arg) {
329
+ if (/^[\w@+=:,./\\-]+$/.test(arg)) return arg;
330
+ return `"${arg.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1")}"`;
331
+ }
332
+ const runCommand = (command, args, { cwd, env }) => new Promise((resolve, reject) => {
333
+ const child = process.platform === "win32" ? spawn([command, ...args].map(quoteWindowsArg).join(" "), {
334
+ cwd,
335
+ env,
336
+ stdio: [
337
+ "ignore",
338
+ "pipe",
339
+ "pipe"
340
+ ],
341
+ shell: true
342
+ }) : spawn(command, args, {
343
+ cwd,
344
+ env,
345
+ stdio: [
346
+ "ignore",
347
+ "pipe",
348
+ "pipe"
349
+ ]
350
+ });
351
+ let stdout = "";
352
+ let stderr = "";
353
+ child.stdout.on("data", (chunk) => {
354
+ stdout += chunk;
355
+ });
356
+ child.stderr.on("data", (chunk) => {
357
+ stderr += chunk;
358
+ });
359
+ child.on("error", reject);
360
+ child.on("close", (code, signal) => {
361
+ if (code === 0) resolve({
362
+ stdout,
363
+ stderr
364
+ });
365
+ else reject(/* @__PURE__ */ new Error(`\`${command} ${args.join(" ")}\` ${signal ? `was stopped by ${signal}` : `exited with ${code}`}.\n${stderr.trim() || stdout.trim()}`));
366
+ });
367
+ });
368
+ function createEnvironment(options = {}) {
369
+ return {
370
+ cwd: options.cwd ?? process.cwd(),
371
+ env: options.env ?? process.env,
372
+ run: options.run ?? runCommand,
373
+ fetch: options.fetch ?? ((input, init) => fetch(input, init)),
374
+ log: options.log ?? ((message) => console.log(message)),
375
+ wait: options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
376
+ };
377
+ }
378
+ /** Variables that hold credentials. Package scripts that run while packing never see them. */
379
+ const secretVariables = [
380
+ "NPM_TOKEN",
381
+ "NODE_AUTH_TOKEN",
382
+ "NPM_ID_TOKEN",
383
+ "GITHUB_TOKEN",
384
+ "GH_TOKEN",
385
+ "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
386
+ "ACTIONS_ID_TOKEN_REQUEST_URL",
387
+ "ACTIONS_RUNTIME_TOKEN"
388
+ ];
389
+ function withoutSecrets(env) {
390
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !secretVariables.includes(key.toUpperCase())));
391
+ }
392
+ async function git(environment, args) {
393
+ return (await environment.run("git", args, {
394
+ cwd: environment.cwd,
395
+ env: environment.env
396
+ })).stdout.trim();
397
+ }
398
+ async function gitSucceeds(environment, args) {
399
+ try {
400
+ await git(environment, args);
401
+ return true;
402
+ } catch {
403
+ return false;
404
+ }
405
+ }
406
+ function lines(output) {
407
+ return output.split("\n").map((line) => line.trim()).filter(Boolean);
408
+ }
409
+ const readJson = readJsonFile;
410
+ const dependencyFields = [
411
+ "dependencies",
412
+ "optionalDependencies",
413
+ "peerDependencies"
414
+ ];
415
+ /** Fields whose packages must be published first; peers only need to exist when installed. */
416
+ const orderFields = ["dependencies", "optionalDependencies"];
417
+ /** Published packages of the repository in dependency order. */
418
+ function getReleasePackages(cwd) {
419
+ const packages = queryReleaseDirs(cwd).map((dir) => {
420
+ const manifest = readJson(path.join(cwd, dir, "package.json"));
421
+ if (!manifest.name) throw new Error(`${dir}/package.json has no name.`);
422
+ return {
423
+ dir,
424
+ name: manifest.name,
425
+ manifest
426
+ };
427
+ });
428
+ const names = /* @__PURE__ */ new Map();
429
+ for (const { dir, name } of packages) {
430
+ if (names.has(name)) throw new Error(`${names.get(name)}/package.json and ${dir}/package.json are both named ${name}.`);
431
+ names.set(name, dir);
432
+ }
433
+ const dependenciesOf = (pkg) => orderFields.flatMap((field) => Object.keys(pkg.manifest[field] ?? {})).filter((name) => names.has(name) && name !== pkg.name);
434
+ const ordered = [];
435
+ const visiting = /* @__PURE__ */ new Set();
436
+ const visit = (pkg) => {
437
+ if (ordered.includes(pkg)) return;
438
+ if (visiting.has(pkg.name)) throw new Error(`Published packages depend on each other in a cycle through ${pkg.name}.`);
439
+ visiting.add(pkg.name);
440
+ for (const name of dependenciesOf(pkg)) visit(packages.find((candidate) => candidate.name === name));
441
+ visiting.delete(pkg.name);
442
+ ordered.push(pkg);
443
+ };
444
+ for (const pkg of [...packages].sort((a, b) => a.dir.localeCompare(b.dir))) visit(pkg);
445
+ return ordered;
446
+ }
447
+ /**
448
+ * The repository URL on GitHub, from `GITHUB_REPOSITORY` or the root package.json.
449
+ * `owner/repo` and `github:owner/repo` are npm shorthands for GitHub.
450
+ */
451
+ function getRepositoryUrl(cwd, env = process.env) {
452
+ if (env.GITHUB_REPOSITORY) return `${(env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/+$/, "")}/${env.GITHUB_REPOSITORY}`;
453
+ const root = path.join(cwd, "package.json");
454
+ if (!fs.existsSync(root)) return void 0;
455
+ const repository = readJson(root).repository;
456
+ const url = (typeof repository === "string" ? repository : repository?.url)?.replace(/#.*$/, "");
457
+ if (!url) return void 0;
458
+ const match = /^(?:github:)?([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url) ?? /(?:^|[/@])github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(url);
459
+ return match ? `https://github.com/${match[1]}` : void 0;
460
+ }
461
+ async function getBranch(environment, branch) {
462
+ if (branch) return branch;
463
+ const checkedOut = await git(environment, [
464
+ "rev-parse",
465
+ "--abbrev-ref",
466
+ "HEAD"
467
+ ]);
468
+ const ref = environment.env.GITHUB_REF_NAME;
469
+ if (!ref) return checkedOut;
470
+ if (checkedOut !== "HEAD" && checkedOut !== ref) throw new Error(`GITHUB_REF_NAME is ${ref}, but ${checkedOut} is checked out. Pass --branch to choose the release branch.`);
471
+ return ref;
472
+ }
473
+ function renderPlanNotes(environment, plan, version, date) {
474
+ const repositoryUrl = getRepositoryUrl(environment.cwd, environment.env);
475
+ return renderReleaseNotes({
476
+ version,
477
+ ...plan.from ? { previousTag: plan.from } : {},
478
+ date: date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
479
+ ...repositoryUrl ? { repositoryUrl } : {},
480
+ commits: plan.commits
481
+ });
482
+ }
483
+ /** Plans the next release of the checked-out commit without changing anything. */
484
+ async function planRelease(options = {}, environmentOptions = {}) {
485
+ const environment = createEnvironment(environmentOptions);
486
+ const { cwd } = environment;
487
+ const project = await loadProjectPolicy(cwd, options.mode ? { mode: options.mode } : {});
488
+ const branch = await getBranch(environment, options.branch);
489
+ const channel = getReleaseChannel(branch) ?? (options.fallbackToMain ? getReleaseChannel("main") : void 0);
490
+ const head = await git(environment, ["rev-parse", "HEAD"]);
491
+ const packages = getReleasePackages(cwd);
492
+ const base = {
493
+ branch,
494
+ head,
495
+ mode: project.policy.mode,
496
+ release: false,
497
+ commits: [],
498
+ packages
499
+ };
500
+ if (!channel) return base;
501
+ if (await git(environment, ["rev-parse", "--is-shallow-repository"]) === "true") throw new Error("The repository is a shallow clone, so its tags and commits are incomplete. Fetch the full history, such as `fetch-depth: 0` in actions/checkout.");
502
+ const ignored = new Set(options.ignoreTags ?? []);
503
+ const reachable = lines(await git(environment, [
504
+ "tag",
505
+ "--list",
506
+ "v*",
507
+ "--merged",
508
+ "HEAD"
509
+ ])).filter((tag) => !ignored.has(tag));
510
+ const all = lines(await git(environment, [
511
+ "tag",
512
+ "--list",
513
+ "v*"
514
+ ])).filter((tag) => !ignored.has(tag));
515
+ const from = getBaseTag(channel, reachable);
516
+ const decision = resolveRelease(getRangeCommits(cwd, from, "HEAD"), project.policy, project.isReleasePath ? { isReleasePath: project.isReleasePath } : {});
517
+ const plan = {
518
+ ...base,
519
+ channel,
520
+ ...from ? { from } : {},
521
+ release: decision.release,
522
+ commits: decision.commits
523
+ };
524
+ if (!decision.release) return plan;
525
+ const version = options.version ?? getNextVersion(channel, decision.release, {
526
+ reachable,
527
+ all
528
+ });
529
+ return {
530
+ ...plan,
531
+ version,
532
+ tag: toVersionTag(version),
533
+ notes: renderPlanNotes(environment, plan, version, options.date)
534
+ };
535
+ }
536
+ /** `.npmrc` content that applies in a repository: the project file, then the user file, as npm reads them. */
537
+ function readNpmConfigs(cwd, env) {
538
+ const userconfig = env.NPM_CONFIG_USERCONFIG ?? env.npm_config_userconfig ?? path.join(os.homedir(), ".npmrc");
539
+ return {
540
+ project: readNpmrc(path.join(cwd, ".npmrc")),
541
+ user: readNpmrc(userconfig)
542
+ };
543
+ }
544
+ /** The last value of an `.npmrc` key, with `${VAR}` and `${VAR?}` expanded, or `undefined`. */
545
+ function readNpmrcValue(content, key, env) {
546
+ let value;
547
+ for (const line of content.split(/\r?\n/)) {
548
+ const trimmed = line.trim();
549
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
550
+ const index = trimmed.indexOf("=");
551
+ if (index === -1 || trimmed.slice(0, index).trim() !== key) continue;
552
+ value = trimmed.slice(index + 1).trim().replace(/^(['"])(.*)\1$/, "$2").replace(/\$\{([^}?]+)\??\}/g, (_, name) => env[name] ?? "");
553
+ }
554
+ return value;
555
+ }
556
+ /**
557
+ * Registry of a package, in npm's order: `publishConfig` (scoped, then plain),
558
+ * the `@scope:registry` of `.npmrc` or the environment, `npm_config_registry`,
559
+ * the `registry` of `.npmrc`, then npm. Pass `npmrc` to read `.npmrc` settings.
560
+ */
561
+ function getRegistry(manifest, env = process.env, npmrc = "") {
562
+ const scope = manifest.name?.startsWith("@") ? manifest.name.split("/")[0] : void 0;
563
+ const configured = [
564
+ scope ? manifest.publishConfig?.[`${scope}:registry`] : void 0,
565
+ manifest.publishConfig?.registry,
566
+ scope ? env[`npm_config_${scope}:registry`] ?? env[`NPM_CONFIG_${scope}:registry`] : void 0,
567
+ scope ? readNpmrcValue(npmrc, `${scope}:registry`, env) : void 0,
568
+ env.NPM_CONFIG_REGISTRY ?? env.npm_config_registry,
569
+ readNpmrcValue(npmrc, "registry", env)
570
+ ].find((value) => typeof value === "string" && value);
571
+ const registry = typeof configured === "string" ? configured : officialRegistry;
572
+ return registry.endsWith("/") ? registry : `${registry}/`;
573
+ }
574
+ function nerfDart(registry) {
575
+ const url = new URL(registry);
576
+ return `//${url.host}${url.pathname.endsWith("/") ? url.pathname : `${url.pathname}/`}`;
577
+ }
578
+ function isTransient(status) {
579
+ return status === 408 || status === 429 || status >= 500;
580
+ }
581
+ async function readPackument(environment, registry, name) {
582
+ const headers = {
583
+ accept: "application/json",
584
+ "cache-control": "no-cache"
585
+ };
586
+ if (registry !== "https://registry.npmjs.org/" && environment.env.NPM_TOKEN) headers.authorization = `Bearer ${environment.env.NPM_TOKEN}`;
587
+ for (let attempt = 1;; attempt++) {
588
+ let response;
589
+ try {
590
+ response = await environment.fetch(`${registry}${name.replace("/", "%2f")}`, { headers });
591
+ } catch (error) {
592
+ if (attempt >= 4) throw new Error(`Could not read ${name} from ${registry}: ${error.message}`, { cause: error });
593
+ await environment.wait(2e3 * attempt);
594
+ continue;
595
+ }
596
+ if (response.status === 404) return void 0;
597
+ if (response.ok) return await response.json();
598
+ if (!isTransient(response.status) || attempt >= 4) throw new Error(`Could not read ${name} from ${registry}: ${response.status} ${response.statusText}`);
599
+ await environment.wait(2e3 * attempt);
600
+ }
601
+ }
602
+ async function getGitHubIdToken(environment, audience) {
603
+ const { ACTIONS_ID_TOKEN_REQUEST_URL: url, ACTIONS_ID_TOKEN_REQUEST_TOKEN: token } = environment.env;
604
+ if (!url || !token) return void 0;
605
+ const response = await environment.fetch(`${url}${url.includes("?") ? "&" : "?"}audience=${encodeURIComponent(audience)}`, { headers: {
606
+ authorization: `Bearer ${token}`,
607
+ accept: "application/json"
608
+ } });
609
+ if (!response.ok) {
610
+ environment.log(`! Could not get a GitHub Actions OIDC token: ${response.status} ${response.statusText}`);
611
+ return;
612
+ }
613
+ return (await response.json()).value;
614
+ }
615
+ /** Exchanges a GitHub Actions OIDC token for a short-lived npm publish token (npm trusted publishing). */
616
+ async function exchangeOidcToken(environment, name, registry) {
617
+ if (registry !== "https://registry.npmjs.org/") return void 0;
618
+ const identity = environment.env.NPM_ID_TOKEN || await getGitHubIdToken(environment, "npm:registry.npmjs.org");
619
+ if (!identity) return void 0;
620
+ const response = await environment.fetch(`${officialRegistry}-/npm/v1/oidc/token/exchange/package/${name.replace("/", "%2f")}`, {
621
+ method: "POST",
622
+ headers: { authorization: `Bearer ${identity}` }
623
+ });
624
+ if (!response.ok) {
625
+ environment.log(`! Could not exchange the OIDC token for ${name}: ${response.status} ${response.statusText}`);
626
+ return;
627
+ }
628
+ return (await response.json()).token;
629
+ }
630
+ function readNpmrc(file) {
631
+ return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
632
+ }
633
+ /** actions/setup-node writes `_authToken=${NODE_AUTH_TOKEN}` and sets this placeholder when no token is given. */
634
+ const placeholderToken = "XXXXX-XXXXX-XXXXX-XXXXX";
635
+ /**
636
+ * Auth for the registry in `.npmrc` content: `valid`, `invalid` (an unset
637
+ * `${VAR}` or the setup-node placeholder), or `undefined` when there is none.
638
+ * npm 9+ rejects auth without a registry prefix, so only `//host/path/:` keys count.
639
+ */
640
+ function readRegistryAuth(content, registry, env) {
641
+ const nerfed = nerfDart(registry);
642
+ const usable = (value) => value === void 0 ? void 0 : Boolean(value) && value !== placeholderToken;
643
+ const token = usable(readNpmrcValue(content, `${nerfed}:_authToken`, env));
644
+ const auth = usable(readNpmrcValue(content, `${nerfed}:_auth`, env));
645
+ const username = usable(readNpmrcValue(content, `${nerfed}:username`, env));
646
+ const password = usable(readNpmrcValue(content, `${nerfed}:_password`, env));
647
+ if (token || auth || username && password) return "valid";
648
+ if (token === false || auth === false || username === false || password === false) return "invalid";
649
+ }
650
+ /** Whether `.npmrc` content has usable auth for the registry. */
651
+ function hasRegistryAuth(content, registry, env = process.env) {
652
+ return readRegistryAuth(content, registry, env) === "valid";
653
+ }
654
+ let npmrcCount = 0;
655
+ /**
656
+ * npm environment for one npm command: existing `.npmrc` auth, then npm trusted
657
+ * publishing through GitHub Actions OIDC, then `NPM_TOKEN`. An OIDC token
658
+ * authorizes one registry route, so every command exchanges its own.
659
+ *
660
+ * npm reads the project `.npmrc` before the user config, so a project
661
+ * `_authToken=${NPM_TOKEN}` would win over a token in the user config. Both
662
+ * files go into one temporary user config instead, the token last, and npm
663
+ * runs outside the repository.
664
+ */
665
+ async function createNpmEnv(environment, name, registry, directory, skipOidc = false) {
666
+ const { project, user } = readNpmConfigs(environment.cwd, environment.env);
667
+ const config = [user.trim(), project.trim()].filter(Boolean).join("\n");
668
+ const write = (content) => {
669
+ const file = path.join(directory, `${name.replace(/[@/]/g, "_")}-${++npmrcCount}.npmrc`);
670
+ fs.writeFileSync(file, `${content}\n`, { mode: 384 });
671
+ return {
672
+ ...environment.env,
673
+ NPM_CONFIG_USERCONFIG: file
674
+ };
675
+ };
676
+ if ((readRegistryAuth(project, registry, environment.env) ?? readRegistryAuth(user, registry, environment.env)) === "valid") return {
677
+ env: write(config),
678
+ source: "npmrc"
679
+ };
680
+ const oidc = skipOidc ? void 0 : await exchangeOidcToken(environment, name, registry);
681
+ const token = oidc ?? environment.env.NPM_TOKEN;
682
+ if (!token) throw new Error(`No npm auth for ${name}. Configure npm trusted publishing with \`id-token: write\`, set NPM_TOKEN, or add auth to .npmrc for ${registry}.`);
683
+ return {
684
+ env: write(`${config}\n${nerfDart(registry)}:_authToken=${token}`.trim()),
685
+ source: oidc ? "oidc" : "token"
686
+ };
687
+ }
688
+ function detectIndent(content) {
689
+ return /^([ \t]+)"/m.exec(content)?.[1] ?? " ";
690
+ }
691
+ /** A range for `version` in the style of `range`: exact, `~`, or `^`. */
692
+ function rangeFor(range, version) {
693
+ if (semver.valid(range)) return version;
694
+ return range.trim().startsWith("~") ? `~${version}` : `^${version}`;
695
+ }
696
+ /**
697
+ * Writes the release version and git head into a package manifest. Versions are
698
+ * never committed, so ranges on packages of the same release, such as `^0.0.0`,
699
+ * are moved to the release version when they do not accept it.
700
+ */
701
+ function writeReleaseManifest(cwd, dir, version, gitHead, options = {}) {
702
+ const file = path.join(cwd, dir, "package.json");
703
+ const content = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
704
+ const manifest = JSON.parse(content);
705
+ manifest.version = version;
706
+ manifest.gitHead = gitHead;
707
+ for (const field of dependencyFields) {
708
+ const dependencies = manifest[field];
709
+ for (const [name, range] of Object.entries(dependencies ?? {})) {
710
+ if (!options.internal?.includes(name) || !semver.validRange(range)) continue;
711
+ if (!semver.satisfies(version, range, { includePrerelease: true })) dependencies[name] = rangeFor(range, version);
712
+ }
713
+ }
714
+ if (dir !== "." && !manifest.repository && options.repository) {
715
+ const root = options.repository;
716
+ manifest.repository = typeof root === "string" ? {
717
+ type: "git",
718
+ url: root,
719
+ directory: dir
720
+ } : {
721
+ ...root,
722
+ directory: dir
723
+ };
724
+ }
725
+ fs.writeFileSync(file, `${JSON.stringify(manifest, null, detectIndent(content))}\n`);
726
+ return manifest;
727
+ }
728
+ /** Yarn 1 names the tarball with `--filename`; Yarn 2+ uses `--out`. */
729
+ async function isYarnClassic(environment, env) {
730
+ const declared = /^yarn@(\d+)/.exec(String(readJson(path.join(environment.cwd, "package.json")).packageManager ?? ""));
731
+ if (declared) return declared[1] === "1";
732
+ const { stdout } = await environment.run("yarn", ["--version"], {
733
+ cwd: environment.cwd,
734
+ env
735
+ });
736
+ return stdout.trim().startsWith("1.");
737
+ }
738
+ async function packCommand(environment, packageManager, destination, env) {
739
+ switch (packageManager) {
740
+ case "bun": return ["bun", [
741
+ "pm",
742
+ "pack",
743
+ "--destination",
744
+ destination,
745
+ "--quiet"
746
+ ]];
747
+ case "pnpm": return ["pnpm", [
748
+ "pack",
749
+ "--pack-destination",
750
+ destination
751
+ ]];
752
+ case "yarn": return ["yarn", [
753
+ "pack",
754
+ await isYarnClassic(environment, env) ? "--filename" : "--out",
755
+ path.join(destination, "package.tgz")
756
+ ]];
757
+ default: return ["npm", [
758
+ "pack",
759
+ "--pack-destination",
760
+ destination
761
+ ]];
762
+ }
763
+ }
764
+ /** Reads `package/package.json` from a packed tarball. */
765
+ function readPackedManifest(tarball) {
766
+ const archive = gunzipSync(fs.readFileSync(tarball));
767
+ let offset = 0;
768
+ let longName;
769
+ while (offset + 512 <= archive.length) {
770
+ const header = archive.subarray(offset, offset + 512);
771
+ if (header.every((byte) => byte === 0)) break;
772
+ const field = (start, length) => header.subarray(start, start + length).toString("utf8").replace(/\0.*$/s, "");
773
+ const size = parseInt(field(124, 12).trim() || "0", 8);
774
+ const type = field(156, 1);
775
+ const prefix = field(345, 155);
776
+ const name = longName ?? (prefix ? `${prefix}/${field(0, 100)}` : field(0, 100));
777
+ const body = archive.subarray(offset + 512, offset + 512 + size);
778
+ longName = void 0;
779
+ if (type === "L") longName = body.toString("utf8").replace(/\0.*$/s, "");
780
+ else if (type === "x") {
781
+ const pathRecord = /\d+ path=([^\n]+)\n/.exec(body.toString("utf8"));
782
+ if (pathRecord) longName = pathRecord[1];
783
+ } else if (name.replace(/^\.\//, "").split("/").slice(1).join("/") === "package.json") return JSON.parse(body.toString("utf8").replace(/^\uFEFF/, ""));
784
+ offset += 512 + Math.ceil(size / 512) * 512;
785
+ }
786
+ throw new Error(`${tarball} has no package.json.`);
787
+ }
788
+ /** Local protocols that only resolve inside the repository. */
789
+ const localProtocols = /^(?:workspace|file|link|portal|catalog):/;
790
+ function validatePackedManifest(manifest, pkg, version, gitHead, internal) {
791
+ if (manifest.name !== pkg.name) throw new Error(`The tarball of ${pkg.dir} is ${manifest.name}, not ${pkg.name}.`);
792
+ if (manifest.version !== version) throw new Error(`The tarball of ${pkg.name} has version ${manifest.version}, not ${version}.`);
793
+ if (manifest.gitHead !== gitHead) throw new Error(`The tarball of ${pkg.name} has gitHead ${String(manifest.gitHead)}, not ${gitHead}.`);
794
+ for (const field of dependencyFields) for (const [name, range] of Object.entries(manifest[field] ?? {})) {
795
+ if (localProtocols.test(range)) throw new Error(`The tarball of ${pkg.name} keeps \`${name}: ${range}\` in ${field}.`);
796
+ if (internal.includes(name) && semver.validRange(range) && !semver.satisfies(version, range, { includePrerelease: true })) throw new Error(`The tarball of ${pkg.name} has \`${name}: ${range}\` in ${field}, which does not accept the release version ${version}.`);
797
+ }
798
+ }
799
+ async function waitForVersion(environment, registry, name, version) {
800
+ for (let attempt = 1; attempt <= 30; attempt++) {
801
+ const published = (await readPackument(environment, registry, name))?.versions?.[version];
802
+ if (published) return published;
803
+ await environment.wait(1e4);
804
+ }
805
+ throw new Error(`${name}@${version} did not appear on ${registry}.`);
806
+ }
807
+ /** Cuts notes at a line to fit a GitHub release body, keeping the full changelog link. */
808
+ function truncateNotes(notes, limit = releaseBodyLimit) {
809
+ if (notes.length <= limit) return notes;
810
+ const footer = /\n\n(\*\*Full Changelog\*\*: \S+)\n?$/.exec(notes)?.[1];
811
+ const tail = `\n\n${`…the notes are too long for a GitHub release${footer ? "; see the full changelog" : ""}.`}${footer ? `\n\n${footer}` : ""}\n`;
812
+ const cut = notes.slice(0, Math.max(0, limit - tail.length));
813
+ const end = cut.lastIndexOf("\n");
814
+ return `${(end > 0 ? cut.slice(0, end) : cut).trimEnd()}${tail}`;
815
+ }
816
+ /** Creates the GitHub release of a tag unless it exists. Needs `GITHUB_TOKEN` and the repository. */
817
+ async function publishGitHubRelease(environment, release) {
818
+ const token = environment.env.GITHUB_TOKEN ?? environment.env.GH_TOKEN;
819
+ const repository = getRepositoryUrl(environment.cwd, environment.env)?.replace(/^https?:\/\/[^/]+\//, "");
820
+ if (!token || !repository) {
821
+ environment.log("! Skip the GitHub release: set GITHUB_TOKEN and a GitHub repository URL.");
822
+ return;
823
+ }
824
+ const api = `${(environment.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/+$/, "")}/repos/${repository}/releases`;
825
+ const headers = {
826
+ authorization: `Bearer ${token}`,
827
+ accept: "application/vnd.github+json",
828
+ "content-type": "application/json",
829
+ "x-github-api-version": "2022-11-28"
830
+ };
831
+ const existing = await environment.fetch(`${api}/tags/${encodeURIComponent(release.tag)}`, { headers });
832
+ if (existing.ok) {
833
+ environment.log(`✓ GitHub release ${release.tag} exists.`);
834
+ return (await existing.json()).html_url;
835
+ }
836
+ if (existing.status !== 404) throw new Error(`Could not check the GitHub release ${release.tag}: ${existing.status} ${await existing.text()}`);
837
+ const latest = release.latest ?? !release.prerelease;
838
+ const response = await environment.fetch(api, {
839
+ method: "POST",
840
+ headers,
841
+ body: JSON.stringify({
842
+ tag_name: release.tag,
843
+ ...release.target ? { target_commitish: release.target } : {},
844
+ name: release.tag,
845
+ body: truncateNotes(release.notes),
846
+ prerelease: release.prerelease,
847
+ make_latest: latest ? "true" : "false"
848
+ })
849
+ });
850
+ if (!response.ok) throw new Error(`Could not create the GitHub release ${release.tag}: ${response.status} ${await response.text()}`);
851
+ const url = (await response.json()).html_url;
852
+ environment.log(`✓ Created the GitHub release ${url ?? release.tag}.`);
853
+ return url;
854
+ }
855
+ function describePlan(plan) {
856
+ const range = plan.from ? `${plan.from}..${plan.head.slice(0, 9)}` : `all commits up to ${plan.head.slice(0, 9)}`;
857
+ return [
858
+ `Branch: ${plan.branch}${plan.channel ? ` (npm dist-tag ${plan.channel.distTag})` : ""}`,
859
+ `Mode: ${plan.mode}`,
860
+ `Range: ${range}`,
861
+ ...plan.commits.map((decision) => ` ${(decision.release || "none").padEnd(5)} ${decision.hash?.slice(0, 9) ?? ""} ${decision.header}`),
862
+ `Release: ${plan.release || "none"}${plan.version ? ` → ${plan.version}` : ""}`,
863
+ `Packages: ${plan.packages.map(({ name }) => name).join(", ") || "none"}`
864
+ ].join("\n");
865
+ }
866
+ /** Whether a stable version is the highest stable version tagged in the repository. */
867
+ async function isLatestStable(environment, version) {
868
+ return !lines(await git(environment, [
869
+ "tag",
870
+ "--list",
871
+ "v*"
872
+ ])).flatMap((tag) => parseVersionTag(tag) ?? []).some((other) => !semver.prerelease(other) && semver.gt(other, version));
873
+ }
874
+ /** Pushes a version tag without the repository's pre-push hooks, which would run after npm already published. */
875
+ async function pushTag(environment, tag) {
876
+ await git(environment, [
877
+ "push",
878
+ "--no-verify",
879
+ "origin",
880
+ `refs/tags/${tag}`
881
+ ]);
882
+ }
883
+ /** Finishes a release whose tag points at HEAD: pushes the tag and creates a missing GitHub release. */
884
+ async function resumeTaggedRelease(environment, options, plan) {
885
+ const channel = plan.channel;
886
+ if (!channel) return false;
887
+ const tag = lines(await git(environment, [
888
+ "tag",
889
+ "--list",
890
+ "v*",
891
+ "--points-at",
892
+ "HEAD"
893
+ ])).find((candidate) => {
894
+ const version = parseVersionTag(candidate);
895
+ if (!version) return false;
896
+ const prerelease = semver.prerelease(version);
897
+ return channel.prerelease ? prerelease?.[0] === channel.prerelease : !prerelease;
898
+ });
899
+ if (!tag || options.dryRun) return false;
900
+ const previous = await planRelease({
901
+ ...options,
902
+ ignoreTags: [tag],
903
+ version: parseVersionTag(tag)
904
+ }, environment);
905
+ if (!previous.notes) return false;
906
+ environment.log(`${tag} points at HEAD; make sure the tag is pushed and its GitHub release exists.`);
907
+ await pushTag(environment, tag);
908
+ await publishGitHubRelease(environment, {
909
+ tag,
910
+ notes: previous.notes,
911
+ prerelease: Boolean(channel.prerelease),
912
+ target: plan.head,
913
+ latest: !channel.prerelease && await isLatestStable(environment, previous.version)
914
+ });
915
+ return true;
916
+ }
917
+ /** The next version to try when one is taken: the next patch, or the next prerelease number. */
918
+ function nextFreeVersion(version) {
919
+ return semver.prerelease(version) ? semver.inc(version, "prerelease") : semver.inc(version, "patch");
920
+ }
921
+ /**
922
+ * Releases the checked-out commit:
923
+ *
924
+ * 1. plan the version from the commits since the channel's last tag;
925
+ * 2. write the version and git head into every published package and pack it;
926
+ * 3. publish each package, in dependency order, on the `aronrepo-staging`
927
+ * dist-tag, skipping versions already published from the same commit;
928
+ * 4. wait until the registry serves every package, then move the channel's
929
+ * dist-tag to the new version;
930
+ * 5. push the version tag and create the GitHub release.
931
+ *
932
+ * Every step checks what an interrupted run already did, so rerunning the job
933
+ * finishes a release instead of skipping or duplicating it.
934
+ */
935
+ async function runRelease(options = {}, environmentOptions = {}) {
936
+ const environment = createEnvironment(environmentOptions);
937
+ const { cwd, log } = environment;
938
+ const plan = await planRelease(options, environment);
939
+ log(describePlan(plan));
940
+ if (!plan.channel) {
941
+ log(`Branch ${plan.branch} does not release. Release branches: main, alpha, beta, rc, canary.`);
942
+ return {
943
+ status: "skipped",
944
+ plan
945
+ };
946
+ }
947
+ if (!plan.version || !plan.tag || !plan.notes) {
948
+ if (await resumeTaggedRelease(environment, options, plan)) return {
949
+ status: "resumed",
950
+ plan
951
+ };
952
+ log("No release.");
953
+ return {
954
+ status: "none",
955
+ plan
956
+ };
957
+ }
958
+ if (!plan.packages.length) throw new Error("No published package found. Add `\"publishConfig\": { \"access\": \"public\" }` to the packages that ship.");
959
+ for (const pkg of plan.packages) if (pkg.name.startsWith("@") && !pkg.manifest.publishConfig?.access) throw new Error(`${pkg.name} is scoped, and npm publishes scoped packages as restricted by default. Set \`publishConfig.access\` in ${pkg.dir}/package.json.`);
960
+ const dirty = await git(environment, ["status", "--porcelain"]);
961
+ const existingTag = await git(environment, [
962
+ "tag",
963
+ "--list",
964
+ plan.tag
965
+ ]);
966
+ if (existingTag && await gitSucceeds(environment, [
967
+ "merge-base",
968
+ "--is-ancestor",
969
+ "HEAD",
970
+ plan.tag
971
+ ])) {
972
+ log(`${plan.tag} is on a newer commit of ${plan.branch}; this commit is already released.`);
973
+ return {
974
+ status: "none",
975
+ plan
976
+ };
977
+ }
978
+ if (options.dryRun) {
979
+ if (dirty) log("! The working tree has changes; a release would stop here.");
980
+ if (existingTag) log(`! ${plan.tag} exists but is not reachable from ${plan.branch}; a release would stop here.`);
981
+ log(`\nDry run: would publish ${plan.tag} on ${plan.channel.distTag}.\n\n${plan.notes}`);
982
+ return {
983
+ status: "dry-run",
984
+ plan
985
+ };
986
+ }
987
+ if (dirty) throw new Error("The working tree has changes. Release from a clean checkout.");
988
+ if (existingTag) throw new Error(`${plan.tag} exists but is not reachable from ${plan.branch}.`);
989
+ const npmrc = (() => {
990
+ const { project, user } = readNpmConfigs(cwd, environment.env);
991
+ return `${user}\n${project}`;
992
+ })();
993
+ const registryOf = (pkg) => getRegistry(pkg.manifest, environment.env, npmrc);
994
+ let version = plan.version;
995
+ for (let attempt = 0;; attempt++) {
996
+ const taken = [];
997
+ for (const pkg of plan.packages) {
998
+ const published = (await readPackument(environment, registryOf(pkg), pkg.name))?.versions?.[version];
999
+ if (!published || published.gitHead === plan.head) continue;
1000
+ if (!published.gitHead) throw new Error(`${pkg.name}@${version} is already published without a gitHead, so aronrepo cannot tell which commit it came from. Tag that commit ${toVersionTag(version)}, or publish a newer version.`);
1001
+ taken.push(`${pkg.name}@${version} (${published.gitHead.slice(0, 9)})`);
1002
+ }
1003
+ if (!taken.length) break;
1004
+ if (attempt >= 20 || await git(environment, [
1005
+ "tag",
1006
+ "--list",
1007
+ toVersionTag(nextFreeVersion(version))
1008
+ ])) throw new Error(`${taken.join(", ")} is already published from another commit, not ${plan.head}.`);
1009
+ const next = nextFreeVersion(version);
1010
+ log(`! ${taken.join(", ")} is already published from another commit without a ${toVersionTag(version)} tag; releasing ${next} instead.`);
1011
+ version = next;
1012
+ }
1013
+ if (version !== plan.version) {
1014
+ plan.version = version;
1015
+ plan.tag = toVersionTag(version);
1016
+ plan.notes = renderPlanNotes(environment, plan, version, options.date);
1017
+ }
1018
+ const { tag, notes, channel } = {
1019
+ tag: plan.tag,
1020
+ notes: plan.notes,
1021
+ channel: plan.channel
1022
+ };
1023
+ for (const pkg of plan.packages) {
1024
+ const packument = await readPackument(environment, registryOf(pkg), pkg.name);
1025
+ const current = packument?.["dist-tags"]?.[channel.distTag];
1026
+ if (current && semver.valid(current) && semver.gt(current, version)) throw new Error(`${pkg.name} has ${current} on ${channel.distTag}, newer than ${version}. Tag the commit of ${current} as ${toVersionTag(current)} so releases continue from it.`);
1027
+ if (!packument && channel.prerelease) log(`! ${pkg.name} is new; npm also points \`latest\` at its first version, ${version}.`);
1028
+ }
1029
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "aronrepo-release-"));
1030
+ const manifests = new Map(plan.packages.map(({ dir }) => {
1031
+ const file = path.join(cwd, dir, "package.json");
1032
+ return [file, fs.readFileSync(file, "utf8")];
1033
+ }));
1034
+ let restored = false;
1035
+ const restore = () => {
1036
+ if (restored) return;
1037
+ restored = true;
1038
+ fs.rmSync(directory, {
1039
+ recursive: true,
1040
+ force: true
1041
+ });
1042
+ for (const [file, content] of manifests) fs.writeFileSync(file, content);
1043
+ };
1044
+ const onSignal = (signal) => {
1045
+ restore();
1046
+ process.kill(process.pid, signal);
1047
+ };
1048
+ process.once("SIGINT", onSignal);
1049
+ process.once("SIGTERM", onSignal);
1050
+ const runNpm = async (pkg, args) => {
1051
+ const registry = registryOf(pkg);
1052
+ const command = [
1053
+ ...args,
1054
+ "--registry",
1055
+ registry
1056
+ ];
1057
+ const auth = await createNpmEnv(environment, pkg.name, registry, directory);
1058
+ try {
1059
+ return await environment.run("npm", command, {
1060
+ cwd: directory,
1061
+ env: auth.env
1062
+ });
1063
+ } catch (error) {
1064
+ if (auth.source !== "oidc" || !environment.env.NPM_TOKEN) throw error;
1065
+ log(`! \`npm ${args[0]}\` failed with the trusted publishing token; retrying with NPM_TOKEN.`);
1066
+ const fallback = await createNpmEnv(environment, pkg.name, registry, directory, true);
1067
+ return await environment.run("npm", command, {
1068
+ cwd: directory,
1069
+ env: fallback.env
1070
+ });
1071
+ }
1072
+ };
1073
+ try {
1074
+ const packageManager = detectPackageManager(cwd);
1075
+ const internal = plan.packages.map(({ name }) => name);
1076
+ const repository = (() => {
1077
+ const root = path.join(cwd, "package.json");
1078
+ return fs.existsSync(root) ? readJson(root).repository : void 0;
1079
+ })();
1080
+ for (const pkg of plan.packages) pkg.manifest = writeReleaseManifest(cwd, pkg.dir, version, plan.head, {
1081
+ internal,
1082
+ repository
1083
+ });
1084
+ const packEnv = withoutSecrets(environment.env);
1085
+ const tarballs = /* @__PURE__ */ new Map();
1086
+ for (const [index, pkg] of plan.packages.entries()) {
1087
+ const destination = path.join(directory, String(index));
1088
+ fs.mkdirSync(destination);
1089
+ const [command, args] = await packCommand(environment, packageManager, destination, packEnv);
1090
+ await environment.run(command, args, {
1091
+ cwd: path.join(cwd, pkg.dir),
1092
+ env: packEnv
1093
+ });
1094
+ const tarball = fs.readdirSync(destination).find((file) => file.endsWith(".tgz"));
1095
+ if (!tarball) throw new Error(`Packing ${pkg.name} produced no tarball.`);
1096
+ const file = path.join(destination, tarball);
1097
+ validatePackedManifest(readPackedManifest(file), pkg, version, plan.head, internal);
1098
+ tarballs.set(pkg.name, file);
1099
+ log(`✓ Packed ${pkg.name}@${version}`);
1100
+ }
1101
+ for (const pkg of plan.packages) {
1102
+ const registry = registryOf(pkg);
1103
+ const isPublished = async () => (await readPackument(environment, registry, pkg.name))?.versions?.[version]?.gitHead === plan.head;
1104
+ if (await isPublished()) {
1105
+ log(`✓ ${pkg.name}@${version} is already published from this commit.`);
1106
+ continue;
1107
+ }
1108
+ const args = [
1109
+ "publish",
1110
+ tarballs.get(pkg.name),
1111
+ "--tag",
1112
+ stagingDistTag
1113
+ ];
1114
+ if (pkg.manifest.publishConfig?.access) args.push("--access", String(pkg.manifest.publishConfig.access));
1115
+ if (pkg.manifest.publishConfig?.provenance) args.push("--provenance");
1116
+ try {
1117
+ await runNpm(pkg, args);
1118
+ } catch (error) {
1119
+ await environment.wait(5e3);
1120
+ if (!await isPublished()) throw error;
1121
+ log(`✓ ${pkg.name}@${version} was already published from this commit.`);
1122
+ continue;
1123
+ }
1124
+ log(`✓ Published ${pkg.name}@${version} on ${stagingDistTag}`);
1125
+ }
1126
+ for (const pkg of plan.packages) {
1127
+ const registry = registryOf(pkg);
1128
+ const published = await waitForVersion(environment, registry, pkg.name, version);
1129
+ if (pkg.manifest.publishConfig?.provenance && !published.dist?.attestations) log(`! ${pkg.name}@${version} has no provenance attestation on ${registry}.`);
1130
+ }
1131
+ for (const pkg of plan.packages) {
1132
+ const tags = (await readPackument(environment, registryOf(pkg), pkg.name))?.["dist-tags"] ?? {};
1133
+ if (tags[channel.distTag] !== version) await runNpm(pkg, [
1134
+ "dist-tag",
1135
+ "add",
1136
+ `${pkg.name}@${version}`,
1137
+ channel.distTag
1138
+ ]);
1139
+ if (tags["aronrepo-staging"]) await runNpm(pkg, [
1140
+ "dist-tag",
1141
+ "rm",
1142
+ pkg.name,
1143
+ stagingDistTag
1144
+ ]).catch((error) => log(`! Could not remove the ${stagingDistTag} dist-tag of ${pkg.name}: ${error.message}`));
1145
+ log(`✓ ${pkg.name}@${version} is on ${channel.distTag}`);
1146
+ }
1147
+ } finally {
1148
+ process.removeListener("SIGINT", onSignal);
1149
+ process.removeListener("SIGTERM", onSignal);
1150
+ restore();
1151
+ }
1152
+ await git(environment, [
1153
+ "-c",
1154
+ "tag.gpgSign=false",
1155
+ "tag",
1156
+ tag,
1157
+ plan.head
1158
+ ]);
1159
+ await pushTag(environment, tag);
1160
+ log(`✓ Pushed ${tag}`);
1161
+ await publishGitHubRelease(environment, {
1162
+ tag,
1163
+ notes,
1164
+ prerelease: Boolean(channel.prerelease),
1165
+ target: plan.head,
1166
+ latest: !channel.prerelease && await isLatestStable(environment, version)
1167
+ });
1168
+ return {
1169
+ status: "released",
1170
+ plan
1171
+ };
1172
+ }
1173
+ // src/commands.ts
1174
+ function resolveCwd(options) {
1175
+ return options.cwd ? path.resolve(options.cwd) : getRepositoryRoot(process.cwd());
1176
+ }
1177
+ function usage(message) {
1178
+ return {
1179
+ code: 2,
1180
+ output: message
1181
+ };
1182
+ }
1183
+ const ignoredMessagePatterns = [
1184
+ /^Merge pull request #\d+ from \S+/,
1185
+ /^Merge (?:branch|branches|remote-tracking branch|tag|commit) '[^']+'/,
1186
+ /^Merge [0-9a-f]{7,40}(?: into \S+)?$/,
1187
+ /^Merged in \S+ \(pull request #\d+\)/,
1188
+ /^Automatic merge from /,
1189
+ /^Auto-merged \S+ into \S+/,
1190
+ /^(?:fixup|squash|amend)! /,
1191
+ /^Initial commit$/
1192
+ ];
1193
+ function isIgnoredMessage(message) {
1194
+ const header = message.trim().split("\n")[0] ?? "";
1195
+ return ignoredMessagePatterns.some((pattern) => pattern.test(header));
1196
+ }
1197
+ function escapeRegExp(text) {
1198
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1199
+ }
1200
+ /**
1201
+ * A commit message file without git's comments and, with `git commit -v`,
1202
+ * without the diff below the scissors line. `commentChar` is git's
1203
+ * `core.commentChar`; `auto` falls back to `#`.
1204
+ */
1205
+ function readEditMessage(file, commentChar = "#") {
1206
+ const comment = commentChar && commentChar !== "auto" ? commentChar : "#";
1207
+ return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").split(new RegExp(`^${escapeRegExp(comment)} -+ >8 -+$`, "m"))[0].split("\n").filter((line) => !line.startsWith(comment)).join("\n").trim();
1208
+ }
1209
+ async function project(cwd, options) {
1210
+ return loadProjectPolicy(cwd, options.mode ? { mode: options.mode } : {});
1211
+ }
1212
+ function isError(violation) {
1213
+ return (violation.severity ?? "error") === "error";
1214
+ }
1215
+ function formatViolation(violation) {
1216
+ return ` ${isError(violation) ? "✗" : "!"} ${violation.message}${violation.suggestion ? `\n Suggested header: ${violation.suggestion}` : ""}`;
1217
+ }
1218
+ function formatDecision(decision) {
1219
+ return `${decision.hash ? `${decision.hash.slice(0, 9)} ` : ""}${(decision.release || "none").padEnd(5)} ${decision.header}\n${decision.reasons.map((reason) => ` ${reason}`).join("\n")}`;
1220
+ }
1221
+ /** Repository-relative POSIX paths, as git prints them. */
1222
+ function normalizeFiles(files, cwd) {
1223
+ return files.map((file) => {
1224
+ return (path.isAbsolute(file) ? path.relative(cwd, file) : file).replace(/\\/g, "/").replace(/^(?:\.\/)+/, "");
1225
+ });
1226
+ }
1227
+ async function check(options) {
1228
+ const modes = [
1229
+ options.edit !== void 0 && "--edit",
1230
+ options.message !== void 0 && "--message",
1231
+ options.title !== void 0 && "--title",
1232
+ options.from !== void 0 && "--from"
1233
+ ].filter(Boolean);
1234
+ if (!modes.length) return usage("Pass --edit <file>, --message <text>, --title <text>, or --from <ref>.");
1235
+ if (modes.length > 1) return usage(`Pass only one of ${modes.join(", ")}.`);
1236
+ if (options.to !== void 0 && options.from === void 0) return usage("--to needs --from.");
1237
+ if ((options.base !== void 0 || options.head !== void 0) && options.title === void 0) return usage("--base and --head only apply to --title.");
1238
+ if (options.base === void 0 !== (options.head === void 0)) return usage("Pass --base and --head together.");
1239
+ if (options.files && options.from !== void 0) return usage("--files does not apply to --from; each commit has its own files.");
1240
+ const cwd = resolveCwd(options);
1241
+ const { policy, isReleasePath } = await project(cwd, options);
1242
+ const pathOptions = isReleasePath ? { isReleasePath } : {};
1243
+ const files = options.files && normalizeFiles(options.files, cwd);
1244
+ const notes = [];
1245
+ let entries;
1246
+ if (options.title !== void 0) {
1247
+ const diff = files ?? (options.base && options.head ? getDiffFiles(cwd, options.base, options.head) : void 0);
1248
+ if (!diff && isReleasePath) notes.push("! Release paths are not checked; pass --base and --head, or --files.");
1249
+ entries = [{
1250
+ message: options.title,
1251
+ ...diff ? { files: diff } : {},
1252
+ label: "PR title",
1253
+ ignorable: false
1254
+ }];
1255
+ } else if (options.edit !== void 0 || options.message !== void 0) {
1256
+ let message = options.message ?? "";
1257
+ if (options.edit !== void 0) {
1258
+ message = readEditMessage(path.resolve(cwd, options.edit), getGitConfig(cwd, "core.commentChar"));
1259
+ if (!message) return {
1260
+ code: 0,
1261
+ output: "The commit message is empty; git aborts the commit."
1262
+ };
1263
+ }
1264
+ let changed = files ?? getStagedFiles(cwd);
1265
+ if (!changed.length && !files && options.edit !== void 0) try {
1266
+ changed = getCommitFiles(cwd, "HEAD");
1267
+ } catch {
1268
+ changed = [];
1269
+ }
1270
+ entries = [{
1271
+ message,
1272
+ files: changed,
1273
+ label: "Commit message",
1274
+ ignorable: true
1275
+ }];
1276
+ } else entries = getRangeCommits(cwd, options.from, options.to ?? "HEAD").map((commit) => ({
1277
+ ...commit,
1278
+ label: commit.hash.slice(0, 9),
1279
+ ignorable: true
1280
+ }));
1281
+ const results = entries.filter(({ message, ignorable }) => !ignorable || !isIgnoredMessage(message)).map((entry) => ({
1282
+ entry,
1283
+ decision: resolveCommitRelease(parseCommit(entry.message, entry.hash), policy, {
1284
+ ...pathOptions,
1285
+ ...entry.files ? { files: entry.files } : {}
1286
+ })
1287
+ }));
1288
+ const failures = results.filter(({ decision }) => decision.violations.some(isError));
1289
+ if (options.json) return {
1290
+ code: failures.length ? 1 : 0,
1291
+ output: JSON.stringify({
1292
+ mode: policy.mode,
1293
+ results: results.map(({ entry, decision }) => ({
1294
+ label: entry.label,
1295
+ ...decision
1296
+ })),
1297
+ ...notes.length ? { notes } : {}
1298
+ }, null, 2)
1299
+ };
1300
+ if (!failures.length) return {
1301
+ code: 0,
1302
+ output: [
1303
+ ...results.map(({ entry, decision }) => [`✓ ${entry.label}: ${decision.release || "none"} ← ${decision.header}`, ...decision.violations.map(formatViolation)].join("\n")),
1304
+ ...notes,
1305
+ `${results.length} ${results.length === 1 ? "header follows" : "headers follow"} the Aronrepo ${policy.mode} release policy.`
1306
+ ].join("\n")
1307
+ };
1308
+ return {
1309
+ code: 1,
1310
+ output: [
1311
+ ...failures.map(({ entry, decision }) => `${entry.label}: ${decision.header}\n${decision.violations.map(formatViolation).join("\n")}`),
1312
+ ...notes,
1313
+ "",
1314
+ "Run `aronrepo policy` to see which types release and which paths ship."
1315
+ ].join("\n")
1316
+ };
1317
+ }
1318
+ async function explain(options) {
1319
+ const cwd = resolveCwd(options);
1320
+ const { policy, isReleasePath, releasePaths } = await project(cwd, options);
1321
+ let plan;
1322
+ let from;
1323
+ let to = options.to ?? "HEAD";
1324
+ if (options.from === void 0 && options.to === void 0) {
1325
+ plan = await planRelease({
1326
+ fallbackToMain: true,
1327
+ ...options.mode ? { mode: options.mode } : {}
1328
+ }, { cwd });
1329
+ from = plan.from;
1330
+ to = "HEAD";
1331
+ } else {
1332
+ const channel = getReleaseChannel(getCurrentBranch(cwd)) ?? getReleaseChannel("main");
1333
+ const reachable = getVersionTags(cwd, `${to}^`);
1334
+ from = options.from ?? getBaseTag(channel, reachable);
1335
+ const decision = resolveRelease(getRangeCommits(cwd, from, to), policy, isReleasePath ? { isReleasePath } : {});
1336
+ const version = decision.release && options.from === void 0 ? getNextVersion(channel, decision.release, {
1337
+ reachable,
1338
+ all: getVersionTags(cwd)
1339
+ }) : void 0;
1340
+ plan = {
1341
+ ...decision,
1342
+ channel,
1343
+ ...version ? {
1344
+ version,
1345
+ tag: toVersionTag(version)
1346
+ } : {}
1347
+ };
1348
+ }
1349
+ if (options.json) return {
1350
+ code: 0,
1351
+ output: JSON.stringify({
1352
+ mode: policy.mode,
1353
+ ...plan.branch ? { branch: plan.branch } : {},
1354
+ ...plan.channel ? { channel: plan.channel } : {},
1355
+ from: from ?? null,
1356
+ to,
1357
+ releasePaths: releasePaths ?? null,
1358
+ release: plan.release,
1359
+ version: plan.version ?? null,
1360
+ tag: plan.tag ?? null,
1361
+ commits: plan.commits
1362
+ }, null, 2)
1363
+ };
1364
+ return {
1365
+ code: 0,
1366
+ output: [
1367
+ `Range: ${from ? `${from}..${to}` : `all commits up to ${to}`}`,
1368
+ `Mode: ${policy.mode}`,
1369
+ ...plan.channel ? [`Channel: ${plan.channel.branch} (npm dist-tag ${plan.channel.distTag})`] : [],
1370
+ `Release paths: ${releasePaths?.include ? releasePaths.include.join(", ") : "every path"}`,
1371
+ "",
1372
+ ...plan.commits.length ? plan.commits.map(formatDecision) : ["No commits."],
1373
+ "",
1374
+ `Next release: ${plan.release || "none"}${plan.version ? ` (${plan.version})` : ""}`
1375
+ ].join("\n")
1376
+ };
1377
+ }
1378
+ async function release(options) {
1379
+ const cwd = resolveCwd(options);
1380
+ const result = await runRelease({
1381
+ ...options.mode ? { mode: options.mode } : {},
1382
+ ...options.branch ? { branch: options.branch } : {},
1383
+ ...options.dryRun ? { dryRun: true } : {}
1384
+ }, {
1385
+ cwd,
1386
+ log: options.json ? () => {} : options.log ?? ((message) => console.log(message))
1387
+ });
1388
+ if (!options.json) return {
1389
+ code: 0,
1390
+ output: ""
1391
+ };
1392
+ const { packages, ...plan } = result.plan;
1393
+ return {
1394
+ code: 0,
1395
+ output: JSON.stringify({
1396
+ status: result.status,
1397
+ ...plan,
1398
+ packages: packages.map(({ dir, name }) => ({
1399
+ dir,
1400
+ name
1401
+ }))
1402
+ }, null, 2)
1403
+ };
1404
+ }
1405
+ async function policy(options) {
1406
+ if (options.json && options.markdown) return usage("Pass --json or --markdown, not both.");
1407
+ const resolved = await project(resolveCwd(options), options);
1408
+ if (options.markdown) return {
1409
+ code: 0,
1410
+ output: renderAgentsBlock(resolved.policy, {
1411
+ cli: resolved.cli,
1412
+ ...resolved.releasePaths ? { releasePaths: resolved.releasePaths } : {}
1413
+ })
1414
+ };
1415
+ const table = getReleaseTable(resolved.policy);
1416
+ const data = {
1417
+ mode: resolved.policy.mode,
1418
+ configFile: resolved.configFile ? path.relative(resolved.cwd, resolved.configFile) : null,
1419
+ releasePaths: resolved.releasePaths ?? null,
1420
+ releaseTable: Object.fromEntries(Object.entries(table).map(([level, rules]) => [level, rules.map((rule) => rule.replace(/`/g, ""))])),
1421
+ types: commits.map(({ type, scope, description, group }) => ({
1422
+ type,
1423
+ ...scope ? { scope } : {},
1424
+ release: getReleaseType(type, scope, resolved.policy) ?? false,
1425
+ description,
1426
+ ...group ? { group } : {}
1427
+ })),
1428
+ legacyTypes: legacyCommits,
1429
+ agent: getAgentCommitPolicy(resolved.policy, { cli: resolved.cli })
1430
+ };
1431
+ if (options.json) return {
1432
+ code: 0,
1433
+ output: JSON.stringify(data, null, 2)
1434
+ };
1435
+ return {
1436
+ code: 0,
1437
+ output: [
1438
+ `Mode: ${data.mode}${data.configFile ? ` (${data.configFile})` : " (default; no aronrepo.config.js)"}`,
1439
+ `Release paths: ${data.releasePaths?.include ? data.releasePaths.include.join(", ") : "every path"}`,
1440
+ ...data.releasePaths?.include ? [`Excluded: ${(data.releasePaths.exclude ?? defaultReleasePathExcludes).join(", ")}`] : [],
1441
+ "",
1442
+ `Major: ${table.major.join(", ") || "-"}`,
1443
+ `Minor: ${table.minor.join(", ") || "-"}`,
1444
+ `Patch: ${table.patch.join(", ") || "-"}`,
1445
+ `None: ${table.none.join(", ") || "-"}`,
1446
+ "",
1447
+ ...data.agent.ownerDecisions.map((rule) => `- ${rule}`)
1448
+ ].join("\n")
1449
+ };
1450
+ }
1451
+ /** Where the generated block is, `undefined` when there is none; throws when its markers are broken. */
1452
+ function findAgentsBlock(content) {
1453
+ const start = content.indexOf(agentsBlockStart);
1454
+ const firstEnd = content.indexOf(agentsBlockEnd);
1455
+ if (start === -1 && firstEnd === -1) return void 0;
1456
+ const end = start === -1 ? -1 : content.indexOf(agentsBlockEnd, start);
1457
+ if (start === -1 || end === -1 || firstEnd < start) throw new Error(`The Aronrepo commit policy markers are out of order. Keep one ${agentsBlockStart} followed by one ${agentsBlockEnd}.`);
1458
+ if (content.indexOf("<!-- aronrepo:commit-policy:start -->", start + 1) !== -1 || content.indexOf("<!-- aronrepo:commit-policy:end -->", end + 1) !== -1) throw new Error("The Aronrepo commit policy block appears more than once. Keep one block.");
1459
+ return {
1460
+ start,
1461
+ end: end + agentsBlockEnd.length
1462
+ };
1463
+ }
1464
+ function replaceAgentsBlock(content, block) {
1465
+ const found = findAgentsBlock(content);
1466
+ if (found) return content.slice(0, found.start) + block + content.slice(found.end);
1467
+ return `${content.trimEnd()}${content.trim() ? "\n\n" : ""}${block}\n`;
1468
+ }
1469
+ function readAgentsBlock(content) {
1470
+ try {
1471
+ const found = findAgentsBlock(content);
1472
+ return found ? content.slice(found.start, found.end) : void 0;
1473
+ } catch {
1474
+ return;
1475
+ }
1476
+ }
1477
+ async function agents(options) {
1478
+ const cwd = resolveCwd(options);
1479
+ const resolved = await project(cwd, options);
1480
+ const file = path.resolve(cwd, options.file ?? "AGENTS.md");
1481
+ const relative = path.relative(cwd, file);
1482
+ const block = renderAgentsBlock(resolved.policy, {
1483
+ cli: resolved.cli,
1484
+ ...resolved.releasePaths ? { releasePaths: resolved.releasePaths } : {}
1485
+ });
1486
+ const raw = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
1487
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
1488
+ const content = raw.replace(/\r\n/g, "\n");
1489
+ const result = (code, status, message) => ({
1490
+ code,
1491
+ output: options.json ? JSON.stringify({
1492
+ file: relative,
1493
+ status
1494
+ }, null, 2) : message
1495
+ });
1496
+ if (options.write) {
1497
+ const next = replaceAgentsBlock(content || "# Agent Guide\n", block);
1498
+ if (next === content) return result(0, "current", `✓ ${relative} is up to date.`);
1499
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1500
+ fs.writeFileSync(file, eol === "\n" ? next : next.replace(/\n/g, eol));
1501
+ return result(0, "written", `✓ Wrote the Aronrepo commit policy block to ${relative}.`);
1502
+ }
1503
+ const current = readAgentsBlock(content);
1504
+ if (current === block) return result(0, "current", `✓ ${relative} has the current Aronrepo commit policy block.`);
1505
+ return result(1, current ? "outdated" : "missing", `✗ ${relative} ${current ? "has an outdated" : "is missing the"} Aronrepo commit policy block. Run \`${resolved.cli} agents --write\`.`);
1506
+ }
1507
+ function findFile(cwd, names) {
1508
+ return names.map((name) => path.join(cwd, name)).find((file) => fs.existsSync(file));
1509
+ }
1510
+ function readText(file) {
1511
+ return file ? fs.readFileSync(file, "utf8") : "";
1512
+ }
1513
+ const legacyDependencies = [
1514
+ "semantic-release",
1515
+ "@commitlint/cli",
1516
+ "@aronrepo/commitlint-config",
1517
+ "@aronrepo/conventional-changelog-config",
1518
+ "@aronrepo/semantic-release-config",
1519
+ "@aronrepo/semantic-release-pnpm"
1520
+ ];
1521
+ const legacyConfigFiles = [
1522
+ "release.config.js",
1523
+ "release.config.mjs",
1524
+ "release.config.cjs",
1525
+ ".releaserc",
1526
+ ".releaserc.js",
1527
+ ".releaserc.mjs",
1528
+ ".releaserc.json",
1529
+ "commitlint.config.js",
1530
+ "commitlint.config.mjs",
1531
+ "commitlint.config.ts",
1532
+ ".commitlintrc",
1533
+ ".commitlintrc.js",
1534
+ ".commitlintrc.mjs",
1535
+ ".commitlintrc.json"
1536
+ ];
1537
+ function installCommand(packageManager) {
1538
+ return {
1539
+ bun: "bun add -d aronrepo",
1540
+ pnpm: "pnpm add -D aronrepo",
1541
+ yarn: "yarn add -D aronrepo",
1542
+ npm: "npm install -D aronrepo"
1543
+ }[packageManager];
1544
+ }
1545
+ async function doctor(options) {
1546
+ const cwd = resolveCwd(options);
1547
+ const checks = [];
1548
+ let resolved;
1549
+ const packageManager = (() => {
1550
+ try {
1551
+ return detectPackageManager(cwd);
1552
+ } catch {
1553
+ return "npm";
1554
+ }
1555
+ })();
1556
+ const cli = getCliCommand(packageManager);
1557
+ try {
1558
+ resolved = await project(cwd, options);
1559
+ checks.push(resolved.configFile ? {
1560
+ status: "pass",
1561
+ message: `Release mode ${resolved.policy.mode} from ${path.relative(cwd, resolved.configFile)}.`
1562
+ } : {
1563
+ status: "warn",
1564
+ message: `No aronrepo.config.js; using the default ${resolved.policy.mode} mode.`,
1565
+ fix: "Ask the repository owner whether releases are explicit (owner-controlled) or auto, then create aronrepo.config.js with `release.mode`."
1566
+ });
1567
+ } catch (error) {
1568
+ checks.push({
1569
+ status: "fail",
1570
+ message: `The Aronrepo config failed to load: ${error.message}`
1571
+ });
1572
+ }
1573
+ if (resolved) checks.push(resolved.releasePaths?.include ? {
1574
+ status: "pass",
1575
+ message: `Release paths: ${resolved.releasePaths.include.join(", ")}.`
1576
+ } : {
1577
+ status: "warn",
1578
+ message: "No published package found; every path can release a version.",
1579
+ fix: "Set `release.paths.include` in aronrepo.config.js, or add `publishConfig.access: \"public\"` to published packages."
1580
+ });
1581
+ let pkg = {};
1582
+ try {
1583
+ const file = findFile(cwd, ["package.json"]);
1584
+ if (file) pkg = readJsonFile(file);
1585
+ } catch (error) {
1586
+ checks.push({
1587
+ status: "fail",
1588
+ message: error.message
1589
+ });
1590
+ }
1591
+ const dependencies = {
1592
+ ...pkg.dependencies,
1593
+ ...pkg.devDependencies
1594
+ };
1595
+ checks.push(dependencies.aronrepo ? {
1596
+ status: "pass",
1597
+ message: "aronrepo is installed."
1598
+ } : {
1599
+ status: "fail",
1600
+ message: "aronrepo is not a dependency.",
1601
+ fix: `Install it as a dev dependency: \`${installCommand(packageManager)}\`.`
1602
+ });
1603
+ const leftovers = [...legacyDependencies.filter((name) => dependencies[name]), ...legacyConfigFiles.filter((name) => fs.existsSync(path.join(cwd, name)))];
1604
+ checks.push(leftovers.length ? {
1605
+ status: "warn",
1606
+ message: `Leftovers from semantic-release or commitlint: ${leftovers.join(", ")}.`,
1607
+ fix: "Remove them; `aronrepo check` lints commits and `aronrepo release` publishes releases."
1608
+ } : {
1609
+ status: "pass",
1610
+ message: "No semantic-release or commitlint leftovers."
1611
+ });
1612
+ try {
1613
+ const releasePackages = getReleasePackages(cwd);
1614
+ const withoutRepository = releasePackages.filter(({ manifest }) => manifest.publishConfig?.provenance && !manifest.repository && !pkg.repository);
1615
+ checks.push(!releasePackages.length ? {
1616
+ status: "warn",
1617
+ message: "`aronrepo release` finds no published package.",
1618
+ fix: "Add `\"publishConfig\": { \"access\": \"public\" }` to each package that ships."
1619
+ } : withoutRepository.length ? {
1620
+ status: "warn",
1621
+ message: `npm provenance needs a \`repository\` field: ${withoutRepository.map(({ name }) => name).join(", ")}.`,
1622
+ fix: "Add `repository` to the root package.json."
1623
+ } : {
1624
+ status: "pass",
1625
+ message: `\`aronrepo release\` publishes ${releasePackages.map(({ name }) => name).join(", ")}.`
1626
+ });
1627
+ } catch (error) {
1628
+ checks.push({
1629
+ status: "fail",
1630
+ message: error.message
1631
+ });
1632
+ }
1633
+ const hook = readText(findFile(cwd, [".husky/commit-msg"])).split(/\r?\n/).filter((line) => !line.trim().startsWith("#")).join("\n");
1634
+ const hooksPath = getGitConfig(cwd, "core.hooksPath");
1635
+ if (!hook.includes("aronrepo check")) checks.push({
1636
+ status: "warn",
1637
+ message: "No commit-msg hook runs `aronrepo check`.",
1638
+ fix: `Add .husky/commit-msg with \`${cli} check --edit "$1"\`.`
1639
+ });
1640
+ else if (!hooksPath?.replace(/\\/g, "/").startsWith(".husky")) checks.push({
1641
+ status: "warn",
1642
+ message: ".husky/commit-msg is not installed; git does not run it.",
1643
+ fix: "Run `husky` once, for example from the `prepare` script, to set `core.hooksPath`."
1644
+ });
1645
+ else checks.push({
1646
+ status: "pass",
1647
+ message: ".husky/commit-msg runs aronrepo check."
1648
+ });
1649
+ if (resolved) {
1650
+ const agentsResult = await agents({
1651
+ ...options,
1652
+ cwd,
1653
+ json: false
1654
+ });
1655
+ checks.push(agentsResult.code === 0 ? {
1656
+ status: "pass",
1657
+ message: "AGENTS.md has the current commit policy block."
1658
+ } : {
1659
+ status: "fail",
1660
+ message: agentsResult.output.replace(/^✗ /, "").replace(/ Run `.*`\.$/, ""),
1661
+ fix: `${resolved.cli} agents --write`
1662
+ });
1663
+ }
1664
+ const failed = checks.some(({ status }) => status === "fail");
1665
+ if (options.json) return {
1666
+ code: failed ? 1 : 0,
1667
+ output: JSON.stringify({
1668
+ ok: !failed,
1669
+ checks
1670
+ }, null, 2)
1671
+ };
1672
+ const icon = {
1673
+ pass: "✓",
1674
+ warn: "!",
1675
+ fail: "✗"
1676
+ };
1677
+ return {
1678
+ code: failed ? 1 : 0,
1679
+ output: [
1680
+ ...checks.map(({ status, message, fix }) => `${icon[status]} ${message}${fix ? `\n Fix: ${fix}` : ""}`),
1681
+ "",
1682
+ failed ? "Aronrepo setup is incomplete." : "Aronrepo setup is complete."
1683
+ ].join("\n")
1684
+ };
1685
+ }
1686
+ export { findConfigFile as A, getStagedFiles as B, runRelease as C, configFileNames as D, writeReleaseManifest as E, getGitConfig as F, queryReleaseDirs as G, loadConfig as H, getLatestTag as I, readWorkspacePackage as J, queryWorkspaceDirs as K, getProjectCliCommand as L, getCommitFiles as M, getCurrentBranch as N, detectPackageManager as O, getDiffFiles as P, getRangeCommits as R, runCommand as S, truncateNotes as T, loadProjectPolicy as U, getVersionTags as V, queryPublicWorkspaceDirs as W, resolveReleasePaths as X, readWorkspacePatterns as Y, planRelease as _, isIgnoredMessage as a, readPackedManifest as b, readEditMessage as c, exchangeOidcToken as d, getRegistry as f, officialRegistry as g, hasRegistryAuth as h, explain as i, getCliCommand as j, explainRange as k, release as l, getRepositoryUrl as m, check as n, policy as o, getReleasePackages as p, readJsonFile as q, doctor as r, readAgentsBlock as s, agents as t, replaceAgentsBlock as u, publishGitHubRelease as v, stagingDistTag as w, releaseBodyLimit as x, quoteWindowsArg as y, getRepositoryRoot as z };
1687
+
1688
+ //# sourceMappingURL=commands-ClWK0lQ9.js.map