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" # 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