fix(image): ship node_modules in three layers, and count them before pushing
All checks were successful
PR checks / checks (pull_request) Successful in 9m47s

The merge that landed phase 12 built its image and could not publish it.
`docker push` answered 413 Payload Too Large on one blob and stopped, so the
registry stayed empty and `needs: build` meant the deploy never ran — the site
was merged and undeployed, and the log said only that a digest was too large.

The limit is Cloudflare's, not Gitea's: the instance is proxied, and Cloudflare
refuses a request body over 100 MB below Enterprise. A push uploads each layer
as one monolithic PUT, so the ceiling is per layer and the rejection happens at
the edge, where Gitea never sees it and no Gitea setting can lift it.

One layer was over, by nine megabytes: COPY node_modules at 108.8 MB compressed,
in a 188.6 MB image whose next largest layer is the 47.6 MB Node base. @pagefind
and @img (sharp's libvips) account for it and both are needed at run time — the
boot rewrite re-indexes the site and re-derives the brand images — so what could
move is where they land, not whether they ship.

The build stage moves them aside after `npm prune` and the runtime stage copies
them as their own layers: 46.8 + 50.5 + 11.5 MB, and the image is exactly the
same total size, the same bytes divided differently. Moving rather than copying
twice keeps the three disjoint, so a dependency added later needs no maintenance
here.

A split is a margin and not a guarantee, so the workflow now counts layers before
it pushes: docker save, re-compress anything over 8 MB the way the push would,
fail at 90 MB — not 100, the blob is not the only thing in the request — naming
the layer and what would have happened. Tested in both directions; on the broken
image it reports 108 MB, which is what the registry recorded.

Verified by running the built container, not only by measuring it: healthy in
~25s, the mounted brand rewritten across 51 files, 50 pages re-indexed by
pagefind, and the favicon served as `x-brand-source: derived:mount` — which is
sharp resolving from its new layer. npm run verify is green, all eleven checks
and both suites.

Recorded as D58; DEPLOY.md gains the symptom and what it means for the host
(nothing — the running container is untouched).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-26 23:10:34 -05:00
parent 3b4067fa37
commit 92f00ab20a
4 changed files with 142 additions and 6 deletions

51
PLAN.md
View File

@@ -251,6 +251,7 @@ somewhere other than the thing it decided. The count of record is **fifty-seven*
| D47D50 | §6, "How phase 10 polished it" | Search reaches the marketing pages, the CSP is a real response header from the container, `robots.txt` allows everything and names the sitemap, two blocks of structured data and no more |
| D51D53 | §6, "How phase 11 validated it" | The chrome and the head follow the brand mount while the consent sentence does not, the documentation half gets phase 10's skip-link fix, and no twelfth check |
| D54D57 | §6, "How phase 12 delivered it" | A merge deploys (amending D6), the proxy is documented by its requirements rather than by an example, the container trusts the forwarded headers with nothing to configure, and the operator note is its own file while the two policy files are pointers |
| D58 | §6, "How phase 12 delivered it" | `node_modules` ships in three layers because Cloudflare refuses a request body over 100 MB, and the workflow counts layers before it pushes |
---
@@ -521,7 +522,8 @@ The last phase, and the one that turns a repository into a deployment: a two-sta
pull-only `docker-compose.yml` carrying both bind mounts, `.env.example`, the publishing workflow,
`CONTRIBUTING.md` with the AI-disclosure requirement, the community-health files this repository was
the only one in the organisation to lack, and `DEPLOY.md`. Four decisions, **D54D57**, taking the
count of record to **fifty-seven**.
count of record to **fifty-seven** — and a fifth, **D58**, added when the merge that shipped the
phase could not publish its own image.
It also found a defect that would have made the closed beta impossible, and it is the only phase
that could have found it. Everything before this ran the site the way a developer runs it: one
@@ -634,6 +636,40 @@ surface to attack.
variables the code reads, `.env.example` declares and the README tabulates — and the org lead held
the line. Eleven checks stand.
#### D58 — the image ships in three layers, because of the registry rather than the site
The merge that landed phase 12 could not publish the image it had just built. `docker push` answered
**`413 Payload Too Large`** on one blob and stopped; nothing reached the registry, and `needs: build`
meant nothing reached the host either. The site was merged and undeployed, and the log said only that
a digest was too large.
**The limit is Cloudflare's, not Gitea's.** `gitea.whitlocktech.com` is proxied, and Cloudflare
refuses a request body over **100 MB** on every plan below Enterprise — a plan limit, not a setting.
`docker push` uploads each layer as a single monolithic `PUT`, so the ceiling applies per layer, and
the rejection happens at the edge with Gitea never seeing the request. It is invisible from the
Gitea side and unfixable from it.
Measured on the merge commit, one layer was over and only just: **`COPY node_modules` at 108.8 MB
compressed**, against a 188.6 MB image whose next largest layer was the 47.6 MB Node base. Two
packages account for it, `@pagefind` (the search binaries) and `@img` (sharp's libvips), and both are
needed at **run** time — the boot rewrite re-indexes the site and re-derives the brand images — so
what could move was where they land, not whether they ship.
The build stage now moves those two aside after `npm prune`, and the runtime stage copies them as
their own layers: **46.8 + 50.5 + 11.5 MB** in place of 108.8, largest layer 50.5, and the image
**exactly the same total size**, because the same bytes are simply divided differently. Moving rather
than copying twice is what keeps the three disjoint — whatever remains in `node_modules` is the
remainder by construction, so a dependency added later needs no maintenance here.
**The workflow now counts layers before it pushes**, because a split is a margin and not a
guarantee: `docker save`, re-compress anything over 8 MB the way the push would, and fail at **90 MB**
— not 100, since the blob is not the only thing in the request — naming the layer and what would
have happened. It was tested in both directions, against the fixed image and the broken one, and the
number it reports for the broken layer (108 MB) agrees with what the registry recorded.
This is a workflow step and not a twelfth check script: it needs a built image rather than a source
tree, which is the one thing the eleven never have. D53 holds.
---
## 7. Branding is bind-mounted data
@@ -1616,10 +1652,15 @@ a mechanism rather than diligence:
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
the closed test is the nearest real deadline.
**All twelve are built, as of 2026-08-25.** What is left is not a phase: point the DNS record at the
host (§14, N1), start the `rgcom` runner, and — when the demo VM exists (§15) and the Play track is
open — put two URLs into the mounted `brand.json`. None of those is a code change, which was the
point.
**All twelve are built, as of 2026-08-25.** The `rgcom` runner was registered on the host the next
day, in host mode as §7 of `DEPLOY.md` requires. What is left is not a phase: point the DNS record at
the host (§14, N1), and — when the demo VM exists (§15) and the Play track is open — put two URLs
into the mounted `brand.json`. Neither is a code change, which was the point.
**One thing did need a code change.** The merge that landed phase 12 built its image and then could
not publish it: Cloudflare rejected the largest layer with `413 Payload Too Large`, so the registry
stayed empty and the deploy never ran. See **D58** — the layer is split, and the workflow now counts
layers before it pushes.
---