@decohere/ci-tools 0.0.1-dev.12 → 0.0.1-dev.120
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 +8 -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/credential-nudge.mjs +245 -0
- package/src/github/oidc-bootstrap-nudge.mjs +210 -0
- package/src/github/publish-verify.mjs +206 -0
- package/src/github/smoke-create-launcher.mjs +70 -0
- package/src/lib/github-issue.mjs +75 -0
- 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 +58 -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/credential-nudge.test.mjs +116 -0
- package/tests/github/oidc-bootstrap-nudge.test.mjs +89 -0
- package/tests/github/publish-verify.test.mjs +115 -0
- package/tests/github/smoke-create-launcher.test.mjs +71 -0
- package/tests/lib/github-issue.test.mjs +85 -0
- package/tests/lib/lcov.test.mjs +4 -4
- 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,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Run `npx @decohere/create@version --help` from an empty temp directory.
|
|
10
|
+
* Must not use the repo cwd — after `@decohere/cli` is pinned, pnpm installs
|
|
11
|
+
* `@decohere/create` into the workspace tree and npm's npx misses the bin.
|
|
12
|
+
*
|
|
13
|
+
* @param {{
|
|
14
|
+
* createVersion: string,
|
|
15
|
+
* authToken: string,
|
|
16
|
+
* mkdtempSync?: typeof mkdtempSync,
|
|
17
|
+
* writeFileSync?: typeof writeFileSync,
|
|
18
|
+
* spawnSync?: typeof spawnSync,
|
|
19
|
+
* tmpdir?: () => string,
|
|
20
|
+
* stdio?: import("node:child_process").StdioOptions,
|
|
21
|
+
* }} options
|
|
22
|
+
*/
|
|
23
|
+
export function runCreateLauncherSmoke(options) {
|
|
24
|
+
const version = options.createVersion?.trim() ?? "";
|
|
25
|
+
const token = options.authToken?.trim() ?? "";
|
|
26
|
+
if (!version) {
|
|
27
|
+
throw new Error("CREATE_VERSION is required");
|
|
28
|
+
}
|
|
29
|
+
if (!token) {
|
|
30
|
+
throw new Error("NODE_AUTH_TOKEN is required");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const mkdtemp = options.mkdtempSync ?? mkdtempSync;
|
|
34
|
+
const writeFile = options.writeFileSync ?? writeFileSync;
|
|
35
|
+
const spawn = options.spawnSync ?? spawnSync;
|
|
36
|
+
const tmp = options.tmpdir ?? tmpdir;
|
|
37
|
+
|
|
38
|
+
const workDir = mkdtemp(join(tmp(), "decohere-create-smoke-"));
|
|
39
|
+
const npmrc = join(workDir, ".npmrc");
|
|
40
|
+
writeFile(
|
|
41
|
+
npmrc,
|
|
42
|
+
`@decohere:registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=${token}\n`,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const result = spawn("npx", ["--yes", `@decohere/create@${version}`, "--help"], {
|
|
46
|
+
cwd: workDir,
|
|
47
|
+
env: {
|
|
48
|
+
...process.env,
|
|
49
|
+
NPM_CONFIG_USERCONFIG: npmrc,
|
|
50
|
+
NODE_AUTH_TOKEN: token,
|
|
51
|
+
},
|
|
52
|
+
stdio: options.stdio ?? "inherit",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
workDir,
|
|
57
|
+
status: result.status ?? 1,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const isCli =
|
|
62
|
+
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
63
|
+
|
|
64
|
+
if (isCli) {
|
|
65
|
+
const { status } = runCreateLauncherSmoke({
|
|
66
|
+
createVersion: process.env.CREATE_VERSION ?? "",
|
|
67
|
+
authToken: process.env.NODE_AUTH_TOKEN ?? "",
|
|
68
|
+
});
|
|
69
|
+
process.exit(status);
|
|
70
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One GitHub issue per subject, found again by its exact title and updated in place: CI files
|
|
3
|
+
* a reminder once and keeps it current instead of opening a new issue every run. Needs `gh` and
|
|
4
|
+
* GH_TOKEN (or GITHUB_TOKEN) with issues: write.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string[]} args
|
|
11
|
+
* @param {string} [input]
|
|
12
|
+
*/
|
|
13
|
+
export function gh(args, input) {
|
|
14
|
+
const result = spawnSync("gh", args, { encoding: "utf8", input, env: process.env });
|
|
15
|
+
if (result.status !== 0) {
|
|
16
|
+
throw new Error(result.stderr || result.stdout || `gh ${args.join(" ")} failed`);
|
|
17
|
+
}
|
|
18
|
+
return result.stdout;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The issue titled exactly `title`, preferring an open one over a closed one.
|
|
23
|
+
*
|
|
24
|
+
* @param {{ number: number, title: string, state: string, url: string }[]} issues
|
|
25
|
+
* @param {string} title
|
|
26
|
+
*/
|
|
27
|
+
export function pickIssue(issues, title) {
|
|
28
|
+
const matches = issues.filter((issue) => issue.title === title);
|
|
29
|
+
return matches.find((issue) => issue.state === "OPEN") ?? matches[0] ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ title: string, body: string, assignees?: string[], repo?: string }} options
|
|
34
|
+
* @param {typeof gh} [run]
|
|
35
|
+
* @returns {{ number: number, url: string, created: boolean }}
|
|
36
|
+
*/
|
|
37
|
+
export function upsertIssue(options, run = gh) {
|
|
38
|
+
const { title, body, assignees = [] } = options;
|
|
39
|
+
const repo = options.repo ? ["--repo", options.repo] : [];
|
|
40
|
+
const issues = JSON.parse(
|
|
41
|
+
run([
|
|
42
|
+
"issue",
|
|
43
|
+
"list",
|
|
44
|
+
...repo,
|
|
45
|
+
"--state",
|
|
46
|
+
"all",
|
|
47
|
+
"--limit",
|
|
48
|
+
"200",
|
|
49
|
+
"--search",
|
|
50
|
+
// Quoted: GitHub's search can't match a title like `security(ci): …` unquoted (#681).
|
|
51
|
+
// The exact-title match in pickIssue still decides.
|
|
52
|
+
`"${title.replaceAll('"', "")}" in:title`,
|
|
53
|
+
"--json",
|
|
54
|
+
"number,title,state,url",
|
|
55
|
+
]),
|
|
56
|
+
);
|
|
57
|
+
const assign = assignees.flatMap((login) => ["--add-assignee", login]);
|
|
58
|
+
const existing = pickIssue(issues, title);
|
|
59
|
+
if (existing != null) {
|
|
60
|
+
if (existing.state !== "OPEN") {
|
|
61
|
+
run(["issue", "reopen", String(existing.number), ...repo]);
|
|
62
|
+
}
|
|
63
|
+
run(
|
|
64
|
+
["issue", "edit", String(existing.number), ...repo, "--body-file", "-", ...assign],
|
|
65
|
+
body,
|
|
66
|
+
);
|
|
67
|
+
return { number: existing.number, url: existing.url, created: false };
|
|
68
|
+
}
|
|
69
|
+
const url = run(
|
|
70
|
+
["issue", "create", ...repo, "--title", title, "--body-file", "-", ...assign],
|
|
71
|
+
body,
|
|
72
|
+
).trim();
|
|
73
|
+
const number = Number(url.split("/").pop());
|
|
74
|
+
return { number, url, created: true };
|
|
75
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// The same rule as WorkspaceDefaults in @decohere/workspace (`projects/**/package.json`, minus these
|
|
5
|
+
// folders, and a package.json inside another project is not a project). CI scripts run on plain
|
|
6
|
+
// Node before any install, so they can't import the workspace package itself.
|
|
7
|
+
const SKIP = new Set(["node_modules", "__fixtures__", "dist"]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every project directory under `<root>/projects`, at any depth (`projects/cli`,
|
|
11
|
+
* `projects/data/data-typeorm`).
|
|
12
|
+
* @param {string} root
|
|
13
|
+
* @returns {string[]} absolute paths
|
|
14
|
+
*/
|
|
15
|
+
export function listProjectDirs(root) {
|
|
16
|
+
const found = [];
|
|
17
|
+
const walk = (dir) => {
|
|
18
|
+
if (existsSync(join(dir, "package.json"))) {
|
|
19
|
+
found.push(dir);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
if (entry.isDirectory() && !SKIP.has(entry.name) && !entry.name.startsWith(".")) {
|
|
24
|
+
walk(join(dir, entry.name));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
walk(join(root, "projects"));
|
|
29
|
+
return found;
|
|
30
|
+
}
|
package/src/pages/artifact.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import { normalize } from "../bench/history.mjs";
|
|
5
|
+
import { buildBenchmarkHtml, buildStylesCss } from "./bench-page.mjs";
|
|
3
6
|
|
|
4
7
|
const branch = process.env.GITHUB_REF_NAME ?? "local";
|
|
5
8
|
const sha = process.env.GITHUB_SHA ?? "local";
|
|
@@ -8,201 +11,53 @@ const outputDir = process.env.PAGES_OUTPUT_DIR ?? "site";
|
|
|
8
11
|
const badgesDir = "projects/ci-tools/dist/ci/badges";
|
|
9
12
|
const benchHistoryPath = "projects/ci-tools/dist/ci/bench/bench-history.json";
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
function averageBenchmarks(runs) {
|
|
25
|
-
const first = runs[0]?.vitestOutput;
|
|
26
|
-
if (first == null) return [];
|
|
27
|
-
|
|
28
|
-
const latestBenchmarks = first.files
|
|
29
|
-
.flatMap((file) => file.groups)
|
|
30
|
-
.flatMap((group) => group.benchmarks);
|
|
31
|
-
|
|
32
|
-
return latestBenchmarks.map((benchmark) => {
|
|
33
|
-
const peers = runs
|
|
34
|
-
.flatMap((run) => run.vitestOutput?.files ?? [])
|
|
35
|
-
.flatMap((file) => file.groups ?? [])
|
|
36
|
-
.flatMap((group) => group.benchmarks ?? [])
|
|
37
|
-
.filter((candidate) => candidate.name === benchmark.name);
|
|
38
|
-
|
|
39
|
-
const averaged = { name: benchmark.name };
|
|
40
|
-
for (const field of numericFields) {
|
|
41
|
-
const values = peers
|
|
42
|
-
.map((peer) => peer[field])
|
|
43
|
-
.filter((value) => typeof value === "number");
|
|
44
|
-
if (values.length > 0) {
|
|
45
|
-
averaged[field] = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return averaged;
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function buildBenchmarkHtml(benchHistory) {
|
|
54
|
-
const contexts = Object.entries(benchHistory.runs ?? {})
|
|
55
|
-
.filter(([, entry]) => Array.isArray(entry.runs) && entry.runs.length > 0)
|
|
56
|
-
.sort((a, b) => new Date(b[1].runs[0].date) - new Date(a[1].runs[0].date));
|
|
57
|
-
|
|
58
|
-
if (contexts.length === 0) {
|
|
59
|
-
return `<!doctype html>
|
|
60
|
-
<html lang="en">
|
|
61
|
-
<head>
|
|
62
|
-
<meta charset="utf-8" />
|
|
63
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
64
|
-
<title>Ark - Benchmarks</title>
|
|
65
|
-
<link rel="stylesheet" href="./styles.css" />
|
|
66
|
-
</head>
|
|
67
|
-
<body>
|
|
68
|
-
<main class="container">
|
|
69
|
-
<header class="hero">
|
|
70
|
-
<h1>Benchmarks</h1>
|
|
71
|
-
<p class="note">No benchmark history is available yet.</p>
|
|
72
|
-
</header>
|
|
73
|
-
</main>
|
|
74
|
-
</body>
|
|
75
|
-
</html>
|
|
76
|
-
`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const rows = [];
|
|
80
|
-
for (const [, entry] of contexts) {
|
|
81
|
-
const benchmarks = averageBenchmarks(entry.runs);
|
|
82
|
-
const ctx = entry.context;
|
|
83
|
-
const environment = `${ctx.benchEnv ?? "unknown"}-${ctx.os.arch}`;
|
|
84
|
-
for (const benchmark of benchmarks) {
|
|
85
|
-
rows.push(`<tr>
|
|
86
|
-
<td>${environment}</td>
|
|
87
|
-
<td><code>${benchmark.name}</code></td>
|
|
88
|
-
<td>${fmtMs(benchmark.mean)}</td>
|
|
89
|
-
<td>${typeof benchmark.rme === "number" ? `±${benchmark.rme.toFixed(1)}%` : "-"}</td>
|
|
90
|
-
<td>${fmtMs(benchmark.p99)}</td>
|
|
91
|
-
<td>${fmtHz(benchmark.hz)}</td>
|
|
92
|
-
<td>${typeof benchmark.sampleCount === "number" ? Math.round(benchmark.sampleCount) : "-"}</td>
|
|
93
|
-
</tr>`);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const details = contexts
|
|
98
|
-
.map(([, entry]) => {
|
|
99
|
-
const ctx = entry.context;
|
|
100
|
-
return `<li><strong>${ctx.benchEnv ?? "unknown"}-${ctx.os.arch}</strong> - ${ctx.os.platform} - ${ctx.cpu.model} x ${ctx.cpu.count} - ${ctx.memoryGb} GB - Node ${ctx.node} - rolling mean of ${entry.runs.length} run(s)</li>`;
|
|
101
|
-
})
|
|
102
|
-
.join("\n");
|
|
103
|
-
|
|
104
|
-
const updatedDate = contexts[0][1].runs[0].date.slice(0, 10);
|
|
105
|
-
|
|
106
|
-
return `<!doctype html>
|
|
107
|
-
<html lang="en">
|
|
108
|
-
<head>
|
|
109
|
-
<meta charset="utf-8" />
|
|
110
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
111
|
-
<title>Ark - Benchmarks</title>
|
|
112
|
-
<link rel="stylesheet" href="./styles.css" />
|
|
113
|
-
</head>
|
|
114
|
-
<body>
|
|
115
|
-
<main class="container">
|
|
116
|
-
<div class="hero">
|
|
117
|
-
<h1>Benchmarks</h1>
|
|
118
|
-
<p class="note">Updated ${updatedDate} · Branch: ${branch} · Commit: ${sha.slice(0, 12)}</p>
|
|
119
|
-
</div>
|
|
120
|
-
<section class="section">
|
|
121
|
-
<h2>Performance Table</h2>
|
|
122
|
-
<table>
|
|
123
|
-
<thead>
|
|
124
|
-
<tr>
|
|
125
|
-
<th>Environment</th>
|
|
126
|
-
<th>Benchmark</th>
|
|
127
|
-
<th>Mean</th>
|
|
128
|
-
<th>±RME</th>
|
|
129
|
-
<th>P99</th>
|
|
130
|
-
<th>Ops/s</th>
|
|
131
|
-
<th>Runs</th>
|
|
132
|
-
</tr>
|
|
133
|
-
</thead>
|
|
134
|
-
<tbody>
|
|
135
|
-
${rows.join("\n")}
|
|
136
|
-
</tbody>
|
|
137
|
-
</table>
|
|
138
|
-
</section>
|
|
139
|
-
<section class="section">
|
|
140
|
-
<h2>Environment Notes</h2>
|
|
141
|
-
<ul>
|
|
142
|
-
${details}
|
|
143
|
-
</ul>
|
|
144
|
-
</section>
|
|
145
|
-
</main>
|
|
146
|
-
</body>
|
|
147
|
-
</html>
|
|
148
|
-
`;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function buildStylesCss() {
|
|
152
|
-
return `:root {
|
|
153
|
-
--bg: #f4f7f3;
|
|
154
|
-
--surface: #ffffff;
|
|
155
|
-
--text: #132b24;
|
|
156
|
-
--muted: #4f6f65;
|
|
157
|
-
--accent: #0f9d72;
|
|
158
|
-
--accent-strong: #0d7f5c;
|
|
159
|
-
--border: #d6e3dd;
|
|
160
|
-
--shadow: 0 14px 40px rgba(12, 58, 45, 0.08);
|
|
161
|
-
}
|
|
162
|
-
* { box-sizing: border-box; }
|
|
163
|
-
body { margin: 0; font-family: "Segoe UI", "Helvetica Neue", sans-serif; color: var(--text); background: radial-gradient(circle at top right, #dbefe7, var(--bg) 40%); }
|
|
164
|
-
a { color: var(--accent-strong); }
|
|
165
|
-
.container { max-width: 1100px; margin: 0 auto; padding: 28px 18px 60px; }
|
|
166
|
-
.hero { background: linear-gradient(145deg, #183a31, #0e7f5c); color: #f3fff8; padding: 26px; border-radius: 18px; box-shadow: var(--shadow); }
|
|
167
|
-
.hero h1 { margin: 6px 0 10px; }
|
|
168
|
-
.note { color: #d8f5eb; margin: 0; }
|
|
169
|
-
.section { margin-top: 26px; }
|
|
170
|
-
table { width: 100%; border-collapse: collapse; background: var(--surface); border-radius: 12px; overflow: hidden; }
|
|
171
|
-
th, td { border-bottom: 1px solid var(--border); padding: 9px 10px; text-align: left; }
|
|
172
|
-
th { color: var(--muted); font-weight: 600; background: #f3faf7; }
|
|
173
|
-
td code { color: var(--accent-strong); }
|
|
174
|
-
@media (max-width: 700px) { .hero { padding: 20px; } .container { padding: 18px 12px 40px; } }
|
|
175
|
-
`;
|
|
14
|
+
/** Every badge file under `dir`, relative to it: the manifest restore-badges.mjs reads back. */
|
|
15
|
+
async function listBadges(dir, prefix = "") {
|
|
16
|
+
const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
|
|
17
|
+
const nested = await Promise.all(
|
|
18
|
+
entries.map((entry) =>
|
|
19
|
+
entry.isDirectory()
|
|
20
|
+
? listBadges(dir, path.posix.join(prefix, entry.name))
|
|
21
|
+
: entry.name.endsWith(".svg")
|
|
22
|
+
? [path.posix.join(prefix, entry.name)]
|
|
23
|
+
: [],
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
return nested.flat().sort();
|
|
176
27
|
}
|
|
177
28
|
|
|
178
29
|
await rm(outputDir, { recursive: true, force: true });
|
|
179
|
-
await mkdir(outputDir, { recursive: true });
|
|
30
|
+
await mkdir(path.join(outputDir, "data"), { recursive: true });
|
|
180
31
|
|
|
181
32
|
await writeFile(path.join(outputDir, "styles.css"), buildStylesCss());
|
|
182
33
|
|
|
183
34
|
// index.html redirects to benchmark.html — no separate dashboard needed
|
|
184
35
|
await writeFile(
|
|
185
36
|
path.join(outputDir, "index.html"),
|
|
186
|
-
`<!doctype html><html><head><meta charset="utf-8"/><meta http-equiv="refresh" content="0;url=./benchmark.html"/><title>
|
|
37
|
+
`<!doctype html><html><head><meta charset="utf-8"/><meta http-equiv="refresh" content="0;url=./benchmark.html"/><title>Decohere Pages</title></head><body><p><a href="./benchmark.html">Benchmarks</a></p></body></html>\n`,
|
|
187
38
|
);
|
|
188
39
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
await
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
40
|
+
// No history yet (first deploy, or cleared) publishes the empty page, not a failure.
|
|
41
|
+
const benchHistory = normalize(
|
|
42
|
+
existsSync(benchHistoryPath) ? JSON.parse(await readFile(benchHistoryPath, "utf8")) : null,
|
|
43
|
+
);
|
|
44
|
+
await writeFile(
|
|
45
|
+
path.join(outputDir, "benchmark.html"),
|
|
46
|
+
buildBenchmarkHtml(benchHistory, { branch, sha }),
|
|
47
|
+
);
|
|
48
|
+
await writeFile(
|
|
49
|
+
path.join(outputDir, "data", "bench-history.json"),
|
|
50
|
+
`${JSON.stringify(benchHistory, null, 2)}\n`,
|
|
51
|
+
);
|
|
201
52
|
|
|
202
|
-
|
|
53
|
+
// Badges are generated by earlier steps (CI status, coverage) or restored from the live site.
|
|
54
|
+
if (existsSync(badgesDir)) {
|
|
203
55
|
await cp(badgesDir, path.join(outputDir, "badges"), { recursive: true });
|
|
204
|
-
|
|
205
|
-
|
|
56
|
+
const badges = await listBadges(badgesDir);
|
|
57
|
+
await writeFile(
|
|
58
|
+
path.join(outputDir, "badges", "manifest.json"),
|
|
59
|
+
`${JSON.stringify({ badges }, null, 2)}\n`,
|
|
60
|
+
);
|
|
206
61
|
}
|
|
207
62
|
|
|
208
63
|
await writeFile(path.join(outputDir, ".nojekyll"), "");
|