# 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 and push # 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}" \ . 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