Files
scrabble-game/.gitea/workflows/ci.yaml
T
Ilia Denisov e16076c89e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
Stage 17 round 6 (#16-20): landing page, /app/ move, cache + stream fixes
Close out Stage 17 round 6:

- Landing page at / — one Vite build with two entries (index.html = game
  SPA, landing.html = a lightweight landing reusing the theme/i18n/
  aboutContent leaf modules, not the app store).
- Move the web game SPA to /app/; the Telegram Mini App stays at /telegram/
  (gateway webui.Handler(stripPrefix, indexName): landing at /, SPA at /app/
  + /telegram/). Per-language "Play in Telegram" link via new
  VITE_TELEGRAM_LINK_EN/_RU build vars (button hides when unset).
- Cache headers: hash-named /assets/* immutable, HTML shells no-cache (the
  go:embed zero modtime emitted no validators, so the client re-downloaded
  the whole bundle every launch).
- Live-stream 15s abort fix: an immediate heartbeat on open + a 10s default
  interval (the first tick at 15s raced the edge idle timeout -> reconnect
  storm).

PLAN/ARCHITECTURE(§13)/FUNCTIONAL(+ru)/gateway+ui+deploy READMEs updated;
round 6 closed. Tests: gateway webui/connectsrv units, ui landing unit + e2e,
full e2e (60) green.
2026-06-08 13:33:05 +02:00

346 lines
14 KiB
YAML

name: CI
# Single gated pipeline for the test contour (Stage 16/17). 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 (Stage 18).
#
# Path-conditional jobs (Stage 17): `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]
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/|go\.work)'; then go=true; fi
if echo "$files" | grep -qE '^ui/'; 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/*
DICT_VERSION: v1.0.0
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/...
- name: build
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
- name: test
env:
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
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/*
DICT_VERSION: v1.0.0
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
- name: Install Playwright browsers
run: pnpm exec playwright install chromium webkit
timeout-minutes: 5
- name: E2E smoke (mock)
run: pnpm run test:e2e
timeout-minutes: 5
# 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]
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 }}"; 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_EN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_EN }}
TELEGRAM_BOT_TOKEN_RU: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_RU }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
# The test contour always uses Telegram's test environment — pinned here,
# not an operator variable. Stage 18's 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_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }}
VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
# Unset vars render empty -> the compose ":-" defaults apply.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
DICT_VERSION: ${{ vars.TEST_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 "$conf"/
export SCRABBLE_CONFIG_DIR="$conf"
# 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)"
docker compose --ansi never build --progress plain
docker compose --ansi never 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 Caddyfile or Grafana dashboard is ignored — force-recreate them to
# pick up the fresh config.
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
- name: Probe the gateway through caddy
run: |
set -u
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/; then
echo "healthy: GET http://scrabble/"
exit 0
fi
sleep 3
done
echo "probe failed; recent gateway logs:"
docker logs --tail 50 scrabble-gateway || true
exit 1
- name: Probe the Telegram connector liveness
run: |
set -u
# The gateway probe cannot see a crash-looping connector (it long-polls and
# egresses through the VPN sidecar, with no public ingress). Inspect the
# container directly: it must be running, not restarting, with a stable
# restart count. A grace period lets the VPN handshake settle (the connector
# may restart a few times first).
sleep 20
for i in $(seq 1 20); do
status="$(docker inspect -f '{{.State.Status}}' scrabble-telegram 2>/dev/null || echo missing)"
restarting="$(docker inspect -f '{{.State.Restarting}}' scrabble-telegram 2>/dev/null || echo true)"
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
c1="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
sleep 5
c2="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
if [ "$c1" = "$c2" ]; then
echo "connector healthy: status=$status restarts=$c2"
exit 0
fi
echo "connector still restarting ($c1 -> $c2); waiting"
fi
sleep 3
done
echo "connector not healthy; recent logs:"
docker logs --tail 80 scrabble-telegram || true
exit 1
- name: Prune dangling images
if: always()
run: docker image prune -f