@decohere/ci-tools 0.0.1-dev.19 → 0.0.1-dev.20261105.46
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 +135 -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 +324 -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
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Outputs markdown formatted links for GitHub release notes
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
+
import { listProjectDirs } from "../lib/workspace-packages.mjs";
|
|
9
10
|
|
|
10
11
|
const VERSION = process.argv[2] || process.env.VERSION;
|
|
11
12
|
|
|
@@ -15,20 +16,12 @@ if (!VERSION) {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
let links = "## Published Packages\n\n";
|
|
18
|
-
const projectsDir = join(process.cwd(), "projects");
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
27
|
-
if (pkg.name?.startsWith("@decohere/")) {
|
|
28
|
-
links += `- [\`${pkg.name}@${VERSION}\`](https://www.npmjs.com/package/${pkg.name}/v/${VERSION})\n`;
|
|
29
|
-
}
|
|
30
|
-
} catch {
|
|
31
|
-
// Skip projects without package.json
|
|
21
|
+
for (const projectDir of listProjectDirs(process.cwd())) {
|
|
22
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
|
|
23
|
+
if (pkg.name?.startsWith("@decohere/")) {
|
|
24
|
+
links += `- [\`${pkg.name}@${VERSION}\`](https://www.npmjs.com/package/${pkg.name}/v/${VERSION})\n`;
|
|
32
25
|
}
|
|
33
26
|
}
|
|
34
27
|
|
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
import { execSync } from "node:child_process";
|
|
8
8
|
|
|
9
9
|
const DRY_RUN = process.env.DRY_RUN === "true";
|
|
10
|
-
const TERMINAL_CLI = process.env.TERMINAL_CLI || "dist/debug/projects/terminal/src/cli.js";
|
|
11
10
|
const DECOHERE_CONFIG = process.env.DECOHERE_CONFIG || ".decohere.js";
|
|
12
11
|
|
|
13
|
-
const baseCmd = `
|
|
12
|
+
const baseCmd = `pnpm forge --log-level info --config-file "${DECOHERE_CONFIG}" dependencies --configuration release sync`;
|
|
14
13
|
|
|
15
14
|
try {
|
|
16
15
|
if (DRY_RUN) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
formatLog,
|
|
4
|
+
formatMarkdown,
|
|
5
|
+
highCritical,
|
|
6
|
+
parseAudit,
|
|
7
|
+
} from "../../src/audit/format.mjs";
|
|
8
|
+
|
|
9
|
+
const fixture = {
|
|
10
|
+
metadata: {
|
|
11
|
+
vulnerabilities: { info: 0, low: 1, moderate: 0, high: 1, critical: 0, total: 2 },
|
|
12
|
+
},
|
|
13
|
+
advisories: {
|
|
14
|
+
1: {
|
|
15
|
+
id: 1,
|
|
16
|
+
module_name: "nanoid",
|
|
17
|
+
severity: "high",
|
|
18
|
+
title: "nanoid: custom generators can loop indefinitely when size is zero",
|
|
19
|
+
url: "https://github.com/advisories/GHSA-2v37-7h3g-55p8",
|
|
20
|
+
vulnerable_versions: "<3.3.18",
|
|
21
|
+
patched_versions: ">=3.3.18",
|
|
22
|
+
cves: [],
|
|
23
|
+
findings: [{ version: "3.3.17", paths: [". > vite > postcss > nanoid@3.3.17"] }],
|
|
24
|
+
},
|
|
25
|
+
2: {
|
|
26
|
+
id: 2,
|
|
27
|
+
module_name: "esbuild",
|
|
28
|
+
severity: "low",
|
|
29
|
+
title: "esbuild Windows file read",
|
|
30
|
+
url: "https://github.com/advisories/GHSA-g7r4-m6w7-qqqr",
|
|
31
|
+
vulnerable_versions: "<0.28.1",
|
|
32
|
+
patched_versions: ">=0.28.1",
|
|
33
|
+
cves: [],
|
|
34
|
+
findings: [{ version: "0.27.3", paths: [". > vite > esbuild"] }],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("parseAudit", () => {
|
|
40
|
+
it("reads severity counts and sorts advisories critical/high first", () => {
|
|
41
|
+
const parsed = parseAudit(fixture);
|
|
42
|
+
expect(parsed.total).toBe(2);
|
|
43
|
+
expect(parsed.high).toBe(1);
|
|
44
|
+
expect(parsed.low).toBe(1);
|
|
45
|
+
expect(parsed.advisories.map((a) => a.module)).toEqual(["nanoid", "esbuild"]);
|
|
46
|
+
expect(parsed.advisories[0]?.severity).toBe("high");
|
|
47
|
+
expect(parsed.advisories[0]?.ghsa).toBe("GHSA-2v37-7h3g-55p8");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("formatLog", () => {
|
|
52
|
+
it("prints severity counts and advisory lines including GHSA", () => {
|
|
53
|
+
const log = formatLog(parseAudit(fixture));
|
|
54
|
+
expect(log).toContain("high=1");
|
|
55
|
+
expect(log).toContain("[high] nanoid GHSA-2v37-7h3g-55p8");
|
|
56
|
+
expect(log).toContain("[low] esbuild");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("formatMarkdown", () => {
|
|
61
|
+
it("includes a severity table and linked advisories", () => {
|
|
62
|
+
const md = formatMarkdown(parseAudit(fixture));
|
|
63
|
+
expect(md).toContain("| High | 1 |");
|
|
64
|
+
expect(md).toContain("**high** `nanoid`");
|
|
65
|
+
expect(md).toContain("GHSA-2v37-7h3g-55p8");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("highCritical", () => {
|
|
70
|
+
it("filters to high and critical only", () => {
|
|
71
|
+
expect(highCritical(parseAudit(fixture)).map((a) => a.module)).toEqual(["nanoid"]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("parseAudit npm v2", () => {
|
|
76
|
+
it("dedupes GHSA rows from package via objects", () => {
|
|
77
|
+
const v2 = {
|
|
78
|
+
auditReportVersion: 2,
|
|
79
|
+
metadata: {
|
|
80
|
+
vulnerabilities: { info: 0, low: 0, moderate: 0, high: 1, critical: 0, total: 1 },
|
|
81
|
+
},
|
|
82
|
+
vulnerabilities: {
|
|
83
|
+
nanoid: {
|
|
84
|
+
name: "nanoid",
|
|
85
|
+
severity: "high",
|
|
86
|
+
via: [
|
|
87
|
+
{
|
|
88
|
+
source: 1103848,
|
|
89
|
+
name: "nanoid",
|
|
90
|
+
title: "nanoid infinite loop",
|
|
91
|
+
url: "https://github.com/advisories/GHSA-2v37-7h3g-55p8",
|
|
92
|
+
severity: "high",
|
|
93
|
+
range: "<3.3.18",
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
range: "<3.3.18",
|
|
97
|
+
nodes: ["node_modules/.pnpm/nanoid@3.3.17/node_modules/nanoid"],
|
|
98
|
+
},
|
|
99
|
+
postcss: {
|
|
100
|
+
name: "postcss",
|
|
101
|
+
severity: "high",
|
|
102
|
+
via: ["nanoid"],
|
|
103
|
+
effects: [],
|
|
104
|
+
range: "*",
|
|
105
|
+
nodes: ["node_modules/.pnpm/postcss@8.5.26/node_modules/postcss"],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const parsed = parseAudit(v2);
|
|
110
|
+
expect(parsed.advisories).toHaveLength(1);
|
|
111
|
+
expect(parsed.advisories[0]?.module).toBe("nanoid");
|
|
112
|
+
expect(parsed.advisories[0]?.ghsa).toBe("GHSA-2v37-7h3g-55p8");
|
|
113
|
+
expect(parsed.advisories[0]?.vulnerable).toBe("<3.3.18");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
appendRun,
|
|
4
|
+
assertValid,
|
|
5
|
+
compare,
|
|
6
|
+
contextId,
|
|
7
|
+
compareRounds,
|
|
8
|
+
latestRun,
|
|
9
|
+
pruneStale,
|
|
10
|
+
formatComparison,
|
|
11
|
+
fromVitest,
|
|
12
|
+
getContext,
|
|
13
|
+
normalize,
|
|
14
|
+
parseResults,
|
|
15
|
+
previousRun,
|
|
16
|
+
RESULT_PREFIX,
|
|
17
|
+
} from "../../src/bench/history.mjs";
|
|
18
|
+
|
|
19
|
+
const fakeOs = {
|
|
20
|
+
cpus: () => [{ model: " Test CPU " }, { model: "Test CPU" }],
|
|
21
|
+
platform: () => "linux",
|
|
22
|
+
arch: () => "x64",
|
|
23
|
+
totalmem: () => 16 * 1024 ** 3,
|
|
24
|
+
};
|
|
25
|
+
const ctx = getContext(
|
|
26
|
+
fakeOs,
|
|
27
|
+
{ BENCH_ENV: "gh-actions" },
|
|
28
|
+
"v24.1.0",
|
|
29
|
+
new Date("2026-10-20T03:17:00Z"),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const result = (id, median, extra = {}) => ({
|
|
33
|
+
id,
|
|
34
|
+
label: id,
|
|
35
|
+
unit: "ms",
|
|
36
|
+
median,
|
|
37
|
+
p90: median * 1.2,
|
|
38
|
+
min: median * 0.9,
|
|
39
|
+
max: median * 1.3,
|
|
40
|
+
samples: 5,
|
|
41
|
+
...extra,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const vitestOutput = {
|
|
45
|
+
files: [
|
|
46
|
+
{
|
|
47
|
+
groups: [
|
|
48
|
+
{
|
|
49
|
+
benchmarks: [
|
|
50
|
+
{
|
|
51
|
+
name: "forge build es6 + types (18 files)",
|
|
52
|
+
mean: 70,
|
|
53
|
+
median: 64,
|
|
54
|
+
min: 60,
|
|
55
|
+
max: 80,
|
|
56
|
+
sampleCount: 5,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "forge build es6 only (18 files)",
|
|
60
|
+
mean: 10,
|
|
61
|
+
min: 8,
|
|
62
|
+
max: 12,
|
|
63
|
+
sampleCount: 5,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
describe("getContext / contextId", () => {
|
|
73
|
+
it("describes the machine and names the CI environment with the Node major", () => {
|
|
74
|
+
expect(ctx).toEqual({
|
|
75
|
+
benchEnv: "gh-actions-node24",
|
|
76
|
+
date: "2026-10-20T03:17:00.000Z",
|
|
77
|
+
os: { platform: "linux", arch: "x64" },
|
|
78
|
+
cpu: { model: "Test CPU", count: 2 },
|
|
79
|
+
memoryGb: 16,
|
|
80
|
+
node: "v24.1.0",
|
|
81
|
+
});
|
|
82
|
+
expect(getContext(fakeOs, {}, "v24.1.0").benchEnv).toBe("(local)");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("gives the same id to the same machine regardless of date or env label", () => {
|
|
86
|
+
const later = getContext(fakeOs, {}, "v24.1.0", new Date("2027-01-01"));
|
|
87
|
+
expect(contextId(later)).toBe(contextId(ctx));
|
|
88
|
+
expect(contextId({ ...ctx, cpu: { ...ctx.cpu, count: 4 } })).not.toBe(contextId(ctx));
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("parseResults", () => {
|
|
93
|
+
it("reads the last result line among other output", () => {
|
|
94
|
+
const results = [result("flux.load.cold", 250)];
|
|
95
|
+
const output = `flux load: median 250 ms\n${RESULT_PREFIX}[]\n${RESULT_PREFIX}${JSON.stringify(results)}\n`;
|
|
96
|
+
expect(parseResults(output, "flux-load")).toEqual(results);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("fails, naming the suite, when there is no result line", () => {
|
|
100
|
+
expect(() => parseResults("crashed\n", "flux-serve")).toThrow(/flux-serve/);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("fromVitest", () => {
|
|
105
|
+
it("maps forge benchmarks to stable ids, keeping the file count out of the label", () => {
|
|
106
|
+
expect(fromVitest(vitestOutput)).toEqual([
|
|
107
|
+
{
|
|
108
|
+
id: "forge.build.local",
|
|
109
|
+
label: "forge build es6 + types",
|
|
110
|
+
unit: "ms",
|
|
111
|
+
median: 64,
|
|
112
|
+
min: 60,
|
|
113
|
+
max: 80,
|
|
114
|
+
samples: 5,
|
|
115
|
+
files: 18,
|
|
116
|
+
},
|
|
117
|
+
// No median from vitest: the mean stands in.
|
|
118
|
+
expect.objectContaining({ id: "forge.build.debug", median: 10, files: 18 }),
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("fails on a benchmark it has no id for, instead of charting it under a moving name", () => {
|
|
123
|
+
const output = {
|
|
124
|
+
files: [{ groups: [{ benchmarks: [{ name: "something new", mean: 1 }] }] }],
|
|
125
|
+
};
|
|
126
|
+
expect(() => fromVitest(output)).toThrow(/something new/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("assertValid", () => {
|
|
131
|
+
it("passes real results and rejects NaN or empty ones, naming them", () => {
|
|
132
|
+
expect(() => assertValid([result("a", 1)])).not.toThrow();
|
|
133
|
+
expect(() =>
|
|
134
|
+
assertValid([result("a", 1), result("b", Number.NaN), result("c", 2, { samples: 0 })]),
|
|
135
|
+
).toThrow(/b, c/);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("normalize", () => {
|
|
140
|
+
it("keeps a version 2 history as it is", () => {
|
|
141
|
+
const history = { version: 2, contexts: { x: { context: ctx, runs: [] } } };
|
|
142
|
+
expect(normalize(history)).toBe(history);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("converts version 1 forge runs so their trend carries on", () => {
|
|
146
|
+
const v1 = {
|
|
147
|
+
runs: {
|
|
148
|
+
abc: {
|
|
149
|
+
context: ctx,
|
|
150
|
+
runs: [{ date: "2026-08-12T14:18:10Z", context: ctx, vitestOutput }],
|
|
151
|
+
},
|
|
152
|
+
empty: { context: ctx, runs: [] },
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
const history = normalize(v1);
|
|
156
|
+
expect(Object.keys(history.contexts)).toEqual(["abc"]);
|
|
157
|
+
expect(history.contexts.abc.runs[0]).toMatchObject({
|
|
158
|
+
date: "2026-08-12T14:18:10Z",
|
|
159
|
+
sha: null,
|
|
160
|
+
results: [{ id: "forge.build.local" }, { id: "forge.build.debug" }],
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("starts empty when there is no history", () => {
|
|
165
|
+
expect(normalize(null)).toEqual({ version: 2, contexts: {} });
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("appendRun / previousRun", () => {
|
|
170
|
+
const run = (day) => ({
|
|
171
|
+
date: `2026-10-${day}T03:17:00Z`,
|
|
172
|
+
sha: `sha${day}`,
|
|
173
|
+
results: [result("a", Number(day))],
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("puts the new run first, trims to the limit, and leaves the input untouched", () => {
|
|
177
|
+
const empty = normalize(null);
|
|
178
|
+
const one = appendRun(empty, ctx, run("18"), 2);
|
|
179
|
+
const two = appendRun(one, ctx, run("19"), 2);
|
|
180
|
+
const three = appendRun(two, ctx, run("20"), 2);
|
|
181
|
+
|
|
182
|
+
expect(empty.contexts).toEqual({});
|
|
183
|
+
expect(one.contexts[contextId(ctx)].runs).toHaveLength(1);
|
|
184
|
+
expect(three.contexts[contextId(ctx)].runs.map((r) => r.sha)).toEqual(["sha20", "sha19"]);
|
|
185
|
+
expect(previousRun(three, contextId(ctx))?.sha).toBe("sha20");
|
|
186
|
+
expect(previousRun(three, "other-machine")).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("compare / formatComparison", () => {
|
|
191
|
+
it("reports the change in median against the previous run, and n/a for new benchmarks", () => {
|
|
192
|
+
const rows = compare([result("a", 120), result("new", 5)], {
|
|
193
|
+
results: [result("a", 100)],
|
|
194
|
+
});
|
|
195
|
+
expect(rows).toEqual([
|
|
196
|
+
{ id: "a", current: 120, previous: 100, delta: 20, percent: 20 },
|
|
197
|
+
{ id: "new", current: 5 },
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
const table = formatComparison(rows).split("\n");
|
|
201
|
+
expect(table[0]).toMatch(/^Benchmark \| +Median \| Previous \| +Delta \| +%$/);
|
|
202
|
+
expect(table[2]).toMatch(/^a +\| +120\.0 ms \| +100\.0 ms \| +\+20\.0 ms \| \+20\.0%$/);
|
|
203
|
+
expect(table[3]).toMatch(/^new +\| +5\.0 ms \| +n\/a \| +n\/a \| +n\/a$/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("compares against nothing on a machine's first run", () => {
|
|
207
|
+
expect(compare([result("a", 1)], undefined)).toEqual([{ id: "a", current: 1 }]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("compareRounds", () => {
|
|
212
|
+
const round = (...medians) => medians.map((median, index) => result(`b${index}`, median));
|
|
213
|
+
|
|
214
|
+
it("flags a benchmark slower on head than base in every round, with both rounds' numbers", () => {
|
|
215
|
+
const [regression] = compareRounds([round(130, 50)], [round(100, 50)], {
|
|
216
|
+
threshold: 0.2,
|
|
217
|
+
rounds: 1,
|
|
218
|
+
});
|
|
219
|
+
expect(regression).toMatchObject({
|
|
220
|
+
id: "b0",
|
|
221
|
+
base: 100,
|
|
222
|
+
head: 130,
|
|
223
|
+
rounds: [{ base: 100, head: 130 }],
|
|
224
|
+
});
|
|
225
|
+
expect(regression.percent).toBeCloseTo(30);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("needs every round slower: one slow round is as likely the runner as the code", () => {
|
|
229
|
+
expect(compareRounds([round(130), round(105)], [round(100), round(100)])).toEqual([]);
|
|
230
|
+
expect(compareRounds([round(130), round(126)], [round(100), round(100)])).toHaveLength(1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("ignores slowdowns within the threshold", () => {
|
|
234
|
+
expect(compareRounds([round(115), round(118)], [round(100), round(100)])).toEqual([]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("skips benchmarks only one side has, and compares nothing without enough rounds", () => {
|
|
238
|
+
const head = [
|
|
239
|
+
[result("new", 500), result("old", 100)],
|
|
240
|
+
[result("new", 500), result("old", 100)],
|
|
241
|
+
];
|
|
242
|
+
const base = [[result("old", 100)], [result("old", 100)]];
|
|
243
|
+
expect(compareRounds(head, base)).toEqual([]);
|
|
244
|
+
expect(compareRounds([round(200)], [round(100)])).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("pruneStale / latestRun", () => {
|
|
249
|
+
const at = (date, sha) => ({ date, sha, results: [] });
|
|
250
|
+
const history = {
|
|
251
|
+
version: 2,
|
|
252
|
+
contexts: {
|
|
253
|
+
old: { context: ctx, runs: [at("2026-08-12T03:17:00Z", "aaa")] },
|
|
254
|
+
recent: {
|
|
255
|
+
context: ctx,
|
|
256
|
+
runs: [at("2026-10-20T03:17:00Z", "ccc"), at("2026-10-19T03:17:00Z", "bbb")],
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
it("drops machine types with no run in the last 30 days", () => {
|
|
262
|
+
expect(
|
|
263
|
+
Object.keys(pruneStale(history, new Date("2026-10-21T00:00:00Z")).contexts),
|
|
264
|
+
).toEqual(["recent"]);
|
|
265
|
+
expect(
|
|
266
|
+
Object.keys(pruneStale(history, new Date("2026-09-01T00:00:00Z")).contexts),
|
|
267
|
+
).toEqual(["old", "recent"]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("prunes when a run is appended", () => {
|
|
271
|
+
const next = appendRun(history, ctx, at("2026-10-21T03:17:00Z", "ddd"));
|
|
272
|
+
expect(Object.keys(next.contexts)).not.toContain("old");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("finds the newest run on any machine: the commit the previous nightly measured", () => {
|
|
276
|
+
expect(latestRun(history)?.sha).toBe("ccc");
|
|
277
|
+
expect(latestRun({ version: 2, contexts: {} })).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { issueBody, issueTitle } from "../../src/bench/regression-issue.mjs";
|
|
3
|
+
|
|
4
|
+
const regression = {
|
|
5
|
+
id: "flux.load.cold",
|
|
6
|
+
label: "flux load, first import (cold process)",
|
|
7
|
+
base: 300,
|
|
8
|
+
head: 400,
|
|
9
|
+
percent: 33.3,
|
|
10
|
+
rounds: [
|
|
11
|
+
{ base: 300, head: 395 },
|
|
12
|
+
{ base: 300, head: 405 },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
const context = {
|
|
16
|
+
repoUrl: "https://github.com/typesmiths/decohere",
|
|
17
|
+
runUrl: "https://github.com/typesmiths/decohere/actions/runs/1",
|
|
18
|
+
machine: "gh-actions-node24, Test CPU × 4",
|
|
19
|
+
threshold: 0.2,
|
|
20
|
+
base: "aaaaaaaaaaaaaaaa",
|
|
21
|
+
head: "cccccccccccccccc",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("regression issue", () => {
|
|
25
|
+
it("has one stable title per benchmark, so the nightly run finds and updates it", () => {
|
|
26
|
+
expect(issueTitle(regression)).toBe("perf(bench): flux.load.cold is slower");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("gives both rounds, the commit range and how to reproduce", () => {
|
|
30
|
+
const body = issueBody(regression, context);
|
|
31
|
+
expect(body).toContain(
|
|
32
|
+
"is 33% slower on [`ccccccc`](https://github.com/typesmiths/decohere/commit/cccccccccccccccc)",
|
|
33
|
+
);
|
|
34
|
+
expect(body).toContain("| 1 | 300.0 ms | 395.0 ms |");
|
|
35
|
+
expect(body).toContain("| 2 | 300.0 ms | 405.0 ms |");
|
|
36
|
+
expect(body).toContain(
|
|
37
|
+
"(https://github.com/typesmiths/decohere/compare/aaaaaaaaaaaaaaaa...cccccccccccccccc)",
|
|
38
|
+
);
|
|
39
|
+
expect(body).toContain("check out `aaaaaaa`, run `pnpm bench:save`");
|
|
40
|
+
expect(body).toContain(context.runUrl);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const fs = await vi.importActual("node:fs");
|
|
5
|
+
const CI_YML = fileURLToPath(new URL("../../../../.github/workflows/ci.yml", import.meta.url));
|
|
6
|
+
|
|
7
|
+
describe("ci.yml and the merge queue", () => {
|
|
8
|
+
const yml = fs.readFileSync(CI_YML, "utf8");
|
|
9
|
+
const triggers = yml.slice(yml.indexOf("\non:"), yml.indexOf("\nconcurrency:"));
|
|
10
|
+
const gate = yml.slice(yml.indexOf("- id: gate"), yml.indexOf("# Static gates"));
|
|
11
|
+
|
|
12
|
+
it("runs for queued pull requests, or the queue waits forever for the required check", () => {
|
|
13
|
+
expect(triggers).toMatch(/^\s+merge_group:/m);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("runs the whole suite in the queue: the docs-only shortcut is only for pull requests", () => {
|
|
17
|
+
expect(gate).toContain('"${{ github.event_name }}" == "merge_group"');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { vol } from "memfs";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { runBootstrap } from "../../src/github/npm-bootstrap.mjs";
|
|
4
|
+
|
|
5
|
+
const root = "/workspace";
|
|
6
|
+
|
|
7
|
+
function seedWorkspace() {
|
|
8
|
+
vol.fromJSON({
|
|
9
|
+
[`${root}/package.json`]: JSON.stringify({ version: "0.0.1-dev.5" }),
|
|
10
|
+
[`${root}/projects/scribe/scribe/package.json`]: JSON.stringify({
|
|
11
|
+
name: "@decohere/scribe",
|
|
12
|
+
version: "0.0.1-dev.5",
|
|
13
|
+
}),
|
|
14
|
+
[`${root}/projects/scribe/scribe-console/package.json`]: JSON.stringify({
|
|
15
|
+
name: "@decohere/scribe-console",
|
|
16
|
+
version: "0.0.1-dev.5",
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A fake registry: `published` names exist, and `trusted` names have a Trusted
|
|
23
|
+
* Publisher. Each call is recorded as its arguments, plus the code npm would
|
|
24
|
+
* get through NPM_CONFIG_OTP.
|
|
25
|
+
*/
|
|
26
|
+
function fakeNpm({ published = [], trusted = [], loggedIn = true } = {}) {
|
|
27
|
+
const calls = [];
|
|
28
|
+
const npm = (args, { otp } = {}) => {
|
|
29
|
+
calls.push(`${args.join(" ")}${otp ? ` [otp ${otp}]` : ""}`);
|
|
30
|
+
if (args[0] === "whoami") {
|
|
31
|
+
return { status: loggedIn ? 0 : 1, output: "" };
|
|
32
|
+
}
|
|
33
|
+
if (args[0] === "trust" && args[1] === "list") {
|
|
34
|
+
return trusted.includes(args[2])
|
|
35
|
+
? { status: 0, output: '[{"repository":"typesmiths/decohere","file":"publish.yml"}]' }
|
|
36
|
+
: { status: 0, output: "[]" };
|
|
37
|
+
}
|
|
38
|
+
return { status: 0, output: "" };
|
|
39
|
+
};
|
|
40
|
+
return { npm, calls, nameExists: (name) => published.includes(name) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function run(argv, registry) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
return runBootstrap({
|
|
46
|
+
argv,
|
|
47
|
+
root,
|
|
48
|
+
io: {
|
|
49
|
+
npm: registry.npm,
|
|
50
|
+
nameExists: registry.nameExists,
|
|
51
|
+
log: (line) => lines.push(line),
|
|
52
|
+
},
|
|
53
|
+
}).then((code) => ({ code, lines }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vol.reset();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("npm-bootstrap", () => {
|
|
61
|
+
it("first-publishes unpublished names public, then sets the Trusted Publisher", async () => {
|
|
62
|
+
seedWorkspace();
|
|
63
|
+
const registry = fakeNpm({ published: ["@decohere/scribe"] });
|
|
64
|
+
|
|
65
|
+
const { code } = await run(["--otp", "123456"], registry);
|
|
66
|
+
|
|
67
|
+
expect(code).toBe(0);
|
|
68
|
+
expect(registry.calls).toEqual([
|
|
69
|
+
"whoami",
|
|
70
|
+
"publish ./projects/scribe/scribe-console --access public --tag dev [otp 123456]",
|
|
71
|
+
"trust list @decohere/scribe-console --json [otp 123456]",
|
|
72
|
+
"trust github @decohere/scribe-console --file publish.yml --repo typesmiths/decohere --env npm-publish --allow-publish --yes [otp 123456]",
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("skips the steps that are already done", async () => {
|
|
77
|
+
seedWorkspace();
|
|
78
|
+
const registry = fakeNpm({
|
|
79
|
+
published: ["@decohere/scribe-console"],
|
|
80
|
+
trusted: ["@decohere/scribe-console"],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { code } = await run(["@decohere/scribe-console"], registry);
|
|
84
|
+
|
|
85
|
+
expect(code).toBe(0);
|
|
86
|
+
expect(registry.calls).toEqual(["whoami", "trust list @decohere/scribe-console --json"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** `npm trust` rejects `--otp`: the code goes through the environment. */
|
|
90
|
+
it("never passes the code as an argument, and lets npm ask when none is given", async () => {
|
|
91
|
+
seedWorkspace();
|
|
92
|
+
const withCode = fakeNpm({ published: ["@decohere/scribe"] });
|
|
93
|
+
await run(["--otp", "604381"], withCode);
|
|
94
|
+
expect(withCode.calls.join("\n")).not.toContain("--otp");
|
|
95
|
+
|
|
96
|
+
const withoutCode = fakeNpm({ published: ["@decohere/scribe"] });
|
|
97
|
+
expect((await run([], withoutCode)).code).toBe(0);
|
|
98
|
+
expect(withoutCode.calls.join("\n")).not.toContain("otp");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("prints the commands on a dry run, calling npm only to check the login", async () => {
|
|
102
|
+
seedWorkspace();
|
|
103
|
+
const registry = fakeNpm({ published: ["@decohere/scribe"] });
|
|
104
|
+
|
|
105
|
+
const { code, lines } = await run(["--dry-run"], registry);
|
|
106
|
+
|
|
107
|
+
expect(code).toBe(0);
|
|
108
|
+
expect(registry.calls).toEqual(["whoami"]);
|
|
109
|
+
expect(lines).toContain(
|
|
110
|
+
"[dry run] npm publish ./projects/scribe/scribe-console --access public --tag dev",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("stops when not logged in, or for a name that is not a package", async () => {
|
|
115
|
+
seedWorkspace();
|
|
116
|
+
const loggedOut = fakeNpm({ loggedIn: false });
|
|
117
|
+
expect((await run(["--otp", "1"], loggedOut)).code).toBe(1);
|
|
118
|
+
expect(loggedOut.calls).toEqual(["whoami"]);
|
|
119
|
+
|
|
120
|
+
const typo = fakeNpm();
|
|
121
|
+
const { code, lines } = await run(["@decohere/scribe-consol"], typo);
|
|
122
|
+
expect(code).toBe(1);
|
|
123
|
+
expect(lines).toEqual(["Not a publishable package in projects/: @decohere/scribe-consol"]);
|
|
124
|
+
});
|
|
125
|
+
});
|