Skip to content

CI pipeline speed: measured baseline + prioritized measures (target ~254s → ~150–190s) #624

Description

@CybotTM

Status — updated 2026-07-15

Measured on main (uncontended, CI workflow wall-clock): ~254s → ~166s (−33%).
Baseline c2f4b82a = 253s (post-QW1/QW2, pre-S1) → current b07dbcb7 = 166s (S1 + quick-wins on the flattened graph). The S1 PR run measured 169s independently. Under contention the absolute saving is larger (the old graph reached 280–363s; runs are now 166–253s).

Measure State PR Measured / note
QW1 concurrency groups + cancel ✅ done #625 targets contention bursts (median 455→254s projected)
QW2 E2E matrix 10 → 4 shards ✅ done #625 −~500s runner-time/run, no wall-clock regression
QW3 LDAP networkTimeout ✅ done #629 config-readable, 5s fail-fast default; bounds prod login hangs
QW4 bcrypt cost-4 test hashing ✅ done #629 test env only, keeps $2y$ format
QW9 e2e video: on-first-retry ✅ done #629
QW10 skip profiling build on PRs ✅ done #629
S1 flatten job graph (drop setup) ✅ done #630 253s → 166s main / 169s PR — 89s setup barrier + 872 MB artifact handoff removed
S4 Dockerfile stage reorder ✅ done #631 devtools stage caches dev/e2e tooling below the code copy — verified: after a src change the whole stage stays CACHED. 0 PR-wall-clock; Docker Publish ~226→120s
QW7 vitest isolate: false ❌ dismissed leaks the vi.mock/ESM module registry: 68 failed under --no-file-parallelism vs 576 passed reverted (forks pool masked it); gain ~0.3s (noise)
QW8 deps artifact tar.zst ❌ dismissed subsumed by S1 (#630) — the deps artifact is gone entirely
QW5 zstd :e2e image ⬜ open [EST] — needs a verification push
QW6 one bake for Docker Publish ⬜ open 0 wall-clock, Docker Publish −~60s
QW11 incremental tsc cache ⬜ open −7s runner-time
S2 persist PHP tool caches ✅ done #632 measured Lint 145→72s warm (−50%, cache-hit verified); Integration ~146s stays the gate — runner-time win, not wall-clock
S3 chromium-free :dev image ⬜ open [EST]
S5 coverage xdebug → pcov ❌ dismissed wrong trade: pcov is line-only, would foreclose branch/path coverage for ~18s. Kept xdebug; did S9 + enabled branch coverage instead
S6 paratest -p4 ⬜ open [EST]
S7 native Unit job ⬜ open −~65s runner-time
S8 skip heavy jobs on docs-only ⬜ open
S9 coverage off PR path → nightly ✅ done #633 coverage (unit/integration/e2e) now nightly + workflow_dispatch only; PR path coverage-free. Branch coverage enabled (Cobertura). NB xdebug branch coverage is ~6× slower → nightly job timeouts raised (#634)

Legend: ✅ done · 🔵 open PR · ⬜ open · ❌ dismissed


Summary

CI wall-clock on a PR is bimodal: ~254s for an isolated push, ~560s when 3–4 pushes land together. The ~200s gap is runner queueing, not runtime — 14–16 jobs per run compete for the (inferred) ~20-job public-repo runner cap. The intrinsic pipeline floor is ~254s.

Measured over CI runs 1330–1358 (2026-07-12..14) from the Actions API; per-run job+step timings retained. Estimates are marked [EST]; a full "not verified" list is at the bottom.

Baseline — uncontended critical path (~254s)

push → queue 3s
     → Setup Dependencies 78s   (37s of it = pulling the 872MB :e2e image)
     → wave 2 (slowest gates):
          Integration Tests 166s   ← gate in 5/10 runs
          Lint & Static     148.5s ← 4–23s behind
          slowest E2E shard up to 147s ← gate in the other 5/10
          Unit 107.5s, Frontend 74s (never gate)
     → CI Success 3s

Runner-time budget ≈ 1627s/run (~27 min); the 10-shard E2E matrix is 65% of it. Every one of 14 jobs pulls the same 872MB image (~8.6 min of pure pull/run per run). Structural facts (verified in ci.yml): no concurrency:, no paths: filters, no actions/cache in any of the 7 workflows.

Quick wins (S effort)

# Measure Wall-clock Runner-time Risk
✅ QW1 concurrency groups + cancel-in-progress (4 workflows) 455 → ~254s median (−44%) shrinks bursts full gain needs main-scope cancel or a merge queue (coarser bisect); PR-scope is risk-free
✅ QW2 E2E matrix 10 → 4 shards 0 regression (131s/shard < 166s gate) −~500s/run uneven Playwright sharding; N=4 keeps ~35s headroom
✅ QW3 LDAP networkTimeout (one test hangs 10.7s) Integration −11s low; also bounds prod login hangs
✅ QW4 Cheap test password hashing (bcrypt cost 4, not plaintext) with QW3 caps at −17.5s (Lint takes over) Unit −10s keep cost 4 so hash-format asserts pass
⬜ QW5 Push :e2e image zstd-compressed [EST] path −~40s if extraction-bound holds −~4 min/run verify one :e2e-zstd push first
⬜ QW6 One bake for all 3 Docker Publish targets 0 (never gates) Docker Publish −~60s coarser logs
❌ QW7 Vitest isolate: false (564/564 pass locally) — DISMISSED: leaks vi.mock/ESM registry, breaks under --no-file-parallelism 0 Frontend −14s state leakage; 1-line revert
❌ QW8 Deps artifact as tar.zst (keeps .bin symlinks) 0 −9s/shard subsumed by S1 — interim only
✅ QW9 E2E video: on-first-retry 0 −20–30s/run [EST] loses first-attempt video on pass-on-retry
✅ QW10 Skip profiling image on pull_request 0 −17s/PR profiling breakage surfaces on main push
⬜ QW11 Incremental tsc + cached .tsbuildinfo 0 −7s/run self-healing

Structural (M effort)

  • S1 — flatten the job graph (drop needs: setup, wave-2 self-installs). [ci: flatten the job graph — drop setup, per-job self-install (#624 S1) #630] The biggest uncontended-wall lever: composer install is 13s in-container vs the 78s Setup + handoff paid today → uncontended 258 → ~188s (−70s); measured 253 → 166s on main. Pair with QW2 (offsets the +100s runner-time from 10 shards self-installing). Subsumes QW8.
  • S2 — persist PHP tool caches (actions/cache: PHPStan/PHPat/Rector/CS-Fixer). [ci: persist PHP static-analysis tool caches across runs (#624 S2) #632] Measured: Lint 145 → 72s warm (−50%), cache-hit verified (7 MB restored). PHPStan tmpDir + Rector cache moved to host-mounted var/cache; the deferred bit was they wrote to the ephemeral container /tmp. Runner-time win — Integration (~146s) stays the gate, so ~0 wall-clock until S5 cuts it.
  • S3 — chromium-free :dev image for the 4 non-browser jobs. Stacked with QW5 → ~10–12s pulls [EST].
  • S4 — reorder Dockerfile stages (dev tools + chromium before COPY . .). [perf(ci): cache dev/e2e Docker tooling below the code copy (#624 S4) #631, CI green] Every commit re-runs a measured 68.3s of apt/pecl/chromium/composer because the dev+e2e stages build after the app-code copy → E2E build 131 → ~30s, Docker Publish 226 → ~120s. 0 CI wall-clock but faster image availability. Verified: a src change leaves the new devtools stage CACHED.
  • S5 — CI coverage xdebug → pcov. DISMISSED. pcov is line-coverage-only and would foreclose branch/path coverage for a marginal ~18s. Kept xdebug; see S9 instead. Separately enabled branch coverage (branchCoverage=true → Cobertura) — the thing pcov could not do.
  • S6 — paratest -p4 with per-process DBs. Runner-time only until S2/QW2; DB-isolation is the real work [EST].
  • S7 — run the Unit job natively (setup-php, no Docker). −~65s/run; overlaps S1/S5.
  • S8 — skip heavy jobs on docs-only pushes (in-workflow paths-filter). ~455 → ~40s per docs-only push (real here: the two docs(adr-024) commits ran the full matrix). Must be in-workflow so the required CI Success check still resolves.
  • S9 — coverage off the PR path → nightly. [ci: coverage off the PR path → nightly + branch coverage (#624 S9, drops S5) #633] unit/integration/e2e run coverage-free on PR & push; a nightly schedule + workflow_dispatch re-collect it (with branch coverage, Cobertura) and upload to Codecov. Only CI Success is required, so the per-PR Codecov uploads were safe to drop.

Projected "after" (uncontended wall-clock)

  • Quick wins only: median ~235s verified (QW1 + QW3 + QW4); ~205s if the QW5 zstd estimate holds. Runner-time 1627 → 1050–1100s (−35%, dominated by QW2).
  • Everything: ~150–190s, anchored at ~188s from S1 alone (measured arithmetic); the ~150s lower bound stacks several [EST] figures and is directional until a trial run. Runner-time → 850–900s (−45%). Docker Publish → ~110–120s.
  • Actual so far: 166s on main with QW1/QW2 + step-2 quick wins + S1 landed — already inside the target band.

Recommended order

  1. QW1 + QW2 — one tiny PR, most of the win (median −44%, runner-time −500s), lowest risk. [ci: concurrency groups + E2E 4 shards (measured speed wins from #624) #625]
  2. QW3, QW4, QW9, QW10 — mechanical, independently mergeable. [ci: #624 step-2 quick wins (LDAP timeout, cheap test hashing, e2e video, PR profiling skip) #629] (QW7 dropped.)
  3. S1 — the uncontended-wall step. [ci: flatten the job graph — drop setup, per-job self-install (#624 S1) #630]
  4. S4 — Docker Publish + image-availability. [perf(ci): cache dev/e2e Docker tooling below the code copy (#624 S4) #631]
  5. ✅/⬜ S2 [ci: persist PHP static-analysis tool caches across runs (#624 S2) #632, measured] + QW5 + S3 — the caching/image-diet block. S2 done (Lint −50% warm) but wall-clock-neutral until S5 (pcov) cuts the Integration gate — that is the next real wall-clock lever. QW5/S3 are runner-time [EST].

Not verified (estimates)

zstd pull speedup (QW5/S3); 412MB layer = chromium (S3); PHPStan cache hit rate (S2); pcov overhead (S5); paratest DB isolation (S6); the post-change composition band (computed from per-step medians, not a trial run); docs-only push frequency (S8); the ~20-job runner cap (inferred, not read from billing); xdebug/video overheads (S9/QW9). Full detail with per-measure arithmetic is in the analysis report.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions