name: CI on: push: branches: [main] pull_request: branches: [main] merge_group: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: security: name: Security Scan runs-on: ubuntu-latest permissions: contents: read pull-requests: read steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Gitleaks if: github.actor != 'dependabot[bot]' uses: trails-cool/gitleaks-action@4cbc857b9cfa2a3297fe2be1078e196d30d1b424 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - name: Dependency audit run: pnpm audit --audit-level=high continue-on-error: true dockerfile-check: name: Dockerfile Package Check runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: bash scripts/check-dockerfiles.sh openspec: name: OpenSpec Validate runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm openspec validate --all --strict --no-interactive typecheck: name: Typecheck runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint test: name: Unit Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test build: name: Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build visual-tests: name: Visual Tests runs-on: ubuntu-latest permissions: contents: read pull-requests: write steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('pnpm-lock.yaml') }} - name: Install Playwright Chromium if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm exec playwright install --with-deps chromium - name: Install Playwright deps only if: steps.playwright-cache.outputs.cache-hit == 'true' run: pnpm exec playwright install-deps chromium - name: Run visual regression tests id: visual-tests run: pnpm --filter @trails-cool/planner test:visual - name: Post diff comment on PR if: failure() && steps.visual-tests.outcome == 'failure' && github.event_name == 'pull_request' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diffs=$(find apps/planner/.vitest-attachments -name "*-diff-*.png" 2>/dev/null | sort) if [ -z "$diffs" ]; then exit 0; fi artifact_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" body="## Visual regression failures"$'\n\n' body+="The following tests produced screenshot diffs:"$'\n\n' for diff in $diffs; do name=$(basename "$diff" | sed 's/-diff-chromium-[a-z]*\.png//' | sed 's/-/ /g') body+="- \`$name\`"$'\n' done body+=$'\n'"**[Download the \`visual-snapshots-diff\` artifact]($artifact_url)** to inspect the diffs locally."$'\n\n' body+="To update snapshots if the change is intentional:"$'\n' body+="\`\`\`"$'\n' body+="pnpm --filter @trails-cool/planner test:visual:update"$'\n' body+="\`\`\`" gh pr comment ${{ github.event.pull_request.number }} --body "$body" - name: Upload screenshots on failure if: failure() uses: actions/upload-artifact@v7 with: name: visual-snapshots-diff path: apps/planner/.vitest-attachments/ include-hidden-files: true retention-days: 7 e2e: name: E2E Tests needs: build runs-on: ubuntu-latest env: DATABASE_URL: postgres://trails:trails@localhost:5432/trails steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - name: Cache BRouter segment id: segment-cache uses: actions/cache@v5 with: path: /tmp/brouter-segments key: brouter-segment-E10_N50-v1.7.9 - name: Download Berlin segment if: steps.segment-cache.outputs.cache-hit != 'true' run: | mkdir -p /tmp/brouter-segments wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5 - name: Pre-seed BRouter segment volume run: | docker volume create trails_brouter_segments docker run --rm \ -v /tmp/brouter-segments:/src:ro \ -v trails_brouter_segments:/dst \ alpine sh -c "cp /src/*.rd5 /dst/ && chmod a+r /dst/*.rd5" - name: Start services run: docker compose -f docker-compose.dev.yml up -d --wait --build env: BROUTER_URL: http://localhost:17777 - name: Wait for BRouter routing run: | for i in $(seq 1 60); do curl -s 'http://localhost:17777/brouter?lonlats=13.4,52.5|13.5,52.5&profile=trekking&format=geojson' 2>/dev/null | grep -q "FeatureCollection" && echo "BRouter ready" && break [ "$i" = "60" ] && echo "BRouter not ready after 120s" && exit 1 sleep 2 done - name: Push database schema run: pnpm db:push - name: Seed database run: pnpm db:seed - name: Run integration tests # These talk to real Postgres. The unit-test job has no DB so # the `*.integration.test.ts` files skip there; this job has # the DB up + schema pushed, so flip the gate env vars to "1" # and let them run. Each gate is read by one file — see # `runIntegration` in each test. # # --no-file-parallelism: integration tests share the journal # schema and clean up by `DELETE FROM ... WHERE email LIKE # '%@example.test'`. Parallel files step on each other's rows # and trip FK constraints. Running sequentially is still <3s. run: pnpm --filter @trails-cool/journal exec vitest run --no-file-parallelism --reporter=default app/lib/explore.integration.test.ts app/lib/follow.integration.test.ts app/lib/demo-bot.integration.test.ts app/lib/notifications.integration.test.ts app/jobs/notifications-fanout.integration.test.ts env: EXPLORE_INTEGRATION: "1" FOLLOW_INTEGRATION: "1" DEMO_BOT_INTEGRATION: "1" NOTIFICATIONS_INTEGRATION: "1" - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('pnpm-lock.yaml') }} - name: Install Playwright if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm exec playwright install --with-deps chromium - name: Install Playwright deps only if: steps.playwright-cache.outputs.cache-hit == 'true' run: pnpm exec playwright install-deps chromium - name: Build for production run: pnpm build env: VITE_SENTRY_ENVIRONMENT: ci - name: Run E2E tests run: pnpm test:e2e env: BROUTER_URL: http://localhost:17777 # E2E=true is the explicit opt-out from the fail-loud # requireSecret() / getDatabaseUrl() guards — playwright boots # the server via `react-router serve` (NODE_ENV=production) but # against the local dev Postgres + local cookie secrets. E2E: "true" INTEGRATION_SECRET: ${{ secrets.INTEGRATION_SECRET }} - name: Playwright job summary if: ${{ !cancelled() }} run: | if [ -f playwright-results.json ]; then node -e " const r = require('./playwright-results.json'); const s = r.stats; const dur = (s.duration / 1000).toFixed(1); let md = '## Playwright E2E Results\n\n'; md += '| Status | Count |\n|--------|-------|\n'; md += '| :white_check_mark: Passed | ' + s.expected + ' |\n'; if (s.unexpected > 0) md += '| :x: Failed | ' + s.unexpected + ' |\n'; if (s.flaky > 0) md += '| :warning: Flaky | ' + s.flaky + ' |\n'; if (s.skipped > 0) md += '| :fast_forward: Skipped | ' + s.skipped + ' |\n'; md += '| :stopwatch: Duration | ' + dur + 's |\n\n'; for (const file of r.suites) { for (const describe of (file.suites || [])) { md += '### ' + describe.title + '\n\n'; for (const spec of (describe.specs || [])) { const icon = spec.ok ? ':white_check_mark:' : ':x:'; const t = spec.tests?.[0]?.results?.[0]?.duration; md += '- ' + icon + ' ' + spec.title + (t ? ' (' + t + 'ms)' : '') + '\n'; } md += '\n'; } } require('fs').appendFileSync(process.env.GITHUB_STEP_SUMMARY, md); " fi - uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} with: name: playwright-report path: playwright-report/ retention-days: 30 journal-image-smoke: # Build the journal's *production* Docker image (the `runtime` stage) # and actually boot it. Nothing else in CI does this: typecheck / # lint / test / build all run against the source tree, and the e2e # job boots the journal via `react-router-serve`, not the production # `node server.ts` entrypoint. The runtime stage copies source files # in by name (server.ts, app/lib, serve-static.ts, ...), so a refactor # that adds a file `server.ts` imports — without a matching COPY — # builds green everywhere and only crash-loops once deployed # (ERR_MODULE_NOT_FOUND). That has taken prod down more than once # (app/lib, app/jobs, serve-static.ts). Booting the real image and # hitting /api/health closes that gap: a missing static OR dynamic # import never reaches a healthy 200. name: Journal Image Smoke Test runs-on: ubuntu-latest services: postgres: image: imresamu/postgis:16-3.4 env: POSTGRES_USER: trails POSTGRES_PASSWORD: trails POSTGRES_DB: trails ports: - 5432:5432 # The postgis image restarts mid-init while it creates the # extension; the health check only passes once the final server # is up, so dependents don't race the init restart. options: >- --health-cmd "pg_isready -U trails" --health-interval 5s --health-timeout 5s --health-retries 20 env: DATABASE_URL: postgres://trails:trails@localhost:5432/trails steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile # seedOAuthClient + the demo/notifications job worker run on boot # and write to real tables, so the image needs a schema to come up # healthy. The postgis extension is auto-created by the image. - name: Push database schema run: pnpm db:push - name: Build journal runtime image run: docker build --target runtime -f apps/journal/Dockerfile -t journal-smoke . - name: Boot image and wait for healthy run: | # --network host: reach the service Postgres at localhost:5432 # and publish the server on localhost:3000 in one shot. # E2E=true is the documented opt-out from the fail-loud # getDatabaseUrl() prod guard (CI points at a local Postgres). docker run -d --name journal-smoke --network host \ -e NODE_ENV=production -e E2E=true \ -e DATABASE_URL="$DATABASE_URL" \ journal-smoke code=000 for i in $(seq 1 30); do code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:3000/api/health || echo 000) echo "attempt $i: /api/health -> $code" [ "$code" = "200" ] && break if [ "$(docker inspect -f '{{.State.Running}}' journal-smoke 2>/dev/null)" != "true" ]; then echo "::error::journal container exited during boot" break fi sleep 2 done if [ "$code" != "200" ]; then echo "::error::journal production image failed to boot healthy (see logs below)" docker logs journal-smoke 2>&1 || true exit 1 fi echo "journal production image booted healthy" - name: Container logs if: always() run: docker logs journal-smoke 2>&1 | tail -40 || true