Repository navigation
Job posts: postings by URL or keyword and the people behind them; bot… #244
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| concurrency: | |
| group: ci-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| check: | |
| runs-on: ubuntu-latest | |
| # Format, typecheck, 2000-plus tests and a Next build. The ceiling was 15 | |
| # and the suite grew into it: a run that does the same work in five and a | |
| # half minutes locally took 15m16s here and was killed at the mark, with | |
| # two unrelated tests timing out on the way as the runner starved. Raised | |
| # rather than left to flake, because a job cancelled at its ceiling | |
| # reports as a test failure and sends the next person reading it hunting | |
| # through a suite that is fine. `image` has had 25 all along. | |
| timeout-minutes: 30 | |
| # Production runs on Postgres, so the database-bound tests run against one | |
| # here: each test gets a database of its own, cloned from a template that | |
| # holds the migrated schema (packages/db/src/testing.ts). | |
| services: | |
| postgres: | |
| image: postgres:17-alpine | |
| env: | |
| POSTGRES_PASSWORD: ci | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U postgres" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| env: | |
| TEST_DATABASE_URL: postgres://postgres:ci@127.0.0.1:5432/postgres | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.3.14 | |
| - name: Install | |
| run: bun install --frozen-lockfile | |
| - name: Format | |
| run: bun run format:check | |
| - name: Typecheck | |
| run: bun run typecheck | |
| - name: Test | |
| run: bun test | |
| # The web app has its own tsconfig (JSX, Next types) and is excluded from | |
| # the root typecheck, so it needs its own build to be covered at all. | |
| - name: Build the PWA | |
| run: bun run --filter '@outreachgraph/web' build | |
| # Proves the committed migrations apply to an empty database, which is | |
| # what every deploy does before the new image serves traffic — on | |
| # Postgres (production) and on a SQLite file (a laptop). | |
| - name: Migrations apply cleanly (Postgres) | |
| run: bun run db:migrate && bun run db:status | |
| env: | |
| DATABASE_URL: postgres://postgres:ci@127.0.0.1:5432/postgres | |
| - name: Migrations apply cleanly (SQLite) | |
| run: bun run db:migrate && bun run db:status | |
| env: | |
| DATABASE_URL: file:./ci.db | |
| # One image now: the PWA, the API and the worker in a single container. | |
| # | |
| # Building is not enough — an image once built cleanly and then crashed on | |
| # boot with a missing workspace dependency. This job boots the container and | |
| # waits for both the API health endpoint and a page served by the Next child, | |
| # so a broken supervisor or a dead proxy fails here rather than in production. | |
| image: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| # The image refuses to boot on anything but Postgres, so the smoke test | |
| # gives it one. The service publishes 5432 on the runner; the app | |
| # container reaches it through the host gateway. | |
| services: | |
| postgres: | |
| image: postgres:17-alpine | |
| env: | |
| POSTGRES_PASSWORD: ci | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U postgres" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: docker/setup-buildx-action@v3 | |
| - name: Build the image | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: docker/Dockerfile | |
| push: false | |
| load: true | |
| tags: outreachgraph:ci | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| - name: Boot the container | |
| run: | | |
| docker run -d --name og \ | |
| --add-host=host.docker.internal:host-gateway \ | |
| -e PORT=8080 \ | |
| -e API_TOKEN=ci-smoke-token \ | |
| -e DATABASE_URL=postgres://postgres:ci@host.docker.internal:5432/postgres \ | |
| -e WORKER_TICK_MS=3600000 \ | |
| -p 8080:8080 \ | |
| outreachgraph:ci | |
| for i in $(seq 1 60); do | |
| if curl -fsS http://127.0.0.1:8080/health/live >/dev/null 2>&1; then | |
| echo "api healthy after ${i}s" | |
| break | |
| fi | |
| if [ "$(docker inspect -f '{{.State.Running}}' og)" != "true" ]; then | |
| echo "container exited early:"; docker logs og; exit 1 | |
| fi | |
| sleep 1 | |
| done | |
| curl -fsS http://127.0.0.1:8080/health/live || { docker logs og; exit 1; } | |
| - name: The Next child is proxied | |
| run: | | |
| # /offline is prerendered and needs neither API nor database, so a | |
| # 200 here proves the supervisor reached the web child. | |
| for i in $(seq 1 60); do | |
| code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/offline || echo 000) | |
| if [ "$code" = "200" ]; then echo "web proxied after ${i}s"; exit 0; fi | |
| sleep 1 | |
| done | |
| echo "web never answered through the proxy:"; docker logs og; exit 1 | |
| - name: The PWA can reach the API through the supervisor | |
| run: | | |
| # /offline is static and proves nothing about the API wiring. This | |
| # route renders through the API, so it catches the case where the web | |
| # child calls the wrong address — which shipped once, as a 500 from | |
| # the child fetching itself instead of the API. | |
| code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/today) | |
| echo "GET /today -> $code" | |
| case "$code" in | |
| 200|302|303|307|308) ;; | |
| *) echo "expected the inbox or a redirect to /login"; docker logs og; exit 1 ;; | |
| esac | |
| - name: The landing page is public and actually has a body | |
| run: | | |
| # --compressed matters: plain curl sends no Accept-Encoding, but every | |
| # browser does. A proxy that forwards the child's content-encoding | |
| # while fetch has already decompressed the body returns a 200 with | |
| # zero bytes — which shipped once, and which a bare curl cannot see. | |
| code=$(curl -s --compressed -o /tmp/landing.html -w '%{http_code}' http://127.0.0.1:8080/) | |
| bytes=$(wc -c < /tmp/landing.html) | |
| echo "GET / -> $code, $bytes bytes" | |
| [ "$code" = "200" ] || { echo "the landing page must be public"; docker logs og; exit 1; } | |
| [ "$bytes" -gt 1000 ] || { echo "landing page body is empty or truncated"; docker logs og; exit 1; } | |
| # Assert on the headline and on the sample card's evidence, so this | |
| # catches a shell that renders but drops its content — the specific | |
| # failure that once shipped a 200 with an empty body. | |
| grep -q "reason to talk" /tmp/landing.html \ | |
| || { echo "landing page did not render its headline"; exit 1; } | |
| grep -q "cross-border payouts" /tmp/landing.html \ | |
| || { echo "landing page did not render the sample approval card"; exit 1; } | |
| # The signed-in shell must survive compression too. | |
| login_bytes=$(curl -s --compressed http://127.0.0.1:8080/login | wc -c) | |
| echo "GET /login -> $login_bytes bytes" | |
| [ "$login_bytes" -gt 500 ] || { echo "/login body is empty"; docker logs og; exit 1; } | |
| - name: Auth is enforced | |
| run: | | |
| code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/v1/recommendations) | |
| echo "unauthenticated api -> $code" | |
| [ "$code" = "401" ] || { docker logs og; exit 1; } | |
| - name: Registration and login work end to end | |
| run: | | |
| set -e | |
| reg=$(curl -s -o /dev/null -w '%{http_code}' -X POST \ | |
| -H 'content-type: application/json' \ | |
| -d '{"email":"ci@example.com","password":"correct horse battery staple"}' \ | |
| http://127.0.0.1:8080/api/v1/auth/register) | |
| echo "register -> $reg" | |
| [ "$reg" = "201" ] || { docker logs og; exit 1; } | |
| curl -s -c /tmp/jar -X POST -H 'content-type: application/json' \ | |
| -d '{"email":"ci@example.com","password":"correct horse battery staple"}' \ | |
| http://127.0.0.1:8080/api/v1/auth/login > /dev/null | |
| me=$(curl -s -b /tmp/jar -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/v1/auth/me) | |
| echo "authenticated /auth/me -> $me" | |
| [ "$me" = "200" ] || { docker logs og; exit 1; } | |
| - name: Container logs | |
| if: always() | |
| run: docker logs og 2>&1 | tail -60 |