-
Notifications
You must be signed in to change notification settings - Fork 0
390 lines (347 loc) · 16.6 KB
/
Copy pathci.yml
File metadata and controls
390 lines (347 loc) · 16.6 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
name: CI
# One workflow, two deployables that share a repository and nothing else.
#
# the app workers/, packages/, migrations/, tests/ -> the `podium` Worker
# the marketing site www/ -> the `podium-www` Worker
#
# Pull requests get the checks. Pushes to main get the checks and, only if they
# all pass, a deploy — of whichever of the two actually changed.
#
# Why the `changes` job rather than `on.push.paths`: path filters in `on:` are
# workflow-level, so they would gate both deployables together. The whole point
# of the split is that a copy fix on the landing page must not run a D1
# migration against production, and that only holds if the filter is per job.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
changes:
name: Detect what changed
runs-on: ubuntu-latest
outputs:
app: ${{ steps.filter.outputs.app }}
www: ${{ steps.filter.outputs.www }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Needed to diff against the base commit; the default shallow clone
# does not contain it.
fetch-depth: 0
- id: filter
env:
EVENT: ${{ github.event_name }}
BEFORE: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
run: |
set -euo pipefail
# Pick a base to diff against. A manual run has none, and the first
# push to a branch reports an all-zero `before` — both mean "assume
# everything changed". That is the safe direction to be wrong in: the
# cost is a redundant deploy, where the cost of the other direction is
# a change that silently never ships.
base=""
if [ "$EVENT" = "pull_request" ]; then
base="$BASE_SHA"
elif [ "$EVENT" = "push" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] \
&& git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
base="$BEFORE"
fi
app=false
www=false
if [ -z "$base" ]; then
echo "No usable base ref ($EVENT) — treating everything as changed."
app=true
www=true
else
files=$(git diff --name-only "$base" "$HEAD_SHA")
echo "Changed files:"
echo "$files" | sed 's/^/ /'
while IFS= read -r f; do
[ -z "$f" ] && continue
case "$f" in
# This file gates both, so a change to it must run both.
.github/workflows/ci.yml) app=true; www=true ;;
# Agent definitions and their reference docs are prose that
# nothing in this workflow reads or executes. `.claude/skills/`
# is the opposite — `npm run drift`, `npm run security` and the
# semgrep step all run out of it — so it stays in the catch-all
# below and is deliberately not matched here.
.claude/agents/*) ;;
www/*) www=true ;;
*) app=true ;;
esac
done <<< "$files"
fi
echo "app=$app" >> "$GITHUB_OUTPUT"
echo "www=$www" >> "$GITHUB_OUTPUT"
echo "-> app=$app www=$www"
check:
name: Typecheck, tests, model drift, security
needs: changes
if: needs.changes.outputs.app == 'true'
runs-on: ubuntu-latest
# A healthy run of this job is about two minutes. The cap is not a
# performance budget — it is the backstop for a wedged run, which without
# one is billed at GitHub's six-hour default before anyone is told the run
# is dead. Thirty minutes clears the integration step's own worst case
# (five 4-minute attempts, below) plus every other step, so this only ever
# fires on something the retry did not anticipate.
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# `semgrep --baseline-commit` checks out and re-scans the base, so
# the default shallow clone is not enough.
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: npm
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- run: npm ci
# worker-configuration.d.ts is generated, not committed (.gitignore), so
# a fresh checkout has no Env type and `tsc` fails without this. Reads
# wrangler.jsonc only — no Cloudflare credentials involved.
- name: Generate binding types
run: npx wrangler types
- run: npm run typecheck
- name: Unit tests
run: npm run test:unit
# @cloudflare/vitest-pool-workers runs workerd locally against real
# bindings; still no account credentials needed.
#
# Retried, and retried in this specific shape, because the pool has a
# startup race we cannot fix from here. A large minority of full-suite
# runs die with
#
# EnvironmentTeardownError: [vitest-worker]: Closing rpc while
# "resolve" was pending
#
# raised as an uncaught exception inside workerd: an environment is torn
# down while module resolution is still in flight, and the run then
# wedges forever — no summary, no exit, no failure. That last part is
# why this needs handling at all; a wedge is indistinguishable from a
# slow run until the job timeout fires.
#
# It is not this suite's doing, and the failure scales with the number
# of test files rather than with anything in them. What was measured:
# a one-file run is clean 6/6 in ten seconds, an 11-file shard wedges,
# and the full 44-file suite fails somewhere between half and three
# quarters of attempts. None of upgrading the pool to 0.21.2,
# serialising with `--no-file-parallelism`, sharding into quarters, or
# clearing leaked state between runs changes that. Every wedge observed
# landed during collection, before a single test had run.
#
# So the retry keys on the distinction that matters: `timeout` reports a
# wedge as 124, and only 124 is retried. A genuine test failure has some
# other non-zero status and fails the build on the first attempt, which
# is what keeps this from quietly papering over a real red test.
#
# Five attempts, because three is not enough at the observed rate: at
# the measured one-in-three-to-one-in-four success per attempt, three
# attempts still leaves roughly a 30% chance of a spurious red, and five
# brings that to the low teens. Four minutes each is many times over a
# healthy run here, which takes well under a minute on CI hardware.
#
# This is a compromise, not a cure — a spurious red is still possible.
# The fix belongs upstream in @cloudflare/vitest-pool-workers; this only
# stops the bug costing six hours and a blocked deploy each time it
# fires.
- name: Integration tests
run: |
set -uo pipefail
for attempt in 1 2 3 4 5; do
timeout 240 npm run test:integration && exit 0
status=$?
if [ "$status" -ne 124 ]; then
echo "Integration tests failed (exit $status) — a real failure, not the pool wedge."
exit "$status"
fi
echo "::warning::Integration run $attempt wedged in vitest-pool-workers startup; retrying."
# `timeout` kills vitest but not the workerd children it spawned:
# they reparent to init and keep running. Measured, they survive
# and accumulate, so without this each retry starts dirtier than
# the attempt before it.
pkill -x workerd 2>/dev/null || true
rm -rf /tmp/miniflare* 2>/dev/null || true
sleep 2
done
echo "Integration tests wedged on all 5 attempts."
exit 1
# CLAUDE.md: the domain model is the specification, and a change that
# lands in code without landing in docs/domain/ is an incomplete commit.
# `--check` exits non-zero on drift, which is the point of running it here.
- name: Domain model drift
run: npm run drift
# The same rule applied to what we tell agents about the product. The
# `podium-api` skill ships an endpoint catalogue generated from the
# routes, and a route added without regenerating it is drift between the
# product and its own reference — an agent that trusts a stale catalogue
# spends its turn on a 404. `--check` writes nothing and exits non-zero
# when the file is out of date.
- name: Plugin endpoint catalogue
run: npm run plugin:check
# `.claude/skills/security-audit/`. Two halves, both cheap and offline:
#
# npm run security the attack-surface inventory — fails only on
# surface that is *new* since the last accepted
# baseline (a route with no guard, a fresh
# `raw()`, a `db.raw()` naming no org_id)
# npm run security:semgrep taint and pattern rules for this repo's own
# sinks, which no stock ruleset knows about
#
# Deliberately in `check` rather than a job of its own: these gate the
# deploy exactly like the tests do. A new unguarded admin route is not a
# warning to read later.
- name: Attack surface
run: npm run security
- name: Install semgrep
run: pip install --disable-pip-version-check --quiet semgrep
# Gated on what is *new*, not on the total — the same shape as
# `npm run security`'s baseline, and for the same reason. Most of what
# these rules report is an inventory a human has to read (16 `raw()`
# interpolations, 36 `db.raw()` calls doing their own org-scoping); it is
# worth reviewing and it is not worth blocking every unrelated PR on.
# `--baseline-commit` reruns the scan against the base and subtracts, so
# only a finding this change introduced fails the build.
#
# No base ref (a manual run, or the first push to a branch) means no
# subtraction is possible. That reports everything, which would be a wall
# of pre-existing findings rather than a signal, so the gate steps aside
# and says so — the scheduled CodeQL run and the quarterly audit are what
# cover that case.
- name: Semgrep (Podium rules)
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
EVENT: ${{ github.event_name }}
run: |
set -euo pipefail
base=""
if [ "$EVENT" = "pull_request" ] && [ -n "$BASE_SHA" ]; then
base="$BASE_SHA"
elif git rev-parse --verify --quiet HEAD~1 >/dev/null; then
base=$(git rev-parse HEAD~1)
fi
if [ -z "$base" ]; then
echo "No base commit to diff against — reporting without gating."
npm run security:semgrep
exit 0
fi
echo "Gating on findings new since $base"
semgrep scan --metrics=off --error --baseline-commit "$base" \
--config .claude/skills/security-audit/rules/ \
--exclude=node_modules --exclude=www --exclude=.wrangler
deploy:
name: Deploy the app
needs: [changes, check]
if: needs.changes.outputs.app == 'true' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
# Gate approvals, and the production secrets, on a GitHub Environment.
environment:
name: production
# The app does not know its own hostname — see scripts/deploy-config.mjs.
# Neither does this workflow; it reads the one the deployment was given.
url: https://${{ vars.PODIUM_HOSTNAME }}
# Never two deploys at once: migrations and `wrangler deploy` are not safe
# to interleave. Not cancel-in-progress — a half-applied migration set is
# worse than a queued deploy.
concurrency:
group: deploy-production
cancel-in-progress: false
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PODIUM_HOSTNAME: ${{ vars.PODIUM_HOSTNAME }}
CF_D1_DATABASE_ID: ${{ secrets.CF_D1_DATABASE_ID }}
CF_KV_CACHE_ID: ${{ secrets.CF_KV_CACHE_ID }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: npm
- run: npm ci
# Migrations before the Worker: `migrations/` is append-only, so the new
# schema is always compatible with the currently-deployed code, while the
# reverse order leaves new code reading columns that do not exist yet.
- name: Apply D1 migrations
run: npm run deploy:migrate
- name: Deploy Worker
run: npm run deploy
# Fail the run if the deploy produced a site that does not answer. /login
# rather than / because it renders without an event or a session.
#
# Following redirects rather than asserting 200 on the first response: on
# a deployment with no Organization yet, /login legitimately 303s to
# /setup (01, "First-run setup"). What this step is for is proving the
# Worker is up and serving, and the end of the chain is where that shows.
- name: Smoke check
run: |
# Split with parameter expansion, not `read`: curl's -w output has no
# trailing newline, so `read` returns non-zero at EOF and the step
# dies under `bash -e` before it can report anything.
out=$(curl -sSL -o /dev/null \
-w '%{http_code} %{url_effective}' --retry 5 --retry-delay 5 \
--retry-all-errors --max-redirs 5 --max-time 30 \
"https://${PODIUM_HOSTNAME}/login")
code=${out%% *}
url=${out#* }
echo "GET /login -> $code ($url)"
test "$code" = "200"
deploy-www:
name: Deploy the marketing site
needs: changes
if: needs.changes.outputs.www == 'true' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
# Reuses the `production` environment for its two secrets. If that
# environment ever gains required reviewers, split this onto its own
# `production-www` holding the same secrets with no gate — a landing-page
# copy fix should not need an approval.
environment:
name: production
url: https://podiumstack.com
# Independent of deploy-production: nothing here touches D1, so there is no
# reason for a marketing deploy to queue behind an app deploy or vice versa.
concurrency:
group: deploy-www
cancel-in-progress: false
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
defaults:
run:
working-directory: www
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: npm
cache-dependency-path: www/package-lock.json
# Note what is absent: no typecheck of app code, no tests, and above all
# no `deploy:migrate`. This job cannot reach the production database.
- run: npm ci
- name: Build and deploy
run: npm run deploy
# Assert the redirect *target*, not merely that some 3xx came back. A
# `_redirects` rule that fails to parse is logged and skipped rather than
# failing the deploy, so "not a 200" is not enough to prove the file
# survived the trip.
- name: Smoke check
run: |
root=$(curl -sS -o /dev/null -w '%{http_code}' --retry 5 --retry-delay 5 \
--retry-all-errors --max-time 30 https://podiumstack.com/)
loc=$(curl -sS -o /dev/null -w '%{redirect_url}' --max-time 30 https://podiumstack.com/login)
echo "GET / -> $root ; GET /login -> $loc"
test "$root" = "200"
test "$loc" = "https://app.podiumstack.com/login"