forked from ChelseaKR/cairn
-
Notifications
You must be signed in to change notification settings - Fork 0
550 lines (502 loc) · 25.1 KB
/
Copy pathci.yml
File metadata and controls
550 lines (502 loc) · 25.1 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
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
# Cairn CI.
#
# Eight jobs, in deliberate order of dependency:
#
# core install, lint, test. Standard library only, no network
# needed, and no auditor: this is the path a contributor
# runs, and it must not depend on anything outside this
# repository. Matrixed across supported Python versions, on
# Linux.
# core-windows,
# core-macos the same install/lint/test/demo/independence steps as
# `core`, one canary job per non-Linux OS at the newer
# supported Python version — the stdlib-only claim is a
# claim about the package, not about Ubuntu.
# image builds the container image release.yml pushes to GHCR, and
# asks it the demo question over HTTP, on every change — not
# only at release time.
# package builds the sdist and wheel release.yml's pypi job publishes,
# installs the wheel into a clean environment, and asks it the
# demo question too — an editable install (what `core` tests)
# proves nothing about whether the distributed artifact is
# complete on its own.
# interface the accessibility behaviours that need a real browser.
# audit the merge gate. Resolves the pinned auditor, grades the
# recorded evidence, and then holds the result against the
# committed baseline — because a floor is a minimum and a score
# can decay a long way above one without breaching it.
# live the same questions, asked over HTTP against a running server,
# and compared to the evidence the gate graded. Separate from
# `audit` on purpose: the gate must stay offline and
# deterministic, and nothing in this job can make it pass.
#
# `audit` is the job that must be marked required in branch protection, and as
# of 2026-08-22 it is: the ruleset committed at .github/rulesets/main.json was
# applied, and verified two ways, not just applied — see
# .github/rulesets/README.md. It is not enough for a gate to exist. A gate
# nobody has made blocking is a report, which is what this job was until the
# ruleset made it one, and this comment is here so nobody reads the job name
# and assumes otherwise about whichever of those is currently true.
#
# ---------------------------------------------------------------------------
# Why this gate fails instead of skipping
# ---------------------------------------------------------------------------
# The obvious way to write an audit job is to skip it when the harness cannot
# be fetched — the network is flaky, the run is on a fork, someone is offline.
# Every one of those is a real inconvenience and none of them is a reason to
# report green.
#
# A skipped gate and a passed gate look identical on a pull request: one green
# check. The difference only exists in a log nobody opens. So the moment the
# harness is unreachable is exactly the moment the gate has told you nothing,
# and the honest report of "told you nothing" is a red check.
#
# So: no `continue-on-error`, no `if:` that lets this job be skipped, and
# never `PLUMBLINE_SRC`. If the harness cannot be resolved,
# ./plumbline-gate.sh exits 4 and this job fails. A gate that could not run is
# not a gate that passed.
#
# ---------------------------------------------------------------------------
# Why every gate invocation says `env -u PLUMBLINE_SRC`
# ---------------------------------------------------------------------------
# The runner honours `PLUMBLINE_SRC` as a develop-the-harness escape hatch: set
# it and resolution is skipped entirely, the pin is not consulted, and the run
# is graded by whatever is in that directory. It warns loudly on stderr and
# then does it anyway — which is the right call for the harness's own
# developers and the wrong one for a merge gate, because a warning in a log
# nobody opens next to a green check is a green check.
#
# The runner is vendored from Plumbline byte for byte (a step below proves it),
# so the fix does not belong in the file: patching it here would fork the one
# implementation of resolution this repository has, which is the thing the
# vendoring exists to prevent. It belongs at the call site. `env -u` removes
# the variable from the gate's environment whatever put it there — a workflow
# edit, a repository-level variable, a self-hosted runner's profile — so the
# only harness this job can be graded by is the pinned one.
# ---------------------------------------------------------------------------
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
core:
name: core (install, lint, test — auditor unreachable)
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.11", "3.12"]
steps:
# Nothing in this workflow pushes, so no job has any use for a
# credential left behind in .git/config where every later step and
# every uploaded artifact can reach it.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python }}
- name: Install
run: pip install -e ".[dev]"
- name: Lint
run: ruff check .
- name: Test
run: python3 -m unittest discover -s tests
- name: The demo path runs with no install at all
run: |
python3 -m cairn index
python3 -m cairn ask "How much is the monthly grocery allowance for one person?"
python3 -m cairn ask --explain "What vaccinations does my dog need?"
- name: Nothing above resolved the auditor
run: |
if [ -d .plumbline-cache ]; then
echo "the core path fetched the auditor; it must not need it" >&2
exit 1
fi
if grep -rnE "^\s*(import|from)\s+plumbline\b" --include="*.py" cairn/; then
echo "the engine imports the auditor; it must not" >&2
exit 1
fi
- name: Fail-closed drill — the gate fails when the harness cannot be resolved
run: |
# Same runner, same pin format, a repository that cannot be fetched.
# The gate must exit 4 rather than skipping or reporting success. If
# this step ever passes silently, the audit job below is decorative.
sed 's#^repo = .*#repo = file:///nonexistent/harness.git#' plumbline.pin > /tmp/unreachable.pin
set +e
env -u PLUMBLINE_SRC PLUMBLINE_PIN_FILE=/tmp/unreachable.pin PLUMBLINE_CACHE_DIR=/tmp/empty-cache ./plumbline-gate.sh
code=$?
set -e
echo "gate exited $code with an unreachable harness"
test "$code" -eq 4
# The stdlib-only, no-dependency claim is a claim about the package, not
# about Ubuntu — `core` above never left it untested until now. One canary
# job per non-Linux OS, at the newer supported Python version, running the
# same install/lint/test/demo/independence steps `core` runs. Deliberately
# NOT a matrix dimension on `core` itself: this repository's own
# tests/test_rulesets.py holds every required-status-check context in
# .github/rulesets/main.json against a parser of this file that assumes one
# matrix key per job, and a second matrix key (`os`) changes every check
# run's name (GitHub folds every matrix key into the name, not just the
# ones that vary) — a two-key `core` matrix would need that parser rewritten
# to stay honest. Two extra single-OS jobs sidestep that entirely and read
# as what they are: "does this also work here", not a second lint/version
# sweep.
#
# No fail-closed drill: that step runs the vendored gate script itself
# (`env -u`, `sed`, a `#!/usr/bin/env bash` shebang invoked as
# `./plumbline-gate.sh`) and is already covered on `ubuntu-latest` by
# `core`, `audit`, and `live`. Running it again here would test Git Bash's
# (Windows) or macOS's own bash's shell compatibility, not this repository.
core-windows:
name: core, windows (install, lint, test — auditor unreachable)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install
run: pip install -e ".[dev]"
- name: Lint
run: ruff check .
- name: Test
run: python -m unittest discover -s tests
- name: The demo path runs with no install at all
run: |
python -m cairn index
python -m cairn ask "How much is the monthly grocery allowance for one person?"
python -m cairn ask --explain "What vaccinations does my dog need?"
# Bash explicitly: the default shell for a `run:` step is PowerShell on
# Windows and this step's syntax (`[ -d ... ]`, `grep`) is POSIX. Every
# GitHub-hosted Windows runner ships Git Bash for exactly this.
- name: Nothing above resolved the auditor
shell: bash
run: |
if [ -d .plumbline-cache ]; then
echo "the core path fetched the auditor; it must not need it" >&2
exit 1
fi
if grep -rnE "^\s*(import|from)\s+plumbline\b" --include="*.py" cairn/; then
echo "the engine imports the auditor; it must not" >&2
exit 1
fi
core-macos:
name: core, macos (install, lint, test — auditor unreachable)
runs-on: macos-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install
run: pip install -e ".[dev]"
- name: Lint
run: ruff check .
- name: Test
run: python3 -m unittest discover -s tests
- name: The demo path runs with no install at all
run: |
python3 -m cairn index
python3 -m cairn ask "How much is the monthly grocery allowance for one person?"
python3 -m cairn ask --explain "What vaccinations does my dog need?"
- name: Nothing above resolved the auditor
run: |
if [ -d .plumbline-cache ]; then
echo "the core path fetched the auditor; it must not need it" >&2
exit 1
fi
if grep -rnE "^\s*(import|from)\s+plumbline\b" --include="*.py" cairn/; then
echo "the engine imports the auditor; it must not" >&2
exit 1
fi
# Builds the image release.yml pushes to GHCR — proves it on every change,
# not only at release time, which is the only reason to have this job in
# this file rather than only in that one. Does not push anywhere: no
# registry credential this job needs, and a required check that could push
# on a fork's pull request would be its own kind of hole.
image:
name: image (the container actually builds)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build
run: docker build -t cairn:ci .
# Not just "the build exited 0" — a container whose entrypoint fails
# on its first real request would still build cleanly. Runs the same
# question the demo path in `core` and `core-windows`/`core-macos`
# already ask, so an answer that drifts from what those jobs got is
# this job failing too, not a silent extra path.
- name: Run it and ask it the demo question
run: |
docker run -d --name cairn-ci -p 18765:8765 cairn:ci
for _ in $(seq 1 20); do
curl -fs http://127.0.0.1:18765/ >/dev/null && break
sleep 0.5
done
out=$(curl -fs http://127.0.0.1:18765/ask \
-d 'question=How much is the monthly grocery allowance for one person?' \
-d 'lang=en')
docker logs cairn-ci
echo "$out" | grep -q '\$212' || {
echo "the containerized server did not ground the demo question" >&2
exit 1
}
# Proves the artifact release.yml's pypi job publishes is actually
# installable, on every change — not only when a release makes that job
# run for real. `pip install -e ".[dev]"` in `core` never builds a real
# sdist or wheel, only an editable install against the checkout; this
# does what an operator running `pip install cairn-assistant` will get.
package:
name: package (the sdist and wheel actually install and run)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Build the sdist and the wheel
run: |
pip install --no-cache-dir -e ".[release]"
python3 -m build
# A fresh venv, not the one `pip install -e` above already put cairn
# into — installing from an editable checkout would prove nothing
# about whether the wheel dist/ just produced is complete on its own.
- name: Install the wheel into a clean environment and run it
run: |
python3 -m venv /tmp/wheel-env
/tmp/wheel-env/bin/pip install --no-cache-dir dist/*.whl
/tmp/wheel-env/bin/cairn index
out=$(/tmp/wheel-env/bin/cairn ask \
"How much is the monthly grocery allowance for one person?")
echo "$out"
echo "$out" | grep -q '\$212' || {
echo "the installed wheel did not ground the demo question" >&2
exit 1
}
interface:
name: interface (accessibility behaviours in Chromium)
runs-on: ubuntu-latest
steps:
# Nothing in this workflow pushes, so no job has any use for a
# credential left behind in .git/config where every later step and
# every uploaded artifact can reach it.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "20"
# `npm ci`, never `npm install`. The lock file is committed and the
# manifest names exact versions; `ci` installs precisely that and exits
# non-zero if the two disagree, where `install` would quietly resolve
# something newer and rewrite the lock. The rule set grading this page
# is axe-core, and a floating rule set means "62/62 passed" is a
# statement about whatever npm picked this morning. a11y.mjs then checks
# the version that actually reached the browser, because an install can
# be right and a stale node_modules still wrong.
- name: Install browser checks
working-directory: tests/browser
run: |
npm ci
npx playwright install --with-deps chromium
- name: Index
run: python3 -m cairn index
- name: Run the behaviour checks
working-directory: tests/browser
run: npm run check
audit:
name: audit (merge gate — must be required in branch protection)
runs-on: ubuntu-latest
needs: [core]
steps:
# Nothing in this workflow pushes, so no job has any use for a
# credential left behind in .git/config where every later step and
# every uploaded artifact can reach it.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
# Keyed on the pin file, so bumping the pin fetches the new harness and
# every other run reuses the old one. The key is the pin, never a
# branch name: the cache must not be able to serve a different commit
# than the one this repository asked for.
- name: Cache the pinned harness
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .plumbline-cache
key: plumbline-${{ hashFiles('plumbline.pin') }}
- name: Re-record the evidence from the current engine
run: |
python3 -m cairn index
python3 -m cairn record
- name: The committed evidence matches what the engine produces now
run: |
# `cairn record` is deterministic. If this diff is non-empty the
# engine's behaviour changed without the bundle being updated, and
# the audit below would be grading yesterday's answers.
git diff --exit-code -- plumbline/bundle
- name: Run the pinned audit
run: env -u PLUMBLINE_SRC ./plumbline-gate.sh --summary-file "$GITHUB_STEP_SUMMARY"
# The reason the bypass above is fixed at the call site rather than in
# the runner. If this diff is ever non-empty, "vendored verbatim" — said
# in DESIGN.md, in plumbline-live.sh, and in the comment at the top of
# this file — has stopped being true, and the argument for not patching
# the runner locally has stopped holding with it. It runs here because
# this is the job that has a resolved harness to compare against; the
# step above is what put it there.
- name: The runner is the one the pinned harness ships, byte for byte
run: |
ref=$(sed -n 's/^[[:space:]]*ref[[:space:]]*=[[:space:]]*\([0-9a-f]\{40\}\).*/\1/p' plumbline.pin | head -n 1)
test -n "$ref"
diff -u ".plumbline-cache/$ref/gate/plumbline-gate.sh" plumbline-gate.sh
# The gate checks floors. This checks what a floor cannot: a score that
# moved without breaching one — in either direction, because an
# improvement nobody adopts leaves the recorded bar below what the
# system does, and the whole gap becomes decay nothing will notice — and
# a suite switched off without saying so. It reads the report the step
# above just wrote, so it runs after it and never instead of it. No
# `continue-on-error`, for the same reason the gate has none.
# The one test in the suite that needs a resolved harness, run in the
# one job that has one. It holds plumbline/target.toml's floors and its
# suite list against the defaults parsed out of the pinned harness's own
# source, and it skips itself when there is no checkout to read — which,
# in `core`, is always, because `core` fails if the cache exists at all.
# So it ran on a laptop where somebody had happened to run the gate, and
# nowhere else, while its docstring said it ran here.
- name: The floors and the suite list, against the harness that defines them
run: python3 -m unittest tests.test_audit_guard -v
- name: The committed baseline still matches, and no gap is undeclared
run: python3 audit_guard.py --summary-file "$GITHUB_STEP_SUMMARY"
- name: Keep the report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: audit-report
path: plumbline/audits/
if-no-files-found: warn
# -------------------------------------------------------------------------
# The gate grades a recording. This grades the thing the recording is of.
#
# A bundle is bytes on disk; the server is code that changes. They agreed on
# the day `cairn record` wrote the bundle, and every audit report this
# repository publishes is a statement about the recording. Whether it is
# also a statement about the interface a person meets was an assumption
# until this job existed.
#
# It is deliberately NOT the gate, and the separation is structural rather
# than a convention:
#
# - `plumbline.pin` names plumbline/target.toml. Nothing in the gate's
# path reads plumbline/live.toml, so the gate cannot acquire a socket by
# configuration.
# - ./plumbline-live.sh cannot resolve the harness. It requires a checkout
# the gate already fetched and verified, so a run against a live server
# is never the act that installs the thing grading it.
# - the `audit` job does not need this job and does not call this script;
# tests/test_live.py fails if it ever does.
#
# The drift check itself does not need this job either — tests/test_live.py
# posts every committed question to a real loopback server in the core path,
# offline, with no harness. What this job adds is the harness in the loop:
# it is the harness's own HTTP recorder that seals the evidence, so what is
# graded is what the harness saw, not what Cairn says the harness would have
# seen.
# -------------------------------------------------------------------------
live:
name: live (the served interface, graded by the pinned harness)
runs-on: ubuntu-latest
needs: [core]
steps:
# Nothing in this workflow pushes, so no job has any use for a
# credential left behind in .git/config where every later step and
# every uploaded artifact can reach it.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Cache the pinned harness
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .plumbline-cache
key: plumbline-${{ hashFiles('plumbline.pin') }}
# ./plumbline-gate.sh is the only thing in this repository that fetches
# the harness, and running it here is not a duplicate of the audit job's
# work by accident — it is the order the comparison needs. Asking
# whether the server matches the graded evidence is only a question
# worth answering while that evidence is passing.
- name: Resolve the harness, by running the gate that owns resolution
run: env -u PLUMBLINE_SRC ./plumbline-gate.sh
- name: Grade the running server and compare it to the recording
run: ./plumbline-live.sh --summary-file "$GITHUB_STEP_SUMMARY"
- name: Keep the live report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: live-report
path: .plumbline-live/audits/
if-no-files-found: warn
gauntlet:
name: gauntlet (pinned adversarial suites)
runs-on: ubuntu-latest
needs: [core]
steps:
# Same credential rule as every other job above.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
# The gate cannot fetch its own harness, by design (gauntlet-gate.sh's
# own header explains why: no path that resolves *something* if the
# pin cannot be honoured). So CI is the place the checkout at the
# exact pinned commit is made: cloned fresh, checked out to
# gauntlet.pin's commit, and verified by the gate itself before any
# suite runs. A moving ref here would grade this repository with a
# harness nobody reviewed.
- name: Check out the harness at the pinned commit
run: |
REPO=$(sed -n 's/^repository = "\(.*\)"/\1/p' gauntlet.pin)
COMMIT=$(sed -n 's/^commit = "\(.*\)"/\1/p' gauntlet.pin)
# A plain clone fetches only the default branch, and the pinned
# commit is not guaranteed to be on it (found the hard way: this
# pin's own commit lives on a feature branch of the harness repo,
# not main, and `git clone` + `checkout --detach $COMMIT` failed
# with "unable to read tree" the first time this job ever ran).
# Fetching the commit directly works regardless of which branch,
# if any, it is reachable from.
mkdir -p "$RUNNER_TEMP/gauntlet"
git -C "$RUNNER_TEMP/gauntlet" init --quiet
git -C "$RUNNER_TEMP/gauntlet" remote add origin "https://github.com/$REPO"
git -C "$RUNNER_TEMP/gauntlet" fetch --quiet origin "$COMMIT"
git -C "$RUNNER_TEMP/gauntlet" checkout --quiet --detach FETCH_HEAD
- name: Run the pinned suites
env:
GAUNTLET_CHECKOUT: ${{ runner.temp }}/gauntlet
run: ./gauntlet-gate.sh
- name: Keep the results pack
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gauntlet-results
path: .cairn/gauntlet-results.json
if-no-files-found: warn