Skip to content

Measure CI/CD time to live after the v2.3 work: 1090 s median before #569

Closed

Description

@HMarzban

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.
  • Before. 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.
  • Status, 2026-11-12. Window 1 closed at run 42344958557, the 8th qualifying deploy (2026-11-07). FIRST_AFTER is 41608014517. The 10th qualifying row is 42582921035 (2026-11-09), so the p90 was judged too. The window 1 verdict is posted (Measured) and ruled a clear win (ruling). The 620 s median target is missed by 10 s. Window 2 (set E) is not started.
  • Owns. The metric definitions, sample rules, statistics rules, baseline, "after" protocol and targets. It is the only source of an M1 number. Every other issue quotes M1 from this issue's stats.ts output only.

Benchmark

Field Value
Metric M1, time to live: run createdAt to the 🩺 Verify Deployment step completedAt, in seconds. Secondary: M2g, M2i, M3, M5, W, M6.
Before Frozen. M1 median 1090 s, p90 1386 s, range 777–1510 s. n = 14 (build): push deploys, attempt 1, 2026-09-22 to 2026-10-20 (Baseline Summary below). M5 median 59 s (57–70 s, n = 14).
After This issue posts the after value from its stats script (stats.ts). Window 1: M1 median 630 s, range 595–714 s, n = 8, runs 41608014517 to 42344958557 (2026-11-03 to 2026-11-07). Window 1b: p90 673 s, n = 10, through 42582921035 (2026-11-09). Source: Measured 6812443811. Window 2 (set E, Step 3.6): not measured. #567 Step 1b was not on main at the v2.3.6 release audit (2026-11-14).
Condition prod.docs.plus.yml. Gates on GitHub-hosted ubuntu-latest; images and deploy on the 4-vCPU self-hosted production runner. Attempt 1 only. Rows tagged with cache class, Ext suites, obs, db_region and Traefik timeout. M2g is the 🏗️ Build Verification job end in window 1. Rows after the #747 host move form a new sample.
How to run Steps 0 to 6 below: bench.ts for the rows, then bun stats.ts after-all.json --from <FIRST_AFTER> --to <LAST_AFTER> --before before.json. Never hand-compute a median.
Pass rule Targets below: M1 median 620 s or less; M1 p90 720 s or less at n >= 10; stretch 540 s judged only in window 2. Stop rule: the Accountability stop conditions, then #565 reverts per SP4 (#554).
Guard Disk: any after row with free_gb below 20 is reported on #564. The deploy tail (M5) is recorded, not fixed here. #760 owns the M5 rise (ruling 2026-11-12).
Owner Agent in the repo: Steps 0 to 6 and the draft comment. Window 1 is done: the maintainer posted it and ruled on 2026-11-12. Window 2: the same split, after its 5th qualifying deploy. The maintainer posts each result here and on #554.
Claim Ruled 2026-11-12 (ruling): "A docs.plus production deploy now goes live in a median of 630 s, down from 1090 s (n = 14 before, 2026-09-22 to 2026-10-20; n = 8 after, 2026-11-03 to 2026-11-07)." Condition: the 620 s median target is missed by 10 s.

Depends on / Blocks

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

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 Frozen for window 1. After the #554 Q1 change, rows use the latest end among the jobs that deploy needs. Never compare rows across the two definitions.
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 in the baseline. 80 s (58–127 s, n = 8) in window 1. #760 owns it (ruling 2026-11-12).
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.
  • createdAt is 2–6 s after the push. Record queue_s, the first job's startedAt minus createdAt, so a GitHub queue delay stays visible.
  • M1 starts at the run created_at, not the attempt created_at. This answers the 2026-11-03 question. On run 41677887088 the two differ by 1 s.
  • 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 deploy job 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. Once #554 Q3 is on main, the range is 0–1.
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
db_region NYC1 or London. The collab database moved to London on 2026-11-11, which changes the migrate step. Every window 1 row is NYC1.
traefik_timeout yes for runs 41626334830 and 41636667896, which hold the 61–62 s Traefik timeout. no for every other row.

