Reduce CI pipeline runtime #46

Closed
opened 2026-05-10 00:47:18 +02:00 by myrmidex · 3 comments
Owner

CI runs are taking longer than necessary. Investigate and apply optimisations to bring down total pipeline time.

Areas to investigate

  • Composer cache — verify cache hit rate; key on composer.lock
  • npm cache — add caching for node_modules keyed on package-lock.json
  • PHP extension installsetup-php reinstalls extensions on every run; check if a pre-built image with extensions baked in would be faster
  • Parallelism — split lint, tests, and asset build into parallel jobs where possible
  • MySQL readiness — the nc wait loop adds up to 60s worst-case; check if a healthcheck-based approach is faster in practice
CI runs are taking longer than necessary. Investigate and apply optimisations to bring down total pipeline time. ## Areas to investigate - **Composer cache** — verify cache hit rate; key on `composer.lock` - **npm cache** — add caching for `node_modules` keyed on `package-lock.json` - **PHP extension install** — `setup-php` reinstalls extensions on every run; check if a pre-built image with extensions baked in would be faster - **Parallelism** — split lint, tests, and asset build into parallel jobs where possible - **MySQL readiness** — the `nc` wait loop adds up to 60s worst-case; check if a healthcheck-based approach is faster in practice
myrmidex added this to the v0.4.0 milestone 2026-05-10 00:47:18 +02:00
myrmidex added the
enhancement
label 2026-05-10 00:47:18 +02:00
myrmidex self-assigned this 2026-05-10 00:47:18 +02:00
Author
Owner

Stays in v0.4.0, which has been repurposed as the simplification milestone.

#57 is the concrete implementation of this: prebuilt CI image + images.yml, mirroring ffr's approach. This ticket holds the goal and the before/after timing; #57 holds the method.

Cheapest to do after #52 — once React is gone the npm ci + npm run build job largely disappears, which changes what the CI image needs to contain.

Stays in v0.4.0, which has been repurposed as the simplification milestone. #57 is the concrete implementation of this: prebuilt CI image + `images.yml`, mirroring ffr's approach. This ticket holds the goal and the before/after timing; #57 holds the method. Cheapest to do after #52 — once React is gone the `npm ci` + `npm run build` job largely disappears, which changes what the CI image needs to contain.
Author
Owner

Implementation landed in #57 (1fecc2b) — this ticket now holds the measurement

