# 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