Skip to content

Job posts: postings by URL or keyword and the people behind them; bot… #244

Job posts: postings by URL or keyword and the people behind them; bot…

Job posts: postings by URL or keyword and the people behind them; bot… #244

Workflow file for this run

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