Skip to content

style(frontend): prettier --write across 10 stale files #17

style(frontend): prettier --write across 10 stale files

style(frontend): prettier --write across 10 stale files #17

Workflow file for this run

name: CI
# Per-PR pipeline. Goal is twofold:
# 1. Run every test layer (backend unit/API, frontend unit, E2E against a
# real docker-compose stack) so a green PR is a real green PR.
# 2. Treat the artifact bundle attached to each run AS the "preview env"
# -- coverage HTML, Playwright report with screenshots, full compose
# logs. Reviewers download them from the Checks page; no external
# cloud deploy needed.
on:
push:
branches:
- main
- "features/**"
pull_request:
branches: [main]
# Cancel superseded runs on the same ref so PR pushes don't queue forever.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write # needed by the sticky-comment summary job
jobs:
# ---------------------------------------------------------------------------
# Backend: lint + types + pytest with coverage
# ---------------------------------------------------------------------------
backend-unit:
name: Backend (lint + tests + coverage)
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: lims
POSTGRES_PASSWORD: lims
POSTGRES_DB: lims_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U lims"
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
# conftest.py auto-creates lims_test, so we point at the maintenance DB
# 'lims' here -- conftest connects to 'postgres' to issue CREATE DATABASE.
DATABASE_URL: postgresql+asyncpg://lims:lims@localhost:5432/lims
TEST_DATABASE_URL: postgresql+asyncpg://lims:lims@localhost:5432/lims_test
REDIS_URL: redis://localhost:6379/0
JWT_SECRET: ci-test-secret-not-for-prod
ENV: ci
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12" # 3.14 breaks SQLAlchemy 2.0.x (see backend/pyproject.toml)
cache: "pip"
cache-dependency-path: |
backend/requirements.txt
backend/requirements-dev.txt
- name: Install dependencies
working-directory: backend
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt
# JUnit XML + HTML coverage are nice-to-have artifacts; install only
# if not already present, so requirements-dev.txt stays authoritative.
pip install --quiet pytest-html || true
- name: Lint (ruff)
working-directory: backend
run: ruff check .
- name: Format check (ruff)
working-directory: backend
run: ruff format --check .
- name: Type check (mypy)
working-directory: backend
run: mypy app
- name: Verify alembic migration on the test DB
working-directory: backend
# Point alembic at the test DB so the migration is actually exercised
# against the database the tests use. Without this override the step
# ran against `lims` (which doesn't exist on the GHA postgres service,
# since POSTGRES_DB above is `lims_test`) and silently no-op'd.
# conftest still drops + recreates via Base.metadata before tests, so
# this is purely a migration-drift guard.
env:
DATABASE_URL: postgresql+asyncpg://lims:lims@localhost:5432/lims_test
run: alembic upgrade head
- name: Tests (pytest + coverage)
working-directory: backend
# --cov-fail-under intentionally low -- this is a scaffold project.
# Raise once the team has real coverage.
run: |
pytest \
--cov=app \
--cov-report=term-missing \
--cov-report=xml:coverage.xml \
--cov-report=html:htmlcov \
--junitxml=pytest-junit.xml
- name: Upload backend coverage (HTML)
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-coverage-html
path: backend/htmlcov/
retention-days: 30
- name: Upload backend coverage (XML + JUnit)
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-coverage-xml
path: |
backend/coverage.xml
backend/pytest-junit.xml
retention-days: 30
# ---------------------------------------------------------------------------
# Frontend: lint + typecheck + vitest + Next.js build
# ---------------------------------------------------------------------------
frontend-unit:
name: Frontend (lint + tests + build)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: "20.19.0"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Lint (eslint)
working-directory: frontend
# `npm run lint` runs eslint --max-warnings 0. Pipe to a file too so
# we can attach it as an artifact even on failure.
run: |
npm run lint 2>&1 | tee eslint-report.txt
- name: Type check (tsc)
working-directory: frontend
run: npm run typecheck
- name: Build (next build)
working-directory: frontend
run: npm run build
- name: Test (vitest with coverage)
working-directory: frontend
# vitest exits 0 if there are no tests (--passWithNoTests). Coverage
# is best-effort: install @vitest/coverage-v8 ad-hoc if the team
# hasn't added it yet, so the testing-engineer's tests pick it up
# whenever they land.
run: |
if ! npm ls @vitest/coverage-v8 >/dev/null 2>&1; then
npm install --no-save --no-audit --no-fund @vitest/coverage-v8
fi
npx vitest run --coverage --passWithNoTests --reporter=default --reporter=junit --outputFile=vitest-junit.xml
- name: Upload frontend coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: |
frontend/coverage/
frontend/vitest-junit.xml
retention-days: 30
if-no-files-found: ignore
- name: Upload eslint report
if: always()
uses: actions/upload-artifact@v4
with:
name: frontend-eslint
path: frontend/eslint-report.txt
retention-days: 14
if-no-files-found: ignore
- name: Upload Next.js build output
if: success()
uses: actions/upload-artifact@v4
with:
name: frontend-next-build
# Skip the chonky cache dir; ship only what proves the build worked
# and what a reviewer would actually open.
path: |
frontend/.next/standalone/
frontend/.next/static/
frontend/.next/BUILD_ID
frontend/.next/build-manifest.json
retention-days: 14
if-no-files-found: warn
# ---------------------------------------------------------------------------
# Docker compose build smoke -- catches Dockerfile regressions before the
# heavier integration-e2e job spins everything up.
# ---------------------------------------------------------------------------
docker-build:
name: Docker compose build
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build all services (with overlay)
run: |
docker compose \
-f docker-compose.yml \
-f docker-compose.ci.yml \
build
# ---------------------------------------------------------------------------
# The headline job: spin up the full stack via docker compose, migrate +
# seed, then run Playwright E2E against it. Artifacts (Playwright HTML
# report with screenshots/traces + full compose logs) are the "preview env"
# reviewers download from the Checks page.
# ---------------------------------------------------------------------------
integration-e2e:
name: Integration + E2E (compose stack)
runs-on: ubuntu-latest
needs: [docker-build]
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: "20"
# We cache the e2e harness's node_modules separately; only enable
# the built-in npm cache if a lockfile exists.
cache: "npm"
cache-dependency-path: tests/e2e/package-lock.json
- name: Prepare backend env
run: cp backend/.env.example backend/.env
- name: Create lims_devnet (devcontainer-shared network)
# postgres + redis attach to this external network so the
# .devcontainer workspace can reach them by service name. CI
# doesn't use the devcontainer but compose still requires the
# network to exist before `up`.
run: docker network inspect lims_devnet >/dev/null 2>&1 || docker network create lims_devnet
- name: Boot the stack
run: |
docker compose \
-f docker-compose.yml \
-f docker-compose.ci.yml \
up -d --build \
postgres redis backend celery-worker celery-beat frontend
- name: Wait for backend /health
# Backend Dockerfile already declares HEALTHCHECK on /health; we
# additionally poll from the runner so we fail fast with a clear
# message instead of letting Playwright connect to a half-up app.
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
echo "backend up after ${i}s"
exit 0
fi
sleep 2
done
echo "backend never became healthy"
docker compose -f docker-compose.yml -f docker-compose.ci.yml ps
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs --no-color backend | tail -200
exit 1
- name: Migrate + seed
run: |
docker compose -f docker-compose.yml -f docker-compose.ci.yml \
exec -T backend alembic upgrade head
docker compose -f docker-compose.yml -f docker-compose.ci.yml \
exec -T backend python scripts/seed_dev.py
- name: Wait for frontend
run: |
for i in $(seq 1 60); do
# Next.js standalone returns 200 on / once compiled. A 404 from
# the framework is also "up" (means server is responding), so
# accept any HTTP response code below 500.
code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 || echo "000")
if [ "$code" != "000" ] && [ "$code" -lt 500 ]; then
echo "frontend up after ${i}s (HTTP $code)"
exit 0
fi
sleep 2
done
echo "frontend never became healthy"
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs --no-color frontend | tail -200
exit 1
- name: Install Playwright harness
# If the testing-engineer agent hasn't landed tests/e2e yet, create
# a stub so the workflow still passes and uploads a (empty) report.
# Once tests/e2e/package.json lands, the `npm install` below installs
# it for real.
run: |
if [ ! -d tests/e2e ]; then
echo "tests/e2e missing -- creating stub so CI still produces artifacts"
mkdir -p tests/e2e
cat > tests/e2e/package.json <<'EOF'
{
"name": "lims-e2e",
"private": true,
"scripts": { "test": "playwright test" },
"devDependencies": { "@playwright/test": "^1.49.0" }
}
EOF
cat > tests/e2e/playwright.config.ts <<'EOF'
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./",
reporter: [["html", { open: "never" }], ["list"]],
use: { baseURL: process.env.FRONTEND_URL ?? "http://localhost:3000", trace: "retain-on-failure", screenshot: "only-on-failure" },
});
EOF
cat > tests/e2e/smoke.spec.ts <<'EOF'
import { test, expect } from "@playwright/test";
test("frontend responds", async ({ page }) => {
const res = await page.goto("/");
expect(res?.status() ?? 500).toBeLessThan(500);
});
EOF
fi
cd tests/e2e && npm install --no-audit --no-fund
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('tests/e2e/package-lock.json', 'tests/e2e/package.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: tests/e2e
run: npx playwright install --with-deps chromium
- name: Install Playwright system deps (cache hit path)
if: steps.playwright-cache.outputs.cache-hit == 'true'
working-directory: tests/e2e
run: npx playwright install-deps chromium
- name: Run Playwright
working-directory: tests/e2e
env:
FRONTEND_URL: http://localhost:3000
BACKEND_URL: http://localhost:8000
run: npx playwright test
- name: Capture compose logs
if: always()
run: |
mkdir -p artifacts
docker compose -f docker-compose.yml -f docker-compose.ci.yml \
logs --no-color --timestamps > artifacts/compose-logs.txt 2>&1 || true
docker compose -f docker-compose.yml -f docker-compose.ci.yml \
ps > artifacts/compose-ps.txt 2>&1 || true
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: tests/e2e/playwright-report/
retention-days: 30
if-no-files-found: warn
- name: Upload Playwright test-results (traces + screenshots)
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-test-results
path: tests/e2e/test-results/
retention-days: 30
if-no-files-found: ignore
- name: Upload compose logs
if: always()
uses: actions/upload-artifact@v4
with:
name: compose-logs
path: artifacts/
retention-days: 30
if-no-files-found: warn
- name: Tear down stack
if: always()
run: |
docker compose -f docker-compose.yml -f docker-compose.ci.yml \
down -v --remove-orphans || true
# ---------------------------------------------------------------------------
# Summary: sticky PR comment with status of each layer + artifact links.
# Non-blocking; if it fails (e.g. forked PR can't write comments) the rest
# of the run is still green.
# ---------------------------------------------------------------------------
summary:
name: PR summary
runs-on: ubuntu-latest
needs: [backend-unit, frontend-unit, docker-build, integration-e2e]
if: always() && github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: write
steps:
- name: Compose summary body
id: body
env:
BACKEND: ${{ needs.backend-unit.result }}
FRONTEND: ${{ needs.frontend-unit.result }}
DOCKER: ${{ needs.docker-build.result }}
E2E: ${{ needs.integration-e2e.result }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
icon() {
case "$1" in
success) echo ":white_check_mark:" ;;
failure) echo ":x:" ;;
cancelled) echo ":no_entry:" ;;
skipped) echo ":fast_forward:" ;;
*) echo ":grey_question:" ;;
esac
}
{
echo "body<<EOF"
echo "### CI summary"
echo ""
echo "| Layer | Status |"
echo "|---|---|"
echo "| Backend (lint + tests + coverage) | $(icon "$BACKEND") $BACKEND |"
echo "| Frontend (lint + tests + build) | $(icon "$FRONTEND") $FRONTEND |"
echo "| Docker compose build | $(icon "$DOCKER") $DOCKER |"
echo "| Integration + E2E (compose stack) | $(icon "$E2E") $E2E |"
echo ""
echo "Download artifacts (coverage HTML, Playwright report with screenshots, full compose logs) from the [run page]($RUN_URL)."
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Post sticky comment
# Skip on forked PRs -- GITHUB_TOKEN there is read-only.
if: github.event.pull_request.head.repo.full_name == github.repository
uses: marocchino/sticky-pull-request-comment@v2
with:
header: ci-summary
message: ${{ steps.body.outputs.body }}