@decohere/ci-tools 0.0.1-dev.13 → 0.0.1-dev.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/package.json +17 -3
- package/src/audit/enforce.mjs +7 -5
- package/src/audit/format.mjs +133 -0
- package/src/audit/summarize.mjs +10 -32
- package/src/audit/upsert-issue.mjs +25 -74
- package/src/bench/bench.mjs +134 -368
- package/src/bench/file-regressions.mjs +76 -0
- package/src/bench/history.mjs +248 -0
- package/src/bench/nightly.mjs +118 -0
- package/src/bench/regression-issue.mjs +44 -0
- package/src/bench/trace.mjs +18 -14
- package/src/github/npm-bootstrap.mjs +179 -0
- package/src/github/oidc-bootstrap-nudge.mjs +214 -0
- package/src/github/publish-verify.mjs +206 -0
- package/src/github/smoke-create-launcher.mjs +85 -0
- package/src/lib/github-issue.mjs +75 -0
- package/src/lib/lcov.mjs +1 -1
- package/src/lib/workspace-packages.mjs +30 -0
- package/src/pages/artifact.mjs +38 -183
- package/src/pages/bench-page.mjs +323 -0
- package/src/pages/restore-badges.mjs +35 -0
- package/src/pages/restore-bench-history.mjs +15 -28
- package/src/release/npm-links.mjs +6 -13
- package/src/release/sync-dependencies.mjs +1 -2
- package/tests/audit/format.test.mjs +115 -0
- package/tests/bench/history.test.mjs +279 -0
- package/tests/bench/regression-issue.test.mjs +42 -0
- package/tests/github/ci-workflow.test.mjs +19 -0
- package/tests/github/npm-bootstrap.test.mjs +125 -0
- package/tests/github/oidc-bootstrap-nudge.test.mjs +89 -0
- package/tests/github/package-metadata.test.mjs +69 -0
- package/tests/github/publish-verify.test.mjs +115 -0
- package/tests/github/smoke-create-launcher.test.mjs +132 -0
- package/tests/lib/github-issue.test.mjs +85 -0
- package/tests/lib/lcov.test.mjs +8 -8
- package/tests/pages/bench-page.test.mjs +123 -0
- package/src/coverage/enforce-thresholds.mjs +0 -144
- package/src/pages/clear-bench-history.mjs +0 -27
- 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
|
+
}
|
package/src/bench/trace.mjs
CHANGED
|
@@ -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.
|
|
10
|
-
const TRACE_SPEC = "projects/
|
|
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(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-publish a new package name (public) and give it its Trusted Publisher,
|
|
3
|
+
* so `publish.yml` can release it over OIDC from then on.
|
|
4
|
+
*
|
|
5
|
+
* pnpm npm:bootstrap [--otp <code>] [--dry-run] [--channel dev] [<package-name>...]
|
|
6
|
+
*
|
|
7
|
+
* Without names: every publishable package whose name is not on npm yet.
|
|
8
|
+
* Run from the repo root after `npm login` to the org. Each step is skipped when
|
|
9
|
+
* it is already done (the name exists, the Trusted Publisher is set).
|
|
10
|
+
*
|
|
11
|
+
* npm runs on this terminal, so it does its own 2FA: it asks for a code, or
|
|
12
|
+
* opens the browser for web authentication. `--otp` hands it a code up front
|
|
13
|
+
* through `NPM_CONFIG_OTP`; `npm trust` takes no `--otp` flag of its own.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import { parseArgs } from "node:util";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
import { listPublishablePackages, npmNameExists } from "./publish-verify.mjs";
|
|
20
|
+
|
|
21
|
+
/** The Trusted Publisher every package gets: `publish.yml`'s npm job. */
|
|
22
|
+
export const TRUSTED_PUBLISHER = {
|
|
23
|
+
repo: "typesmiths/decohere",
|
|
24
|
+
file: "publish.yml",
|
|
25
|
+
environment: "npm-publish",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** @param {string | undefined} version */
|
|
29
|
+
export function needsPrereleaseTag(version) {
|
|
30
|
+
return String(version ?? "").includes("-");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* npm arguments for a name-only first publish. Scoped names default to restricted,
|
|
35
|
+
* so `--access public`. `./` stops npm reading the path as a GitHub `owner/repo`.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ dir: string, version?: string }} pkg
|
|
38
|
+
* @param {string} channel
|
|
39
|
+
*/
|
|
40
|
+
export function publishArgs(pkg, channel) {
|
|
41
|
+
const tag = needsPrereleaseTag(pkg.version) ? ["--tag", channel] : [];
|
|
42
|
+
return ["publish", `./projects/${pkg.dir}`, "--access", "public", ...tag];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {string} name */
|
|
46
|
+
export function trustArgs(name) {
|
|
47
|
+
return [
|
|
48
|
+
"trust",
|
|
49
|
+
"github",
|
|
50
|
+
name,
|
|
51
|
+
"--file",
|
|
52
|
+
TRUSTED_PUBLISHER.file,
|
|
53
|
+
"--repo",
|
|
54
|
+
TRUSTED_PUBLISHER.repo,
|
|
55
|
+
"--env",
|
|
56
|
+
TRUSTED_PUBLISHER.environment,
|
|
57
|
+
"--allow-publish",
|
|
58
|
+
"--yes",
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string} output `npm trust list <name> --json` */
|
|
63
|
+
export function hasTrustedPublisher(output) {
|
|
64
|
+
return output.includes(TRUSTED_PUBLISHER.file) && output.includes(TRUSTED_PUBLISHER.repo);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {{ capture?: boolean, otp?: string }} NpmOptions
|
|
69
|
+
* `capture` keeps stdout to read it (npm can then not prompt for 2FA);
|
|
70
|
+
* otherwise npm owns the terminal.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string[]} args
|
|
75
|
+
* @param {NpmOptions} [options]
|
|
76
|
+
* @returns {{ status: number, output: string }}
|
|
77
|
+
*/
|
|
78
|
+
function runNpm(args, { capture = false, otp } = {}) {
|
|
79
|
+
const result = spawnSync("npm", args, {
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
stdio: capture ? ["inherit", "pipe", "inherit"] : "inherit",
|
|
82
|
+
env: otp ? { ...process.env, NPM_CONFIG_OTP: otp } : process.env,
|
|
83
|
+
});
|
|
84
|
+
return { status: result.status ?? 1, output: result.stdout ?? "" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {{
|
|
89
|
+
* argv?: string[],
|
|
90
|
+
* root?: string,
|
|
91
|
+
* io?: {
|
|
92
|
+
* npm?: (args: string[], options?: NpmOptions) => { status: number, output: string },
|
|
93
|
+
* nameExists?: (name: string) => boolean,
|
|
94
|
+
* log?: (line: string) => void,
|
|
95
|
+
* },
|
|
96
|
+
* }} [options]
|
|
97
|
+
* @returns {Promise<number>} exit code
|
|
98
|
+
*/
|
|
99
|
+
export async function runBootstrap(options = {}) {
|
|
100
|
+
const { values, positionals } = parseArgs({
|
|
101
|
+
args: options.argv ?? process.argv.slice(2),
|
|
102
|
+
options: {
|
|
103
|
+
otp: { type: "string" },
|
|
104
|
+
"dry-run": { type: "boolean", default: false },
|
|
105
|
+
channel: { type: "string", default: "dev" },
|
|
106
|
+
},
|
|
107
|
+
allowPositionals: true,
|
|
108
|
+
});
|
|
109
|
+
const io = options.io ?? {};
|
|
110
|
+
const npm = io.npm ?? runNpm;
|
|
111
|
+
const nameExists = io.nameExists ?? ((name) => npmNameExists(name));
|
|
112
|
+
const log = io.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
113
|
+
const dryRun = values["dry-run"];
|
|
114
|
+
|
|
115
|
+
// Even on a dry run: the names are private, so `npm view` 404s without a login
|
|
116
|
+
// and every package would look unpublished.
|
|
117
|
+
if (npm(["whoami"]).status !== 0) {
|
|
118
|
+
log("Not logged in to npm: run `npm login` first.");
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const publishable = listPublishablePackages(options.root ?? process.cwd());
|
|
123
|
+
const unknown = positionals.filter((name) => !publishable.some((pkg) => pkg.name === name));
|
|
124
|
+
if (unknown.length > 0) {
|
|
125
|
+
log(`Not a publishable package in projects/: ${unknown.join(", ")}`);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
const packages =
|
|
129
|
+
positionals.length > 0
|
|
130
|
+
? publishable.filter((pkg) => positionals.includes(pkg.name))
|
|
131
|
+
: publishable.filter((pkg) => !nameExists(pkg.name));
|
|
132
|
+
if (packages.length === 0) {
|
|
133
|
+
log("Every package name is on npm already. Name one to check its Trusted Publisher.");
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A code npm rejects (they last about 30 seconds) makes npm ask for a new one.
|
|
138
|
+
/** @param {string[]} args */
|
|
139
|
+
const change = (args) => {
|
|
140
|
+
if (dryRun) {
|
|
141
|
+
log(`[dry run] npm ${args.join(" ")}`);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
return npm(args, { otp: values.otp }).status === 0;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
for (const pkg of packages) {
|
|
148
|
+
log(`\n${pkg.name} (projects/${pkg.dir}, ${pkg.version})`);
|
|
149
|
+
|
|
150
|
+
if (nameExists(pkg.name)) {
|
|
151
|
+
log(" name is on npm: skip the first publish");
|
|
152
|
+
} else if (!change(publishArgs(pkg, values.channel))) {
|
|
153
|
+
log(` first publish failed: stopping`);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const listed = dryRun
|
|
158
|
+
? { status: 1, output: "" }
|
|
159
|
+
: npm(["trust", "list", pkg.name, "--json"], { capture: true, otp: values.otp });
|
|
160
|
+
if (listed.status === 0 && hasTrustedPublisher(listed.output)) {
|
|
161
|
+
log(" Trusted Publisher is set: skip");
|
|
162
|
+
} else if (!change(trustArgs(pkg.name))) {
|
|
163
|
+
log(" Trusted Publisher failed: stopping");
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
log(
|
|
169
|
+
"\nDone. Approve the waiting npm-oidc-bootstrap deployment on the Publish run, or rerun it: `gh run rerun --failed <run-id>`.",
|
|
170
|
+
);
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const isCli =
|
|
175
|
+
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
176
|
+
|
|
177
|
+
if (isCli) {
|
|
178
|
+
process.exit(await runBootstrap());
|
|
179
|
+
}
|