Files
scrabble-game/.gitea/workflows/ci.yaml
T
Ilia Denisov e9f4cb0178
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
test(offline): mock e2e for a device-local vs_ai game (C8)
A Playwright spec drives the whole offline flow in the mock build: force the
installed-PWA display mode, enter offline via the Settings toggle (its readiness check
fetches the dawgs), assert the blue chrome + online-games-hidden + Stats-disabled, then
create and play a device-local English vs_ai game with a pinned bag seed (deterministic
rack NEWYMAO) -- play the opening WAY across the centre, watch the robot reply with a
real move, and reload to confirm the IndexedDB replay.

Enabling infra (all e2e-only; nothing enters the production build):
- mock/client.ts fetchDict serves the per-variant dawgs from the preview build's
  /e2edict/ (was: threw 'unsupported in mock').
- scripts/e2e-dict.mjs copies the real dawgs into dist-e2e from E2E_DICT_DIR (the ui CI
  job fetches the scrabble-dictionary release like the Go jobs; local default: the
  sibling scrabble-solver/dawg); playwright.config runs it between build and preview.
- localgame/id.ts setForcedSeed + gateway.ts window.__mock.setLocalSeed: a mock-only
  seam to pin a local game's bag seed (tree-shaken from prod).
- ci.yaml ui job: fetch the dawgs + pass E2E_DICT_DIR to the e2e step.
- docs/TESTING.md: the offline e2e + the mock-dawg wiring.

Verified: check 0 / unit 482 / e2e 198 (both engines) / app entry 113.8/114.
2026-07-06 20:54:03 +02:00

550 lines
26 KiB
YAML

