Repository navigation
260 lines (254 loc) · 14.5 KB
/
Copy pathci-stack.yml
File metadata and controls
260 lines (254 loc) · 14.5 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
name: ci-stack
# Neither service's gate can see the stack. Each one stays green while the pipe
# cannot reach the brain, the brain answers into the void, a compose path points
# at a directory that moved, or a stand-in starts with nothing wired to it —
# every one of which has happened here, and none of which either suite noticed.
#
# No paths filter on the WORKFLOW on purpose: this is the integration test, so a
# change to either service must run it, and the root files it also covers —
# docker-compose.yml, the deploy configs — were previously covered by nothing at
# all. What the `stack` job does skip, decided inside the `docs` job below from
# the actual diff: a push whose every changed file is a note, a doc or a picture.
# Nothing the stack boots reads markdown; the docs job, which does, still runs.
#
# Measured before this (2026-09-28, the account's Actions minutes exhausted): the
# stack job spent about 110 of its 140 seconds building four images with no layer
# cache, and every PR was tested twice — once as a PR and once as the merge
# commit on main, the same tree. So the images are baked with a GitHub Actions
# layer cache before the smoke runs, and a push to main that IS a pull request's
# merge commit runs nothing here: that PR's own run already tested the merge of
# its head with main. The residual it does not cover — main moved between the
# PR's last run and its merge — is the case a person merging a stale PR sees on
# the PR page, and the local gate is still read before every merge here.
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
permissions:
contents: read
# Every push still starts a run; superseded runs are cancelled instead of
# queued — three commits pushed in a row used to queue three full stack builds,
# of which only the last one's answer mattered. Nothing here publishes or
# mutates anything outside the runner, so a cancelled run costs only the answer
# it was about to give about a commit that is no longer the tip.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
docs:
runs-on: ubuntu-latest
timeout-minutes: 5
if: ${{ !(github.event_name == 'push' && startsWith(github.event.head_commit.message, 'Merge pull request')) }}
outputs:
code: ${{ steps.changed.outputs.code }}
steps:
- uses: actions/checkout@v7
with:
# Enough history to name the files this push or PR changed.
fetch-depth: 0
# Which kind of change this is, from the diff and nothing else. Anything
# that is not plainly a note, a doc or a picture counts as code — including
# a failure to compute the diff at all, because "could not tell, so skipped
# the integration test" is the wrong default.
- name: is anything but notes, docs and pictures changing?
id: changed
env:
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
code=true
if [ -n "$BASE" ] && [ "$BASE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BASE" 2>/dev/null; then
files="$(git diff --name-only "$BASE" HEAD || echo UNKNOWN)"
if [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '^(\.agents/|docs/|[^/]+\.md$|(hookrelay|hookjudge|hookprobe)/docs/|.*\.(md|png)$)'; then
code=false
fi
printf 'changed files:\n%s\n' "$files"
fi
echo "code=$code" >> "$GITHUB_OUTPUT"
echo "stack job runs: $code"
- name: every relative link in the docs resolves
run: python3 scripts/check-docs.py
- name: the three pages share one design language
run: python3 scripts/assert_design.py
# A board behind a reverse proxy may sit under a prefix; a request that
# starts at the origin's root works on the board's own port and breaks
# under the prefix, and only a browser would notice.
- name: every board addresses its service relative to where it is mounted
run: python3 scripts/assert_mount_aware.py
- name: agent notes keep their shape
run: python3 scripts/assert_agent_notes.py
# ESTATE_GUARD_REQUIRED makes a missing list a failure: an unset secret
# writes a zero-byte file, which would otherwise read as "clean".
# A pull request from a fork gets no secrets at all, so there the list
# cannot exist: the next step says so instead of failing every outside
# contribution, and the push that merges it runs the guard with the list.
- name: estate identifiers
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
env:
ESTATE_IDENTIFIERS: ${{ secrets.ESTATE_IDENTIFIERS }}
ESTATE_GUARD_REQUIRED: "1"
run: |
printf '%s' "$ESTATE_IDENTIFIERS" > .estate-identifiers
python3 scripts/assert_no_estate_identifiers.py
- name: estate identifiers (not on a fork's pull request)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
run: echo "A fork's pull request gets no secrets, so the estate list is not here; the guard runs on the merge to main."
# Not a dependency resolve — that would need a network round trip per
# service and minutes per run, so it would end up scheduled and skipped.
# This reads the two files and asks whether they still agree, which is
# what "somebody bumped a floor and forgot scripts/relock.sh" looks like.
- name: every requirements.lock still answers its requirements.txt
run: python3 scripts/assert_locks.py
# The pins against the Dockerfiles: ci-hookprobe.yml and release.yml's
# gate matrix said 3.12 while hookprobe's image was 3.14 (2026-10-06).
- name: every service's CI tests on the interpreter its image ships
run: python3 scripts/assert_python_pins.py
# Three self-contained deployables copy a handful of helpers rather than
# share a package, which is the right trade and the one that drifts. This
# compares the copies by parsed body, so each may keep its own comments.
- name: copied helpers have not drifted apart
run: python3 scripts/assert_copies.py
# Every list a person reads is newest first, and a deviation has to be named
# with a reason rather than inherited from a file's append order.
- name: one ordering convention, deviations on the record
run: python3 scripts/assert_ordering.py
# Routes, env vars and enumerated sets are either documented or named as
# deliberately not. Cannot check whether a paragraph is still true.
- name: every door and knob is written up or named as not
run: python3 scripts/assert_docs.py
# Generated from settings.py and the handlers; this catches a generator run
# whose output was never committed.
- name: the generated reference matches the code
run: python3 scripts/gen_reference.py --check
- name: the site's front pages are the READMEs, re-addressed
run: python3 scripts/gen_front_pages.py --check
# Was in scripts/gate.sh and nowhere else, so every ceiling it enforces was
# advisory on any machine but a maintainer's. A cap nobody checks on the way
# in is a comment.
- name: the pipe and the brain are inside their stated ceilings
run: python3 scripts/assert_weight.py
# Every headline number, in the two classes it can be in: reproducible here
# (run now, fail if it has stopped holding) or measured on the retired
# deployments and named as such. The two pytest-backed claims are UNRUN in
# this job (no service venvs) and reported as UNRUN, never as OK; the
# whole-repository gate, where the component gates just built the venvs,
# runs them for real.
- name: every headline number is reproduced here or named as reported
run: python3 scripts/reproduce_claims.py
# Three services build one image tag, and the tag is whichever build ran
# last: a build arg one of them does not pass is an image the others did
# not mean. The work image shipped without kubectl this way (2026-09-29).
- name: services sharing an image tag build it alike
run: python3 scripts/assert_shared_images_build_alike.py
# A live production component on the card-callback path, outside every
# service package and therefore outside every component gate. It was
# covered by nothing at all until it was edited and the question was asked.
- name: the lark bridge parses and lints
run: |
python3 -m compileall -q deploy/lark-bridge
# Pinned to the same version the services pin, or this step lints with
# whatever shipped today and goes red on a rule nobody in this repo
# has agreed to. The version is read from a service's requirements so
# there is still one source of truth.
pip install "$(grep -E '^ruff==' hookrelay/requirements.txt)"
ruff check deploy/lark-bridge
ruff format --check deploy/lark-bridge
# The sidecars (egress proxy, MCP gate, watch signer, ingress), the watch
# wrapper and the bridge each sit in the path of something an agent does,
# outside every component gate. scripts/gate.sh ran their tests and this
# did not, so a change to the MCP gate went green here on 2026-10-09
# without one of its tests having run. Same commands as the gate.
- name: the sidecars, the watch wrapper and the bridge pass their tests
run: |
pip install "$(grep -E '^pytest==' hookrelay/requirements.lock)" "$(grep -E '^ruff==' hookrelay/requirements.txt)"
python3 -m compileall -q deploy/sidecars deploy/watch
ruff check deploy/sidecars deploy/watch
ruff format --check deploy/sidecars deploy/watch
python3 -m pytest -q deploy/sidecars/tests deploy/watch/tests
python3 -m pytest -q deploy/lark-bridge/tests
stack:
runs-on: ubuntu-latest
timeout-minutes: 15
# After the docs job for its verdict on what changed; a notes-only push does
# not boot the family. A PR merge commit on main runs nothing (see the header).
needs: docs
if: ${{ needs.docs.outputs.code == 'true' }}
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
# The four images, built with a layer cache the smoke's own `compose up
# --build` cannot use, and loaded into the daemon under the names the
# compose file gives them. Measured before this: about 110 of the job's
# 140 seconds were spent rebuilding layers that had not changed — the
# investigator's Node runtime and CLI install above all. If this step
# fails the smoke below still builds what is missing, so a cache problem
# costs the minutes it used to cost and never the answer.
#
# One scope per image: a scope holds ONE cache, so four targets exporting
# into the same scope would keep only the last one's layers. A run reads
# the caches its own branch and main created, so a direct push to main is
# what keeps main's cache warm for every branch's first run; a merge
# commit runs nothing here and seeds nothing, on purpose — a job is billed
# in whole minutes, and a seeding run would cost what it saves.
- name: bake the family's images with a layer cache
uses: docker/bake-action@v7
continue-on-error: true
with:
source: .
files: docker-compose.yml
load: true
set: |
hookrelay.cache-from=type=gha,scope=stack-hookrelay
hookrelay.cache-to=type=gha,scope=stack-hookrelay,mode=max
hookjudge.cache-from=type=gha,scope=stack-hookjudge
hookjudge.cache-to=type=gha,scope=stack-hookjudge,mode=max
hookprobe.cache-from=type=gha,scope=stack-hookprobe
hookprobe.cache-to=type=gha,scope=stack-hookprobe,mode=max
lark-bridge.cache-from=type=gha,scope=stack-lark-bridge
lark-bridge.cache-to=type=gha,scope=stack-lark-bridge,mode=max
# No "logs on failure" step. There was one, and it could never print
# anything: the smoke arms an EXIT trap that runs `docker compose down -v`,
# so by the time a failure:() step ran there were no containers left to
# read logs from — every red run showed an empty log dump and sent people
# looking for the real output somewhere else. That output already exists:
# fail() in the smoke dumps `docker compose logs --tail 40` inline, at the
# moment of the failure, while the stack is still standing. Deleting the
# step is the fix; adding KEEP=1 here to keep containers alive for it would
# make CI run something different from what a developer runs, which is the
# one thing this repo's gates are built not to do.
- name: the whole family comes up and judges correctly
env:
STACK_PREBUILT: "1"
# The smoke's own fail() prints the container logs inline, but a failed
# step's stdout is only visible to someone who can open the job log.
# Put the tail on the check run as an annotation too, so a public run
# names its failure without an admin token.
run: |
set +e
bash scripts/stack-smoke.sh 2>&1 | tee /tmp/stack-smoke.log
status=${PIPESTATUS[0]}
if [ "$status" -ne 0 ]; then
python3 - <<'PY'
import re
from pathlib import Path
log = Path("/tmp/stack-smoke.log").read_text(encoding="utf-8", errors="replace")
def escape(text: str) -> str:
return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
lines = log.splitlines()
reason = []
for index, line in enumerate(lines):
if "STACK RED" in line or "::error" in line:
reason = lines[max(0, index - 2):index + 5]
if not reason:
reason = [line for line in lines if re.search(r"Traceback|AssertionError|::error|refused|does not|no .* carried", line, re.I)][-12:]
print(f"::error title=stack smoke reason::{escape(chr(10).join(reason)[-3000:])}")
action = [
line for line in lines
if re.search(r"/hooks/action|remediation|proposal|approve|denied|superseded|cooling|permission", line, re.I)
][-30:]
print(f"::error title=stack smoke action::{escape(chr(10).join(action)[-5000:])}")
print(f"::error title=stack smoke tail::{escape(log[-3000:])}")
PY
fi
exit "$status"