ci.yml runs in a prebuilt incr-ci:php8.3-1 image. Removed from every run:

  • setup-php installing PHP 8.3 with 8 extensions plus pcov, from scratch
  • A silently-broken Composer cache (wrong path for this image — see #57)

Kept deliberately: the mysql:8.0 service and its readiness loop. The migration tests query information_schema and exercise MySQL-specific DDL, so SQLite would weaken real coverage.

Also from #52, which shrank the other job: the frontend build went from 2264 modules and 311 kB of JS to 2 modules and CSS only, so npm ci now installs 4 packages and the build takes ~140ms.

To close this ticket

The before/after numbers cannot be taken until the image exists, which happens when v0.4.0 merges to main and images.yml fires.

After the first green run on main:

  1. Record the wall-clock time of the ci job
  2. Compare against a pre-1fecc2b run for the baseline
  3. Post both here

Expect the saving to come almost entirely from the vanished setup-php step. The MySQL wait loop is unchanged and will still dominate the remaining startup cost — if it turns out to be the bulk of what is left, replacing the 30×2s poll with a proper service healthcheck is the obvious follow-up.

## Implementation landed in #57 (`1fecc2b`) — this ticket now holds the measurement `ci.yml` runs in a prebuilt `incr-ci:php8.3-1` image. Removed from every run: - `setup-php` installing PHP 8.3 with 8 extensions plus pcov, from scratch - A silently-broken Composer cache (wrong path for this image — see #57) Kept deliberately: the `mysql:8.0` service and its readiness loop. The migration tests query `information_schema` and exercise MySQL-specific DDL, so SQLite would weaken real coverage. Also from #52, which shrank the other job: the frontend build went from 2264 modules and 311 kB of JS to **2 modules and CSS only**, so `npm ci` now installs 4 packages and the build takes ~140ms. ## To close this ticket The before/after numbers cannot be taken until the image exists, which happens when v0.4.0 merges to `main` and `images.yml` fires. **After the first green run on `main`:** 1. Record the wall-clock time of the `ci` job 2. Compare against a pre-`1fecc2b` run for the baseline 3. Post both here Expect the saving to come almost entirely from the vanished `setup-php` step. The MySQL wait loop is unchanged and will still dominate the remaining startup cost — if it turns out to be the bulk of what is left, replacing the 30×2s poll with a proper service healthcheck is the obvious follow-up.
Author
Owner

Done — CI is green and fast

Runs #38 and #39, both jobs passing twice consecutively:

Job Duration
ci (lint, PHPStan, tests, coverage) 39s
build (npm ci, vite build) 56s

Before / after

Baseline (May) Now
ci job 2m38s typical; 14m–41m on bad runs 39s
Test suite alone 53s against MySQL ~1s on sqlite in memory
Migrations replayed per run 13 3

The worst historical runs (41m47s, 32m23s, 29m27s) were dominated by setup-php and MySQL startup. Both are gone.

What actually delivered the win

Not the prebuilt image alone. In order of impact:

  1. Dropping the MySQL service. Tests run on sqlite in memory — 53s → ~1s, and the wait-for-MySQL loop (up to 60s, often the whole budget) disappeared entirely.
  2. Squashing 13 migrations to 3. The old chain created and dropped nine tables to arrive at one; replaying it needed MySQL-specific DDL, which is what forced the service in the first place.
  3. The prebuilt image (#57). setup-php no longer installs PHP and eight extensions on every run.
  4. Fixing the Composer cache path. It pointed at ~/.composer/cache; the image uses ~/.cache/composer, so nothing was ever cached.

The road here

The service container was never going to work. Runner-level docker events showed MySQL starting cleanly and running 102 seconds before being SIGKILLed at cleanup — it was healthy the whole time. The job container simply could not route to it: service and job containers land on differently-truncated WORKFLOW-* networks. Runner-internal, and not worth changing global runner config that every other project depends on.

Four workflow-level theories were tried and disproved along the way (networking, startup timing, registry qualification, missing Docker CLI). The lesson recorded for next time: when a service container is unreachable, check docker events on the runner first — it distinguishes "never created", "crashed" and "running but unreachable" in one shot, and each has a completely different fix.

Also fixed en route

  • withoutVite() in TestCase — the page-render test needed a Vite manifest the ci job never builds
  • git and ca-certificates in the build job — checkout@v4 without git falls back to a REST tarball Forgejo does not serve (404), and without CA certs cannot verify the forge's TLS certificate

Cost

CI no longer exercises MySQL; dev and production still do. For a single-table app with no raw SQL that is an acceptable trade, but it is a real reduction — an engine-specific bug could now reach production unseen.

## Done — CI is green and fast Runs #38 and #39, both jobs passing twice consecutively: | Job | Duration | |---|---| | `ci` (lint, PHPStan, tests, coverage) | **39s** | | `build` (npm ci, vite build) | **56s** | ### Before / after | | Baseline (May) | Now | |---|---|---| | `ci` job | 2m38s typical; 14m–41m on bad runs | **39s** | | Test suite alone | 53s against MySQL | **~1s** on sqlite in memory | | Migrations replayed per run | 13 | 3 | The worst historical runs (`41m47s`, `32m23s`, `29m27s`) were dominated by `setup-php` and MySQL startup. Both are gone. ### What actually delivered the win Not the prebuilt image alone. In order of impact: 1. **Dropping the MySQL service.** Tests run on sqlite in memory — 53s → ~1s, and the wait-for-MySQL loop (up to 60s, often the whole budget) disappeared entirely. 2. **Squashing 13 migrations to 3.** The old chain created and dropped nine tables to arrive at one; replaying it needed MySQL-specific DDL, which is what forced the service in the first place. 3. **The prebuilt image (#57).** `setup-php` no longer installs PHP and eight extensions on every run. 4. **Fixing the Composer cache path.** It pointed at `~/.composer/cache`; the image uses `~/.cache/composer`, so nothing was ever cached. ### The road here The service container was never going to work. Runner-level `docker events` showed MySQL starting cleanly and running 102 seconds before being SIGKILLed at cleanup — it was healthy the whole time. The job container simply could not route to it: service and job containers land on differently-truncated `WORKFLOW-*` networks. Runner-internal, and not worth changing global runner config that every other project depends on. Four workflow-level theories were tried and disproved along the way (networking, startup timing, registry qualification, missing Docker CLI). The lesson recorded for next time: **when a service container is unreachable, check `docker events` on the runner first** — it distinguishes "never created", "crashed" and "running but unreachable" in one shot, and each has a completely different fix. ### Also fixed en route - `withoutVite()` in `TestCase` — the page-render test needed a Vite manifest the `ci` job never builds - `git` and `ca-certificates` in the `build` job — `checkout@v4` without git falls back to a REST tarball Forgejo does not serve (404), and without CA certs cannot verify the forge's TLS certificate ### Cost CI no longer exercises MySQL; dev and production still do. For a single-table app with no raw SQL that is an acceptable trade, but it is a real reduction — an engine-specific bug could now reach production unseen.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lvl0/incr#46
No description provided.