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

View File

@@ -90,7 +90,7 @@ jobs:
echo "${{ secrets.REGISTRY_TOKEN }}" \
| docker login "${REGISTRY}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build and push
- name: Build
# Two tags from one build: `latest` for the compose default, `sha-<7>` so
# a deploy can be pinned or rolled back to an exact commit.
run: |
@@ -99,6 +99,61 @@ jobs:
-t "${IMAGE}:latest" \
-t "${IMAGE}:${TAG}" \
.
- name: No layer may exceed the registry's request limit
# Gitea is behind Cloudflare, which refuses a request body over 100 MB on
# every plan below Enterprise, and `docker push` uploads each layer as one
# monolithic PUT. An oversized layer is therefore rejected at the EDGE:
# Gitea never sees it, the log says only `413 Payload Too Large` against a
# blob digest, and the image is not published at all. That is what happened
# on the first merge after phase 12, and the Dockerfile's three-layer
# node_modules split is what fixed it.
#
# A split is a margin, not a guarantee, so this counts the layers before
# the push rather than letting the next fat dependency rediscover the 413.
# 90 MB, not 100: the cap is on the whole request, and the blob is not the
# only thing in it.
#
# Measured by re-compressing what `docker save` writes, because the daemon
# exposes uncompressed sizes only and the limit applies to the compressed
# blob. gzip is what the push uses, so the numbers agree to within a per
# cent; both archive layouts are handled, since a layer is already gzipped
# in one of them and plain in the other.
run: |
set -euo pipefail
LIMIT_MB=90
docker save "${IMAGE}:${TAG}" -o /tmp/image.tar
mkdir -p /tmp/layers
tar -xf /tmp/image.tar -C /tmp/layers
WORST_MB=0
WORST_FILE=""
# Only files big enough to matter; everything else is metadata.
while IFS= read -r f; do
if [ "$(head -c 2 "$f" | od -An -tx1 | tr -d ' \n')" = "1f8b" ]; then
SIZE=$(stat -c %s "$f") # already compressed
else
SIZE=$(gzip -c "$f" | wc -c) # compress it the way the push will
fi
MB=$(( SIZE / 1048576 ))
if [ "$MB" -gt "$WORST_MB" ]; then
WORST_MB=$MB
WORST_FILE=$f
fi
done < <(find /tmp/layers -type f -size +8M)
rm -rf /tmp/image.tar /tmp/layers
echo "Largest layer: ${WORST_MB} MB compressed (limit ${LIMIT_MB} MB)"
if [ "$WORST_MB" -gt "$LIMIT_MB" ]; then
echo "::error::A layer is ${WORST_MB} MB compressed (${WORST_FILE}). Cloudflare rejects a request body over 100 MB, so this push would fail with 413 Payload Too Large and publish nothing. Split the layer in the Dockerfile — see the COPY block that splits node_modules."
exit 1
fi
- name: Push
run: |
set -euo pipefail
docker push "${IMAGE}:latest"
docker push "${IMAGE}:${TAG}"