Skip to content

fix(i18n): resolve ISO 639-2/B language codes (ger, fre, dut) instead of showing "Unknown" #2411

fix(i18n): resolve ISO 639-2/B language codes (ger, fre, dut) instead of showing "Unknown"

fix(i18n): resolve ISO 639-2/B language codes (ger, fre, dut) instead of showing "Unknown" #2411

Workflow file for this run

name: Test Suite
on:
push:
# Only fire on direct pushes to main/dev (auto-merge to main, hotfixes).
# PR branches get CI via the `pull_request:` trigger below — listing both
# `branches: ['**']` here AND `pull_request:` causes dual-trigger CI runs
# on the same SHA, which doubles the surface for the xdist worker-IPC
# hang documented in notes/xdist-worker-ipc-hang-followup-2026-05-21.md.
branches: [main, dev]
# Version tags run the SPA e2e release gate (the e2e-tests job pulls the
# tag's GHCR image once it publishes). Without this, the job's tag
# condition is dead code — `branches:` alone filters out tag events.
tags: ['v*']
pull_request:
branches: [main, dev] # Run Job 1 on PRs to main/dev
workflow_dispatch: # Manual trigger button in Actions tab
inputs:
run_integration:
description: 'Run integration tests'
required: false
type: boolean
default: false
run_e2e:
description: 'Run E2E tests'
required: false
type: boolean
default: false
# Cancel in-progress runs when new commit pushed
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ============================================================================
# JOB 1: Fast Tests (Smoke + Unit)
# Runs on: Every push, every PR
# Duration: ~2 minutes
# Purpose: Catch basic errors immediately
# ============================================================================
fast-tests:
name: Fast Tests (Smoke + Unit)
runs-on: ubuntu-latest
env:
PYTHONDONTWRITEBYTECODE: '1'
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Remove Python bytecode and pycache
run: |
echo "Removing any leftover .pyc and pycache"
find . -name '__pycache__' -type d -print -exec rm -rf {} +
find . -name '*.pyc' -type f -print -delete
- name: Set up Python 3.13
uses: actions/setup-python@v6
with:
python-version: '3.13'
cache: 'pip'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libldap2-dev libsasl2-dev libssl-dev gettext
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Create test environment structure
run: |
sudo mkdir -p /config /books/import /books/ingest /config/processed_books
sudo chmod -R 777 /config /books
touch /config/epub-fixer.log
touch /config/converter.log
touch /config/cwa.db
- name: Run smoke and unit tests
timeout-minutes: 10 # Last-resort kill; --timeout below is the real gate.
env:
PYTHONPATH: ${{ github.workspace }}:${{ github.workspace }}/scripts
run: |
# --timeout=120 + --timeout-method=thread converts hung tests into named
# test failures instead of opaque step-level 10-min step timeouts. Without
# this, a stuck test on one xdist worker only manifests as a step timeout
# with no indication of which test or worker stalled.
#
# --dist=loadfile pins all tests in a file to a single worker. This
# eliminates the work-stealing race that's been hanging fast-tests
# runs (one of two parallel triggers stalls at ~9 minutes with no
# currently-executing test for pytest-timeout to interrupt). See
# notes/xdist-worker-ipc-hang-followup-2026-05-21.md for diagnostic
# log signature + hypothesis. Fewer worker handoffs ≈ fewer races.
#
# faulthandler_timeout (INI option, set via -o — there is no CLI
# flag): last line of defense for whole-process freezes (e.g. the
# GIL↔sqlite-mutex AB-BA deadlock, see
# notes/fix-udf-gil-deadlock-DESIGN.md). faulthandler's watchdog
# writes every thread's stack WITHOUT needing the GIL, so a freeze
# that starves pytest-timeout still produces named stacks in the
# log instead of a silent 10-minute step wall. 90 < the 120s
# pytest-timeout on purpose: a hung test logs stacks first, then
# pytest-timeout attempts the interrupt.
pytest -m "smoke or unit" \
-n auto \
--dist=loadfile \
--maxfail=3 \
-v \
--tb=short \
--timeout=120 \
--timeout-method=thread \
-o faulthandler_timeout=90 \
--cov=cps \
--cov-report=xml \
--cov-report=term-missing
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
if: always()
with:
files: ./coverage.xml
flags: unittests
name: fast-tests
fail_ci_if_error: false
- name: Comment PR with results
uses: actions/github-script@v9
if: github.event_name == 'pull_request' && failure()
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '⚠️ **Fast tests failed!** Please check the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) and fix before merging.'
})
# ============================================================================
# JOB 1b: Frontend Build (SPA bundle)
# Runs on: Every push, every PR
# Duration: ~1 minute
# Purpose: Fast TypeScript + Vite build feedback for the React SPA, so a
# broken bundle surfaces immediately instead of only inside the ~15-min
# Docker integration build. Mirrors the Dockerfile `frontend-build` stage.
# ============================================================================
frontend-build:
name: Frontend Build (SPA bundle)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Node.js 22
uses: actions/setup-node@v7
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies (npm ci)
working-directory: frontend
run: npm ci
- name: Build SPA (tsc -b && vite build)
working-directory: frontend
run: npm run build
- name: Assert bundle emitted to cps/static/app
run: |
test -f cps/static/app/index.html || { echo "::error::SPA bundle missing index.html"; exit 1; }
test -d cps/static/app/assets || { echo "::error::SPA bundle missing assets/"; exit 1; }
echo "✅ SPA bundle present at cps/static/app"
# ============================================================================
# JOB 2: Integration Tests (Docker)
# Runs on: Merge to main/dev, manual trigger
# Duration: ~15-20 minutes
# Purpose: Validate real-world behavior in Docker container
# ============================================================================
integration-tests:
name: Integration Tests (Docker)
runs-on: ubuntu-latest
permissions:
contents: read
packages: read # pull the private pbs-cache mirror images the Dockerfile COPYs from
# Tier-2 auto-merge requires Integration Tests as a hard gate — small
# code changes still need to clear the actual ingest/Calibre flow.
# We keep the broad-PR exemption: tier-1 (.po / *.md) PRs and PRs
# without a tier label don't pay the 15-minute integration cost.
# On main / dev pushes the job stays advisory so flaky-network failures
# don't block downstream auto-revert decisions. On tier-2 PRs the job
# is mandatory.
continue-on-error: ${{ !contains(github.event.pull_request.labels.*.name, 'safe-tier-2') }}
# Run on:
# - merge to main / dev (advisory)
# - manual dispatch with flag
# - any PR labeled safe-tier-2 (gating)
if: |
github.ref == 'refs/heads/main' ||
github.ref == 'refs/heads/dev' ||
(github.event_name == 'workflow_dispatch' && inputs.run_integration) ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'safe-tier-2'))
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v6
with:
python-version: '3.13'
cache: 'pip'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR (pbs-cache mirror pull)
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
# GH_PAT (read:packages) so BuildKit can COPY --from our private
# pbs-cache mirror; falls back to GITHUB_TOKEN where the PAT
# secret isn't in scope.
password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libldap2-dev libsasl2-dev libssl-dev gettext
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Build Docker image
uses: docker/build-push-action@v7
with:
context: .
push: false
load: true
tags: crocodilestick/calibre-web-automated:latest
cache-from: type=gha
cache-to: type=gha,mode=max
# PBS_SOURCE=ghcr pulls Python/kepubify from our GHCR mirror rather
# than the release CDN, which intermittently 404s the Actions egress.
# Contributors build without it and fall back to the CDN (see Dockerfile).
build-args: |
PBS_SOURCE=ghcr
- name: Set test UID/GID
run: |
echo "CWA_TEST_PUID=$(id -u)" >> $GITHUB_ENV
echo "CWA_TEST_PGID=$(id -g)" >> $GITHUB_ENV
- name: Run Docker integration tests
run: |
# CRITICAL: No -n flag! Docker tests must run sequentially
pytest tests/docker/ tests/integration/ \
-v \
--tb=long \
--durations=10 \
--junitxml=integration-results.xml
env:
CWA_TEST_PORT: "8083" # CI uses production default port
CWA_TEST_IMAGE: "crocodilestick/calibre-web-automated:latest"
timeout-minutes: 30 # Kill if stuck
- name: Upload test results
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-test-results
path: |
integration-results.xml
tests/logs/
tests/tmp/
retention-days: 7
- name: Notify on failure
uses: sarisia/actions-status-discord@v1
if: failure() && env.DISCORD_WEBHOOK != ''
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
title: "❌ Integration tests failed on ${{ github.ref_name }}"
description: |
**Commit**: ${{ github.sha }}
**Author**: ${{ github.actor }}
**Branch**: ${{ github.ref_name }}
[View logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
color: 0xFF0000
username: GitHub Actions
# ============================================================================
# JOB 2b: Which parts of the tree did this PR touch?
# Runs on: pull requests only. Duration: ~20s.
# Purpose: let the SPA e2e job gate frontend PRs without charging a .po-only
# or docs-only PR half an hour for a suite that cannot tell it anything.
# Deliberately plain checkout + git rather than a paths-filter action —
# this repo does not take new third-party dependencies for convenience.
# ============================================================================
changed_paths:
name: Detect changed paths
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 5
outputs:
frontend: ${{ steps.detect.outputs.frontend }}
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Detect
id: detect
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Three-dot: what this branch changed relative to the merge base, so a
# busy main does not make every PR look like it touched everything.
files="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}" || git diff --name-only "${BASE_SHA}" "${HEAD_SHA}")"
echo "$files"
if echo "$files" | grep -qE '^(frontend/|cps/static/app/)'; then
echo "frontend=true" >> "$GITHUB_OUTPUT"
else
echo "frontend=false" >> "$GITHUB_OUTPUT"
fi
# ============================================================================
# JOB 3: E2E Tests (Full Stack)
# Runs on: Manual trigger, all release tags (v3.2.0, v3.2.0-rc1, etc.)
# Duration: ~30-45 minutes
# Purpose: Validate complete user workflows
# NOTE: Tests run AFTER tag creation. Check results before publishing release!
# ============================================================================
e2e-tests:
name: E2E Tests (SPA)
runs-on: ubuntu-latest
# `always()` matters: changed_paths only runs on PRs, and without it a
# skipped dependency would drag the tag and dispatch runs down with it —
# silently turning the release gate off, which is the exact class of
# failure this whole change exists to remove.
needs: [changed_paths]
# Release gate + manual dispatch. The SPA e2e matrix (Layer 2 of the
# verification system, frontend/e2e/) is the "earn the release" gate;
# /CWNG_verify runs the same harness on-demand in dev. Tag runs test the
# tag's own GHCR image (requires `on.push.tags` above — a `branches:`-only
# push trigger never delivers tag events). The dev-branch condition is
# forward wiring: no dev branch exists today, so per-merge canary coverage
# is a follow-up (workflow_run after the :dev image build).
# ADVISORY: a failure warns in test-summary + alerts Discord on tag runs,
# but does not block. Promote to a hard gate once several tag runs are green.
#
# PRs touching frontend/** now run too (#953). They cannot be handled the
# same way: the resolver below tests a PUBLISHED image, and a pull request
# has none. Running the suite against :dev on a PR would test main's build
# regardless of what the PR did to the SPA — green either way, and worse
# than skipping because it would look like a gate.
#
# So PR runs take the API from :dev and overlay the PR's OWN SPA bundle
# into the container. The specs here assert frontend behaviour and the SPA
# is a static bundle under cps/static/app, so this gates exactly what they
# cover, with no from-source image build (the resolver comment explains why
# that path is avoided). Limit, stated rather than glossed: it will not
# catch a regression that needs the PR's BACKEND changes. It is an SPA
# gate, not a full-stack one — and the alternative today is no gate at all.
if: |
always() && (
startsWith(github.ref, 'refs/tags/v') ||
github.ref == 'refs/heads/dev' ||
(github.event_name == 'workflow_dispatch' && inputs.run_e2e) ||
(github.event_name == 'pull_request' && needs.changed_paths.outputs.frontend == 'true')
)
# Tag runs may wait up to ~40 min for the GHCR release build to publish
# the image (see the pull loop below) before the ~15 min harness run.
# Advisory on PRs until several runs are green, matching the ADVISORY
# stance above. Promote to a hard gate once it has earned it.
continue-on-error: ${{ github.event_name == 'pull_request' }}
timeout-minutes: 75
permissions:
contents: read
packages: read # pull the private pbs-cache mirror images the Dockerfile COPYs from
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Log in to GHCR (to pull the app image)
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve image to test
id: img
run: |
# Test the image users actually get (Class 2/4: verify the shipped
# artifact, not a locally-rebuilt one). Version tag → that tag's image;
# otherwise → the :dev canary. Avoids the from-source build, which
# cold-pulls the private pbs-cache mirror and is fragile in CI.
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
echo "image=ghcr.io/new-usemame/calibre-web-nextgen:${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
else
# PRs land here too: :dev supplies the API, and the overlay step
# below replaces its SPA with the one built from this PR (#953).
echo "image=ghcr.io/new-usemame/calibre-web-nextgen:dev" >> "$GITHUB_OUTPUT"
fi
- name: Start container (SPA enabled)
env:
IMAGE: ${{ steps.img.outputs.image }}
run: |
echo "Testing image: $IMAGE"
# On tag pushes this job races the GHCR release build, which publishes
# the tag's image 15–35 min after the tag lands (v4.0.169 lesson) —
# poll instead of failing on the first pull. :dev / dispatch runs hit
# an already-published image and pull on the first attempt.
for i in $(seq 1 80); do
docker pull "$IMAGE" && break
echo "image not pullable yet (attempt $i/80); retrying in 30s..."
sleep 30
done
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "::error::$IMAGE never became pullable — check the GHCR build for this ref"
exit 1
fi
mkdir -p /tmp/cwn/config /tmp/cwn/ingest
docker run -d --name cwn-e2e \
-e PUID=1000 -e PGID=1000 -e TZ=UTC -e CWNG_SPA=1 \
-p 8083:8083 \
-v /tmp/cwn/config:/config \
-v /tmp/cwn/ingest:/cwa-book-ingest \
"$IMAGE"
echo "Waiting for the app to answer..."
timeout 240 bash -c 'until curl -fsS http://localhost:8083/ -o /dev/null; do sleep 5; done'
- name: Seed books and wait for ingest
run: |
# Drop public-domain EPUBs into the ingest folder; auto-ingest imports
# them. The container's init chowns the bind-mounted /cwa-book-ingest
# to uid 1000, so the runner can't write to it from the host. Use
# `docker cp` (root inside the container) and hand each file to uid
# 1000, which the ingest service needs to process and then remove it.
#
# THREE books, not one. A single-book library silently failed 24 specs
# on every run of this job — "the catalog fixture needs at least two
# books" — and because the job is advisory, nobody ever read it. A
# release gate that cannot tell a real failure from a fixture that was
# never big enough is not a gate. pride_and_prejudice.epub is left out
# deliberately: at 24 MB it dominates ingest time for no extra
# coverage.
for book in alice_in_wonderland christmas_carol metamorphosis; do
docker cp "tests/fixtures/sample_books/${book}.epub" \
"cwn-e2e:/cwa-book-ingest/${book}.epub"
docker exec -u 0 cwn-e2e chown 1000:1000 "/cwa-book-ingest/${book}.epub"
done
echo "Polling for the imported book via the API..."
jar=$(mktemp); total=0
for i in $(seq 1 60); do
csrf=$(curl -s -c "$jar" http://localhost:8083/api/v1/auth/csrf | python3 -c 'import sys,json;print(json.load(sys.stdin)["csrf_token"])' 2>/dev/null || true)
[ -n "$csrf" ] && curl -s -b "$jar" -c "$jar" -X POST http://localhost:8083/api/v1/auth/login \
-H 'Content-Type: application/json' -H "X-CSRFToken: $csrf" \
-d '{"username":"admin","password":"admin123"}' -o /dev/null || true
total=$(curl -s -b "$jar" 'http://localhost:8083/api/v1/books?limit=1' | python3 -c 'import sys,json;print(json.load(sys.stdin).get("total",0))' 2>/dev/null || echo 0)
echo " attempt $i: books total=$total"
# Wait for ALL of them: breaking at the first import is what left
# the library at one book while the step reported success.
[ "${total:-0}" -ge 3 ] && break
sleep 5
done
if [ "${total:-0}" -lt 3 ]; then
echo "::error::ingest imported only ${total:-0} of 3 seed books — dumping ingest logs"
docker logs cwn-e2e 2>&1 | grep -iE 'ingest|convert|import|error|watch' | tail -40 || true
exit 1
fi
- name: Seed shelves and series
run: |
# The library the harness gets had three books and NO shelves, so
# every spec asserting shelf grouping failed on the fixture rather
# than on anything real (#1130). Reuse the session the ingest poll
# above already logged in with.
#
# Verification fixture only: these shelves exist so the sidebar has
# something to group. Nothing here changes product behaviour, and the
# e2e job stays advisory on PRs.
jar=$(mktemp)
csrf=$(curl -s -c "$jar" http://localhost:8083/api/v1/auth/csrf \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["csrf_token"])')
curl -s -b "$jar" -c "$jar" -X POST http://localhost:8083/api/v1/auth/login \
-H 'Content-Type: application/json' -H "X-CSRFToken: $csrf" \
-d '{"username":"admin","password":"admin123"}' -o /dev/null
csrf=$(curl -s -b "$jar" -c "$jar" http://localhost:8083/api/v1/auth/csrf \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["csrf_token"])')
for shelf in "Reading now" "To read"; do
code=$(curl -s -o /tmp/shelf.json -w '%{http_code}' \
-b "$jar" -c "$jar" -X POST http://localhost:8083/api/v1/shelves \
-H 'Content-Type: application/json' -H "X-CSRFToken: $csrf" \
-d "{\"name\": \"$shelf\"}")
echo " create shelf '$shelf' -> $code"
# 409 is fine (already there); anything else is a broken fixture and
# should fail loudly rather than resurface as confusing spec failures.
if [ "$code" != "201" ] && [ "$code" != "409" ]; then
echo "::error::could not create shelf '$shelf' (HTTP $code)"; cat /tmp/shelf.json; exit 1
fi
done
total=$(curl -s -b "$jar" http://localhost:8083/api/v1/shelves \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print(len(d.get("items",d if isinstance(d,list) else [])))' 2>/dev/null || echo 0)
echo "shelves now: $total"
[ "${total:-0}" -ge 1 ] || { echo "::error::no shelves after seeding"; exit 1; }
# Give two books a series. Without one, no book CARD renders a series
# line — and the card series line is where #1135's contrast failure
# lives, so the a11y gate could only catch that class of bug by luck
# of fixture. A gate that depends on the seed happening to contain the
# right shape of data is not a gate.
csrf=$(curl -s -b "$jar" -c "$jar" http://localhost:8083/api/v1/auth/csrf \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["csrf_token"])')
ids=$(curl -s -b "$jar" 'http://localhost:8083/api/v1/books?per_page=2' \
| python3 -c 'import sys,json;print(" ".join(str(b["id"]) for b in json.load(sys.stdin)["items"]))')
idx=1
for bid in $ids; do
code=$(curl -s -o /tmp/md.json -w '%{http_code}' \
-b "$jar" -c "$jar" -X POST "http://localhost:8083/api/v1/books/$bid/metadata" \
-H 'Content-Type: application/json' -H "X-CSRFToken: $csrf" \
-d "{\"series\": \"E2E Fixture Series\", \"series_index\": $idx}")
echo " book $bid series -> $code"
if [ "$code" != "200" ]; then
echo "::error::could not set series on book $bid (HTTP $code)"; cat /tmp/md.json; exit 1
fi
idx=$((idx + 1))
done
series_count=$(curl -s -b "$jar" http://localhost:8083/api/v1/series \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print(len(d.get("items",[])))' 2>/dev/null || echo 0)
echo "series now: $series_count"
[ "${series_count:-0}" -ge 1 ] || { echo "::error::no series after seeding"; exit 1; }
- name: Install Playwright
working-directory: frontend
run: |
npm ci
npx playwright install --with-deps chromium
# The whole point of running on a PR: without this the container serves
# main's SPA and the suite says nothing about the change under review.
- name: Overlay this PR's SPA bundle into the container
if: github.event_name == 'pull_request'
working-directory: frontend
run: |
npm ci
npm run build # writes ../cps/static/app
docker cp ../cps/static/app/. cwn-e2e:/app/calibre-web-automated/cps/static/app/
# The bundle is static; no restart needed. Confirm the shell still
# answers so a broken overlay fails here rather than as 30 confusing
# spec failures.
curl -fsS http://localhost:8083/app/ -o /dev/null
echo "PR bundle in place"
- name: Run SPA e2e harness
working-directory: frontend
env:
CI: 'true'
E2E_BASE_URL: http://localhost:8083
run: npm run test:e2e
- name: Upload Playwright report
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
path: |
frontend/e2e/.report
frontend/e2e/.results
retention-days: 14
- name: Dump container logs
if: always()
run: docker logs cwn-e2e > cwn-e2e-logs.txt 2>&1 || true
- name: Upload container logs
uses: actions/upload-artifact@v7
if: always()
with:
name: cwn-e2e-logs
path: cwn-e2e-logs.txt
retention-days: 14
- name: Notify on failure
uses: sarisia/actions-status-discord@v1
if: failure() && startsWith(github.ref, 'refs/tags/v') && env.DISCORD_WEBHOOK != ''
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
title: "⛔ SPA e2e failed for ${{ github.ref_name }}"
description: |
**DO NOT RELEASE** until fixed!
**Tag**: ${{ github.ref_name }} · **Commit**: ${{ github.sha }}
[View logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
color: 0xFF0000
username: GitHub Actions
- name: Cleanup
if: always()
run: docker rm -f cwn-e2e || true
# ============================================================================
# Summary Job (Optional)
# Shows overall status in GitHub UI
# ============================================================================
test-summary:
name: Test Suite Summary
runs-on: ubuntu-latest
needs: [fast-tests, frontend-build, integration-tests, e2e-tests]
if: always()
# The unified gate that branch protection requires. auto-merge.yml
# leans on this job being strict-for-tier-2 instead of polling
# Integration Tests itself: when this summary is green, GitHub
# auto-merge fires.
steps:
- name: Check test results
env:
# contains() evaluates to a 'true' / 'false' string; we pass
# it through env to keep the shell predicate readable.
IS_TIER2_PR: ${{ contains(github.event.pull_request.labels.*.name, 'safe-tier-2') }}
run: |
echo "Fast Tests: ${{ needs.fast-tests.result }}"
echo "Frontend Build: ${{ needs.frontend-build.result }}"
echo "Integration Tests: ${{ needs.integration-tests.result }}"
echo "E2E Tests: ${{ needs.e2e-tests.result }}"
echo "Is tier-2 PR: $IS_TIER2_PR"
if [[ "${{ needs.fast-tests.result }}" == "failure" ]]; then
echo "❌ Fast tests failed"
exit 1
fi
# The SPA bundle is a hard gate: a broken TypeScript/Vite build ships
# an empty/broken frontend, so it blocks the merge like fast-tests.
if [[ "${{ needs.frontend-build.result }}" == "failure" ]]; then
echo "❌ Frontend build failed"
exit 1
fi
# Integration Tests is a hard gate for safe-tier-2 PRs (the
# whole point of the tier — small code change must clear the
# actual ingest / Calibre flow). For tier-1 PRs and pushes to
# main/dev the suite is advisory (continue-on-error in the
# job definition keeps it from blocking auto-revert decisions).
if [[ "${{ needs.integration-tests.result }}" == "failure" ]]; then
if [[ "$IS_TIER2_PR" == "true" ]]; then
echo "❌ Integration tests failed on safe-tier-2 PR — auto-merge gate"
exit 1
fi
echo "⚠️ Integration tests failed (advisory; not a tier-2 PR)"
fi
if [[ "${{ needs.e2e-tests.result }}" == "failure" ]]; then
echo "⛔ E2E tests failed - do not release!"
# Don't fail the summary, just warn.
fi
echo "✅ Test suite completed"