"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 push run when its 🚀 Deploy Production job ended success on 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_h is 24 or less, with no bun.lock change and no extension change. Every other deploy is cold.
  • Report a (build): back run 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).
  • The 📊 Run Report line benchmark sample: yes must agree with these rules. If it disagrees, these rules win, and the comment names the run under "Left out".
  • Rows from after the Move production to a new Debian 13 droplet #747 host move form a new sample. Never pool them with earlier rows.
  • M6 with n = 5. Take the 5 samples from 5 pushes, for example 5 empty commits on a draft PR. Never take a sample from a gh run rerun attempt (house rule). A re-run keeps the run createdAt, so attempts 2 to 5 include the wait since attempt 1.

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 CACHED on the three fastest image builds (M3 316–472 s). Neither was CACHED on the other 11 deploys.
  • Left out: 37906965365 (re-run), 39274130383 and 38689468498 (the (build):front subject 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 the logs of six baseline deploy jobs. #559 and #567 now own these parts.

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

  1. Measure normal deploys only. Measure whether the backend image build still needs --no-cache #566 is the one exception, with its own procedure.
  2. Each change set gets a quick read from the 📊 Run Report of its next one or two deploys. The sibling issue owns it. It is not the result.
  3. 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 at n of 10 or more. Window 1 already holds 10 rows, so one "Measured" comment carries both verdicts.
  4. A change set landing inside the window does not restart the count. Each row records the later change sets it contains.
  5. 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), judged in window 1. Stretch: about 540 s after set E, judged only in window 2 (Step 3.6). Not measured: #567 Step 1b was not on main at the v2.3.6 release audit (2026-11-14).
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.

  1. Update the clone: git -C <repo> fetch origin main. The script needs every deployed SHA locally.
  2. Confirm the two dependencies are on main.
    git -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
    Each command must print one commit. The first SHA is IMAGES_EARLY_SHA. If either prints nothing, stop and comment here.
  3. Confirm each step name exists exactly once.
    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
    Expect 1 on every line. If a name changed, update its regex in bench.ts and note it in the result comment.
  4. 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 Images in 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 Production may match /Deploy Production/.
      If any rule fails, fix the regex in bench.ts and note it.
  5. 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'.
  6. 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. Then cached_installs and free_gb read null for old rows; the frozen values stay the baseline.
  7. 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.

M1 starts at the run created_at, not the attempt created_at. bench.ts already reads run.created_at.

Step 2. Prove the scripts on the baseline

Run this step before 2026-12-15, so all 14 baseline logs are still readable. 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.json row 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

  1. List every push and workflow_dispatch run from 39398067267 to now, oldest first, so the first after deploy has its real diff base.
    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
    Dispatch rows are diff bases only; stats.ts keeps push rows. List each under "Left out" as "A/B dispatch (Measure whether the backend image build still needs --no-cache #566)".
  2. FIRST_AFTER is the first push row whose subject starts with (build): and for which git -C <repo> merge-base --is-ancestor <IMAGES_EARLY_SHA> <row sha> && echo contains prints contains.
  3. LAST_AFTER. A qualifying row has event push, deploy success, a numeric m1, and a subject that starts with (build): and contains front. A (build): back row does not count; stats.ts prints it on its own line. Count from FIRST_AFTER, oldest first.
    • LAST_AFTER is the 8th qualifying row, or, if day 30 comes first, the last qualifying row created before FIRST_AFTER createdAt plus 30 days. Read createdAt from pushes.json.
    • END_TIME is the createdAt of LAST_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 with n of 8, or the day-30 count.
  4. 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.
    git -C <repo> log origin/main --format='%H %cs %s' -S 'wait "${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
    An empty line means the change set is not on main; tag it "no". If a sibling used another string, read its merge commit there.
  5. Tag the confounds. Give each row obs, db_region and traefik_timeout. Report the All slice twice: with and without the traefik_timeout rows.
  6. Set E window (window 2). It starts at the first (build): push that contains the Run the backend and webapp image builds at the same time, to cut the image build step #567 commit, or the Measure whether the backend image build still needs --no-cache #566 drop commit if Run the backend and webapp image builds at the same time, to cut the image build step #567 reverts. It ends at the 5th qualifying deploy. Use the same stats.ts call with --from and --to set to those runs. Only this comment judges 540 s. Rows after the Move production to a new Debian 13 droplet #747 move start a new sample.
  7. 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_gb and load1 from the 📊 Run Report table at https://github.com/docs-plus/docs.plus/actions/runs/<id>.
  • The bench.ts free_gb is a cross-check. If they differ, use the report value and note it. If the report shows n/a, use the bench.ts value and say so.
  • If load1 reads n/a, record n/a. Do not read the host.

