Files
runicgateway.com/.gitea/workflows/build-image.yml
Claude 92f00ab20a
All checks were successful
PR checks / checks (pull_request) Successful in 9m47s
fix(image): ship node_modules in three layers, and count them before pushing
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>
2026-08-26 23:10:34 -05:00

210 lines
9.0 KiB
YAML

# Build the container image, publish it to Gitea's container registry, then roll
# the site onto it — on every merge to main.
#
# Gitea Actions caution, learned elsewhere in this org and repeated from
# pr-checks.yml because it costs one comment and has already cost months: never
# leave an empty template expression anywhere in a `run:` script, not even inside
# a comment. The runner silently SKIPS the whole step without failing the job,
# and the problem is invisible in the workflow list.
#
# Two jobs, in sequence:
#
# build — builds and pushes the image (on `ubuntu-latest`)
# deploy — `needs: build`, so it starts only after a clean build and push, and
# pulls + recreates the stack on the host (on `rgcom`)
#
# Prerequisites, one-time:
#
# • A runner labelled `ubuntu-latest` whose jobs have the host Docker socket
# mounted (/var/run/docker.sock), so `docker build` talks to the host daemon.
# This also gives free layer caching between runs. The org already runs one.
#
# • A runner labelled `rgcom` ON the host that serves the site, able to reach
# the Docker daemon and /opt/runicgateway.com — the directory holding the
# production docker-compose.yml and .env. DEPLOY.md has the registration
# command and the directory layout.
#
# • Two repository secrets (Settings → Actions → Secrets), both of which
# already exist for pr-checks.yml's cross-repository checks:
# REGISTRY_USER — the Gitea username owning the token below
# REGISTRY_TOKEN — a token with write:package (and read:package)
#
# Produces, in gitea.whitlocktech.com/runicgateway/ :
# runicgateway-site:latest + runicgateway-site:sha-<7>
#
# and deploys `:latest`, which is what docker-compose.yml defaults IMAGE_TAG to.
#
# WHY THIS REPOSITORY DEPLOYS AND D6 SAID IT WOULD NOT: D6 was written before
# there was a host to deploy to, and read "ship the image, the org lead deploys".
# The org lead amended it on 2026-08-25 (D54): a marketing site whose content is
# its whole purpose is a bad fit for a manual step between merging a fix and the
# fix being visible. What D6 was protecting — that a bad build cannot reach
# production — is held by `needs: build` instead, plus every check in
# pr-checks.yml having already run on the pull request.
name: Build and publish the image
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: image-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: gitea.whitlocktech.com
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out the merged commit
uses: actions/checkout@v4
- name: Derive the image ref
# The registry path must be lowercase for Docker; the org is `RunicGateway`.
run: |
set -euo pipefail
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
SHORT_SHA="${GITHUB_SHA:0:7}"
echo "IMAGE=${REGISTRY}/${OWNER}/runicgateway-site" >> "$GITHUB_ENV"
echo "TAG=sha-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Verify the Docker daemon is reachable
# Fails fast with a clear message if the host socket is not mounted into
# the job container — the one hard runner prerequisite.
run: |
set -euo pipefail
if ! docker info >/dev/null 2>&1; then
echo "::error::Docker daemon not reachable. Mount /var/run/docker.sock into the runner's job containers."
exit 1
fi
echo "Docker daemon OK"
- name: Log in to the Gitea container registry
run: |
set -euo pipefail
echo "${{ secrets.REGISTRY_TOKEN }}" \
| docker login "${REGISTRY}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- 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: |
set -euo pipefail
docker build -f Dockerfile \
-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}"
- name: Log out
if: always()
run: docker logout "${REGISTRY}" || true
deploy:
# Roll the site onto the image `build` just pushed. `needs: build` makes this
# wait for a clean build and push — if the build fails, deploy never fires and
# the running container is left alone rather than torn down for nothing.
needs: build
runs-on: rgcom
# Guard against a workflow_dispatch fired from a branch: only main is deployed.
if: github.ref == 'refs/heads/main'
# Until a runner with this label exists, this job simply QUEUES. That is the
# intended behaviour and it breaks nothing: `build` has already published the
# image, so `docker compose pull && up -d` by hand is available the whole time,
# and the queued job runs the moment the runner registers.
steps:
- name: Pull the fresh image and recreate the container
# No `down` first, deliberately. There is one service and no database to
# keep still, so `up -d` recreates it in place when the pulled digest
# differs — a couple of seconds of connection refused behind the proxy
# rather than the whole stack stopped while an image is fetched.
run: |
set -euo pipefail
cd /opt/runicgateway.com
docker compose pull
docker compose up -d --remove-orphans
docker compose ps
- name: Wait for the container to report healthy
# The image's healthcheck watches an actual response, and `npm start` runs
# the brand rewrite before the server starts — so "running" arrives well
# before "serving". Without this the job would go green on a container
# that is about to crash-loop on, say, an unwritable ./data.
run: |
set -euo pipefail
cd /opt/runicgateway.com
for attempt in $(seq 1 30); do
STATUS="$(docker compose ps --format '{{.Health}}' site | head -n 1)"
echo "attempt ${attempt}: ${STATUS:-unknown}"
if [ "$STATUS" = "healthy" ]; then
echo "Site is healthy."
exit 0
fi
sleep 5
done
echo "::error::The site did not become healthy within 150s."
docker compose logs --tail 100 site
exit 1