9824214fd7
The post-deploy probe checked only the static landing and the gateway-served SPA shell, so a crash-looping backend passed the deploy green. Add an http://backend:8080/readyz probe on the internal network (and dump backend logs on failure) so an unready backend fails the deploy loudly.
357 lines
15 KiB
YAML
357 lines
15 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 the test suite validates against — the current
|
|
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
|
|
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
|
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
|
env:
|
|
DICT_VERSION: v1.2.1
|
|
|
|
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
|
|
# 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
|
|
|
|
- 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: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
|
|
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: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
|
# 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 }}
|
|
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
|
# 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 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 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
|