Step 5. Compute the result

  1. 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.
  2. M6. Run bench.ts on 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 m6 over after-pr.json. Also report how many PRs ran 0 Ext suites.
    • Root docs-only push: an after-all.json row between FIRST_AFTER and LAST_AFTER with event push, conclusion success, deploy not success, typecheck false and ext 0. A push can hold more than one commit, so confirm with git -C <repo> diff --name-only <previous push row sha> <row sha> that every changed file is docs-only. Report its m6, or "no sample".
  3. 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 deploy job: M1 minus the later of M2g and M2i, minus W. Split the deploy job time by db_region.

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. Use stats.ts output only; do not hand-compute medians.

## Measured, <YYYY-MM-DD>

Setup: gates on GitHub-hosted ubuntu-latest; images and deploy on the 4-vCPU self-hosted production runner; window <date> to <date>; Bun <version>; n = <n>.
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 | db_region | traefik_timeout | 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 | | |
| M5 record (missed stage if the after median is above 70 s) | 59 (57–70, n = 14) | | |

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 --short must show no change from this work.
  • Show the maintainer the draft comment, before.json and after-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 stash or branch.

Acceptance criteria

  • Step 0 found IMAGES_EARLY_SHA and the 📊 Run Report commit on main, and every step name once (Measured).
  • Step 2 reproduced the Baseline Summary to the second, or the comment states which fields the expired logs blocked (Measured).
  • FIRST_AFTER is the first (build): push run that contains IMAGES_EARLY_SHA, checked with git merge-base --is-ancestor (Measured).
  • LAST_AFTER follows Step 3.3, and stats.ts ran with --from <FIRST_AFTER> --to <LAST_AFTER> (Measured).
  • Every left-out run in the window is listed with its reason (Measured).
  • Every qualifying row shows every metric and extra field, or n/a (Measured).
  • 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 (Measured).
  • Each of the four targets has a verdict, and each miss names its stage (Measured).
  • The p90 verdict is in the window 1 Measured comment, which carries both verdicts (2026-11-12) (Measured).
  • The same comment exists on [Tracking] Faster, change-aware CI/CD pipeline #554, with a link back here (copy).
  • Window 2 (set E) has its own comment against 540 s, or the stretch row reads "not measured". It reads "not measured" (close rule, 2026-11-14).
  • A "Correction, 2026-11-11" comment is posted. Under the statistics rule, the 2026-11-04 interim median of 6 rows is 622 s, not 621.5 s (Correction).
  • git status --short shows no change from this work (read 2026-11-14).

Verification

  • No workflow or triage code changes here, so actionlint with .github/actionlint.yaml, bash .github/scripts/parse-build-trigger.test.sh and a dry_run_subject dispatch do not apply.
  • Script check: Step 2.
  • The measurement: read with bench.ts, stats.ts and the 📊 Run Report table. 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 median 59 s (57–70 s, n = 14) record; 80 s (58–127 s, n = 8) in window 1, a missed stage. If the after median is above 70 s, name it a missed stage. #760 owns it (ruling 2026-11-12). Do not fix it here.
W median images job end or 🏗️ Build Verification end, to 🚀 Deploy Production start 2–3 s 10 s or less

Accountability

Rollback

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 a 168h prune, and a deploy gate on chatroom E2E. The tracker rejected or deferred each one.

Read first

