CI image rebuild takes 35min–3h and blocks every lockfile change #61

Closed
opened 2026-08-19 14:57:07 +02:00 by myrmidex · 2 comments
Owner

Summary

The ci-image job in .forgejo/workflows/ci.yml takes between 35 minutes and 3+ hours whenever it has to build. The ci job (needs: ci-image) is blocked behind it, so a slow or cancelled image build means lint, static analysis, tests, and browser tests never run at all.

Evidence

Observed durations from list_action_tasks:

Run Commit Result Duration
#20 54 - Fix Pint import issues cancelled 56m53s
#19 46 - Tag CI image by composer.lock hash failure 3h02m25s
#9 50 - Fix tests failing on missing Vite manifest success 39m31s
#8 50 - Avoid codeload 429 via prefer-source failure 35m19s
#7 50 - Avoid codeload 429 via prefer-source failure 58m49s

Run #20 spent 56m53s in "Build and push CI image" and was cancelled before the push completed. The ci job showed 0s — it never started.

Root cause

docker/build/Dockerfile.ci ends with:

WORKDIR /opt/deps
COPY composer.json composer.lock ./
RUN composer install --no-interaction --no-progress --prefer-source --no-scripts

--prefer-source makes Composer perform a full git clone of every package instead of fetching dist zips. It was introduced deliberately in 50 - Avoid codeload 429 via prefer-source and baked phpstan to work around codeload.github.com rate limits (429s) hit under --prefer-dist. It solved the 429s and replaced them with a 35min–3h build.

This is now the dominant cost in CI, and it is paid in full on every cache miss.

Why it is biting now

The image tag is content-addressed from the lockfile:

HASH="$(sha256sum composer.lock | cut -c1-12)"
echo "tag=php8.3-${HASH}" >> "$GITHUB_OUTPUT"