name: CI
# Single gated pipeline for the test contour. Gitea cannot express
# cross-workflow `needs`, so the full test suite and the auto test-deploy live in
# one workflow.
#
# Branch model (CLAUDE.md): feature branches are cut from `development`; a commit
# to a feature branch triggers nothing. The pipeline runs on a PR into
# `development` or `master` (the full test suite — the merge gate) and on a push
# to `development` (after a merge). The deploy job runs only for `development`
# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
# workflow.
#
# Path-conditional jobs: `unit`/`integration`/`ui` run only when their
# code changed (the `changes` job decides). Because a skipped required check would
# block a merge under branch protection, the always-running `gate` job aggregates
# their results and is the ONLY required status check; it passes when every
# upstream job either succeeded or was skipped.
#
# Console output is kept plain (NO_COLOR + `docker compose --ansi never` +
# `--progress plain`) so the Gitea logs stay readable.
on:
pull_request:
branches: [development, master]
push:
branches: [development]
# The dictionary release. One Gitea variable is the single source of truth: the
# test suite validates against it here (inherited by the unit/integration jobs) and
# both contours' deploy jobs seed a fresh volume with the same value. A release bump
# is one edit (the variable). See deploy/README.md.
env:
DICT_VERSION: ${{ vars.DICT_VERSION }}
jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when
# irrelevant. It defaults to running everything when the diff cannot be computed.
changes:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
outputs:
go: ${{ steps.filter.outputs.go }}
ui: ${{ steps.filter.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed paths
id: filter
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
git fetch -q origin "${{ github.base_ref }}" || true
range="origin/${{ github.base_ref }}...HEAD"
else
before="${{ github.event.before }}"
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "${before}^{commit}" 2>/dev/null; then
range="HEAD~1...HEAD"
else
range="${before}...HEAD"
fi
fi
echo "comparison range: $range"
# Default to running everything; narrow only when the diff is computable.
go=true; ui=true
files="$(git diff --name-only "$range" 2>/dev/null || echo __DIFF_FAILED__)"
if [ "$files" != "__DIFF_FAILED__" ]; then
echo "changed files:"; echo "$files"
go=false; ui=false
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
# The render sidecar bundles ui/src/lib, so its dir rides the ui lane (the
# deploy's compose build picks it up either way).
if echo "$files" | grep -qE '^renderer/'; then ui=true; fi
# A workflow or deploy change re-runs everything as a safety net.
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
else
echo "diff failed; running all jobs"
fi
echo "selected: go=$go ui=$ui"
echo "go=$go" >> "$GITHUB_OUTPUT"
echo "ui=$ui" >> "$GITHUB_OUTPUT"
unit:
needs: changes
if: ${{ needs.changes.outputs.go == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
# The engine consumes the published scrabble-solver module from this Gitea;
# GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
GOPRIVATE: gitea.iliadenisov.ru/*
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: gofmt
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "gofmt needed on:"; echo "$unformatted"; exit 1
fi
- name: vet
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
- name: build
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
- name: test
env:
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
integration:
needs: changes
if: ${{ needs.changes.outputs.go == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
# Ryuk (testcontainers' reaper) does not start cleanly on every runner; the
# suite's TestMain terminates its own container, so disable it.
TESTCONTAINERS_RYUK_DISABLED: "true"
GOPRIVATE: gitea.iliadenisov.ru/*
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: Integration tests
# -count=1 disables the cache; -p=1 -parallel=1 keeps the container-backed
# tests serial; the 15-minute timeout bounds a stuck container pull.
env:
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
ui:
needs: changes
if: ${{ needs.changes.outputs.ui == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: ui
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Type-check
run: pnpm run check
- name: Unit tests
run: pnpm run test:unit
- name: Build
run: pnpm run build
- name: Bundle-size budget
run: node scripts/bundle-size.mjs
# The render sidecar executes the shared ui/src/lib/gameimage.ts on skia-canvas;
# its smoke test guards the bundling + skia seam (docs/TESTING.md).
- name: Render sidecar test
working-directory: renderer
run: |
pnpm install --frozen-lockfile
pnpm test
- name: Install Playwright browsers
run: pnpm exec playwright install chromium webkit
timeout-minutes: 5
# The offline e2e plays a real local vs_ai game, so it needs the per-variant dawgs; fetch the
# release the same way the Go jobs do and point the mock preview's copy step (scripts/
# e2e-dict.mjs, via E2E_DICT_DIR below) at it.
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: E2E smoke (mock)
run: pnpm run test:e2e
timeout-minutes: 5
env:
E2E_DICT_DIR: ${{ github.workspace }}/dawg
# conformance proves the client's local move preview (the ported dawg reader +
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
# a Go step generates golden parity vectors from the release dictionaries, then the
# gated Vitest suite replays them. It spans both toolchains, so it runs whenever the
# Go engine side or the UI side changed.
conformance:
needs: changes
if: ${{ needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
GOPRIVATE: gitea.iliadenisov.ru/*
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: Generate golden parity vectors
run: |
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Local-eval conformance (reader + validator vs the Go engine)
working-directory: ui
env:
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
DICT_GOLD_DIR: /tmp/dictgold
DICT_VALID_DIR: /tmp/validgold
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
run: pnpm exec vitest run src/lib/dict/
# gate is the single branch-protection required check. It always runs and passes
# only when each upstream job succeeded or was skipped (a path-filtered no-op),
# failing the merge if any actually failed or was cancelled.
gate:
needs: [unit, integration, ui, conformance]
if: always()
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Aggregate required checks
run: |
fail=
for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}" "conformance:${{ needs.conformance.result }}"; do
name="${r%%:*}"; res="${r#*:}"
echo "$name = $res"
case "$res" in
success|skipped) ;;
*) echo "::error::$name=$res"; fail=1 ;;
esac
done
[ -z "$fail" ] || { echo "one or more required jobs failed"; exit 1; }
echo "all required jobs passed or were skipped"
deploy:
# Auto test-deploy on a PR into development and on the push that merges it.
# A PR into master is test-only (this job is skipped); prod deploy is manual.
# Gates on `gate` (so a real test failure blocks the deploy) but runs even when
# some test jobs were path-skipped.
needs: [gate]
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
NO_COLOR: "1"
DOCKER_CLI_HINTS: "false"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and (re)deploy the test contour
working-directory: deploy
env:
# Sensitive values -> secrets; non-sensitive -> variables. The compose
# interpolates these unprefixed names (see deploy/.env.example).
POSTGRES_PASSWORD: ${{ secrets.TEST_POSTGRES_PASSWORD }}
AWG_CONF: ${{ secrets.TEST_AWG_CONF }}
GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
# One VK Mini App serves every contour -> unprefixed secret.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# VK ID web login (browser VK-identity linking): the VK ID "Web" app's protected key
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
# Signs the finished-game export download URLs (backend + compose interpolation).
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay: one account for every
# contour -> unprefixed host/port/tls/user/pass. Empty host leaves the backend
# on the log mailer (email disabled) but the contour still boots.
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
# host:port. Empty leaves the alert worker off and Grafana SMTP disabled.
SMTP_RELAY_ADMIN_FROM: ${{ vars.TEST_SMTP_RELAY_ADMIN_FROM }}
ADMIN_EMAIL: ${{ vars.TEST_ADMIN_EMAIL }}
SMTP_RELAY_SERVICE_FROM: ${{ vars.TEST_SMTP_RELAY_SERVICE_FROM }}
SERVICE_EMAIL: ${{ vars.TEST_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.TEST_GF_SMTP_ENABLED }}
# Canonical public origin for links in the email (this contour's URL);
# required by the backend whenever SMTP_RELAY_HOST is set.
PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
# TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL and VITE_VK_ID_REDIRECT_URL are derived
# from PUBLIC_BASE_URL in the run step below, not stored as their own variables.
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
# The promo button reuses the UI's Mini App link variable.
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
# The test contour always uses Telegram's test environment — pinned here,
# not an operator variable. The prod workflow leaves it false.
TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
# VK Mini App landing link + VK ID "Web" app id: one value each serves every
# contour -> unprefixed. VITE_VK_APP_ID also feeds the gateway (GATEWAY_VK_ID_APP_ID);
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
# compose ":-" empty default. Other unset vars likewise fall to their defaults.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
run: |
# Seed the config files to a stable host path. The runner checks out into
# an ephemeral act workspace that is removed after the job, which would
# dangle the compose config bind mounts in the long-lived containers
# (e.g. Grafana then logs "no such file or directory"). Bind from a stable
# dir instead (mirrors ../galaxy-game's $HOME/.galaxy-dev/monitoring).
conf="$HOME/.scrabble-deploy"
rm -rf "$conf"
mkdir -p "$conf"
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
export SCRABBLE_CONFIG_DIR="$conf"
# Maintenance page for the redeploy window, mirroring prod-deploy.sh so the SPA
# overlay is exercised on the test contour too (not only prod). Raised just before
# the recreate and lowered once caddy is back (below); the trap clears it if the
# step fails so the contour never sticks in maintenance (and the reseed above wipes a
# stale flag anyway). The caddy-routed probes run in the NEXT step, after it is lowered.
maint_flag="$conf/caddy/on"
trap 'rm -f "$maint_flag"' EXIT
# Derive the public URLs from the one canonical origin instead of storing each as
# its own variable (paths are structural SPA routes / the Caddy /_gm sub-path).
# Exported before build so the VK ID redirect is baked into the SPA.
base="${PUBLIC_BASE_URL%/}"
export TELEGRAM_MINIAPP_URL="$base/telegram/"
export GRAFANA_ROOT_URL="$base/_gm/grafana/"
export VITE_VK_ID_REDIRECT_URL="$base/app/"
# Grafana's SMTP from_address must be a BARE address (it rejects the "Name" <addr>
# form the backend go-mail accepts) and validates it even when SMTP is disabled — a
# bad value crash-loops Grafana. Split the display-format SERVICE From into a bare
# address + name for Grafana; the backend keeps the full form.
svc_from="${SMTP_RELAY_SERVICE_FROM:-}"
case "$svc_from" in
*"<"*">"*)
export GRAFANA_SMTP_FROM_ADDRESS="$(printf '%s' "$svc_from" | sed -E 's/.*<([^>]+)>.*/\1/')"
export GRAFANA_SMTP_FROM_NAME="$(printf '%s' "$svc_from" | sed -E 's/[[:space:]]*<[^>]*>.*$//; s/^"//; s/"$//')" ;;
*)
export GRAFANA_SMTP_FROM_ADDRESS="$svc_from" ;;
esac
# Bot-link mTLS material for the test contour: a private CA + gateway/bot
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
# together so they always share the fresh CA (see deploy/gen-certs.sh).
bash "$GITHUB_WORKSPACE/deploy/gen-certs.sh" "$conf/certs"
# App version for the About screen: the git tag if present, else the short SHA
# (the test checkout is shallow/untagged, so this is the SHA here — fine).
export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
# The telegram-local profile brings the bot + its VPN sidecar; prod runs the
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
# main host omits both. Without the profile they would not start here.
docker compose --ansi never --profile telegram-local build --progress plain
# Raise the maintenance page, THEN bring caddy onto the reseeded config mount so it
# actually carries the flag: the running caddy sits on the stale pre-reseed mount (the
# dir was rm'd + recreated — see the force-recreate note below), so a flag written to
# the new dir is invisible until caddy is recreated. With the fresh caddy up, an open
# SPA sees the 503 marker + overlay for the whole recreate window, not a bare reconnect.
: > "$maint_flag"
docker compose --ansi never up -d --force-recreate --no-deps caddy
docker compose --ansi never --profile telegram-local up -d --remove-orphans
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
# changed Grafana dashboard is ignored — force-recreate them to pick up the fresh
# config. (Caddy was already recreated above so it would carry the maintenance flag.)
docker compose --ansi never up -d --force-recreate --no-deps otelcol prometheus tempo grafana
# Lower the maintenance page: services are back. An open SPA's poll now gets through
# (once the gateway finishes booting) and reloads into the fresh client; the caddy
# probes in the next step see 200. The EXIT trap is a backstop if we failed earlier.
rm -f "$maint_flag"
- name: Probe the landing, gateway and backend
run: |
set -u
# Three probes. "/" is the static landing container and "/app/" the
# gateway-served SPA shell (both through the contour caddy on the edge net).
# The backend /readyz is probed on the internal net as well: the caddy probes
# are blind to a crash-looping backend (the landing is static and the SPA
# shell is served without it), which let a bad deploy go green while the
# backend was down — so check it directly here.
for i in $(seq 1 20); do
if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ &&
docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/ &&
docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then
echo "healthy: GET / (landing) + /app/ (gateway) + backend /readyz"
exit 0
fi
sleep 3
done
echo "probe failed; recent landing + gateway + backend logs:"
docker logs --tail 50 scrabble-landing || true
docker logs --tail 50 scrabble-gateway || true
docker logs --tail 50 scrabble-backend || true
exit 1
- name: Probe the /dict edge route reaches the gateway
run: |
set -u
# The client fetches each game's dictionary blob at {edge}/dict/{variant}/{version}
# for the local move preview. If caddy does not route /dict to the gateway the request
# falls to the static landing and the client silently gets a non-dawg blob. Probed
# unauthenticated it must be the gateway's 401 (the route reaches the gateway), never a
# 404/200 from the landing catch-all.
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/dict/scrabble_en/v1 2>&1 || true)"
echo "$out" | grep -E "HTTP/" || true
if echo "$out" | grep -q " 401"; then
echo "ok: /dict reaches the gateway (401 unauthenticated)"
else
echo "FAIL: /dict did not reach the gateway (expected 401) — caddy route missing?"
exit 1
fi
- name: Probe the Telegram validator and bot liveness
run: |
set -u
# The gateway/backend probes cannot see a crash-looping validator or bot
# (the validator answers only internal gRPC; the bot long-polls + egresses
# through the VPN sidecar with no public ingress). Inspect the containers
# directly: each must be running, not restarting, with a stable restart
# count. A grace period lets the VPN handshake and the bot-link dial settle.
sleep 20
for name in scrabble-telegram-validator scrabble-telegram-bot; do
ok=
for i in $(seq 1 20); do
status="$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo missing)"
restarting="$(docker inspect -f '{{.State.Restarting}}' "$name" 2>/dev/null || echo true)"
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
c1="$(docker inspect -f '{{.RestartCount}}' "$name")"
sleep 5
c2="$(docker inspect -f '{{.RestartCount}}' "$name")"
if [ "$c1" = "$c2" ]; then
echo "$name healthy: status=$status restarts=$c2"
ok=1
break
fi
echo "$name still restarting ($c1 -> $c2); waiting"
fi
sleep 3
done
if [ -z "$ok" ]; then
echo "$name not healthy; recent logs:"
docker logs --tail 80 "$name" || true
exit 1
fi
done
- name: Prune dangling images
if: always()
run: docker image prune -f