Repository navigation
feat: the URL-first pipeline, phases 2 to 4 (#20) #46
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 | |
| timeout-minutes: 15 | |
| 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. | |
| - name: Migrations apply cleanly | |
| run: bun run db:migrate | |
| env: | |
| TURSO_DATABASE_URL: file:./ci.db | |
| - name: Migrations are idempotent | |
| run: bun run db:status | |
| env: | |
| TURSO_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 | |
| 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 \ | |
| -e PORT=8080 \ | |
| -e API_TOKEN=ci-smoke-token \ | |
| -e TURSO_DATABASE_URL=file:./smoke.db \ | |
| -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 |