Repository navigation
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. - 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_AFTERis 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.tsoutput 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
- 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 and Prune Docker build cache toward a free-disk target and keep rollback images #564. Every after row contains both. Run only the CI suites a pull request or push can affect #563, because the docs-only push target needs it. Cut the webapp image installs from 158 s to about 25 s per deploy #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, because each changes a measured stage.
- Window 2 reads: Run the backend and webapp image builds at the same time, to cut the image build step #567, 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. Window 2 starts at that commit (Step 3.6).
- Blocks: nothing. [Tracking] Faster, change-aware CI/CD pipeline #554 and Publish one Bun 1.4.x benchmark report for the marketing team #660 quote M1 only from this issue's
stats.tsoutput. - Related: Find why the deploy roll-out step (M5) got slower after 2026-11-03 #760 owns the M5 rise. Measure CI/CD time to live after the v2.3 work: 1090 s median before #569 records M5 and does not fix it.
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). Since Write a timing table to every CI/CD run summary #556, each run's📊 Run Reportsummary is the quick read, not the result.
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. |
createdAtis 2–6 s after the push. Recordqueue_s, the first job'sstartedAtminuscreatedAt, so a GitHub queue delay stays visible.- M1 starts at the run
created_at, not the attemptcreated_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
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. 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
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).
- The
📊 Run Reportlinebenchmark sample: yesmust 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 rerunattempt (house rule). A re-run keeps the runcreatedAt, 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
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 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
- 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 at n of 10 or more. Window 1 already holds 10 rows, so one "Measured" comment carries both verdicts.- Window 1 ("Measured, Build the deploy images while the quality gates run #565") uses runs 41608014517 to 42344958557 for the median, against 620 s, and through 42582921035 for the p90, against 720 s.
- Window 2 (set E, Step 3.6) is the only window that judges the 540 s stretch.
- 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), 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.
- 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 Cut the webapp image installs from 158 s to about 25 s per deploy #559 renamed a stage, change the install regex. - If Run the backend and webapp image builds at the same time, to cut the image build step #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.
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.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 '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
main; tag it "no". If a sibling used another string, read its merge commit there.- The parallel-compose string
wait "${BACK_PID}"exists only in Run the backend and webapp image builds at the same time, to cut the image build step #567 Step 1b. A Step 1a commit must not match it. - 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'.
- The parallel-compose string
- Tag the confounds. Give each row
obs,db_regionandtraefik_timeout. Report the All slice twice: with and without thetraefik_timeoutrows. - 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 samestats.tscall with--fromand--toset 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.- Judge the Run the backend and webapp image builds at the same time, to cut the image build step #567 effect on M2i and M3. Report the deploy job time by
db_regionbeside the 540 s verdict. Name the London and PostgreSQL 18.6 move (2026-11-11) as a confound in the window 2 Claim.
- Judge the Run the backend and webapp image builds at the same time, to cut the image build step #567 effect on M2i and M3. Report the deploy job time by
- 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. Split thedeployjob time bydb_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 --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 (Measured). - Step 2 reproduced the Baseline Summary to the second, or the comment states which fields the expired logs blocked (Measured).
-
FIRST_AFTERis the first(build):push run that containsIMAGES_EARLY_SHA, checked withgit merge-base --is-ancestor(Measured). -
LAST_AFTERfollows Step 3.3, andstats.tsran 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 --shortshows no change from this work (read 2026-11-14).
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 |
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
- Who. Agent in the repo: reads the runs and drafts the comment. 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. Window 1 already holds 10 rows, so the window 1 comment carries both verdicts. Window 1 was posted and ruled on 2026-11-12. Window 2 posts after its 5th qualifying deploy.
- Close rule (ruled 2026-11-12). Measure CI/CD time to live after the v2.3 work: 1090 s median before #569 closes after the window 2 comment. If Run the backend and webapp image builds at the same time, to cut the image build step #567 Step 1b is not on
mainby the v2.3.6 release audit, the stretch row reads "not measured", and Measure CI/CD time to live after the v2.3 work: 1090 s median before #569 closes. - 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
ci(prod): write a run timing table to every run summary
HMarzban commentedon Nov 3, 2026
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:
HMarzban commentedon Nov 3, 2026
Status, 2026-11-03 (after the 1b05cb2 deploy).
The after-sample so far. Each row is a successful attempt-1
(build):push withbenchmark sample: yes:8e25e83df3bcc821103298c7f831b05cb24dbenchmark sample: no.created_at16:48:08Z, and the runcreated_atis 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.
HMarzban commentedon Nov 4, 2026
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.
HMarzban commentedon Nov 4, 2026
Status, 2026-11-04. 6 of 8 benchmark deploys are now in. The two new rows:
9eb26d2084e1aca058The 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.
[-]Measure the CI/CD after benchmark for v2.3[/-][+]Measure CI/CD time to live after the v2.3 work: 1090 s median before[/+]HMarzban commentedon Nov 11, 2026
Status, 2026-11-11.
FIRST_AFTERis 41608014517. The 10th qualifying row is 42582921035 (2026-11-09), so one Measured comment carries the median and the p90 verdicts.stats.tsoutput. Review reads in other issues are not the result.created_at, not the attemptcreated_at. On run 41677887088 the two differ by 1 s.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.tsoutput replaces it.The body now carries a Benchmark section, the window 2 (set E) rule, row tags for
db_regionandtraefik_timeout, and a sample rule for M6 with n = 5.HMarzban commentedon Nov 11, 2026
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.
HMarzban commentedon Nov 12, 2026
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_AFTERis the first(build):push that containsIMAGES_EARLY_SHAf8d391fa4, checked withgit merge-base --is-ancestor. The 12 earlier(build):pushes do not contain it.(build): backrow.benchmark sample: yes, so the report agrees with the sample rules.72f08af7a, from row 5 on.72f08af7a); Bun check (16c59999b)72f08af7a); Bun check (16c59999b)72f08af7a); Bun check (16c59999b)72f08af7a); Bun check (16c59999b); Bun check never fails (d9bcd3d82)72f08af7a); Bun check (16c59999b); Bun check never fails (d9bcd3d82)72f08af7a); Bun check (16c59999b); Bun check never fails (d9bcd3d82)BACK_PID) and the build-traces trim (outputFileTracingExcludes) are not onmain. Every row is "no" for both.backend_no_cachestill occurs 4 times onmain, so the Measure whether the backend image build still needs --no-cache #566 A/B test is open. Every row is "A/B only".free_gbandload1come from each Run Report. The reportfree_gbequals thebench.tsvalue on all 10 rows.free_gbis 39, so no Prune Docker build cache toward a free-disk target and keep rollback images #564 comment is due.db_regionandtraefik_timeoutfollow the issue body. Every row was created before the 2026-11-11 database move.Window 1,
bun stats.ts after-all.json --from 41608014517 --to 42344958557 --before before.json:Window 1b, the same call with
--to 42582921035:All slice without the
traefik_timeoutrows (41626334830 and 41636667896), fromstats.tson the same rows less those two:Slice verdicts. A slice is a clear win when its after median is below its before minimum.
bun.lockchange, an extension change, or a gap above 24 h.traefik_timeoutrows.traefik_timeoutrows. Stretch 540 s: not measured, because window 2 has not started.bun.lockPRs, and each ran 6 Ext suites. 0 of 3 PRs ran 0 Ext suites.(build):deploy.traefik_timeoutrows.Stage check against the Verification table, window 1 (n = 8):
Missed stage: for the M1 median miss (630 against 620), two stages miss their expected values.
deployjob is 630 − 506 − 9 = 115 s from the medians. The median of per-row values is 120 s (80–147).deployjob bydb_region: NYC1 120 s (80–147, n = 8). London: no rows.deployjob without thetraefik_timeoutrows: 108 s (80–147, n = 6).deployjob, has a median of 80 s, above the 70 s limit. Its before median was 59 s.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 samestats.tscall with--fromand--toset to those runs. Only window 2 judges the 540 s stretch. Status on 2026-11-11: not started. #567 is not onmain, and #566 has no drop commit. Rows after the #747 host move start a new sample.Method notes:
created_atto the🩺 Verify Deploymentstepcompleted_at, attempt 1 only. Nogh run rerundata is used.attempts/1/jobs, andjobs?filter=allkept to attempt 1) gives the same M1 on all 24 rows. The difference is 0 s.main:f8d391fa4andcd96b8dfb. Each step name occurs once. No regex inbench.tschanged. Log retention is 90 days.stats.tsprints M1, M2, M2g, M2i, W and M3.stage.tsgives M4, M5, thedeployjob and the M6 PR median.m2i-split.tsgives the M2i split. Both helpers copy thestats.tsmedian code.abd852f92is onmainand 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.created_at(16:48:07Z). The 2026-11-03 status gave 616 s, from the Run Report attempt line (16:48:08Z).stats.tson 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)."
HMarzban commentedon Nov 12, 2026
Maintainer ruling, 2026-11-12: window 1 verdict
HMarzban commentedon Nov 12, 2026
Maintainer ruling, 2026-11-12: close rule
#569 closes after the window 2 comment. If #567 Step 1b is not on
mainby the v2.3.6 release audit, the window 2 row reads "not measured", and #569 closes.HMarzban commentedon Nov 14, 2026
Close, 2026-11-14: the v2.3.6 release audit
The close rule of 2026-11-12 applies. #567 Step 1b is not on
mainat the audit (origin/mainis9f0e2be02), so the window 2 row reads "not measured", and this issue closes.Closing as completed.