Repository navigation
401 lines (357 loc) · 16.7 KB
/
Copy pathci.yml
File metadata and controls
401 lines (357 loc) · 16.7 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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
name: CI
on:
push:
branches: [main]
pull_request:
# Cancel in-flight runs for the same workflow + ref. Including
# `github.workflow` in the key keeps this from colliding with any other
# workflow that happens to share a ref-based group prefix.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Least-privilege: this workflow only reads the repo to lint/build/verify; it
# never writes back. Anything that needs write scopes lives in a separate
# workflow with its own scoped permissions.
permissions:
contents: read
jobs:
# Every check of the app, for both sites, on every change: lint and
# format, the build, types, both test suites (bench's with its Postgres
# integration tests in Docker), the migration-runner, infrastructure and
# deploy-check tests, the endpoint checks and the browser tests. Lighthouse
# and axe run on the pull request's Vercel preview (lighthouse.yml), where
# bench has its database.
ci:
name: Lint, build, verify
runs-on: ubuntu-26.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.4.4
# Bun is the package manager and script runner, but Next.js (and tsc)
# run on Node. The runner image is pinned, but the Node
# version inside it can still drift; pin via .nvmrc so a future
# GitHub bump can't break the build silently.
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
# Cache Bun's resolved package store keyed on the lockfile hash. Cuts
# ~10s off `bun install` on a clean runner; the restore-key falls back
# to any prior cache from this OS so partial hits still help.
- name: Cache Bun install cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
# Cache Next.js's incremental build output. Keyed on lockfile + commit
# SHA so the exact-key match is always per-commit fresh; restore-keys
# fall back to any previous build on the same lockfile, then any build
# on this OS — so most CI runs hit a cache and skip rebuilding
# unchanged webpack modules.
- name: Cache Next.js build cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-
${{ runner.os }}-nextjs-
- name: Install
run: bun install --frozen-lockfile
- name: Lint + format check
run: bun run check:ci
# Build BEFORE typecheck: the build runs velite (populating .velite/
# which the `#site/content` path alias resolves to) and produces
# next-env.d.ts. tsc fails without these, so order matters here.
- name: Build
run: bun run build
- name: Type check
run: bun run typecheck
# knip finds unused files, exports and dependencies. It runs after the
# build for the same reason tsc does: it resolves `#site/content` to
# .velite/, which the build writes.
- name: Unused code and dependencies
run: bun run knip
- name: Unit tests
run: bun run test
- name: Test the parity check and the deploy check
run: |
bun test scripts/parity/compare-api.test.ts
node --test scripts/verify-web-deploy.test.mjs
- name: Check infrastructure trust policies offline
run: |
bash -n infra/provision.sh
python3 -m unittest discover -s infra/tests -v
- name: Install uv
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
enable-cache: false
# uv has no lockfile here, so hold it to packages at least 7 days old,
# as bunfig.toml holds Bun.
- name: Set the uv cooldown
run: echo "UV_EXCLUDE_NEWER=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_ENV}"
- name: Test the migration runner
working-directory: db
run: uv run --no-project --with 'psycopg[binary]>=3.2' python -m unittest discover -s tests -v
# bench's Postgres integration suite starts Postgres in Docker
# (testcontainers). With CI=true it fails rather than skips without it.
- name: Require Docker for bench's integration suite
run: docker info > /dev/null
- name: bench tests
run: bun run test:bench
- name: Start server
run: |
bun run start > /tmp/server.log 2>&1 &
echo $! > /tmp/server.pid
for i in $(seq 1 30); do
if curl -sf http://localhost:3000 > /dev/null; then
echo "server ready"
exit 0
fi
sleep 1
done
echo "server failed to start in 30s"
cat /tmp/server.log
exit 1
- name: Verify endpoints
run: bun run verify
# Playwright browsers are ~250 MB. Cache them keyed on the
# @playwright/test version pinned in package.json — invalidates on
# bumps, hits otherwise. System deps (apt packages) aren't covered by
# the cache, so install those separately even on cache hit (~10s vs
# ~3min for the full install).
# Key the cache on the installed version, not the range in package.json,
# which a lock refresh can move past, and on the runner image: browser
# builds link against the image's system libraries, so a cache made on
# ubuntu24 fails on ubuntu26.
- name: Get Playwright version
id: playwright-version
run: |
VERSION=$(node -p "require('@playwright/test/package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "image=${ImageOS:-unknown}" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-${{ steps.playwright-version.outputs.image }}-playwright-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers (cache miss)
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: bunx playwright install --with-deps chromium webkit
- name: Install Playwright system deps (cache hit)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: bunx playwright install-deps chromium webkit
# The axe specs need bench's database, so they run on the preview in
# lighthouse.yml.
- name: Browser smoke tests
run: bun run test:e2e --grep-invert @axe
# In CI, playwright.config.ts uses only the `github` reporter, so there
# is no HTML report; a failed test keeps its trace in test-results/.
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-traces
path: test-results/
retention-days: 7
- name: Stop server
if: always()
run: |
if [ -f /tmp/server.pid ]; then
kill $(cat /tmp/server.pid) 2>/dev/null || true
fi
- name: Server logs (on failure)
if: failure()
run: cat /tmp/server.log || true
# Reports dependency advisories WITHOUT gating merges. A new advisory can
# surface at any time from an upstream publish, with no change on our
# side, so a hard gate here blocks unrelated PRs on work we often cannot
# do (see CLAUDE.md "Audit advisories" for the upstream-blocked ones).
# `continue-on-error` on the step keeps the job green while still marking
# the step red and posting the full advisory list to the job summary, so
# the status stays visible on every run. Nothing is `--ignore`d: with no
# gate to keep green, suppressing advisories only hides information.
audit:
name: Dependency audit (non-blocking)
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.4.4
- name: Cache Bun install cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install
run: bun install --frozen-lockfile
- name: Dependency audit
continue-on-error: true
run: |
bun audit | tee /tmp/audit.log
status=${PIPESTATUS[0]}
{
echo "## Dependency audit"
echo
if [ "$status" -eq 0 ]; then
echo "No advisories reported."
else
echo "Advisories reported. Informational only — this does not block merge."
echo
echo '```'
cat /tmp/audit.log
echo '```'
fi
} >> "$GITHUB_STEP_SUMMARY"
exit "$status"
# Runs only on PRs (no baseline diff to compute on a push to main).
# Compares the PR's dependency manifest against main and flags
# high-severity advisories or license incompatibilities. Hard-gated: the
# `bun audit` job is informational, so this is the one advisory check
# that blocks merge, and it is scoped to what the PR itself introduces
# (severity `high` and above) rather than the whole pre-existing tree.
#
# GitHub's dependency graph does not read bun.lock. The dependency-snapshot
# workflow submits it, and the action would pass without it, so a guard
# runs first (see its comment). The retry inputs then cover the short time
# GitHub takes to process a submitted snapshot. `fail-on-scopes` stays at
# the action's default, `runtime`: a development-only advisory does not
# block merge and stays with `bun audit` and CLAUDE.md "Audit advisories".
dependency-review:
if: github.event_name == 'pull_request'
runs-on: ubuntu-26.04
permissions:
contents: read
# Implies read, which the guard uses to list a fork's changed files.
pull-requests: write
# The guard reads the snapshot job's check run.
checks: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The action reviews whatever graph it finds. If the snapshot job
# failed, was skipped or finished late, that graph lacks bun.lock's
# packages and the review passes without them. So: for a pull request
# from this repository, wait for that job on the head commit and fail
# unless it succeeded. A fork's pull request gets no snapshot (its token
# cannot submit one), so fail it if it changes a bun.lock; if it does
# not, it adds no locked package.
- name: Require the bun.lock snapshot
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
CHANGED_FILES: ${{ github.event.pull_request.changed_files }}
FROM_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
# The `name:` of the job in dependency-snapshot.yml.
SNAPSHOT_CHECK: Submit bun.lock
WAIT_SECONDS: 600
run: |
if [ "$FROM_FORK" = true ]; then
files=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files?per_page=100" --jq '.[].filename')
# The API lists at most 3000 files; a longer pull request could hide a bun.lock.
listed=$(grep -c . <<<"$files" || true)
if [ "$listed" -lt "$CHANGED_FILES" ]; then
echo "::error::Listed $listed of the pull request's $CHANGED_FILES changed files, so a bun.lock change could go unseen."
exit 1
fi
if grep -Eq '(^|/)bun\.lock$' <<<"$files"; then
echo "::error::This pull request comes from a fork and changes bun.lock. A fork gets no dependency snapshot, so dependency review cannot see the locked packages. Make the lock change from a branch in this repository."
exit 1
fi
echo "Fork pull request without a bun.lock change: it adds no locked package."
exit 0
fi
name=$(jq -rn --arg name "$SNAPSHOT_CHECK" '$name | @uri')
deadline=$((SECONDS + WAIT_SECONDS))
while :; do
# The newest run of the job (highest id), so a successful rerun counts.
if run=$(gh api "repos/$REPO/commits/$HEAD_SHA/check-runs?check_name=$name&per_page=100" \
--jq '(.check_runs | max_by(.id)) // {} | "\(.status // "missing") \(.conclusion // "none")"'); then
read -r status conclusion <<<"$run"
if [ "$status" = completed ]; then
if [ "$conclusion" = success ]; then
echo "The '$SNAPSHOT_CHECK' job submitted the snapshot for $HEAD_SHA."
exit 0
fi
echo "::error::The '$SNAPSHOT_CHECK' job concluded '$conclusion', so dependency review would not see bun.lock's packages. Rerun it, then rerun this job."
exit 1
fi
echo "'$SNAPSHOT_CHECK' on $HEAD_SHA: $status"
else
echo "::warning::Could not read the check runs of $HEAD_SHA; trying again."
fi
if [ "$SECONDS" -ge "$deadline" ]; then
echo "::error::The '$SNAPSHOT_CHECK' job did not finish on $HEAD_SHA within $WAIT_SECONDS seconds."
exit 1
fi
sleep 15
done
- name: Dependency Review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: high
comment-summary-in-pr: on-failure
retry-on-snapshot-warnings: true
retry-on-snapshot-warnings-timeout: 300
# Defense-in-depth on top of GitHub's push protection. Push protection
# covers high-entropy / known-provider patterns at push time; TruffleHog
# re-scans diffs on PRs + push to main, catching secrets that slipped
# past push protection (low-entropy formats, detector patterns added
# after the secret was committed, or push-protection bypass via the
# "I'll fix it later" allow path).
secret-scan:
runs-on: ubuntu-26.04
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# TruffleHog diffs base..head and needs the full history present.
fetch-depth: 0
persist-credentials: false
- name: TruffleHog scan
uses: trufflesecurity/trufflehog@31ce31ee1abbcc7dc8eee5764c17121c3044c8b2 # v3.99.7
with:
# On PRs: scan the diff between base and head. On push to main:
# scan the previous commit to HEAD. The action infers both from
# the event context.
# --results=verified,unknown drops trufflehog's "unverified" tier
# (pattern-matched but couldn't reach the verifier endpoint) to
# keep noise down without losing coverage of secrets whose
# verifiers don't recognize them yet.
extra_args: --results=verified,unknown
# Keeps every file's license machine-readable, per section 7.d of the
# Vortex Technical Charter. REUSE.toml maps code to Apache-2.0 and docs to
# CC-BY-4.0; a file it doesn't cover (usually a new image or other asset)
# fails here until it's annotated. The action builds from the floating
# `fsfe/reuse:6` image tag, so the job gets no secrets and checkout doesn't
# persist the read-only token into the workspace the container mounts.
reuse:
name: REUSE compliance
runs-on: ubuntu-26.04
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: REUSE lint
uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6.0.0