dcfa73b (#54, Dusk → Pest) rewrote 3370 lines of composer.lock, producing a new tag php8.3-80acd7b52304. The cache-from registry ref was built against the old dependency set, so the build starts cold.

The tag has still never been pushed. Current registry contents:

php8.3-1, php8.3-2, php8.3-3, php8.3-4, latest, 3c5f2d5df174ea30ff0c5f062962487b198a31ff

php8.3-1php8.3-4 are from the old manual revision scheme, before #46 switched to lockfile hashing. No hash-tagged image has ever landed successfully.

Interaction with 1e826e7

1e826e7 - Skip CI image rebuild when the lockfile tag already exists adds a registry existence check so the build is skipped when the tag is already published. That fix is correct but cannot engage until one build completes, because it skips only when the tag exists.

Current state is therefore a loop risk: the next push pays a full cold build; if that build is again cancelled or times out, nothing is pushed, and the following push pays it again.

Suggested directions

  1. Split the baked dependencies into a separate base image that changes rarely, so a lockfile change does not force a re-clone of the entire dependency tree. This is the structural fix.
  2. Restore --prefer-dist with a Composer auth token (COMPOSER_AUTH with a GitHub token) to lift the codeload 429 limit that forced --prefer-source. Authenticated requests get a far higher rate limit, which may make the original workaround unnecessary.
  3. Raise or remove the job timeout so a slow build at least completes once and populates the tag, letting 1e826e7's skip logic take effect.
  4. Verify cache-to: mode=max registry caching is actually being restored across runs — the observed durations suggest it may not be effective.

Acceptance

  • A CI image build completes and pushes its lockfile-hash tag
  • Subsequent pushes that do not change composer.lock skip the build (verifying 1e826e7)
  • A cold build (after a genuine lockfile change) completes in a reasonable time
  • The ci job runs to completion, including the browser tests

Notes

  • Not caused by #54; the --prefer-source cost predates it (introduced under #50). #54's lockfile change is what forced the cold rebuild that exposed it.
  • #54 has been closed on its migration criteria. Its "CI runs browser tests green" acceptance item was never verified — the browser tests have not yet executed in CI, and that is blocked on this issue.
## Summary The `ci-image` job in `.forgejo/workflows/ci.yml` takes between 35 minutes and 3+ hours whenever it has to build. The `ci` job (`needs: ci-image`) is blocked behind it, so a slow or cancelled image build means lint, static analysis, tests, and browser tests never run at all. ## Evidence Observed durations from `list_action_tasks`: | Run | Commit | Result | Duration | |---|---|---|---| | #20 | `54 - Fix Pint import issues` | `cancelled` | 56m53s | | #19 | `46 - Tag CI image by composer.lock hash` | `failure` | 3h02m25s | | #9 | `50 - Fix tests failing on missing Vite manifest` | `success` | 39m31s | | #8 | `50 - Avoid codeload 429 via prefer-source` | `failure` | 35m19s | | #7 | `50 - Avoid codeload 429 via prefer-source` | `failure` | 58m49s | Run #20 spent 56m53s in "Build and push CI image" and was cancelled before the push completed. The `ci` job showed `0s` — it never started. ## Root cause `docker/build/Dockerfile.ci` ends with: ```dockerfile WORKDIR /opt/deps COPY composer.json composer.lock ./ RUN composer install --no-interaction --no-progress --prefer-source --no-scripts ``` `--prefer-source` makes Composer perform a **full `git clone` of every package** instead of fetching dist zips. It was introduced deliberately in `50 - Avoid codeload 429 via prefer-source and baked phpstan` to work around codeload.github.com rate limits (429s) hit under `--prefer-dist`. It solved the 429s and replaced them with a 35min–3h build. This is now the dominant cost in CI, and it is paid in full on every cache miss. ## Why it is biting now The image tag is content-addressed from the lockfile: ```bash HASH="$(sha256sum composer.lock | cut -c1-12)" echo "tag=php8.3-${HASH}" >> "$GITHUB_OUTPUT" ``` `dcfa73b` (#54, Dusk → Pest) rewrote 3370 lines of `composer.lock`, producing a new tag `php8.3-80acd7b52304`. The `cache-from` registry ref was built against the *old* dependency set, so the build starts cold. **The tag has still never been pushed.** Current registry contents: ``` php8.3-1, php8.3-2, php8.3-3, php8.3-4, latest, 3c5f2d5df174ea30ff0c5f062962487b198a31ff ``` `php8.3-1` … `php8.3-4` are from the old manual revision scheme, before #46 switched to lockfile hashing. **No hash-tagged image has ever landed successfully.** ## Interaction with `1e826e7` `1e826e7 - Skip CI image rebuild when the lockfile tag already exists` adds a registry existence check so the build is skipped when the tag is already published. That fix is correct but **cannot engage until one build completes**, because it skips only when the tag exists. Current state is therefore a loop risk: the next push pays a full cold build; if that build is again cancelled or times out, nothing is pushed, and the following push pays it again. ## Suggested directions 1. **Split the baked dependencies into a separate base image** that changes rarely, so a lockfile change does not force a re-clone of the entire dependency tree. This is the structural fix. 2. **Restore `--prefer-dist` with a Composer auth token** (`COMPOSER_AUTH` with a GitHub token) to lift the codeload 429 limit that forced `--prefer-source`. Authenticated requests get a far higher rate limit, which may make the original workaround unnecessary. 3. **Raise or remove the job timeout** so a slow build at least completes once and populates the tag, letting `1e826e7`'s skip logic take effect. 4. Verify `cache-to: mode=max` registry caching is actually being restored across runs — the observed durations suggest it may not be effective. ## Acceptance - [ ] A CI image build completes and pushes its lockfile-hash tag - [ ] Subsequent pushes that do not change `composer.lock` skip the build (verifying `1e826e7`) - [ ] A cold build (after a genuine lockfile change) completes in a reasonable time - [ ] The `ci` job runs to completion, including the browser tests ## Notes - Not caused by #54; the `--prefer-source` cost predates it (introduced under #50). #54's lockfile change is what forced the cold rebuild that exposed it. - #54 has been closed on its migration criteria. Its "CI runs browser tests green" acceptance item was never verified — the browser tests have not yet executed in CI, and that is blocked on this issue.
myrmidex added this to the v0.9.0 milestone 2026-08-19 14:57:07 +02:00
myrmidex added the
ci-cd
bug
labels 2026-08-19 14:57:07 +02:00
Author
Owner

Investigation — root cause found, supersedes the original analysis

Three compounding causes, not one. The original description's "suggested directions" were partly wrong; corrections noted at the end.

1. The registry cache has never worked — and cannot, as configured

cache-from/cache-to point at dishplanner-ci:buildcache. That tag returns 404, while latest, php8.3-1php8.3-4 all return 200:

buildcache -> HTTP 404
latest     -> HTTP 200
php8.3-4   -> HTTP 200

It has never been written once.

This is a known Forgejo/Gitea registry limitation, not a misconfiguration here. BuildKit represents its registry cache as an OCI Image Index whose manifest array contains layer media types rather than the manifest media types the spec requires. Gitea/Forgejo's registry validates strictly and rejects the push:

Consequence: cache-to fails every run, cache-from misses every run, so every build is fully cold. This is the self-sustaining loop — builds too slow to finish → cache never written → next build cold again.

2. --prefer-source clones 154 git repositories

composer.lock contains 78 packages + 76 dev packages = 154 total. Under --prefer-source each is a full git clone with complete history, performed sequentially.

For comparison, the local vendor/ is 157M containing zero .git directories — a dist install. CI is doing categorically more work than any developer machine does.

3. The 429 that forced --prefer-source is an auth problem, not a dist problem

This is the actionable finding. Measured:

Limit
GitHub API, unauthenticated 60 requests/hour
GitHub API, authenticated 5,000 requests/hour (83×)

Composer makes API calls to resolve dist URLs for all 154 packages, which exhausts the unauthenticated 60/hour and produces the 429s that 50 - Avoid codeload 429 via prefer-source was written to dodge.

Meanwhile codeload itself is not rate-limited for these fetches — an unauthenticated download of laravel/framework v12.0.0 returned HTTP 200, 1974794 bytes. The file host was never the bottleneck; the API metadata calls were. --prefer-source treated the symptom rather than the cause.

What the Dockerfile already gets right

Layer order is correct. Extensions, apt packages, and the composer binary all sit above COPY composer.json composer.lock ./, so only a lockfile change invalidates the expensive install layer:

15: FROM php:8.3-cli
17: COPY --from=mlocati/php-extension-installer:2 ...
19: RUN install-php-extensions ...
32: RUN apt-get update && apt-get install -y ... nodejs npm
36: COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
47: WORKDIR /opt/deps
48: COPY composer.json composer.lock ./
49: RUN composer install ... --prefer-source --no-scripts

No restructuring needed. The layering simply never gets to pay off, because the cache backend is broken.

Revised recommendations

  1. Add a COMPOSER_AUTH secret with a GitHub token and revert to --prefer-dist. Addresses the actual cause. Expected to collapse the build from 35min–3h to a few minutes. Requires a PAT with no scopes — public read access is sufficient. This step needs a repo secret to be created manually.
  2. Drop cache-from/cache-to, or move the cache off the Forgejo registry. As written they are dead weight: every run pays the export attempt for a cache that can never land. Alternatives are type=gha if the runner supports it, type=local with runner-side storage, or nothing at all — the existence check added in 1e826e7 already prevents redundant rebuilds once a tag exists.
  3. Get one build to complete so php8.3-80acd7b52304 lands in the registry and activates 1e826e7's skip logic.

Corrections to the original description

  • "Split the baked dependencies into a separate base image — this is the structural fix"unnecessary. The layering is already correct; the cache backend is what is broken.
  • "Verify cache-to: mode=max registry caching is actually being restored"verified: it is not, and cannot be on this registry. Now cause #1 above.
  • The --prefer-dist + auth token suggestion is confirmed as the right direction by the rate-limit measurements.

No code changes were made as part of this investigation.

## Investigation — root cause found, supersedes the original analysis Three compounding causes, not one. The original description's "suggested directions" were partly wrong; corrections noted at the end. ### 1. The registry cache has never worked — and cannot, as configured `cache-from`/`cache-to` point at `dishplanner-ci:buildcache`. That tag returns **404**, while `latest`, `php8.3-1` … `php8.3-4` all return 200: ``` buildcache -> HTTP 404 latest -> HTTP 200 php8.3-4 -> HTTP 200 ``` It has never been written once. This is a **known Forgejo/Gitea registry limitation, not a misconfiguration here.** BuildKit represents its registry cache as an OCI Image *Index* whose manifest array contains layer media types rather than the manifest media types the spec requires. Gitea/Forgejo's registry validates strictly and rejects the push: - https://github.com/go-gitea/gitea/issues/28973 — manifest push fails using the Gitea registry with BuildKit caching - https://github.com/moby/buildkit/issues/1550 — remote cache manifest OCI mediatype spec compatibility - https://github.com/moby/buildkit/issues/2251 — remote cache based on OCI image manifest layout **Consequence:** `cache-to` fails every run, `cache-from` misses every run, so **every build is fully cold**. This is the self-sustaining loop — builds too slow to finish → cache never written → next build cold again. ### 2. `--prefer-source` clones 154 git repositories `composer.lock` contains 78 packages + 76 dev packages = **154 total**. Under `--prefer-source` each is a full `git clone` with complete history, performed sequentially. For comparison, the local `vendor/` is **157M containing zero `.git` directories** — a dist install. CI is doing categorically more work than any developer machine does. ### 3. The 429 that forced `--prefer-source` is an auth problem, not a dist problem This is the actionable finding. Measured: | | Limit | |---|---| | GitHub API, unauthenticated | **60 requests/hour** | | GitHub API, authenticated | **5,000 requests/hour** (83×) | Composer makes API calls to resolve dist URLs for all 154 packages, which exhausts the unauthenticated 60/hour and produces the 429s that `50 - Avoid codeload 429 via prefer-source` was written to dodge. Meanwhile codeload itself is **not** rate-limited for these fetches — an unauthenticated download of `laravel/framework` v12.0.0 returned `HTTP 200, 1974794 bytes`. The file host was never the bottleneck; the API metadata calls were. `--prefer-source` treated the symptom rather than the cause. ### What the Dockerfile already gets right Layer order is correct. Extensions, apt packages, and the composer binary all sit above `COPY composer.json composer.lock ./`, so only a lockfile change invalidates the expensive install layer: ``` 15: FROM php:8.3-cli 17: COPY --from=mlocati/php-extension-installer:2 ... 19: RUN install-php-extensions ... 32: RUN apt-get update && apt-get install -y ... nodejs npm 36: COPY --from=composer:2 /usr/bin/composer /usr/bin/composer 47: WORKDIR /opt/deps 48: COPY composer.json composer.lock ./ 49: RUN composer install ... --prefer-source --no-scripts ``` **No restructuring needed.** The layering simply never gets to pay off, because the cache backend is broken. ## Revised recommendations 1. **Add a `COMPOSER_AUTH` secret with a GitHub token and revert to `--prefer-dist`.** Addresses the actual cause. Expected to collapse the build from 35min–3h to a few minutes. Requires a PAT with no scopes — public read access is sufficient. **This step needs a repo secret to be created manually.** 2. **Drop `cache-from`/`cache-to`, or move the cache off the Forgejo registry.** As written they are dead weight: every run pays the export attempt for a cache that can never land. Alternatives are `type=gha` if the runner supports it, `type=local` with runner-side storage, or nothing at all — the existence check added in `1e826e7` already prevents redundant rebuilds once a tag exists. 3. **Get one build to complete** so `php8.3-80acd7b52304` lands in the registry and activates `1e826e7`'s skip logic. ### Corrections to the original description - ~~"Split the baked dependencies into a separate base image — this is the structural fix"~~ — **unnecessary.** The layering is already correct; the cache backend is what is broken. - ~~"Verify `cache-to: mode=max` registry caching is actually being restored"~~ — **verified: it is not, and cannot be** on this registry. Now cause #1 above. - The `--prefer-dist` + auth token suggestion is **confirmed as the right direction** by the rate-limit measurements. No code changes were made as part of this investigation.
Author
Owner

Resolved

Fixed in cfad3dc and 7f7a945, following the revised recommendations.

cfad3dc — Authenticate composer dist downloads and drop the dead registry cache

  • GH_PAT mounted as a build secret and set as github-oauth for the install, then unset. Lifts the API limit from 60 to 5000 requests/hour, which removes the reason --prefer-source existed; the install is back on dist.
  • cache-from/cache-to removed. They could never land on the Forgejo registry, so every run was paying an export attempt for a cache that never existed.

7f7a945 — Bake node modules and Chromium into the CI image
Beyond the original scope: node_modules and the Playwright Chromium build are now baked at /opt/deps-node and /opt/playwright alongside vendor/. The image tag hashes composer.lock + package-lock.json together, so both stay keyed to the image.

Acceptance

Criterion Result
A CI image build completes and pushes its lockfile-hash tag Run #22, success
Subsequent pushes without a lockfile change skip the build Run #23 re-run: 11m54s vs 48m49s cold — 1e826e7's skip logic engaged
A cold build completes in reasonable time 25m49s / 48m49s, from 3h02m and cancelled-at-1h13m
The ci job runs to completion, including browser tests Runs #22 and #23 green; ci needs ci-image, and Browser tests is its final step

Run durations for the loop this closes: #19 failure 3h02m → #20 cancelled 1h13m → #21 cancelled 1h11m → #22 success 22m42s → #23 success 11m54s.

Follow-ups

  • #54's "CI runs browser tests green" acceptance item was closed unverified and blocked on this issue. It is now satisfied by runs #22/#23.
  • AGENTS.md gained a ## CI section recording the GH_PAT requirement and the Forgejo/BuildKit cache limitation, so neither gets rediscovered. Its stale "browser tests currently use Dusk" line was corrected at the same time.
  • CHANGELOG entry deferred to the v0.9.0 milestone wrap-up.
## Resolved Fixed in `cfad3dc` and `7f7a945`, following the revised recommendations. **`cfad3dc` — Authenticate composer dist downloads and drop the dead registry cache** - `GH_PAT` mounted as a build secret and set as `github-oauth` for the install, then unset. Lifts the API limit from 60 to 5000 requests/hour, which removes the reason `--prefer-source` existed; the install is back on dist. - `cache-from`/`cache-to` removed. They could never land on the Forgejo registry, so every run was paying an export attempt for a cache that never existed. **`7f7a945` — Bake node modules and Chromium into the CI image** Beyond the original scope: `node_modules` and the Playwright Chromium build are now baked at `/opt/deps-node` and `/opt/playwright` alongside `vendor/`. The image tag hashes `composer.lock` + `package-lock.json` together, so both stay keyed to the image. ### Acceptance | Criterion | Result | |---|---| | A CI image build completes and pushes its lockfile-hash tag | ✅ Run #22, `success` | | Subsequent pushes without a lockfile change skip the build | ✅ Run #23 re-run: **11m54s** vs 48m49s cold — `1e826e7`'s skip logic engaged | | A cold build completes in reasonable time | ✅ 25m49s / 48m49s, from 3h02m and cancelled-at-1h13m | | The `ci` job runs to completion, including browser tests | ✅ Runs #22 and #23 green; `ci` needs `ci-image`, and `Browser tests` is its final step | Run durations for the loop this closes: #19 `failure` 3h02m → #20 `cancelled` 1h13m → #21 `cancelled` 1h11m → **#22 `success` 22m42s → #23 `success` 11m54s**. ### Follow-ups - #54's "CI runs browser tests green" acceptance item was closed unverified and blocked on this issue. It is now satisfied by runs #22/#23. - `AGENTS.md` gained a `## CI` section recording the `GH_PAT` requirement and the Forgejo/BuildKit cache limitation, so neither gets rediscovered. Its stale "browser tests currently use Dusk" line was corrected at the same time. - CHANGELOG entry deferred to the v0.9.0 milestone wrap-up.
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/dishplanner#61
No description provided.