@decohere/ci-tools 0.0.1-dev.118 → 0.0.1-dev.12
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/package.json +3 -8
- package/src/audit/enforce.mjs +5 -7
- package/src/audit/summarize.mjs +32 -10
- package/src/audit/upsert-issue.mjs +74 -25
- package/src/bench/bench.mjs +368 -134
- package/src/bench/trace.mjs +14 -18
- package/src/coverage/enforce-thresholds.mjs +144 -0
- package/src/pages/artifact.mjs +183 -38
- package/src/pages/clear-bench-history.mjs +27 -0
- package/src/pages/keep-prior-bench-badge.mjs +38 -0
- package/src/pages/restore-bench-history.mjs +28 -15
- package/src/release/npm-links.mjs +13 -6
- package/src/release/sync-dependencies.mjs +2 -1
- package/tests/lib/lcov.test.mjs +4 -4
- package/src/audit/format.mjs +0 -133
- package/src/bench/file-regressions.mjs +0 -76
- package/src/bench/history.mjs +0 -248
- package/src/bench/nightly.mjs +0 -118
- package/src/bench/regression-issue.mjs +0 -44
- package/src/github/credential-nudge.mjs +0 -245
- package/src/github/oidc-bootstrap-nudge.mjs +0 -210
- package/src/github/publish-verify.mjs +0 -206
- package/src/github/smoke-create-launcher.mjs +0 -70
- package/src/lib/github-issue.mjs +0 -75
- package/src/lib/workspace-packages.mjs +0 -30
- package/src/pages/bench-page.mjs +0 -323
- package/src/pages/restore-badges.mjs +0 -58
- package/tests/audit/format.test.mjs +0 -115
- package/tests/bench/history.test.mjs +0 -279
- package/tests/bench/regression-issue.test.mjs +0 -42
- package/tests/github/credential-nudge.test.mjs +0 -116
- package/tests/github/oidc-bootstrap-nudge.test.mjs +0 -89
- package/tests/github/publish-verify.test.mjs +0 -115
- package/tests/github/smoke-create-launcher.test.mjs +0 -71
- package/tests/lib/github-issue.test.mjs +0 -85
- package/tests/pages/bench-page.test.mjs +0 -123
package/src/audit/format.mjs
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared npm/pnpm audit JSON helpers.
|
|
3
|
-
*
|
|
4
|
-
* Supports pnpm's classic `{ advisories }` report and npm audit v2
|
|
5
|
-
* `{ auditReportVersion: 2, vulnerabilities }` (pnpm 9+/10).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const SEVERITY_ORDER = ["critical", "high", "moderate", "low", "info"];
|
|
9
|
-
|
|
10
|
-
function ghsaFromUrl(url) {
|
|
11
|
-
return typeof url === "string" ? (url.match(/GHSA-[a-z0-9-]+/i)?.[0] ?? "") : "";
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function fromClassicAdvisories(data) {
|
|
15
|
-
return Object.values(data?.advisories ?? {}).map((raw) => {
|
|
16
|
-
const paths = (raw.findings ?? []).flatMap((f) => f.paths ?? []);
|
|
17
|
-
const url = raw.url ?? "";
|
|
18
|
-
return {
|
|
19
|
-
id: raw.id,
|
|
20
|
-
module: raw.module_name ?? "(unknown)",
|
|
21
|
-
severity: String(raw.severity ?? "info").toLowerCase(),
|
|
22
|
-
title: raw.title ?? "",
|
|
23
|
-
url,
|
|
24
|
-
vulnerable: raw.vulnerable_versions ?? "",
|
|
25
|
-
patched: raw.patched_versions ?? "",
|
|
26
|
-
cves: raw.cves ?? [],
|
|
27
|
-
ghsa: ghsaFromUrl(url),
|
|
28
|
-
paths: paths.slice(0, 3),
|
|
29
|
-
};
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function fromNpmV2(data) {
|
|
34
|
-
const byKey = new Map();
|
|
35
|
-
for (const vuln of Object.values(data?.vulnerabilities ?? {})) {
|
|
36
|
-
const vias = Array.isArray(vuln.via) ? vuln.via : [];
|
|
37
|
-
for (const via of vias) {
|
|
38
|
-
if (via == null || typeof via !== "object") continue;
|
|
39
|
-
const url = via.url ?? "";
|
|
40
|
-
const key = url || `${via.name ?? vuln.name}:${via.title ?? ""}`;
|
|
41
|
-
const existing = byKey.get(key);
|
|
42
|
-
const node = Array.isArray(vuln.nodes) ? vuln.nodes[0] : undefined;
|
|
43
|
-
if (existing) {
|
|
44
|
-
if (node && existing.paths.length < 3 && !existing.paths.includes(node)) {
|
|
45
|
-
existing.paths.push(node);
|
|
46
|
-
}
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
byKey.set(key, {
|
|
50
|
-
id: via.source,
|
|
51
|
-
module: via.name ?? vuln.name ?? "(unknown)",
|
|
52
|
-
severity: String(via.severity ?? vuln.severity ?? "info").toLowerCase(),
|
|
53
|
-
title: via.title ?? "",
|
|
54
|
-
url,
|
|
55
|
-
vulnerable: via.range ?? vuln.range ?? "",
|
|
56
|
-
patched: "",
|
|
57
|
-
cves: [],
|
|
58
|
-
ghsa: ghsaFromUrl(url),
|
|
59
|
-
paths: node ? [node] : [],
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return [...byKey.values()];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function parseAudit(data) {
|
|
67
|
-
const counts = data?.metadata?.vulnerabilities ?? {};
|
|
68
|
-
const info = counts.info ?? 0;
|
|
69
|
-
const low = counts.low ?? 0;
|
|
70
|
-
const moderate = counts.moderate ?? 0;
|
|
71
|
-
const high = counts.high ?? 0;
|
|
72
|
-
const critical = counts.critical ?? 0;
|
|
73
|
-
const total = counts.total ?? info + low + moderate + high + critical;
|
|
74
|
-
|
|
75
|
-
const classic = data?.advisories != null && Object.keys(data.advisories).length > 0;
|
|
76
|
-
const advisories = classic ? fromClassicAdvisories(data) : fromNpmV2(data);
|
|
77
|
-
|
|
78
|
-
advisories.sort((a, b) => {
|
|
79
|
-
const d = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity);
|
|
80
|
-
if (d !== 0) return d;
|
|
81
|
-
return String(a.module).localeCompare(String(b.module));
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
return { total, critical, high, moderate, low, info, advisories };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function formatLog(parsed) {
|
|
88
|
-
const lines = [
|
|
89
|
-
`npm audit: total=${parsed.total} critical=${parsed.critical} high=${parsed.high} moderate=${parsed.moderate} low=${parsed.low} info=${parsed.info}`,
|
|
90
|
-
];
|
|
91
|
-
for (const a of parsed.advisories) {
|
|
92
|
-
const id = a.ghsa || a.cves[0] || a.id;
|
|
93
|
-
lines.push(
|
|
94
|
-
` [${a.severity}] ${a.module} ${id}: ${a.title} (vulnerable ${a.vulnerable}; patched ${a.patched})`,
|
|
95
|
-
);
|
|
96
|
-
if (a.url) lines.push(` ${a.url}`);
|
|
97
|
-
if (a.paths[0]) lines.push(` ${a.paths[0]}`);
|
|
98
|
-
}
|
|
99
|
-
return `${lines.join("\n")}\n`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function formatMarkdown(parsed) {
|
|
103
|
-
const lines = [
|
|
104
|
-
"## npm audit",
|
|
105
|
-
"",
|
|
106
|
-
"| Severity | Count |",
|
|
107
|
-
"|---|---|",
|
|
108
|
-
`| Critical | ${parsed.critical} |`,
|
|
109
|
-
`| High | ${parsed.high} |`,
|
|
110
|
-
`| Moderate | ${parsed.moderate} |`,
|
|
111
|
-
`| Low | ${parsed.low} |`,
|
|
112
|
-
`| Info | ${parsed.info} |`,
|
|
113
|
-
`| **Total** | **${parsed.total}** |`,
|
|
114
|
-
"",
|
|
115
|
-
];
|
|
116
|
-
if (parsed.advisories.length === 0) {
|
|
117
|
-
lines.push("No advisories.");
|
|
118
|
-
return `${lines.join("\n")}\n`;
|
|
119
|
-
}
|
|
120
|
-
lines.push("### Advisories", "");
|
|
121
|
-
for (const a of parsed.advisories) {
|
|
122
|
-
const id = a.ghsa || a.cves.join(", ") || a.id;
|
|
123
|
-
const link = a.url ? `[${id}](${a.url})` : id;
|
|
124
|
-
lines.push(`- **${a.severity}** \`${a.module}\` ${link} — ${a.title}`);
|
|
125
|
-
lines.push(` - vulnerable: \`${a.vulnerable}\`; patched: \`${a.patched}\``);
|
|
126
|
-
if (a.paths[0]) lines.push(` - ${a.paths[0]}`);
|
|
127
|
-
}
|
|
128
|
-
return `${lines.join("\n")}\n`;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function highCritical(parsed) {
|
|
132
|
-
return parsed.advisories.filter((a) => a.severity === "high" || a.severity === "critical");
|
|
133
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* file-regressions.mjs — after nightly.mjs, open an issue for each benchmark that got slower
|
|
5
|
-
* (regressions.json: head slower than base in every round on one runner), or update the open
|
|
6
|
-
* one. Needs `gh` and GH_TOKEN with issues: write.
|
|
7
|
-
*
|
|
8
|
-
* node projects/ci-tools/src/bench/file-regressions.mjs # file issues
|
|
9
|
-
* node projects/ci-tools/src/bench/file-regressions.mjs --dry-run # print them only
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { spawnSync } from "node:child_process";
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
14
|
-
import { REGRESSION } from "./history.mjs";
|
|
15
|
-
import { issueBody, issueTitle } from "./regression-issue.mjs";
|
|
16
|
-
|
|
17
|
-
const REGRESSIONS_FILE = "projects/ci-tools/dist/ci/bench/regressions.json";
|
|
18
|
-
const dryRun = process.argv.includes("--dry-run");
|
|
19
|
-
|
|
20
|
-
function gh(args, input) {
|
|
21
|
-
const result = spawnSync("gh", args, { encoding: "utf8", input, env: process.env });
|
|
22
|
-
if (result.status !== 0) {
|
|
23
|
-
throw new Error(result.stderr || result.stdout || `gh ${args.join(" ")} failed`);
|
|
24
|
-
}
|
|
25
|
-
return result.stdout;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const server = process.env.GITHUB_SERVER_URL ?? "https://github.com";
|
|
29
|
-
const repository = process.env.GITHUB_REPOSITORY ?? "typesmiths/decohere";
|
|
30
|
-
const repoUrl = `${server}/${repository}`;
|
|
31
|
-
const runUrl = process.env.GITHUB_RUN_ID
|
|
32
|
-
? `${repoUrl}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
33
|
-
: "(local run)";
|
|
34
|
-
|
|
35
|
-
const { base, head, context, regressions } = JSON.parse(
|
|
36
|
-
readFileSync(REGRESSIONS_FILE, "utf8"),
|
|
37
|
-
);
|
|
38
|
-
if (base == null) {
|
|
39
|
-
console.log("Nothing was compared tonight (no earlier nightly, or no new commit).");
|
|
40
|
-
process.exit(0);
|
|
41
|
-
}
|
|
42
|
-
if (regressions.length === 0) {
|
|
43
|
-
console.log(`No benchmark slower on ${head.slice(0, 7)} than on ${base.slice(0, 7)}.`);
|
|
44
|
-
process.exit(0);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const machine = `${context.benchEnv}, ${context.cpu.model} × ${context.cpu.count}`;
|
|
48
|
-
const open = dryRun
|
|
49
|
-
? []
|
|
50
|
-
: JSON.parse(
|
|
51
|
-
gh(["issue", "list", "--state", "open", "--limit", "200", "--json", "number,title"]),
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
for (const regression of regressions) {
|
|
55
|
-
const title = issueTitle(regression);
|
|
56
|
-
const body = issueBody(regression, {
|
|
57
|
-
repoUrl,
|
|
58
|
-
runUrl,
|
|
59
|
-
machine,
|
|
60
|
-
threshold: REGRESSION.threshold,
|
|
61
|
-
base,
|
|
62
|
-
head,
|
|
63
|
-
});
|
|
64
|
-
if (dryRun) {
|
|
65
|
-
console.log(`# ${title}\n\n${body}\n`);
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
const existing = open.find((issue) => issue.title === title);
|
|
69
|
-
if (existing != null) {
|
|
70
|
-
gh(["issue", "edit", String(existing.number), "--body-file", "-"], body);
|
|
71
|
-
console.log(`Updated #${existing.number}: ${title}`);
|
|
72
|
-
} else {
|
|
73
|
-
const url = gh(["issue", "create", "--title", title, "--body-file", "-"], body).trim();
|
|
74
|
-
console.log(`Opened ${url}: ${title}`);
|
|
75
|
-
}
|
|
76
|
-
}
|
package/src/bench/history.mjs
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Benchmark history: the results of each `pnpm bench` run, kept per machine so runs are only
|
|
3
|
-
* compared like for like (#660). 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 (#667). */
|
|
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 (#667): 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
|
-
}
|
package/src/bench/nightly.mjs
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* nightly.mjs — the nightly benchmark run (#667). 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
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The GitHub issue for a benchmark that got slower (#660, #667): 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
|
-
}
|