Repository navigation
222 lines (189 loc) · 8.51 KB
/
Copy pathci.yml
File metadata and controls
222 lines (189 loc) · 8.51 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
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