@decohere/ci-tools 0.0.1-dev.20 → 0.0.1-dev.20261105.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +15 -0
  2. package/package.json +17 -3
  3. package/src/audit/enforce.mjs +7 -5
  4. package/src/audit/format.mjs +133 -0
  5. package/src/audit/summarize.mjs +10 -32
  6. package/src/audit/upsert-issue.mjs +25 -74
  7. package/src/bench/bench.mjs +135 -368
  8. package/src/bench/file-regressions.mjs +76 -0
  9. package/src/bench/history.mjs +248 -0
  10. package/src/bench/nightly.mjs +118 -0
  11. package/src/bench/regression-issue.mjs +44 -0
  12. package/src/bench/trace.mjs +18 -14
  13. package/src/github/first-publish-check.mjs +70 -0
  14. package/src/github/npm-bootstrap.mjs +182 -0
  15. package/src/github/npm-trust-promote.mjs +139 -0
  16. package/src/github/publish-verify.mjs +204 -0
  17. package/src/github/smoke-create-launcher.mjs +85 -0
  18. package/src/lib/github-issue.mjs +75 -0
  19. package/src/lib/lcov.mjs +1 -1
  20. package/src/lib/workspace-packages.mjs +30 -0
  21. package/src/pages/artifact.mjs +38 -183
  22. package/src/pages/bench-page.mjs +324 -0
  23. package/src/pages/restore-badges.mjs +35 -0
  24. package/src/pages/restore-bench-history.mjs +15 -28
  25. package/src/release/npm-links.mjs +6 -13
  26. package/src/release/promote-latest.mjs +122 -0
  27. package/src/release/publish-to-temporary-registry.mjs +65 -0
  28. package/src/release/sync-dependencies.mjs +1 -2
  29. package/tests/audit/format.test.mjs +115 -0
  30. package/tests/bench/history.test.mjs +279 -0
  31. package/tests/bench/regression-issue.test.mjs +42 -0
  32. package/tests/github/ci-workflow.test.mjs +30 -0
  33. package/tests/github/first-publish-check.test.mjs +51 -0
  34. package/tests/github/npm-bootstrap.test.mjs +125 -0
  35. package/tests/github/package-metadata.test.mjs +69 -0
  36. package/tests/github/publish-verify.test.mjs +115 -0
  37. package/tests/github/smoke-create-launcher.test.mjs +132 -0
  38. package/tests/lib/github-issue.test.mjs +85 -0
  39. package/tests/lib/lcov.test.mjs +8 -8
  40. package/tests/pages/bench-page.test.mjs +123 -0
  41. package/src/coverage/enforce-thresholds.mjs +0 -144
  42. package/src/pages/clear-bench-history.mjs +0 -27
  43. package/src/pages/keep-prior-bench-badge.mjs +0 -38
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Benchmark history: the results of each `pnpm bench` run, kept per machine so runs are only
3
+ * compared like for like. Pure functions over plain data; bench.mjs does the I/O.
4
+ *
5
+ * Shape (version 2):
6
+ * { version: 2, contexts: { [contextId]: { context, runs: [{ date, sha, results }] } } }
7
+ * newest run first. A result is { id, label, unit, median, p90?, min, max, samples, files? }.
8
+ */
9
+
10
+ import crypto from "node:crypto";
11
+
12
+ export const HISTORY_VERSION = 2;
13
+ /** Nightly runs: a month per machine, enough for a trend and a regression baseline. */
14
+ export const MAX_RUNS = 30;
15
+ /** Printed by each suite before its JSON array of results (projects/flux/bench/harness.ts). */
16
+ export const RESULT_PREFIX = "BENCH_RESULT ";
17
+
18
+ /** The machine a run happened on. Runs are compared only within one context. */
19
+ export function getContext(os, env, nodeVersion, date = new Date()) {
20
+ const cpus = os.cpus();
21
+ const nodeMajor = nodeVersion.match(/^v(\d+)/)?.[1] ?? "?";
22
+ const rawEnv = env.BENCH_ENV ?? null;
23
+ return {
24
+ benchEnv: rawEnv ? `${rawEnv}-node${nodeMajor}` : "(local)",
25
+ date: date.toISOString(),
26
+ os: { platform: os.platform(), arch: os.arch() },
27
+ cpu: { model: cpus[0]?.model?.trim() ?? "unknown", count: cpus.length },
28
+ memoryGb: Number.parseFloat((os.totalmem() / 1024 ** 3).toFixed(1)),
29
+ node: nodeVersion,
30
+ };
31
+ }
32
+
33
+ /** 16-char SHA-256 prefix of platform|arch|cpu|cores|memGB|node. */
34
+ export function contextId(ctx) {
35
+ const key = [
36
+ ctx.os.platform,
37
+ ctx.os.arch,
38
+ ctx.cpu.model,
39
+ ctx.cpu.count,
40
+ ctx.memoryGb,
41
+ ctx.node,
42
+ ].join("|");
43
+ return crypto.createHash("sha256").update(key).digest("hex").slice(0, 16);
44
+ }
45
+
46
+ /** The results a suite printed, from its whole output. Throws if it printed none. */
47
+ export function parseResults(output, suite) {
48
+ const line = output
49
+ .split(/\r?\n/)
50
+ .reverse()
51
+ .find((candidate) => candidate.startsWith(RESULT_PREFIX));
52
+ if (line == null)
53
+ throw new Error(`suite "${suite}" printed no ${RESULT_PREFIX.trim()} line`);
54
+ return JSON.parse(line.slice(RESULT_PREFIX.length));
55
+ }
56
+
57
+ const FORGE_BENCHMARKS = [
58
+ ["forge build es6 + types", "forge.build.local"],
59
+ ["forge build es6 only", "forge.build.debug"],
60
+ ["forge build types only", "forge.build.types-only"],
61
+ ];
62
+
63
+ /**
64
+ * Results from `vitest bench --outputJson` for the forge build benchmark. Its names end in a
65
+ * file count, so they are mapped to stable ids; vitest reports no p90, so it is left out.
66
+ */
67
+ export function fromVitest(vitestOutput) {
68
+ return vitestOutput.files
69
+ .flatMap((file) => file.groups)
70
+ .flatMap((group) => group.benchmarks)
71
+ .map((benchmark) => {
72
+ const id = FORGE_BENCHMARKS.find(([prefix]) => benchmark.name.startsWith(prefix))?.[1];
73
+ if (id == null) throw new Error(`unknown forge benchmark "${benchmark.name}"`);
74
+ const files = Number(benchmark.name.match(/\((\d+) files\)/)?.[1]);
75
+ return {
76
+ id,
77
+ label: benchmark.name.replace(/\s*\(\d+ files\)$/, ""),
78
+ unit: "ms",
79
+ median: benchmark.median ?? benchmark.mean,
80
+ min: benchmark.min,
81
+ max: benchmark.max,
82
+ samples: benchmark.sampleCount,
83
+ ...(Number.isFinite(files) ? { files } : {}),
84
+ };
85
+ });
86
+ }
87
+
88
+ /** Throws when a result has no usable numbers: a suite that errored mid-run, not a slow one. */
89
+ export function assertValid(results) {
90
+ const invalid = results.filter(
91
+ (result) => !Number.isFinite(result.median) || !(result.samples > 0),
92
+ );
93
+ if (invalid.length > 0) {
94
+ throw new Error(
95
+ `${invalid.length} benchmark(s) have no valid samples: ${invalid.map((r) => r.id).join(", ")}`,
96
+ );
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Reads a stored history. Version 1 (forge build only, raw vitest output) is converted so its
102
+ * trend carries on; anything unrecognised starts empty rather than mixing formats.
103
+ */
104
+ export function normalize(raw) {
105
+ if (raw?.version === HISTORY_VERSION && raw.contexts != null) return raw;
106
+ const contexts = {};
107
+ for (const [id, entry] of Object.entries(raw?.runs ?? {})) {
108
+ const runs = (entry.runs ?? [])
109
+ .filter((run) => run.vitestOutput != null)
110
+ .map((run) => ({ date: run.date, sha: null, results: fromVitest(run.vitestOutput) }));
111
+ if (runs.length > 0) contexts[id] = { context: entry.context, runs };
112
+ }
113
+ return { version: HISTORY_VERSION, contexts };
114
+ }
115
+
116
+ /** The previous run for this context, if any: what a new run is compared against. */
117
+ export function previousRun(history, id) {
118
+ return history.contexts[id]?.runs[0];
119
+ }
120
+
121
+ /** Machine types with no run for this long are dropped: CI runner types come and go. */
122
+ export const STALE_AFTER_DAYS = 30;
123
+
124
+ /** The history without machine types whose newest run is older than `days` before `now`. */
125
+ export function pruneStale(history, now, days = STALE_AFTER_DAYS) {
126
+ const cutoff = now.getTime() - days * 24 * 60 * 60 * 1000;
127
+ const contexts = Object.fromEntries(
128
+ Object.entries(history.contexts).filter(
129
+ ([, entry]) => entry.runs[0] != null && new Date(entry.runs[0].date).getTime() >= cutoff,
130
+ ),
131
+ );
132
+ return { version: HISTORY_VERSION, contexts };
133
+ }
134
+
135
+ /**
136
+ * A new history with `run` first in its context, trimmed to `max` runs, and machine types
137
+ * without a recent run dropped. Does not mutate.
138
+ */
139
+ export function appendRun(history, ctx, run, max = MAX_RUNS) {
140
+ const id = contextId(ctx);
141
+ const runs = [run, ...(history.contexts[id]?.runs ?? [])].slice(0, max);
142
+ return pruneStale(
143
+ {
144
+ version: HISTORY_VERSION,
145
+ contexts: { ...history.contexts, [id]: { context: ctx, runs } },
146
+ },
147
+ new Date(run.date),
148
+ );
149
+ }
150
+
151
+ /** The newest run on any machine: the commit the previous nightly measured. */
152
+ export function latestRun(history) {
153
+ return Object.values(history.contexts)
154
+ .map((entry) => entry.runs[0])
155
+ .filter((run) => run != null)
156
+ .sort((a, b) => new Date(b.date) - new Date(a.date))[0];
157
+ }
158
+
159
+ /** One row per current result, with the change in median against the previous run. */
160
+ export function compare(results, previous) {
161
+ const before = new Map((previous?.results ?? []).map((result) => [result.id, result]));
162
+ return results.map((result) => {
163
+ const old = before.get(result.id);
164
+ if (old == null) return { id: result.id, current: result.median };
165
+ const delta = result.median - old.median;
166
+ return {
167
+ id: result.id,
168
+ current: result.median,
169
+ previous: old.median,
170
+ delta,
171
+ percent: old.median === 0 ? 0 : (delta / old.median) * 100,
172
+ };
173
+ });
174
+ }
175
+
176
+ /** The comparison as an aligned text table. */
177
+ export function formatComparison(rows) {
178
+ const ms = (value) => (value == null ? "n/a" : `${value.toFixed(1)} ms`);
179
+ const signed = (value, suffix) =>
180
+ value == null ? "n/a" : `${value >= 0 ? "+" : ""}${value.toFixed(1)}${suffix}`;
181
+ const table = [
182
+ ["Benchmark", "Median", "Previous", "Delta", "%"],
183
+ ...rows.map((row) => [
184
+ row.id,
185
+ ms(row.current),
186
+ ms(row.previous),
187
+ signed(row.delta, " ms"),
188
+ signed(row.percent, "%"),
189
+ ]),
190
+ ];
191
+ const widths = table[0].map((_, column) =>
192
+ Math.max(...table.map((row) => row[column].length)),
193
+ );
194
+ const line = (row) =>
195
+ row
196
+ .map((cell, column) =>
197
+ (column === 0 ? cell.padEnd : cell.padStart).call(cell, widths[column]),
198
+ )
199
+ .join(" | ");
200
+ return [
201
+ line(table[0]),
202
+ widths.map((w) => "-".repeat(w)).join("-+-"),
203
+ ...table.slice(1).map(line),
204
+ ].join("\n");
205
+ }
206
+
207
+ /**
208
+ * When a benchmark counts as slower: the nightly job benchmarks tonight's commit (head)
209
+ * and the one the previous nightly measured (base) on the same runner, alternating head, base,
210
+ * head, base. A benchmark regressed when head's median beats base's by more than `threshold`
211
+ * in every round, so one noisy round files nothing.
212
+ */
213
+ export const REGRESSION = { threshold: 0.2, rounds: 2 };
214
+
215
+ /**
216
+ * The benchmarks slower on head than on base. `headRounds` and `baseRounds` are lists of result
217
+ * lists, one per round, run alternately on one machine. Benchmarks either side lacks are skipped.
218
+ */
219
+ export function compareRounds(headRounds, baseRounds, options = REGRESSION) {
220
+ const rounds = Math.min(headRounds.length, baseRounds.length);
221
+ if (rounds < options.rounds) return [];
222
+ const medianIn = (results, id) => results.find((result) => result.id === id)?.median;
223
+ const regressions = [];
224
+ for (const head of headRounds[0]) {
225
+ const pairs = Array.from({ length: rounds }, (_, round) => ({
226
+ head: medianIn(headRounds[round], head.id),
227
+ base: medianIn(baseRounds[round], head.id),
228
+ }));
229
+ const comparable = pairs.every(
230
+ (pair) => Number.isFinite(pair.head) && Number.isFinite(pair.base) && pair.base > 0,
231
+ );
232
+ if (!comparable) continue;
233
+ const slower = pairs.every((pair) => pair.head > pair.base * (1 + options.threshold));
234
+ if (!slower) continue;
235
+ const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
236
+ const headMs = mean(pairs.map((pair) => pair.head));
237
+ const baseMs = mean(pairs.map((pair) => pair.base));
238
+ regressions.push({
239
+ id: head.id,
240
+ label: head.label,
241
+ base: baseMs,
242
+ head: headMs,
243
+ percent: (headMs / baseMs - 1) * 100,
244
+ rounds: pairs,
245
+ });
246
+ }
247
+ return regressions;
248
+ }
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * nightly.mjs — the nightly benchmark run. Benchmarks tonight's commit (head) and the
5
+ * commit the previous nightly measured (base) on this runner, alternating head, base, head,
6
+ * base, so the comparison never mixes CPU models. Then:
7
+ *
8
+ * - appends head's first round to the history (projects/ci-tools/dist/ci/bench/), for the charts;
9
+ * - writes regressions.json next to it: the benchmarks slower on head in every round
10
+ * (history.mjs `compareRounds`), which file-regressions.mjs turns into issues.
11
+ *
12
+ * Base runs from its own checkout (a git worktree) with its own dependencies and its own
13
+ * bench.mjs, so each commit is measured with its own code. With no earlier nightly, or no new
14
+ * commit since, it benchmarks head once and compares nothing.
15
+ *
16
+ * node projects/ci-tools/src/bench/nightly.mjs (from the repo root; needs the full git history)
17
+ */
18
+
19
+ import { execFileSync } from "node:child_process";
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ readFileSync,
25
+ rmSync,
26
+ writeFileSync,
27
+ } from "node:fs";
28
+ import os from "node:os";
29
+ import { dirname, join } from "node:path";
30
+ import {
31
+ appendRun,
32
+ compareRounds,
33
+ getContext,
34
+ latestRun,
35
+ normalize,
36
+ REGRESSION,
37
+ } from "./history.mjs";
38
+
39
+ const ROOT = process.cwd();
40
+ const DIST = join(ROOT, "projects/ci-tools/dist/ci/bench");
41
+ const HISTORY_FILE = join(DIST, "bench-history.json");
42
+ const REGRESSIONS_FILE = join(DIST, "regressions.json");
43
+ const BENCH = "projects/ci-tools/src/bench/bench.mjs";
44
+
45
+ function run(command, args, cwd, env = {}) {
46
+ execFileSync(command, args, {
47
+ cwd,
48
+ stdio: ["ignore", "inherit", "inherit"],
49
+ env: { ...process.env, ...env },
50
+ });
51
+ }
52
+
53
+ function git(...args) {
54
+ return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
55
+ }
56
+
57
+ /** Runs one round of every suite in `cwd` and returns its results, saved to its own history. */
58
+ function benchRound(cwd, label, round, workDir) {
59
+ process.stderr.write(`\n══ ${label}, round ${round + 1}\n`);
60
+ const historyFile = join(workDir, `${label}-${round}.json`);
61
+ run("node", [BENCH, "save"], cwd, { BENCH_HISTORY_FILE: historyFile });
62
+ // Base's bench.mjs may predate BENCH_HISTORY_FILE and save to its checkout's default path.
63
+ const file = existsSync(historyFile)
64
+ ? historyFile
65
+ : join(cwd, "projects/ci-tools/dist/ci/bench/bench-history.json");
66
+ const saved = normalize(JSON.parse(readFileSync(file, "utf8")));
67
+ const newest = latestRun(saved);
68
+ if (newest == null) throw new Error(`${label} round ${round + 1} saved no run`);
69
+ return newest.results;
70
+ }
71
+
72
+ const history = normalize(
73
+ existsSync(HISTORY_FILE) ? JSON.parse(readFileSync(HISTORY_FILE, "utf8")) : null,
74
+ );
75
+ const head = git("rev-parse", "HEAD");
76
+ const base = latestRun(history)?.sha ?? null;
77
+ const workDir = mkdtempSync(join(os.tmpdir(), "decohere-nightly-"));
78
+
79
+ try {
80
+ const headRounds = [];
81
+ const baseRounds = [];
82
+ const compareWith = base != null && base !== head ? base : null;
83
+ if (compareWith == null) {
84
+ process.stderr.write(
85
+ base == null
86
+ ? "No earlier nightly run: benchmarking head only.\n"
87
+ : `No new commit since the last nightly (${head.slice(0, 7)}): benchmarking head only.\n`,
88
+ );
89
+ headRounds.push(benchRound(ROOT, "head", 0, workDir));
90
+ } else {
91
+ const baseDir = join(workDir, "base");
92
+ run("git", ["worktree", "add", "--detach", baseDir, compareWith], ROOT);
93
+ run("pnpm", ["install", "--frozen-lockfile", "--prefer-offline"], baseDir);
94
+ for (let round = 0; round < REGRESSION.rounds; round++) {
95
+ headRounds.push(benchRound(ROOT, "head", round, workDir));
96
+ baseRounds.push(benchRound(baseDir, "base", round, workDir));
97
+ }
98
+ }
99
+
100
+ const ctx = getContext(os, process.env, process.version);
101
+ const next = appendRun(history, ctx, { date: ctx.date, sha: head, results: headRounds[0] });
102
+ mkdirSync(dirname(HISTORY_FILE), { recursive: true });
103
+ writeFileSync(HISTORY_FILE, `${JSON.stringify(next, null, 2)}\n`);
104
+
105
+ const regressions = compareWith == null ? [] : compareRounds(headRounds, baseRounds);
106
+ writeFileSync(
107
+ REGRESSIONS_FILE,
108
+ `${JSON.stringify({ base: compareWith, head, context: ctx, regressions }, null, 2)}\n`,
109
+ );
110
+ process.stderr.write(
111
+ `\nSaved head's run. ${compareWith == null ? "Nothing compared." : `Compared with ${compareWith.slice(0, 7)}: ${regressions.length} benchmark(s) slower in every round.`}\n`,
112
+ );
113
+ } finally {
114
+ if (existsSync(join(workDir, "base"))) {
115
+ run("git", ["worktree", "remove", "--force", join(workDir, "base")], ROOT);
116
+ }
117
+ rmSync(workDir, { recursive: true, force: true });
118
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The GitHub issue for a benchmark that got slower: one open issue per benchmark,
3
+ * found again by its exact title and updated in place while the regression lasts.
4
+ */
5
+
6
+ const PAGES_URL = "https://typesmiths.github.io/decohere/benchmark.html";
7
+
8
+ export function issueTitle(regression) {
9
+ return `perf(bench): ${regression.id} is slower`;
10
+ }
11
+
12
+ function ms(value) {
13
+ return `${value.toFixed(1)} ms`;
14
+ }
15
+
16
+ function short(sha) {
17
+ return sha.slice(0, 7);
18
+ }
19
+
20
+ export function issueBody(regression, { repoUrl, runUrl, machine, threshold, base, head }) {
21
+ const range = `[${short(base)}...${short(head)}](${repoUrl}/compare/${base}...${head})`;
22
+ return [
23
+ "## Problem",
24
+ "",
25
+ `The nightly benchmark **${regression.label}** (\`${regression.id}\`) is ${regression.percent.toFixed(0)}% slower on [\`${short(head)}\`](${repoUrl}/commit/${head}) than on [\`${short(base)}\`](${repoUrl}/commit/${base}), the commit the previous nightly measured. Both ran on the same runner, alternately, and head was more than ${Math.round(threshold * 100)}% slower in every round.`,
26
+ "",
27
+ "| Round | Base | Head |",
28
+ "|---|---|---|",
29
+ ...regression.rounds.map(
30
+ (round, index) => `| ${index + 1} | ${ms(round.base)} | ${ms(round.head)} |`,
31
+ ),
32
+ "",
33
+ `- Machine: ${machine}`,
34
+ `- Commits: ${range}`,
35
+ "",
36
+ "## What to do",
37
+ "",
38
+ `1. Look through the commits in ${range} for the change.`,
39
+ `2. Reproduce locally: check out \`${short(base)}\`, run \`pnpm bench:save\`, then check out \`${short(head)}\` and run \`pnpm bench\` to compare.`,
40
+ `3. Fix the cause, or if the slowdown is intended, say why here and close the issue. Trend: [benchmark page](${PAGES_URL}).`,
41
+ "",
42
+ `Updated by the nightly bench workflow: ${runUrl}`,
43
+ ].join("\n");
44
+ }
@@ -6,8 +6,8 @@ import { join } from "node:path";
6
6
 
7
7
  const WORKSPACE_ROOT = process.cwd();
8
8
  const VITEST_BIN = join(WORKSPACE_ROOT, "node_modules/.bin/vitest");
9
- const CONFIG = join(WORKSPACE_ROOT, "vitest.e2e.config.ts");
10
- const TRACE_SPEC = "projects/terminal/e2e/build.trace.e2e.spec.ts";
9
+ const CONFIG = join(WORKSPACE_ROOT, "vitest.config.ts");
10
+ const TRACE_SPEC = "projects/cli/e2e/build.trace.e2e.spec.ts";
11
11
  const TRACE_LOG_DIR = join(WORKSPACE_ROOT, ".decohere", "logs");
12
12
 
13
13
  function truthy(value, defaultValue = false) {
@@ -149,19 +149,23 @@ if (traceLogPath != null) {
149
149
  mkdirSync(TRACE_LOG_DIR, { recursive: true });
150
150
  }
151
151
 
152
- const result = spawnSync(VITEST_BIN, ["run", "--config", CONFIG, TRACE_SPEC], {
153
- cwd: WORKSPACE_ROOT,
154
- stdio: "pipe",
155
- encoding: "utf8",
156
- maxBuffer: 1024 * 1024 * 200,
157
- env: {
158
- ...process.env,
159
- NODE_OPTIONS: "",
160
- FORGE_TRACE: "1",
161
- FORGE_TRACE_STRUCTURED: "1",
162
- LOG_LEVEL: process.env.LOG_LEVEL ?? "trace",
152
+ const result = spawnSync(
153
+ VITEST_BIN,
154
+ ["run", "--config", CONFIG, "--project", "e2e", TRACE_SPEC],
155
+ {
156
+ cwd: WORKSPACE_ROOT,
157
+ stdio: "pipe",
158
+ encoding: "utf8",
159
+ maxBuffer: 1024 * 1024 * 200,
160
+ env: {
161
+ ...process.env,
162
+ NODE_OPTIONS: "",
163
+ FORGE_TRACE: "1",
164
+ FORGE_TRACE_STRUCTURED: "1",
165
+ LOG_LEVEL: process.env.LOG_LEVEL ?? "trace",
166
+ },
163
167
  },
164
- });
168
+ );
165
169
 
166
170
  const stdout = typeof result.stdout === "string" ? result.stdout : "";
167
171
  const stderr = typeof result.stderr === "string" ? result.stderr : "";
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Finds packages in this workspace that aren't on npm yet. A first publish needs an owner's npm
4
+ * login, so CI can't do it. On a PR that's only a notice, so a contributor can propose a package
5
+ * with no npm access. In the merge queue it fails, because Publish could not upload that package.
6
+ */
7
+ import { resolve } from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+ import { listPublishablePackages } from "./publish-verify.mjs";
10
+
11
+ const REGISTRY = "https://registry.npmjs.org";
12
+
13
+ /**
14
+ * @param {{
15
+ * packages: string[],
16
+ * blocking: boolean,
17
+ * fetch: typeof globalThis.fetch,
18
+ * log?: (message: string) => void,
19
+ * }} options
20
+ * @returns {Promise<number>} exit code
21
+ */
22
+ export async function checkFirstPublish(options) {
23
+ const { packages, blocking, fetch } = options;
24
+ const log = options.log ?? console.log;
25
+
26
+ const results = await Promise.all(
27
+ packages.map(async (name) => {
28
+ // Not /-/package/<name>/dist-tags: for a name that does not exist it answers 401, not 404.
29
+ const url = `${REGISTRY}/${name.replace("/", "%2f")}`;
30
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
31
+ // Anything else, like an outage, says nothing about whether the name exists.
32
+ if (!response.ok && response.status !== 404) {
33
+ throw new Error(`Could not check ${name} on npm: ${response.status}`);
34
+ }
35
+ return { name, missing: response.status === 404 };
36
+ }),
37
+ );
38
+
39
+ const missing = results.filter((result) => result.missing).map((result) => result.name);
40
+ if (missing.length === 0) {
41
+ log(`All ${packages.length} packages are on npm.`);
42
+ return 0;
43
+ }
44
+ const names = missing.join(" ");
45
+ if (blocking) {
46
+ log(
47
+ `::error::Not on npm yet: ${names}. An owner runs \`pnpm npm:bootstrap --otp <code> ${names}\`, ` +
48
+ "then adds this PR to the merge queue again.",
49
+ );
50
+ return 1;
51
+ }
52
+ log(
53
+ `::notice::New package, not on npm yet: ${names}. Contributors have nothing to do: a maintainer ` +
54
+ `runs \`pnpm npm:bootstrap --otp <code> ${names}\` before this merges.`,
55
+ );
56
+ return 0;
57
+ }
58
+
59
+ if (
60
+ process.argv[1] != null &&
61
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
62
+ ) {
63
+ process.exit(
64
+ await checkFirstPublish({
65
+ packages: listPublishablePackages(process.cwd()).map((pkg) => pkg.name),
66
+ blocking: process.env.GITHUB_EVENT_NAME === "merge_group",
67
+ fetch: globalThis.fetch,
68
+ }),
69
+ );
70
+ }