# CI: every pull request and every push to main must pass these checks # (ADR 0014). The deploy pipeline (CD) lives in cd.yml and only runs on # main after this workflow's quality bar. # # Runner requirements: an act_runner with the `ubuntu-latest` label and # Docker access (see deploy/stages.md, issue #9). name: CI on: pull_request: push: branches: [main] jobs: # Warm the runner's shared action cache once, alone, before the parallel # pnpm jobs start. With the runner at capacity > 1, several jobs otherwise # fetch the same actions (checkout, setup-node, pnpm/action-setup) into the # shared offline cache simultaneously and read it half-written — a flaky # "Cannot find module .../dist/…" in "Set up Node.js"/"Set up pnpm". Running # these actions once here populates the cache completely; the dependent jobs # then only read it. Cheap (~30 s) and keeps full parallelism afterwards. prepare: name: Warm action cache runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 - name: Set up pnpm uses: pnpm/action-setup@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22 checks: name: Lint, typecheck, test needs: prepare runs-on: ubuntu-latest services: postgres: image: postgres:17.5-alpine env: POSTGRES_USER: test POSTGRES_PASSWORD: test POSTGRES_DB: test env: # Enables the database-backed test suites (vitest.global-setup.ts). TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test steps: - name: Check out repository uses: actions/checkout@v4 - name: Set up pnpm uses: pnpm/action-setup@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile # Build first: package type checks resolve @dorfteich/shared through # its built dist, and i18n:check imports the built helpers. - name: Build all packages run: pnpm build - name: Lint (ESLint + Prettier) run: pnpm lint - name: Typecheck run: pnpm typecheck - name: Unit and integration tests run: pnpm test - name: Translation key parity (de/en) run: pnpm i18n:check # Job id/display name kept stable (branch-protection/status-check # matching uses the reported "CI / Auth e2e pack" context) even though # it now also runs the content pack (issue #32) against the same # already-built-and-seeded stack, instead of spinning up a second one. auth-e2e: name: Auth e2e pack needs: prepare runs-on: ubuntu-latest services: postgres: image: postgres:17.5-alpine env: POSTGRES_USER: e2e POSTGRES_PASSWORD: e2e POSTGRES_DB: e2e mailpit: image: axllent/mailpit:latest env: DATABASE_URL: postgresql://e2e:e2e@postgres:5432/e2e APP_BASE_URL: http://localhost:5173 SMTP_HOST: mailpit SMTP_PORT: '1025' steps: - name: Check out repository uses: actions/checkout@v4 - name: Set up pnpm uses: pnpm/action-setup@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build packages run: pnpm build - name: Apply migrations run: pnpm --filter @dorfteich/api exec prisma migrate deploy - name: Seed fixtures run: pnpm --filter @dorfteich/api db:seed - name: Start api, collab, and static web server run: | (cd apps/api && PORT=3001 node dist/main.js > /tmp/api.log 2>&1 &) (cd apps/collab && PORT=3002 node dist/index.js > /tmp/collab.log 2>&1 &) (PORT=5173 COLLAB_TARGET=http://127.0.0.1:3002 node scripts/e2e-static-server.mjs > /tmp/web.log 2>&1 &) for i in $(seq 1 30); do curl -sf http://localhost:3001/api/v1/readyz >/dev/null && break sleep 2 done for i in $(seq 1 30); do curl -sf http://localhost:3002/healthz >/dev/null && break sleep 2 done curl -sf http://localhost:5173/ >/dev/null - name: Install Playwright browser run: pnpm --filter @dorfteich/web exec playwright install --with-deps chromium - name: Run auth pack run: | E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \ pnpm --filter @dorfteich/web exec playwright test e2e/auth.spec.ts # auth.spec.ts's own logins (contextForUser per test) already spend # a good chunk of the login rate limit (10/min/IP, operations.md) — # reset it before content.spec.ts's five more, or its later tests # 429 (found by reproducing this job's failure locally, issue #32). - name: Reset login rate limit between e2e packs run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run content pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/content.spec.ts # The collab pack opens two browser contexts per test (more logins), # so reset the login rate limit before it as well (see note above). - name: Reset login rate limit before collab pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run collab pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/collab.spec.ts # Two contexts per test (owner + a second regular account) and grant # changes → reset the login rate limit first (see note above). - name: Reset login rate limit before collab-permissions pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run collab-permissions pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/collab-permissions.spec.ts # Two contexts per test (owner + a second regular account) → reset first. - name: Reset login rate limit before members pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run members pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/members.spec.ts # Three contexts per test (owner + editor + viewer) → reset first. - name: Reset login rate limit before access-rules pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run access-rules pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/access-rules.spec.ts - name: Reset login rate limit before public pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run public pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/public.spec.ts - name: Reset login rate limit before admin-quotas pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run admin-quotas pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/admin-quotas.spec.ts - name: Reset login rate limit before admin-users pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run admin-users pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts - name: Reset login rate limit before permission-matrix pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run permission-matrix pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/permission-matrix.spec.ts - name: Reset login rate limit before attachments pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run attachments pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/attachments.spec.ts - name: Reset login rate limit before import pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" # The .docx case self-skips without a reachable pandoc sidecar (no # E2E_PANDOC here); the .md, failure, and concurrent cases run (#64). - name: Run import pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/import.spec.ts - name: Reset login rate limit before export pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" # The ZIP case runs; the .docx case self-skips without a pandoc sidecar (#65). - name: Run export pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/export.spec.ts - name: Reset login rate limit before fonts pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" # Pond fonts (#66): the GDPR no-off-origin assertion + apply/persist. - name: Run fonts pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/fonts.spec.ts - name: Reset login rate limit before offline pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run offline pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/offline.spec.ts - name: Reset login rate limit before labels pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run labels pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/labels.spec.ts - name: Reset login rate limit before reorder pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run reorder pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/reorder.spec.ts - name: Reset login rate limit before wikilink pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run wikilink pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/wikilink.spec.ts - name: Reset login rate limit before backlinks pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run backlinks pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/backlinks.spec.ts - name: Reset login rate limit before search pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" - name: Run search pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/search.spec.ts - name: Run plugin sandbox pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/plugins.spec.ts - name: Run plugin admin pack run: | E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/plugin-admin.spec.ts - name: Dump server logs on failure if: failure() run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true # The import/export fidelity gate (issue #69, ADR 0009): runs the corpus # snapshot suites and the PDF smoke check against the *pinned* sidecar images # (the same versions the stages run), so a structural regression — ours or a # pandoc/Gotenberg version bump that drifts the output — fails the pipeline # instead of degrading "best effort" silently. Kept a small, separate job so # it stays well under five minutes; the suites self-skip in the main `checks` # job (no sidecars there). fidelity: name: Import/export fidelity gate needs: prepare runs-on: ubuntu-latest # A guard so a hung sidecar can never keep the job (and its containers) # alive on the shared runner host; the suite itself finishes in ~1 min. timeout-minutes: 10 steps: - name: Check out repository uses: actions/checkout@v4 - name: Set up pnpm uses: pnpm/action-setup@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile # The fidelity tests import from @dorfteich/shared's built dist; they run # TypeScript directly (vitest) so only shared needs building, not the api. - name: Build shared package run: pnpm --filter @dorfteich/shared build # The import corpus test reaches through import.service, which imports the # generated Prisma client — generate it (no DB or migration needed; these # suites never touch a database). - name: Generate Prisma client run: pnpm --filter @dorfteich/api exec prisma generate # Start the pinned sidecars with `docker run` (pandoc-server needs the # `server` arg, which Actions `services:` cannot pass) and attach them to # THIS job container's network namespace, so they are reachable at # localhost — no dependency on `ip`/gateway routing (the minimal runner # image has no iproute2). Sharing the netns means no published ports. - name: Start pinned pandoc + Gotenberg sidecars run: | # Clear any leftovers from an earlier interrupted run so the named # containers never collide, and nothing leaks on the shared host. docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true JOB_ID=$(cat /etc/hostname) docker run -d --name fidelity-pandoc \ --network "container:${JOB_ID}" pandoc/core:3.6 server docker run -d --name fidelity-gotenberg \ --network "container:${JOB_ID}" gotenberg/gotenberg:8 for i in $(seq 1 30); do curl -sf http://localhost:3030/version >/dev/null && break sleep 1 done for i in $(seq 1 30); do curl -sf http://localhost:3000/health >/dev/null && break sleep 1 done curl -sf http://localhost:3030/version curl -sf http://localhost:3000/health - name: Run fidelity suite (import + export snapshots, PDF smoke) run: | PANDOC_URL=http://localhost:3030 GOTENBERG_URL=http://localhost:3000 \ pnpm --filter @dorfteich/api exec vitest run \ src/import-export/import.fixtures.test.ts \ src/import-export/export.fidelity.test.ts \ src/import-export/pdf.fidelity.test.ts - name: Dump sidecar logs on failure if: failure() run: | echo '--- pandoc ---'; docker logs fidelity-pandoc 2>&1 | tail -30 || true echo '--- gotenberg ---'; docker logs fidelity-gotenberg 2>&1 | tail -30 || true # Always tear the sidecars down — they run on the shared runner host, so a # leaked (especially Chromium-backed Gotenberg) container would waste its # memory until the next run and break re-runs on the container name. - name: Stop sidecars if: always() run: docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true images: name: Build container images needs: prepare # PR-only: on main the CD workflow builds and pushes the same images — # building twice would waste the runner (ADR 0014: build once, promote). if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 # PRs prove the Dockerfiles still build; pushing happens in cd.yml. - name: Build web image run: docker build -f apps/web/Dockerfile --build-arg APP_VERSION=${{ github.sha }} -t dorfteich-web:ci . - name: Build api image run: docker build -f apps/api/Dockerfile --build-arg APP_VERSION=${{ github.sha }} -t dorfteich-api:ci . - name: Build collab image run: docker build -f apps/collab/Dockerfile --build-arg APP_VERSION=${{ github.sha }} -t dorfteich-collab:ci .