Files
runicgateway.com/.gitea/workflows/build-image.yml
wtclaude 080f9827b8
All checks were successful
PR checks / checks (pull_request) Successful in 3m41s
ci(image): clean up after the image build and the deploy
The build job runs docker on the runner host's daemon, so every run left
two tagged images behind for good. Remove this run's tags once they are
pushed and cap BuildKit's cache at 5 GB, always, never failing the job.

The deploy drops the dangling image its pull replaced on the rgcom host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-11-02 11:20:50 -06:00

233 lines
10 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: Remove this run's image and cap the build cache
# The build runs on the HOST daemon, so whatever it leaves stays on the
# runner host for good: every run used to leave two tagged images, and
# each newer `latest` turned the previous one into a permanent `sha-` image
# until the host ran out of disk. Both tags are in the registry by now, so
# the local copies are dead weight. BuildKit keeps its layer cache apart
# from the images, so the next build stays fast; the cache is pruned to a
# cap instead of growing forever (`--reserved-space` is the same flag's
# name on newer Docker). Runs even after a failure, and never fails the job.
if: always()
run: |
docker image rm "${IMAGE}:latest" "${IMAGE}:${TAG}" 2>/dev/null || true
docker builder prune -f --keep-storage 5GB \
|| docker builder prune -f --reserved-space 5GB || true
- 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
- name: Remove the image this deploy replaced
# The pull moves `latest` to the new image and leaves the old one untagged
# on the host. Untagged images are never used again (a rollback pulls a
# `sha-` tag from the registry), so drop them. Dangling images only: nothing
# tagged and nothing a container is using is touched.
if: always()
run: docker image prune -f || true