@decohere/ci-tools 0.0.1-dev.12 → 0.0.1-dev.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +8 -3
  2. package/src/audit/enforce.mjs +7 -5
  3. package/src/audit/format.mjs +133 -0
  4. package/src/audit/summarize.mjs +10 -32
  5. package/src/audit/upsert-issue.mjs +25 -74
  6. package/src/bench/bench.mjs +134 -368
  7. package/src/bench/file-regressions.mjs +76 -0
  8. package/src/bench/history.mjs +248 -0
  9. package/src/bench/nightly.mjs +118 -0
  10. package/src/bench/regression-issue.mjs +44 -0
  11. package/src/bench/trace.mjs +18 -14
  12. package/src/github/credential-nudge.mjs +245 -0
  13. package/src/github/oidc-bootstrap-nudge.mjs +210 -0
  14. package/src/github/publish-verify.mjs +206 -0
  15. package/src/github/smoke-create-launcher.mjs +70 -0
  16. package/src/lib/github-issue.mjs +75 -0
  17. package/src/lib/workspace-packages.mjs +30 -0
  18. package/src/pages/artifact.mjs +38 -183
  19. package/src/pages/bench-page.mjs +323 -0
  20. package/src/pages/restore-badges.mjs +58 -0
  21. package/src/pages/restore-bench-history.mjs +15 -28
  22. package/src/release/npm-links.mjs +6 -13
  23. package/src/release/sync-dependencies.mjs +1 -2
  24. package/tests/audit/format.test.mjs +115 -0
  25. package/tests/bench/history.test.mjs +279 -0
  26. package/tests/bench/regression-issue.test.mjs +42 -0
  27. package/tests/github/credential-nudge.test.mjs +116 -0
  28. package/tests/github/oidc-bootstrap-nudge.test.mjs +89 -0
  29. package/tests/github/publish-verify.test.mjs +115 -0
  30. package/tests/github/smoke-create-launcher.test.mjs +71 -0
  31. package/tests/lib/github-issue.test.mjs +85 -0
  32. package/tests/lib/lcov.test.mjs +4 -4
  33. package/tests/pages/bench-page.test.mjs +123 -0
  34. package/src/coverage/enforce-thresholds.mjs +0 -144
  35. package/src/pages/clear-bench-history.mjs +0 -27
  36. 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
+ }
@@ -1,5 +1,8 @@
1
- import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
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
- const numericFields = ["hz", "mean", "p99", "rme", "sampleCount"];
12
-
13
- function fmtMs(ms) {
14
- if (typeof ms !== "number" || Number.isNaN(ms)) return "-";
15
- if (ms >= 100) return `${Math.round(ms)} ms`;
16
- return `${ms.toFixed(1)} ms`;
17
- }
18
-
19
- function fmtHz(hz) {
20
- if (typeof hz !== "number" || Number.isNaN(hz)) return "-";
21
- return `${hz.toFixed(2)}/s`;
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>Ark Pages</title></head><body><p><a href="./benchmark.html">Benchmarks</a></p></body></html>\n`,
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
- try {
190
- const benchHistory = JSON.parse(await readFile(benchHistoryPath, "utf8"));
191
- await writeFile(path.join(outputDir, "benchmark.html"), buildBenchmarkHtml(benchHistory));
192
- const benchmarkDataDir = path.join(outputDir, "data");
193
- await mkdir(benchmarkDataDir, { recursive: true });
194
- await writeFile(
195
- path.join(benchmarkDataDir, "bench-history.json"),
196
- `${JSON.stringify(benchHistory, null, 2)}\n`,
197
- );
198
- } catch {
199
- await writeFile(path.join(outputDir, "benchmark.html"), buildBenchmarkHtml({ runs: {} }));
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
- try {
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
- } catch {
205
- // Badges may not exist yet for some runs.
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"), "");