Activity

  1. added this to the v2.3.0 milestone on Oct 20, 2026
  2. HMarzban commented on Nov 3, 2026

    @HMarzban
    CollaboratorAuthor

    Status, 2026-11-03. #565 (f8d391fa4) is pushed and deployed on 2026-11-03. So the after-sample starts at run 41608014517, with M1 598 s.

    Left:

  3. HMarzban commented on Nov 3, 2026

    @HMarzban
    CollaboratorAuthor

    Status, 2026-11-03 (after the 1b05cb2 deploy).

    The after-sample so far. Each row is a successful attempt-1 (build): push with benchmark sample: yes:

    Run Head M1 M2g M2i M3 M5 W Ext suites
    41608014517 8e25e83df 598 496 509 445 59 9 6
    41626334830 3bcc82110 633 348 480 435 59 8 6
    41636667896 3298c7f83 670 403 522 439 58 7 6
    41677887088 1b05cb24d 616 489 440 381 103 3 2
    • 4 of 8 so far. Dispatch 41669629388 deployed too, but it prints benchmark sample: no.
    • Runs 41626334830 and 41636667896 include the 61–62 s Traefik timeout. The host fix came after them.
    • Run 41677887088: the report reads attempt 1 created_at 16:48:08Z, and the run created_at is 16:48:07Z. Step 1 should state which time it uses.

    Left: 4 more benchmark deploys, or 2026-12-03. Then Steps 1 to 6.

  4. modified the milestones: v2.3.0, v2.4.0 on Nov 4, 2026
  5. HMarzban commented on Nov 4, 2026

    @HMarzban
    CollaboratorAuthor

    Maintainer ruling, 2026-11-04

    Moved from milestone v2.3.0 to milestone v2.4.0, from the v2.3.0 open-issue review. 6 of 8 benchmark deploys are in. The Final also judges the set E target, so it moves with set E (#566, #567).

    The issue stays open. Its remaining items are unchanged.

  6. HMarzban commented on Nov 4, 2026

    @HMarzban
    CollaboratorAuthor

    Status, 2026-11-04. 6 of 8 benchmark deploys are now in. The two new rows:

    Run Head M1 M2g M2i M3 M5
    41699693689 9eb26d208 595 420 487 346 79
    41854096960 4e1aca058 627 406 503 444 94

    The median M1 of the six is 621.5 s. M5 rose from 58–59 s to 79–103 s after the Traefik fix, and the cause is not known yet.

  7. modified the milestones: v2.4.0, v2.3.6 on Nov 9, 2026
  8. changed the title [-]Measure the CI/CD after benchmark for v2.3[/-] [+]Measure CI/CD time to live after the v2.3 work: 1090 s median before[/+] on Nov 11, 2026
  9. HMarzban commented on Nov 11, 2026

    @HMarzban
    CollaboratorAuthor

    Status, 2026-11-11.

    • Milestone. This issue sits in v2.3.6 since 2026-11-11, for the benchmark milestone. The 2026-11-04 move to v2.4.0 (Maintainer ruling, 2026-11-04) is void. The set E target now has its own window (Step 3.6), so the window 1 result no longer waits for set E.
    • Window 1 closed at run 42344958557, the 8th qualifying deploy (2026-11-07). FIRST_AFTER is 41608014517. The 10th qualifying row is 42582921035 (2026-11-09), so one Measured comment carries the median and the p90 verdicts.
    • Owner. Agent in the repo runs Steps 0 to 6 and drafts the comment by 2026-11-14. The maintainer reads it, posts it here and on [Tracking] Faster, change-aware CI/CD pipeline #554, and rules.
    • One M1 source. Every other issue quotes M1 only from this issue's stats.ts output. Review reads in other issues are not the result.
    • Open question answered. M1 starts at the run created_at, not the attempt created_at. On run 41677887088 the two differ by 1 s.
    • M5. The deploy tail rose from 57–70 s to 79–103 s after the Traefik fix, cause unknown. This issue records it and does not fix it. The maintainer names the owning issue (candidate Upgrade production Traefik to v3.7.18, with every static edge change in one restart #746).

    Correction, 2026-11-11. The 2026-11-04 status gave the interim median of 6 rows as 621.5 s. The statistics rule rounds an even-n median half up, so it is 622 s. It is an interim hint, not the result. The stats.ts output replaces it.

    The body now carries a Benchmark section, the window 2 (set E) rule, row tags for db_region and traefik_timeout, and a sample rule for M6 with n = 5.

  10. HMarzban commented on Nov 11, 2026

    @HMarzban
    CollaboratorAuthor

    Maintainer ruling, 2026-11-11 (owner)

    The agent in the repo runs Steps 0 to 6 in the current v2.3.6 evidence session. It drafts the window 1 verdict by 2026-11-13, so one day is left for review before the 2026-11-14 due date. M1 values still come only from the stats script.

  11. HMarzban commented on Nov 12, 2026

    @HMarzban
    CollaboratorAuthor

    Measured, 2026-11-11

    Setup: gates on GitHub-hosted ubuntu-latest; images and deploy on the 4-vCPU self-hosted production runner; window 2026-11-03 to 2026-11-07; Bun 1.4.4; n = 8.
    Window: 41608014517 (2026-11-03) to 42344958557 (2026-11-07). n = 8 qualifying deploys.
    Window 1b, for the p90 only: 41608014517 (2026-11-03) to 42582921035 (2026-11-09). n = 10 qualifying deploys.
    Left out: 41669629388 (A/B dispatch (#566); its Run Report prints benchmark sample: no; diff base only), 42443026527 (schedule run, window 1b only; its deploy job was skipped). For M6: 41608343253, 41608354341, 41626692066, 41637030759 (failed PR runs; Step 5.2 counts successful runs only).

    • FIRST_AFTER is the first (build): push that contains IMAGES_EARLY_SHA f8d391fa4, checked with git merge-base --is-ancestor. The 12 earlier (build): pushes do not contain it.
    • The day-30 cutoff is 2026-12-03T09:04:11Z, so it does not bind. No row is failed, cancelled, a re-run or a (build): back row.
    • Each qualifying row is attempt 1. Its Run Report prints benchmark sample: yes, so the report agrees with the sample rules.
    • No row comes after the Move production to a new Debian 13 droplet #747 host move. Move production to a new Debian 13 droplet #747 is open.
    • Bun 1.4.4 runs the scripts. CI Bun moved from 1.4.1 to 1.4.4 in 72f08af7a, from row 5 on.
    Run Date Ext suites M2g M2i W M4 M3 M5 M1 queue_s lock_changed ext_src_changed gap_h cached_installs obs db_region traefik_timeout free_gb load1 Class Later sets
    41608014517 11-03 6 496 509 9 7 445 59 598 10 yes yes 258.5 0 yes NYC1 no 60 1.40 cold #566 A/B only
    41626334830 11-03 6 348 480 8 2 435 59 633 3 yes yes 2.2 0 no NYC1 yes 45 0.12 cold #566 A/B only
    41636667896 11-03 6 403 522 7 29 439 58 670 2 yes no 1.3 0 no NYC1 yes 41 1.05 cold #566 A/B only
    41677887088 11-03 2 490 441 3 1 381 103 617 3 no yes 0.8 0 no NYC1 no 39 0.36 cold #566 A/B only
    41699693689 11-03 6 420 487 9 59 346 79 595 3 no yes 2.3 1 no NYC1 no 41 1.47 cold #566 A/B only; CI Bun 1.4.4 (72f08af7a); Bun check (16c59999b)
    41854096960 11-04 6 406 503 9 14 444 94 627 3 yes yes 20 0 no NYC1 no 39 0.79 cold #566 A/B only; CI Bun 1.4.4 (72f08af7a); Bun check (16c59999b)
    41993501959 11-05 6 396 558 9 62 457 127 714 2 yes yes 17.2 0 no NYC1 no 41 1.01 cold #566 A/B only; CI Bun 1.4.4 (72f08af7a); Bun check (16c59999b)
    42344958557 11-07 6 381 563 9 59 464 81 673 3 no yes 45.7 0 no NYC1 no 42 0.79 cold #566 A/B only; CI Bun 1.4.4 (72f08af7a); Bun check (16c59999b); Bun check never fails (d9bcd3d82)
    42363595092 11-07 6 409 489 8 39 407 95 613 3 yes no 4.4 0 yes NYC1 no 40 1.21 cold #566 A/B only; CI Bun 1.4.4 (72f08af7a); Bun check (16c59999b); Bun check never fails (d9bcd3d82)
    42582921035 11-09 6 344 515 8 60 407 59 604 3 no yes 45.7 0 yes NYC1 no 41 1.19 cold #566 A/B only; CI Bun 1.4.4 (72f08af7a); Bun check (16c59999b); Bun check never fails (d9bcd3d82)

    Window 1, bun stats.ts after-all.json --from 41608014517 --to 42344958557 --before before.json:

    Slice n M1 median M1 p90 M1 min–max M2 median M2g median M2i median W median M3 median Before min Clear win
    All 8 630 — (max 714) 595–714 515 405 506 9 442 777 yes
    Ext suite ran 8 630 — (max 714) 595–714 515 405 506 9 442 1052 yes
    No Ext suite 0 — — (max —) — — — — — — 777 —
    Warm 0 — — (max —) — — — — — — 777 —
    Cold 8 630 — (max 714) 595–714 515 405 506 9 442 971 yes

    Window 1b, the same call with --to 42582921035:

    Slice n M1 median M1 p90 M1 min–max M2 median M2g median M2i median W median M3 median Before min Clear win
    All 10 622 673 595–714 515 405 506 9 437 777 yes
    Ext suite ran 10 622 673 595–714 515 405 506 9 437 1052 yes
    No Ext suite 0 — — (max —) — — — — — — 777 —
    Warm 0 — — (max —) — — — — — — 777 —
    Cold 10 622 673 595–714 515 405 506 9 437 971 yes

    All slice without the traefik_timeout rows (41626334830 and 41636667896), from stats.ts on the same rows less those two:

    Window n M1 median M1 p90 M1 min–max Clear win (before min 777)
    1 (to 42344958557) 6 622 — (max 714) 595–714 yes
    1b (to 42582921035) 8 615 — (max 714) 595–714 yes

    Slice verdicts. A slice is a clear win when its after median is below its before minimum.

    • All: clear win. 630 is below 777 (n = 8).
    • Ext suite ran: clear win. 630 is below 1052 (n = 8).
    • Cold: clear win. 630 is below 971 (n = 8).
    • No Ext suite: no sample (n = 0). Every row ran Ext suites.
    • Warm: no sample (n = 0). Every row is cold: each has a bun.lock change, an extension change, or a gap above 24 h.
    • No slice has n from 1 to 4, so no slice is a hint.
    Target Before After Verdict
    M1 median ≤ 620 s (stretch 540 s, rows with set E) 1090 630 (n = 8, window 1). 622 (n = 6) without the traefik_timeout rows. Missed by 10 s. Also missed without the traefik_timeout rows. Stretch 540 s: not measured, because window 2 has not started.
    M1 p90 ≤ 720 s (n ≥ 10) 1386 673 (n = 10, window 1b) Met.
    M6 PR median lower 519 515 (386–603, n = 3) Hint only, n < 5. All 3 are Dependabot bun.lock PRs, and each ran 6 Ext suites. 0 of 3 PRs ran 0 Ext suites.
    M6 root docs-only push ≤ 120 s 217 no sample Not measured. No root docs-only push lies in the window. Every push in it is a (build): deploy.
    M5 record (missed stage if the after median is above 70 s) 59 (57–70, n = 14) 80 (58–127, n = 8). 88 (59–127, n = 6) without the traefik_timeout rows. Above 70 s, so a missed stage. The cause is unknown. The maintainer names the owning issue (candidate #746).

    Stage check against the Verification table, window 1 (n = 8):

    Metric Before After median (range) Expected after Verdict
    M2g 414 405 (348–496) at or below 414 met
    M2i 1006 (inside the deploy job) 506 (441–563) near or below M2g not met: 101 s above the M2g median
    M3 546 442 (346–464) lower met
    M4 0–72 22 (1–62) informational recorded
    M5 59 (57–70) 80 (58–127) missed stage above 70 s missed
    W 2–3 9 (3–9) 10 s or less met

    Missed stage: for the M1 median miss (630 against 620), two stages miss their expected values.

    • M2i, the long path. Its median is 506 s, against an M2g median of 405 s. M2i ends later than M2g on 7 of 8 rows.
    • M2i split. The images job starts at a median of 45 s. Its disk guard starts 4 s later. M4 then takes 22 s and M3 takes 442 s.
    • W is 9 s, so the prod runner did not queue the deploy.
    • The deploy job is 630 − 506 − 9 = 115 s from the medians. The median of per-row values is 120 s (80–147).
    • deploy job by db_region: NYC1 120 s (80–147, n = 8). London: no rows.
    • deploy job without the traefik_timeout rows: 108 s (80–147, n = 6).
    • M5, inside the deploy job, has a median of 80 s, above the 70 s limit. Its before median was 59 s.
    • Medians do not add up: 506 + 9 + 120 = 635, not 630. So no single stage holds the 10 s miss by itself.
    • The M1 p90 target is met, so it has no missed stage. Neither M6 row is a result, so neither names a stage.

    Stop condition: not met. The All median is 630 (limit 1090). Ext suite ran is 630 (limit 1354). Cold is 630 (limit 1132). No Ext suite and Warm have n = 0. The max M1 is 714 (limit 1510).

    Set E window (window 2): it starts at the first (build): push that contains the #567 commit. If #567 reverts, it starts at the #566 drop commit. It ends at the 5th qualifying deploy. It uses the same stats.ts call with --from and --to set to those runs. Only window 2 judges the 540 s stretch. Status on 2026-11-11: not started. #567 is not on main, and #566 has no drop commit. Rows after the #747 host move start a new sample.

    Method notes:

    • M1 source. M1 runs from the run created_at to the 🩺 Verify Deployment step completed_at, attempt 1 only. No gh run rerun data is used.
    • Second read. A separate REST read (attempts/1/jobs, and jobs?filter=all kept to attempt 1) gives the same M1 on all 24 rows. The difference is 0 s.
    • Step 0. Both dependency commits are on main: f8d391fa4 and cd96b8dfb. Each step name occurs once. No regex in bench.ts changed. Log retention is 90 days.
    • Step 2. The baseline reproduced to the second: 14 of 14 rows (182 fields) and 5 of 5 slices (35 cells). No log had expired, so no field was blocked.
    • Median sources. stats.ts prints M1, M2, M2g, M2i, W and M3. stage.ts gives M4, M5, the deploy job and the M6 PR median. m2i-split.ts gives the M2i split. Both helpers copy the stats.ts median code.
    • One comment, both verdicts. The acceptance box asks for the p90 in a second comment. The body and the 2026-11-11 status say one comment carries both. This draft follows the newer body text.
    • Two windows. The median verdict uses rows 1 to 8. The p90 verdict uses rows 1 to 10. The n = 10 median (622) is shown, but it is not the median verdict.
    • Later sets. Step 3.4 finds no parallel-compose and no build-traces trim. I also tagged the three pipeline commits that landed inside the window, per protocol item 4.
    • Run only the CI suites a pull request or push can affect #563. The issue is open, but its change abd852f92 is on main and in every row. So "Run only the CI suites a pull request or push can affect #563 is open" is not a reason for either M6 row.
    • Run 41677887088. M1 is 617 s, from the run created_at (16:48:07Z). The 2026-11-03 status gave 616 s, from the Run Report attempt line (16:48:08Z).
    • Run 41677887088, other fields. For the same reason, M2g is 490 and M2i is 441, not 489 and 440. Its diff base is dispatch 41669629388, as the sample rules require.
    • Correction, 2026-11-11. stats.ts on the first 6 rows prints 622. That confirms the correction: 622 s, not 621.5 s. The 2026-11-11 status comment already holds that paragraph.

    Claim: the All slice is a clear win (630 is below the before minimum of 777). The 620 s target is still missed, and the maintainer rules on the verdict.

    "A docs.plus production deploy now goes live in a median of 630 s, down from 1090 s (n = 14 before, 2026-09-22 to 2026-10-20; n = 8 after, 2026-11-03 to 2026-11-07)."

  12. HMarzban commented on Nov 12, 2026

    @HMarzban
    CollaboratorAuthor

    Maintainer ruling, 2026-11-12: window 1 verdict

  13. HMarzban commented on Nov 12, 2026

    @HMarzban
    CollaboratorAuthor

    Maintainer ruling, 2026-11-12: close rule

    #569 closes after the window 2 comment. If #567 Step 1b is not on main by the v2.3.6 release audit, the window 2 row reads "not measured", and #569 closes.

  14. HMarzban commented on Nov 14, 2026

    @HMarzban
    CollaboratorAuthor

    Close, 2026-11-14: the v2.3.6 release audit

    The close rule of 2026-11-12 applies. #567 Step 1b is not on main at the audit (origin/main is 9f0e2be02), so the window 2 row reads "not measured", and this issue closes.

    • Before: M1 median 1090 s, n = 14 (body table).
    • After: window 1, M1 median 630 s, range 595–714 s, n = 8 (Measured, 2026-11-11).

    Closing as completed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions