@decohere/ci-tools 0.0.1-dev.109 → 0.0.1-dev.110

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.
@@ -0,0 +1,225 @@
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
+ /** A new history with `run` first in its context, trimmed to `max` runs. Does not mutate. */
122
+ export function appendRun(history, ctx, run, max = MAX_RUNS) {
123
+ const id = contextId(ctx);
124
+ const runs = [run, ...(history.contexts[id]?.runs ?? [])].slice(0, max);
125
+ return {
126
+ version: HISTORY_VERSION,
127
+ contexts: { ...history.contexts, [id]: { context: ctx, runs } },
128
+ };
129
+ }
130
+
131
+ /** One row per current result, with the change in median against the previous run. */
132
+ export function compare(results, previous) {
133
+ const before = new Map((previous?.results ?? []).map((result) => [result.id, result]));
134
+ return results.map((result) => {
135
+ const old = before.get(result.id);
136
+ if (old == null) return { id: result.id, current: result.median };
137
+ const delta = result.median - old.median;
138
+ return {
139
+ id: result.id,
140
+ current: result.median,
141
+ previous: old.median,
142
+ delta,
143
+ percent: old.median === 0 ? 0 : (delta / old.median) * 100,
144
+ };
145
+ });
146
+ }
147
+
148
+ /** The comparison as an aligned text table. */
149
+ export function formatComparison(rows) {
150
+ const ms = (value) => (value == null ? "n/a" : `${value.toFixed(1)} ms`);
151
+ const signed = (value, suffix) =>
152
+ value == null ? "n/a" : `${value >= 0 ? "+" : ""}${value.toFixed(1)}${suffix}`;
153
+ const table = [
154
+ ["Benchmark", "Median", "Previous", "Delta", "%"],
155
+ ...rows.map((row) => [
156
+ row.id,
157
+ ms(row.current),
158
+ ms(row.previous),
159
+ signed(row.delta, " ms"),
160
+ signed(row.percent, "%"),
161
+ ]),
162
+ ];
163
+ const widths = table[0].map((_, column) =>
164
+ Math.max(...table.map((row) => row[column].length)),
165
+ );
166
+ const line = (row) =>
167
+ row
168
+ .map((cell, column) =>
169
+ (column === 0 ? cell.padEnd : cell.padStart).call(cell, widths[column]),
170
+ )
171
+ .join(" | ");
172
+ return [
173
+ line(table[0]),
174
+ widths.map((w) => "-".repeat(w)).join("-+-"),
175
+ ...table.slice(1).map(line),
176
+ ].join("\n");
177
+ }
178
+
179
+ /**
180
+ * When a benchmark counts as slower (#660): its median beat the baseline by more than
181
+ * `threshold` on each of the last `confirmRuns` runs, where the baseline is the median of the
182
+ * `baselineRuns` runs before those. Two nights in a row, so one noisy runner files nothing.
183
+ */
184
+ export const REGRESSION = { threshold: 0.2, confirmRuns: 2, baselineRuns: 7, minBaseline: 3 };
185
+
186
+ function medianOf(values) {
187
+ const sorted = [...values].sort((a, b) => a - b);
188
+ const middle = Math.floor(sorted.length / 2);
189
+ return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
190
+ }
191
+
192
+ /** The benchmarks that regressed on one machine, given its runs newest first. */
193
+ export function findRegressions(runs, options = REGRESSION) {
194
+ const recent = runs.slice(0, options.confirmRuns);
195
+ const before = runs.slice(options.confirmRuns, options.confirmRuns + options.baselineRuns);
196
+ if (recent.length < options.confirmRuns) return [];
197
+ const medianIn = (run, id) => run.results.find((result) => result.id === id)?.median;
198
+ const regressions = [];
199
+ for (const latest of recent[0].results) {
200
+ const baselineValues = before
201
+ .map((run) => medianIn(run, latest.id))
202
+ .filter((value) => Number.isFinite(value));
203
+ if (baselineValues.length < options.minBaseline) continue;
204
+ const baseline = medianOf(baselineValues);
205
+ const recentValues = recent.map((run) => medianIn(run, latest.id));
206
+ const slower = recentValues.every(
207
+ (value) => Number.isFinite(value) && value > baseline * (1 + options.threshold),
208
+ );
209
+ if (!slower) continue;
210
+ regressions.push({
211
+ id: latest.id,
212
+ label: latest.label,
213
+ baseline,
214
+ percent: (recentValues[0] / baseline - 1) * 100,
215
+ recent: recent.map((run, index) => ({
216
+ date: run.date,
217
+ sha: run.sha,
218
+ median: recentValues[index],
219
+ })),
220
+ // The change landed between the newest baseline run and the oldest slow one.
221
+ lastGood: before[0] == null ? null : { date: before[0].date, sha: before[0].sha },
222
+ });
223
+ }
224
+ return regressions;
225
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The GitHub issue for a benchmark that got slower (#660): 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 commitLink(repoUrl, sha) {
17
+ return sha == null ? "unknown" : `[\`${sha.slice(0, 7)}\`](${repoUrl}/commit/${sha})`;
18
+ }
19
+
20
+ export function issueBody(regression, { repoUrl, runUrl, machine, threshold }) {
21
+ const newest = regression.recent[0];
22
+ const oldestSlow = regression.recent.at(-1);
23
+ const range =
24
+ regression.lastGood?.sha != null && newest.sha != null
25
+ ? `[${regression.lastGood.sha.slice(0, 7)}...${newest.sha.slice(0, 7)}](${repoUrl}/compare/${regression.lastGood.sha}...${newest.sha})`
26
+ : "unknown (no baseline commit recorded)";
27
+ return [
28
+ "## Problem",
29
+ "",
30
+ `The nightly benchmark **${regression.label}** (\`${regression.id}\`) has a median ${regression.percent.toFixed(0)}% above its baseline, on each of the last ${regression.recent.length} nightly runs. A benchmark counts as slower above ${Math.round(threshold * 100)}%.`,
31
+ "",
32
+ `- Baseline (median of the runs before): **${ms(regression.baseline)}**`,
33
+ ...regression.recent.map(
34
+ (run) =>
35
+ `- ${run.date.slice(0, 10)}, ${commitLink(repoUrl, run.sha)}: **${ms(run.median)}**`,
36
+ ),
37
+ `- Machine: ${machine}`,
38
+ "",
39
+ `The change landed after the last normal run (${regression.lastGood?.date.slice(0, 10) ?? "unknown"}) and by ${oldestSlow.date.slice(0, 10)}: commits ${range}.`,
40
+ "",
41
+ "## What to do",
42
+ "",
43
+ "1. Check the trend on the [benchmark page](" +
44
+ PAGES_URL +
45
+ "). A step that holds is a real change; a single machine type jumping may be the runner.",
46
+ "2. Reproduce locally: check out the last normal commit, `pnpm bench:save`, then the newest commit, `pnpm bench`, and read the comparison.",
47
+ "3. Fix the cause, or if the slowdown is intended, say why here and close the issue.",
48
+ "",
49
+ `Updated by the nightly bench workflow: ${runUrl}`,
50
+ ].join("\n");
51
+ }
@@ -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"), "");