Repository navigation
Measure the CI/CD after benchmark for v2.3 #569
Description
Part of #554. Rulings: #554 rulings 1, 2, 3, 4, 5, 7. Work on main. This issue changes no repo file. Its output is a "Measured" comment here and the same text on #554.
Summary
- What. Measure the pipeline after the v2.3 CI/CD work with one fixed method. Compare it with the frozen baseline below.
- Why. The tracker computes about 620 s per
(build):deploy after Build the deploy images while the quality gates run #565 lands, and a stretch of about 540 s after set E, from measured steps. This issue is the end-to-end check. - Today. Time to live (M1) has a median of 1090 s and a p90 of 1386 s, over 14 deploys from 2026-09-22 to 2026-10-20. Run 37431581531 took 1510 s; run 39398067267 took 1132 s.
- Owns. The metric definitions, sample rules, statistics rules, baseline, "after" protocol and targets.
Depends on / Blocks
- Depends on Build the deploy images while the quality gates run #565. The "after" sample starts at the first deploy that contains it.
- Depends on Write a timing table to every CI/CD run summary #556. Each run's
📊 Run Reportsummary is the quick read, and it showsfree_gbandload1. - Reads, does not wait for: Start the extension and webapp unit tests without waiting for backend validation #558 (set B, so every after row contains it); Run only the CI suites a pull request or push can affect #563 (the docs-only push target needs it); Prune Docker build cache toward a free-disk target and keep rollback images #564 (it lands before Build the deploy images while the quality gates run #565, so every after row contains it); Make the webapp Docker installs fast and cacheable #559, Skip the duplicate type check inside the webapp Docker build #560, Keep the Actions cache under its limit and pin Bun in CI #561 and Stop extension README media from triggering extension test suites #562 (each changes a measured stage).
- Blocks: nothing.
Current behaviour
Links are pinned to d0a53756f8676556358b6adc6c2397d2ed7c64c8. They are the "before" state; #565 moves some on purpose.
| Job or step | Pinned lines | Metric |
|---|---|---|
🚀 Deploy Production, needs: [triage, build], on the prod runner |
prod.docs.plus.yml:420-441 |
start is M2 |
🏗️ Build Verification, the last gate |
prod.docs.plus.yml:351-355 |
end is M2g |
💾 Pre-deploy disk guard, prints 📊 Disk after prune: ${AVAIL_GB} GB free |
prod.docs.plus.yml:573-594 |
M4; free_gb |
🏗️ Build Docker Images, a step inside 🚀 Deploy Production today |
prod.docs.plus.yml:612-642 |
M3; end is M2i |
🚀 Deploy Services (rolling replace) |
prod.docs.plus.yml:701-718 |
M5 |
🩺 Verify Deployment, ends with echo "✅ Deployment verified" |
prod.docs.plus.yml:720-782 |
end is M1 |
🧪 Ext (${{ matrix.ext }}) matrix, waits for back-validation today |
prod.docs.plus.yml:272-274 |
"Ext suite ran" slice |
- Triage reads only the tip commit subject (
prod.docs.plus.yml:108-112;AGENTS.md:416). So the script readshead_commit.message.displayTitlecan show the workflow name, as in run 36437965916. - Every number comes from production runs, read with read-only
ghandgitcalls. Docker timings do not transfer from a laptop (AGENTS.md:293), and a cache fix cannot be measured under--no-cache(AGENTS.md:301). No run writes its own timing table today.
What we measure
| Metric | Start | End | Note |
|---|---|---|---|
| M1 Time to live (the headline) | run createdAt |
🩺 Verify Deployment step completedAt |
The run total also counts later jobs and GitHub incident tails. |
| M2 Deploy start | run createdAt |
🚀 Deploy Production job startedAt |
Baseline only. Today it is M2g plus 2–3 s. |
| M2g Gate end | run createdAt |
🏗️ Build Verification job completedAt |
|
| M2i Images end | run createdAt |
🏗️ Build Docker Images step completedAt, in whichever job holds it |
Before #565, inside 🚀 Deploy Production. After it, in the images job. |
| M3 Image build | 🏗️ Build Docker Images step start |
step end | |
| M4 Disk guard | 💾 Pre-deploy disk guard step start |
step end | 0–72 s, depending on the prune. |
| M5 Roll-out | 🚀 Deploy Services (rolling replace) step start |
step end | 57–70 s today. |
| W Runner wait | the later of M2g and M2i | 🚀 Deploy Production startedAt |
A queue on the single prod runner. Before #565, W is the gap after M2g only. |
| M6 PR or non-deploy work time | run createdAt |
last successful job completedAt, leaving out the report job |
Skipped jobs that GitHub creates late do not count. |
createdAtis 2–6 s after the push. Recordqueue_s, the first job'sstartedAtminuscreatedAt, so a GitHub queue delay stays visible.- Today M1 minus (M2 + M3 + M4 + M5) is 19–39 s: checkout,
🔧 Ensure Infrastructure, migrate and verify. - After Build the deploy images while the quality gates run #565, M1 equals the later of M2g and M2i, plus W, plus the
deployjob time.
Extra fields per deploy separate a real gain from a lucky cache.
| Field | How to read it |
|---|---|
lock_changed |
git diff --quiet <previous deployed SHA> <SHA> -- bun.lock |
ext_src_changed |
the same check on extensions/ |
gap_h |
hours since the previous deploy run started |
cached_installs |
how many of the two webapp bun install steps print CACHED (0–2), from the BuildKit log |
obs |
whether 🔭 Deploy Observability ran. It adds about 17 s to 🔧 Ensure Infrastructure. |
free_gb, load1 |
free disk and 1-minute load at build start, from the 📊 Run Report table that #556 writes |
"Previous deployed SHA" is the last earlier push or workflow_dispatch run whose deploy succeeded on any attempt. A re-run that deployed counts as a base. Example: run 37976785981 is warm only when re-run 37906965365 is its base. Without that base, it reads yes, yes, 81.9 h.
Sample rules
- Count a
pushrun when its🚀 Deploy Productionjob endedsuccesson attempt 1. - Read the subject from
head_commit.message(gh api repos/docs-plus/docs.plus/actions/runs/<id> -q .head_commit.message). - Leave out failed runs, cancelled runs and re-runs. A re-run carries a human gap: run 37906965365, attempt 2, reads 2569 s.
- Split by "Ext suite ran" and by cache class. Warm means
gap_his 24 or less, with nobun.lockchange and no extension change. Every other deploy is cold. - Report a
(build): backrun on its own row. It runs no front gates, so it stays out of the pooled median. - Statistics. The median of an even n is the mean of the two middle values, rounded half up. The p90 is nearest-rank: sort ascending, then take item ceil(0.9 × n). Show the p90 only when n is 10 or more.
- A slice with n below 5 is a hint, not a result.
- The baseline sample ends with run 39398067267 (2026-10-20 09:04 UTC).
Baseline: (build): deploys
Seconds after run start, attempt 1. Read on 2026-10-20 with the Step 1 script; free_gb is the "Disk after prune" log value. queue_s was 2–3 s on every row except 35723691814 (11 s).
| Run | Date | Ext suites | M2 | M4 | M3 | M5 | M1 | lock_changed |
ext_src_changed |
gap_h |
cached_installs |
free_gb |
obs |
Class |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 35723691814 | 09-22 | 0 | 382 | 48 | 866 | 64 | 1386 | ? | ? | ? | 0 | 74 | no | cold |
| 36324777594 | 09-27 | 5 | 553 | 39 | 664 | 59 | 1354 | yes | yes | 122.3 | 0 | 74 | yes | cold |
| 36437965916 | 09-28 | 1 | 511 | 39 | 733 | 61 | 1371 | no | yes | 24.6 | 0 | 74 | no | cold |
| 36473904247 | 09-28 | 0 | 420 | 5 | 330 | 59 | 834 | no | no | 5.0 | 2 | 61 | no | warm |
| 36550492709 | 09-29 | 0 | 389 | 1 | 472 | 60 | 946 | no | no | 14.0 | 2 | 57 | no | warm |
| 37431581531 | 10-06 | 5 | 666 | 65 | 686 | 70 | 1510 | yes | yes | 166.1 | 0 | 74 | no | cold |
| 37440315600 | 10-06 | 0 | 375 | 4 | 316 | 59 | 777 | no | no | 1.3 | 2 | 60 | no | warm |
| 37976785981 | 10-09 | 0 | 456 | 4 | 533 | 57 | 1089 | no | no | 10.2 | 0 | 60 | yes | warm |
| 38068638114 | 10-10 | 0 | 413 | 41 | 557 | 58 | 1090 | no | no | 21.7 | 0 | 60 | no | warm |
| 38239392350 | 10-12 | 0 | 380 | 72 | 559 | 59 | 1090 | no | no | 42.3 | 0 | 74 | no | cold |
| 38392614215 | 10-13 | 0 | 324 | 27 | 542 | 59 | 971 | yes | yes | 24.9 | 0 | 74 | no | cold |
| 39275929831 | 10-19 | 0 | 331 | 33 | 550 | 58 | 994 | yes | yes | 147.4 | 0 | 74 | no | cold |
| 39297468969 | 10-19 | 1 | 488 | 3 | 483 | 57 | 1052 | no | yes | 2.6 | 0 | 60 | no | cold |
| 39398067267 | 10-20 | 6 | 540 | 0 | 513 | 57 | 1132 | yes | yes | 15.3 | 0 | 46 | no | cold |
- The first row has no diff base, so its diff fields are unknown.
- Both webapp installs were
CACHEDon the three fastest image builds (M3 316–472 s). Neither wasCACHEDon the other 11 deploys. - Left out: 37906965365 (re-run), 39274130383 and 38689468498 (the
(build):frontsubject typo), 37904552781 (flaky test), 35723391885 (cancelled).
Baseline Summary
| Slice | n | M1 median | M1 p90 | M1 min–max | M2 median | M2g median | M3 median |
|---|---|---|---|---|---|---|---|
| All | 14 | 1090 | 1386 | 777–1510 | 417 | 414 | 546 |
| Ext suite ran | 5 | 1354 | — (max 1510) | 1052–1510 | 540 | 538 | 664 |
| No Ext suite | 9 | 994 | — (max 1386) | 777–1386 | 382 | 379 | 542 |
| Warm | 5 | 946 | — (max 1090) | 777–1090 | 413 | 410 | 472 |
| Cold | 9 | 1132 | — (max 1510) | 971–1510 | 488 | 486 | 559 |
W was 2–3 s on every row, because the image build ran inside the deploy job.
Inside M3, from the production BuildKit logs
Parsed from deploy jobs 118549257854, 118217497479, 118142985559, 115281929767, 114780192678 and 114262648443.
Part of 🏗️ Build Docker Images |
Seconds | Note |
|---|---|---|
Backend build --no-cache rest-api hocuspocus-server |
69–80 | Runs alone, before the webapp call. |
Two webapp bun install runs |
144–182 (median 158) | Never CACHED on these 6 jobs. The admin and backend installs of the same 1751 packages take 6–18 s with --ignore-scripts. |
Webapp extension builds (8 serial RUNs) |
about 31 | DTS takes 17.2 s of it. |
Webapp next build |
185–262 | "Collecting build traces" alone takes 99–119 s. The type check takes 25–48 s. |
Baseline: PR and non-deploy push runs (M6)
Successful runs from 2026-09-20 to 2026-10-20:
| Slice | n | Median | Max | Runs |
|---|---|---|---|---|
| PR | 10 | 519 | 579 | 37432063919, 37904633920, 37907249191, 38445029108, 39274283996, 39274304937, 39274590009, 39274647538, 39275480131, 39284030984 |
| Root docs-only push | 1 | — | 217 | 38364279436 (docs(contributing)) |
| Extension docs push | 1 | — | 378 | 39283678757 (docs(extension-mermaid)) |
- Every PR ran 5 or 6 Ext suites and the backend unit suite. Backend E2E does not run on PRs.
- Run 39283678757 changed a README and a GIF, yet ran the mermaid suite (194 s) and Build Verification (161 s).
Target behaviour
One "Measured, " comment here and on #554 holds the "after" result. It uses the metrics and rules above. It shows the pooled median, min–max and every slice. It gives a verdict per target and per slice, with run ids. It names the stage behind each miss.
The "after" protocol
- Measure normal deploys only. Measure whether the backend image build still needs --no-cache #566 is the one exception, with its own procedure.
- Each change set gets a quick read from the
📊 Run Reportof its next one or two deploys. The sibling issue owns it. It is not the result. - The sample window starts at the first
(build):push run that contains the Build the deploy images while the quality gates run #565 merge commit (Step 3.2). It ends at the 8th qualifying deploy, or 30 days after the start, whichever comes first. Post the median verdict at 8. Judge the p90 in a second "Measured" comment at n of 10 or more. - A change set landing inside the window does not restart the count. Each row records the later change sets it contains.
- A slice is a clear win when its "after" median is below its "before" minimum: All 777, Ext suite ran 1052, No Ext suite 777, Warm 777, Cold 971.
Targets
The targets are the author's call, from the tracker estimate.
| Metric | Today | Target |
|---|---|---|
| M1 median, all deploys | 1090 s | 620 s or less (#554: 1090 − 476 ≈ 614 s computed). Stretch: about 540 s after set E, only if #566, #567 and #568 keep their changes. |
| M1 p90, all deploys | 1386 s | 720 s or less, judged at n of 10 or more. No computed estimate. |
| M6 PR median | 519 s | Lower when a PR touches one area. A bun.lock PR still runs everything. Needs #563. |
| M6 root docs-only push | 217 s | 120 s or less: of the app jobs, only lint and format, and security, run. Needs #563; else "not measured". |
Implementation steps
Work in a scratch folder outside the repo. Never commit the scripts. "Comment here" means a comment on this issue.
Step 0. Read and check the base
On current main, check names and behaviour, not pinned line numbers.
- Update the clone:
git -C <repo> fetch origin main. The script needs every deployed SHA locally. - Confirm the two dependencies are on
main.Each command must print one commit. The first SHA isgit -C <repo> log origin/main --format='%H %cs %s' -S '📦 Verify images present' -- .github/workflows/prod.docs.plus.yml | tail -1 git -C <repo> log origin/main --format='%H %cs %s' -S '📊 Run Report' -- .github/workflows/prod.docs.plus.yml | tail -1
IMAGES_EARLY_SHA. If either prints nothing, stop and comment here. - Confirm each step name exists exactly once.
Expect
for n in '🩺 Verify Deployment' '🏗️ Build Docker Images' '💾 Pre-deploy disk guard' '🚀 Deploy Services (rolling replace)' '🚀 Deploy Production' '🏗️ Build Verification' '📊 Run Report'; do printf '%s: ' "$n"; git -C <repo> show origin/main:.github/workflows/prod.docs.plus.yml | grep -cF "name: $n" done
1on every line. If a name changed, update its regex inbench.tsand note it in the result comment. - Confirm the job names fit the script's regexes. On the first after run, run
gh run view <runId> -R docs-plus/docs.plus --json jobs -q '.jobs[].name'.- The images job (
🧱 Build Imagesin Build the deploy images while the quality gates run #565) must not match/Deploy Production/or/Build Verification/. - The report job must match
/report/i, or M6 counts it. - Only
🚀 Deploy Productionmay match/Deploy Production/.
If any rule fails, fix the regex inbench.tsand note it.
- The images job (
- Confirm the parsed log text. With the images job id from Step 0.4, run
gh run view -R docs-plus/docs.plus --job <imagesJobId> --log | grep -E 'Disk after prune: [0-9]+ GB free|\[webapp (build-extensions|builder) [^]]*\] RUN bun install'.- Expect one
Disk after pruneline. If Prune Docker build cache toward a free-disk target and keep rollback images #564 rewrote it, change thefree_gbregex, or readfree_gbfrom the📊 Run Reporttable. - Expect both
[webapp build-extensions …]and[webapp builder …]install lines. If Make the webapp Docker installs fast and cacheable #559 renamed a stage, change the install regex. - If Try running the backend and webapp image builds at the same time #567 is on
main, install lines start with[front], whichbench.tsmatches. Expect each end line to carry the same prefix.
- Expect one
- Confirm log retention:
gh api repos/docs-plus/docs.plus/actions/permissions/artifact-and-log-retention. On 2026-10-20 it printed{"days":90,"maximum_allowed_days":90}, so the 2026-09-22 logs expire about 2026-12-21. Thencached_installsandfree_gbreadnullfor old rows; the frozen values stay the baseline. - Confirm the window has closed (Step 3.3) before Step 4.
Step 1. Write the two scripts
Write both files in ~/ci-bench/ or another scratch folder. They call only gh and git diff. bench.ts prints one JSON row per run.
// bench.ts: bun bench.ts <repoDir> <runId> ... → one JSON row per run, oldest first
// Read-only. Diff fields compare each run with the last earlier run in the list that deployed on any
// attempt (a re-run that deployed is a valid base). Pass every push and dispatch run, oldest first.
const REPO = "docs-plus/docs.plus";
const [repoDir, ...ids] = Bun.argv.slice(2);
const sh = (cmd: string[]) => {
const p = Bun.spawnSync(cmd);
if (p.exitCode !== 0) throw new Error(`${cmd.join(" ")}: ${p.stderr.toString()}`);
return p.stdout.toString();
};
// GitHub reports a step that never ran with a zero date (0001-01-01). Treat it as missing.
const ms = (s?: string | null) => (s && !s.startsWith("0001") ? Date.parse(s) : null);
const rows: any[] = [];
let prev: { sha: string; t0: number } | null = null;
for (const id of ids) {
// Run createdAt comes from the run, not from the attempt: `--attempt 1` reports the attempt's own time.
const run = JSON.parse(sh(["gh", "api", `repos/${REPO}/actions/runs/${id}`]));
const d = JSON.parse(sh(["gh", "run", "view", id, "-R", REPO, "--attempt", "1", "--json", "jobs"]));
const subject = run.head_commit.message.split("\n")[0];
const t0 = Date.parse(run.created_at);
const off = (s?: string | null) => { const v = ms(s); return v === null ? null : Math.round((v - t0) / 1000); };
const dur = (s: any) => { const a = ms(s?.startedAt), b = ms(s?.completedAt); return a === null || b === null ? null : Math.round((b - a) / 1000); };
// Search the steps of every job: after the images-early change, the image steps live in a new job.
const all = d.jobs.flatMap((j: any) => (j.steps ?? []).map((s: any) => ({ ...s, job: j })));
const step = (re: RegExp) => all.find((s: any) => re.test(s.name) && s.conclusion === "success");
const job = (re: RegExp) => d.jobs.find((j: any) => re.test(j.name));
const deployJob = job(/Deploy Production/);
const build = step(/Build Docker Images/);
const m2g = off(job(/Build Verification/)?.completedAt);
const m2i = off(build?.completedAt);
const wStart = off(deployJob?.startedAt);
const done = d.jobs.filter((j: any) => j.conclusion === "success" && !/report/i.test(j.name));
const row: any = {
id, sha: run.head_sha, subject, event: run.event, conclusion: run.conclusion, attempts: run.run_attempt,
deploy: deployJob?.conclusion ?? null,
ext: d.jobs.filter((j: any) => /Ext \(/.test(j.name) && j.conclusion === "success").length,
queue_s: Math.min(...d.jobs.map((j: any) => off(j.startedAt) ?? Infinity)),
m1: off(step(/Verify Deployment/)?.completedAt),
m2g, m2i,
m3: dur(build), m4: dur(step(/disk guard/)), m5: dur(step(/Deploy Services/)),
m2: wStart,
// Before images-early the image build runs inside the deploy job, so W is the gap after M2g only.
w: wStart === null || m2g === null ? null
: build?.job.databaseId === deployJob?.databaseId ? wStart - m2g : wStart - Math.max(m2g, m2i ?? 0),
m6: done.length ? Math.max(...done.map((j: any) => off(j.completedAt) ?? 0)) : null,
typecheck: d.jobs.some((j: any) => /Type Check/.test(j.name) && j.conclusion === "success"),
obs: d.jobs.some((j: any) => /Deploy Observability/.test(j.name) && j.conclusion === "success"),
lock_changed: null, ext_src_changed: null, gap_h: null, cached_installs: null, free_gb: null,
};
if (build) {
const log = sh(["gh", "run", "view", "-R", REPO, "--job", String(build.job.databaseId), "--log"]).split("\n");
let hits = 0;
for (const [i, line] of log.entries()) {
const m = line.match(/(\[(?:front|backend)\] )?(#\d+) \[webapp (build-extensions|builder) [^\]]*\] RUN bun install/);
if (!m) continue;
const [, pre = "", num, stage] = m;
if (log.slice(0, i).some((l) => l.includes(`${pre}${num} [webapp ${stage} `))) continue;
const end = log.slice(i + 1).find((l) => l.includes(`${pre}${num} CACHED`) || l.includes(`${pre}${num} DONE`));
if (end?.includes("CACHED")) hits++;
}
row.cached_installs = hits;
const free = log.map((l) => l.match(/Disk after prune: (\d+) GB free/)).find(Boolean);
row.free_gb = free ? Number(free[1]) : null;
}
if (prev) {
const same = (path: string) => {
const code = Bun.spawnSync(["git", "-C", repoDir, "diff", "--quiet", prev!.sha, run.head_sha, "--", path]).exitCode;
if (code !== 0 && code !== 1) throw new Error(`git diff failed for ${id}; run git fetch origin main`);
return code === 0;
};
row.lock_changed = !same("bun.lock");
row.ext_src_changed = !same("extensions/");
row.gap_h = Math.round(((t0 - prev.t0) / 3600000) * 10) / 10;
}
rows.push(row);
const last = run.run_attempt > 1
? JSON.parse(sh(["gh", "run", "view", id, "-R", REPO, "--json", "jobs"])).jobs.find((j: any) => /Deploy Production/.test(j.name))
: deployJob;
if (last?.conclusion === "success") prev = { sha: run.head_sha, t0 };
}
console.log(JSON.stringify(rows, null, 1));stats.ts applies the sample and statistics rules. --from and --to fail on an unknown run id. --before adds the before minimum and clear-win verdict per slice.
// stats.ts: bun stats.ts rows.json [--from <runId>] [--to <runId>] [--before before.json] → Markdown
// Applies the sample rules and the statistics rules to bench.ts output. --from and --to bound the window.
const args = Bun.argv.slice(2);
const opt = (k: string) => { const i = args.indexOf(k); return i < 0 ? null : args[i + 1]; };
const load = async (p: string) => JSON.parse(await Bun.file(p).text()) as any[];
const at = (rows: any[], id: string) => {
const i = rows.findIndex((r) => r.id === id);
if (i < 0) throw new Error(`run ${id} is not in the input`);
return i;
};
const cut = (rows: any[], from: string | null, to: string | null) =>
rows.slice(from ? at(rows, from) : 0, to ? at(rows, to) + 1 : rows.length);
const backOnly = (r: any) => /^\(build\):/.test(r.subject) && !/front/.test(r.subject);
const warm = (r: any) => r.gap_h !== null && r.gap_h <= 24 && r.lock_changed === false && r.ext_src_changed === false;
// Sample rule: a push whose deploy job ended success on attempt 1. bench.ts reads attempt 1 only.
const pooled = (rows: any[]) => rows.filter((r) => r.event === "push" && r.deploy === "success" && r.m1 !== null && !backOnly(r));
const slices = (rs: any[]): [string, any[]][] => [
["All", rs],
["Ext suite ran", rs.filter((r) => r.ext > 0)],
["No Ext suite", rs.filter((r) => r.ext === 0)],
["Warm", rs.filter(warm)],
["Cold", rs.filter((r) => !warm(r))],
];
const median = (v: number[]) => {
const s = [...v].sort((a, b) => a - b), n = s.length, h = Math.floor(n / 2);
return n === 0 ? null : n % 2 ? s[h] : Math.floor((s[h - 1] + s[h]) / 2 + 0.5); // even n: round half up
};
const p90 = (v: number[]) => { const s = [...v].sort((a, b) => a - b); return s[Math.ceil(0.9 * s.length) - 1]; };
const col = (rs: any[], k: string) => rs.map((r) => r[k]).filter((x) => typeof x === "number");
const rows = cut(await load(args[0]), opt("--from"), opt("--to"));
const before = opt("--before") ? new Map(slices(pooled(await load(opt("--before")!)))) : null;
console.log("| Slice | n | M1 median | M1 p90 | M1 min–max | M2 median | M2g median | M2i median | W median | M3 median |" + (before ? " Before min | Clear win |" : ""));
console.log("|---|---|---|---|---|---|---|---|---|---|" + (before ? "---|---|" : ""));
for (const [name, rs] of slices(pooled(rows))) {
const m1 = col(rs, "m1"), med = median(m1);
const p = m1.length >= 10 ? String(p90(m1)) : `— (max ${m1.length ? Math.max(...m1) : "—"})`;
const range = m1.length ? `${Math.min(...m1)}–${Math.max(...m1)}` : "—";
let tail = "";
if (before) {
const bmin = Math.min(...col(before.get(name)!, "m1"));
const verdict = med === null ? "—" : med < bmin ? (rs.length < 5 ? "yes (hint, n<5)" : "yes") : "no";
tail = ` ${bmin} | ${verdict} |`;
}
console.log(`| ${name} | ${rs.length} | ${med ?? "—"} | ${p} | ${range} | ${median(col(rs, "m2")) ?? "—"} | ${median(col(rs, "m2g")) ?? "—"} | ${median(col(rs, "m2i")) ?? "—"} | ${median(col(rs, "w")) ?? "—"} | ${median(col(rs, "m3")) ?? "—"} |${tail}`);
}
for (const r of rows.filter((r) => r.event === "push" && r.deploy === "success" && backOnly(r)))
console.log(`\n(build): back row: run ${r.id}, M1 ${r.m1} s`);Check: bun --version prints 1.4.0 or later.
Step 2. Prove the scripts on the baseline
Run the scripts on every baseline push run, oldest first. Re-run 37906965365 must be in the list, as a diff base.
cd ~/ci-bench
gh run list --repo docs-plus/docs.plus --workflow prod.docs.plus.yml --event push --limit 400 \
--json databaseId,createdAt > pushes.json
bun -e 'const r = JSON.parse(await Bun.file("pushes.json").text());
console.log(r.filter((x) => x.createdAt >= "2026-09-22T11:50:00Z" && x.createdAt <= "2026-10-20T09:05:00Z")
.reverse().map((x) => x.databaseId).join(" "))' | xargs bun bench.ts <repo> > before.json
bun stats.ts before.json- Pass: every slice row equals the Baseline Summary to the second, and every
before.jsonrow matches the 14-row table. This passed on 2026-10-20. - If the 2026-09 logs have expired (Step 0.6), compare timing fields only.
- If any timing value differs, stop and comment here with the row.
Step 3. List the after runs
- List every push and
workflow_dispatchrun from 39398067267 to now, oldest first, so the first after deploy has its real diff base.Dispatch rows are diff bases only;gh run list --repo docs-plus/docs.plus --workflow prod.docs.plus.yml --limit 400 \ --json databaseId,createdAt,event -q '[.[] | select(.event == "push" or .event == "workflow_dispatch")]' > pushes.json bun -e 'const r = JSON.parse(await Bun.file("pushes.json").text()); console.log(r.filter((x) => x.createdAt >= "2026-10-20T09:04:00Z").reverse().map((x) => x.databaseId).join(" "))' \ | xargs bun bench.ts <repo> > after-all.json
stats.tskeepspushrows. List each under "Left out" as "A/B dispatch (Measure whether the backend image build still needs --no-cache #566)". FIRST_AFTERis the firstpushrow whose subject starts with(build):and for whichgit -C <repo> merge-base --is-ancestor <IMAGES_EARLY_SHA> <row sha> && echo containsprintscontains.LAST_AFTER. A qualifying row haseventpush,deploysuccess, a numericm1, and a subject that starts with(build):and containsfront. A(build): backrow does not count;stats.tsprints it on its own line. Count fromFIRST_AFTER, oldest first.LAST_AFTERis the 8th qualifying row, or, if day 30 comes first, the last qualifying row created beforeFIRST_AFTERcreatedAt plus 30 days. Read createdAt frompushes.json.END_TIMEis the createdAt ofLAST_AFTER. The second comment uses the same rule with 10 in place of 8. If day 30 comes first, it reports "p90 not measured, n = ".- Check:
bun stats.ts after-all.json --from <FIRST_AFTER> --to <LAST_AFTER>prints an All row withnof 8, or the day-30 count.
- Tag each qualifying row with the later change sets it contains. Find each merge SHA, then run
git -C <repo> merge-base --is-ancestor <merge SHA> <row sha> && echo contains.An empty line means the change set is not ongit -C <repo> log origin/main --format='%H %cs %s' -S 'BACK_PID' -- .github/workflows/prod.docs.plus.yml | tail -1 # parallel-compose git -C <repo> log origin/main --format='%H %cs %s' -S 'outputFileTracingExcludes' -- apps/webapp/next.config.js | tail -1 # build-traces trim git -C <repo> log origin/main --format='%H %cs %s' -S 'backend_no_cache' -- .github/workflows/prod.docs.plus.yml | head -1 # backend-nocache-ab Step 6, input removed
main; tag it "no". If a sibling used another string, read its merge commit there.- For Measure whether the backend image build still needs --no-cache #566: if
git -C <repo> show origin/main:.github/workflows/prod.docs.plus.yml | grep -c backend_no_cacheprints more than 0, the A/B test is still open. Tag every row "A/B only". - Otherwise the
head -1commit is its Step 6 change. Tag a row 'drop' only when it contains that commit and the maintainer ruled 'drop' there. A 'keep' ruling changes no deploy, so tag it 'no change'.
- For Measure whether the backend image build still needs --no-cache #566: if
- If the window has not ended (Step 3.3), stop and come back later.
Step 4. Add free_gb and load1
- For each qualifying row, read
free_gbandload1from the📊 Run Reporttable athttps://github.com/docs-plus/docs.plus/actions/runs/<id>. - The
bench.tsfree_gbis a cross-check. If they differ, use the report value and note it. If the report showsn/a, use thebench.tsvalue and say so. - If
load1readsn/a, recordn/a. Do not read the host.
Step 5. Compute the result
- Deploys:
bun stats.ts after-all.json --from <FIRST_AFTER> --to <LAST_AFTER> --before before.json. The p90 prints only at n of 10 or more; judge it in the second comment. - M6. Run
bench.tson the PR runs in the same window.gh run list --repo docs-plus/docs.plus --workflow prod.docs.plus.yml --event pull_request --status success --limit 200 \ --json databaseId,createdAt -q '[.[] | select(.createdAt >= "<FIRST_AFTER createdAt>" and .createdAt <= "<END_TIME>")] | reverse | .[].databaseId' \ | xargs bun bench.ts <repo> > after-pr.json
- PR median: the median of
m6overafter-pr.json. Also report how many PRs ran 0 Ext suites. - Root docs-only push: an
after-all.jsonrow betweenFIRST_AFTERandLAST_AFTERwitheventpush,conclusionsuccess,deploynotsuccess,typecheckfalseandext0. A push can hold more than one commit, so confirm withgit -C <repo> diff --name-only <previous push row sha> <row sha>that every changed file is docs-only. Report itsm6, or "no sample".
- PR median: the median of
- Name the stage that missed, for every missed target. The later of the M2g and M2i medians is the long path. A W median above 10 s (an author threshold) means the prod runner queued the deploy. The rest is the
deployjob: M1 minus the later of M2g and M2i, minus W.
Step 6. Draft the result comment
Post it as this issue's SP3 (#554) record. This layout extends the SP3 template with the window, the row table and the target verdicts. Draft it in the scratch folder.
## Measured, <YYYY-MM-DD>
Window: <FIRST_AFTER> (<date>) to <last run id> (<date>). n = <n> qualifying deploys.
Left out: <run id (reason)>, …
| Run | Date | Ext suites | M2g | M2i | W | M4 | M3 | M5 | **M1** | queue_s | lock_changed | ext_src_changed | gap_h | cached_installs | obs | free_gb | load1 | Class | Later sets |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
<stats.ts output with --before>
| Target | Before | After | Verdict |
|---|---|---|---|
| M1 median ≤ 620 s (stretch 540 s, rows with set E) | 1090 | | |
| M1 p90 ≤ 720 s (n ≥ 10) | 1386 | | |
| M6 PR median lower | 519 | | |
| M6 root docs-only push ≤ 120 s | 217 | | |
Missed stage: <M2g / M2i / W / deploy job, with medians>, or "none".
Stop condition: <not met / met, with the value>.Review checkpoint
git -C <repo> status --shortmust show no change from this work.- Show the maintainer the draft comment,
before.jsonandafter-all.json. Then stop. - After the maintainer reads it, post it here and on [Tracking] Faster, change-aware CI/CD pipeline #554, with a link back here.
- No
git add,git commit,git push,git stashor branch.
Acceptance criteria
- Step 0 found
IMAGES_EARLY_SHAand the📊 Run Reportcommit onmain, and every step name once. - Step 2 reproduced the Baseline Summary to the second, or the comment states which fields the expired logs blocked.
-
FIRST_AFTERis the first(build):push run that containsIMAGES_EARLY_SHA, checked withgit merge-base --is-ancestor. -
LAST_AFTERfollows Step 3.3, andstats.tsran with--from <FIRST_AFTER> --to <LAST_AFTER>. - Every left-out run in the window is listed with its reason.
- Every qualifying row shows every metric and extra field, or
n/a. - All, Ext suite ran, No Ext suite, Warm and Cold each show n, median, min–max and the clear-win verdict. Each slice with n below 5 is marked as a hint.
- Each of the four targets has a verdict, and each miss names its stage.
- The p90 verdict is in a second comment at n of 10 or more, or that comment reads "p90 not measured, n = ".
- The same comment exists on [Tracking] Faster, change-aware CI/CD pipeline #554, with a link back here.
-
git status --shortshows no change from this work.
Verification
- No workflow or triage code changes here, so
actionlintwith.github/actionlint.yaml,bash .github/scripts/parse-build-trigger.test.shand adry_run_subjectdispatch do not apply. - Script check: Step 2.
- The measurement: read with
bench.ts,stats.tsand the📊 Run Reporttable. M1 and M6 rows are in Targets; clear-win minimums are in the protocol.
| Metric | Read from | Before | Expected after |
|---|---|---|---|
| M2g median | 🏗️ Build Verification job end |
414 s | at or below 414 s. Lower on Ext runs when #558 is on main. |
| M2i median | 🏗️ Build Docker Images step end, in the images job |
1006 s (inside the deploy job) | near or below M2g |
| M3 median | 🏗️ Build Docker Images step |
546 s | lower when #559 and #560 are on main (computed savings: about 120 s and 25–48 s) |
| M4 | 💾 Pre-deploy disk guard step |
0–72 s | informational; record it |
| M5 | 🚀 Deploy Services (rolling replace) step |
57–70 s | unchanged; ruling 7 keeps the full roll |
| W median | images job end or 🏗️ Build Verification end, to 🚀 Deploy Production start |
2–3 s | 10 s or less |
Accountability
- Who. The builder reads the runs and drafts the comment. The maintainer reads the draft before posting and rules on the verdict.
- When. At the 8th qualifying deploy or day 30, whichever comes first. A second comment follows at n of 10 for the p90.
- Stop condition, which means "revert". Any one of these:
- the after All median of M1 is above 1090 s;
- a slice with n of 5 or more has an after median above its before median: Ext suite ran 1354, No Ext suite 994, Warm 946, Cold 1132;
- any qualifying after deploy has M1 above 1510 s, the before maximum.
- When it is met: name the missed stage, from M2g, M2i and W. Comment on Build the deploy images while the quality gates run #565 and recommend the revert of the Build the deploy images while the quality gates run #565 commit only. The maintainer decides. Change no workflow from this issue.
- Disk check. If any after row has
free_gbbelow 20, comment on Prune Docker build cache toward a free-disk target and keep rollback images #564 with the run id and the value.
Rollback
- This issue changes no repo file and no host.
- Fix a wrong result with a new comment headed "Correction, " that names the wrong and right value. Do not delete the first comment.
- On the stop condition, Build the deploy images while the quality gates run #565 reverts per SP4 ([Tracking] Faster, change-aware CI/CD pipeline #554): revert only
IMAGES_EARLY_SHA, and keep Prune Docker build cache toward a free-disk target and keep rollback images #564. The next run reads the old workflow from the new tip.
Docs to update
None. #557 owns the stale CACHED and deploy-gap facts in AGENTS.md §Docker.
Out of scope
- Extra deploys to make numbers (ruling 4), a dashboard, a metrics stack or a new CI vendor (ruling 5).
- Committing the scripts, or any workflow edit. Write a timing table to every CI/CD run summary #556 owns the in-run timing table.
- Fixing a missed target here. Comment on the owning sibling issue.
- Laptop timings, and judging a cache fix under
--no-cache. - Reading or touching the prod host. Do not ssh.
- Reopening the GHCR question. The tracker reopens it only when free disk stays under the Prune Docker build cache toward a free-disk target and keep rollback images #564 target across a week of deploys.
- Skipping the backend images on
(build): front(ruling 7), Turbopack, a BuildKit cache mount with a168hprune, and a deploy gate on chatroom E2E. The tracker rejected or deferred each one.
Read first
- [Tracking] Faster, change-aware CI/CD pipeline #554, Build the deploy images while the quality gates run #565 and Write a timing table to every CI/CD run summary #556, in full.
AGENTS.md§Docker (lines 286–307 at the pinned commit) and §Learned Workspace Facts (line 416)..github/workflows/prod.docs.plus.ymlon currentmain: theimages,deployandreportjobs..github/WORKFLOWS.md, for the current job order.
Activity