Speed up CI by prebuilding a PHP image instead of installing PHP on every run #147
Labels
No labels
bug
devops
duplicate
enhancement
good first issue
layout
next major release
next minor release
question
research
testing
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lvl0/fedi-feed-router#147
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
CI runs take around 15 minutes. Recent runs on
release/v1.4.0:The job runs on
catthehacker/ubuntu:act-latest, which ships no PHP. Every runtherefore installs it from scratch via
setup-php@v2(
.forgejo/workflows/ci.yml:17-22):pdo_sqlite, mbstring, xml, dompcovfor coverage, which is compiled rather than downloadedThe
Cache Composer dependenciesstep immediately below only caches~/.composer/cache. It does nothing for the PHP installation, so that work isrepeated on every run regardless of cache state.
To confirm first
The per-step timings have not been captured. Before changing anything, record
where the 15 minutes actually goes, from the run detail view of a completed run.
Set up PHPis the suspected bulk of it, but that is currently an inferencefrom what the step does rather than a measurement, and the
Testsstep runsthe full suite (1231 tests) with coverage enabled, which is not free either.
Fixing the wrong step is the main risk here, so measure before optimising.
Options
A. Prebuilt CI base image. Build an image with PHP 8.3, the four extensions
and
pcovalready installed, publish it alongside the existing applicationimage, and use it as the job's
container.image.Set up PHPthen disappearsfrom the workflow entirely.
This is an established pattern in this repo rather than new infrastructure: the
repository already builds and pushes images to
forge.lvl0.xyz/lvl0, and run #5("40 - Add base image for faster CI build") solved the same problem for the
Docker build. The cost is a second image to maintain and rebuild when the PHP
version or extension set changes.
B. Cache the PHP installation. Keep
setup-phpbut cache what it produces.Less invasive, but
setup-php's own cache support is the thing to check here,and a partially warm cache still leaves per-run work.
C. Drop
pcovwhere coverage is not used. Coverage is currently collectedon every run (
--coverage-clover --coverage-text) but nothing consumes it: thecoverage PR comment was removed in #127. If coverage is not being read, both
compiling
pcovand running the suite under it are unnecessary work on everyrun.
These are not mutually exclusive. C is the smallest change and may be worth
doing regardless of A or B.
Acceptance criteria
is identified rather than assumed
ticket
recorded here
.claude/PLATFORM.mdRelated
the coverage output
Decisions
Set up PHPconfirmed as the slow step. Observed directly on run #81 (thePR pipeline for #146), which sat on that step for the bulk of its runtime. This
was previously an inference from what the step does; it is now observed.
Coverage is dropped. Option C is taken, and not only as a speed measure.
Coverage output has had no consumer since #127 removed the PR comment, so every
run has been compiling
pcovand executing 1231 tests under instrumentation toproduce a file nobody reads.
A README coverage badge was considered as an alternative use and rejected:
coverage.xmland serves the badge. That means sending coverage dataoff-instance and giving an external service access, which does not suit a
self-hosted Forgejo.
every run, which is noisy and risks re-triggering CI.
often read. A number on the README is less actionable, not more.
Coverage is worth having as a gate (fail when it drops below a threshold)
rather than a display, because a gate changes what happens. That is a separate
decision and a separate ticket if it is ever wanted; it is not part of this one.
Removing
--coverage-clover coverage.xml --coverage-textalso removes the needfor
pcovin thesetup-phpextension list, which shortens that step evenbefore any base image work.
Revised approach
Testsstep andpcovfromsetup-php. Measure.Step 1 is a two-line change and may be enough on its own.
Baseline timings
Recorded before any change, for the before/after comparison in the acceptance
criteria. All four ran the same three gates:
release/v1.4.0release/v1.4.0pull_requeston #146Runs #80 and #81 tested the same commit (
4f79aac), one via push and onevia the PR trigger. The identical work took 15m26s and 32m18s, so runtime here
varies by more than a factor of two independent of what changed.
The wider history shows the same spread: #74 took 31m19s and #63 took 1h26m47s,
both eventually succeeding, against a low of ~4m for comparable runs.
So there are two problems, not one:
half an hour, which makes CI unusable as a quick feedback loop and is why a
trivial docs commit held up the v1.4.0 release.
Both point the same way: work that happens on every run and depends on the
network (installing PHP, compiling
pcov, fetching packages) is the partexposed to this variance. Moving it into a prebuilt image removes it from the
per-run path entirely, which addresses the variance and not just the average.
Result
2m12s, against a baseline of 15m26s / 15m48s / 32m18s.
Per-step, on
release/v1.4.1:Set up jobwas the whole problem. It previously carried thesetup-phpinstall of PHP 8.3, four extensions and a compiled
pcovon an image with noPHP at all. Pulling a prebuilt image instead takes 9 seconds.
The suspicion recorded when this ticket was filed turned out to be correct, and
was confirmed directly during the work by watching a run sit on that step.
What was done
PR comment, so every run was compiling
pcovand running 1231 tests underinstrumentation to produce a file nobody read.
coverage: noneand plainphpunit.docker/build/Dockerfile.ci), Debian-based, carryingPHP 8.3 plus the extensions
composer.lockrequires andgd, which theapplication calls directly without declaring.
setup-phpremoved entirely.images.ymlto build and push both this and the pre-existing base image(#148).
ci.ymlcached~/.composer/cache;Composer 2 uses
~/.cache/composer, so the cache step had been storing andrestoring nothing.
artisan testspawnsa subprocess that ignores
-d memory_limit, and the suite needs 172.5MB.release/*to thepull_requesttrigger, so branches merging intoa release branch get CI. Previously only PRs into
maindid.Things learned along the way
ext-gdwas undeclared. The extension list was derived fromcomposer.lock,which only lists what packages declare.
ThumbnailUploadercalls gd directly andnothing required it, so
composer installpassed and seven tests failed atruntime instead.
ext-gdis now declared incomposer.jsonso the platformcheck catches it.
A mutable
:latesttag cost two CI runs. The registry held a correct imageand the runner kept using an older cached copy. Consumers now pin an explicit
version tag; see the comment on #148.
Remaining
install, which is fast enough that it may already be warm, but a second run
with an unchanged
composer.lockwould confirm it.Dockerfile.cichangesneed a manual build and push, and the version tag bumped in
ci.ymlandimages.yml.