What CI runs, and when
Two workflows, and a deliberate asymmetry between them: the checks that are cheap always run; the ones that cost minutes run only when something they could possibly break has moved.
The two workflows
Section titled “The two workflows”| Workflow | Runs on | Costs |
|---|---|---|
| CI | every push and pull request | 12 s, plus ~9 min if code changed |
| Docs site | changes under docs/, website/, site/, or the scripts that assemble them |
~30 s |
| Job | What it proves | When |
|---|---|---|
| What changed | whether anything outside the documentation moved | always, ~5 s |
| Lint, and the configuration held to itself | ruff, the test suite, the recorded witness count, the sidebar, and that the compose file parses | always, ~12 s |
| The image builds (amd64) | it builds, the server starts, /graphs lists the graph |
only when code changed |
| The image builds (arm64) | the same, on a native arm runner | only when code changed |
The image jobs are the expensive pair — 326 s and 213 s — and a typo in a
document cannot break an image. So they hang off scripts/changed_kind.py,
which answers docs or code.
Why the gate is a script and not three lines of YAML
Section titled “Why the gate is a script and not three lines of YAML”Because a regex nobody can run is a regex nobody checks. tests/test_ci_gate.py
runs the real thing over the paths that matter, and most of those tests are
about paths that look like documentation and are not:
| Path | Why it is code |
|---|---|
extensions/das_host/README.md |
inside the build context, and the manifest packages it |
tenapp/backends/data-agent.json |
configuration the graph loads |
Makefile, docker-compose.yml |
how the image is built, and on which platform |
.github/workflows/ci.yml |
the gate itself |
tests/** |
a test is not documentation |
The pattern is anchored, so docs/ means the directory at the root — never
extensions/something/docs/.
It fails open, on purpose
Section titled “It fails open, on purpose”Anything the gate cannot classify counts as code, and no usable base commit — a first push, a force push, a re-run — runs everything.
The two ways of being wrong are not symmetrical. Too eager wastes the time the gate exists to save. Too lax skips the build on the commit that needed it and reports green, which is worse than never having had a gate, because it looks like it ran. An empty diff is therefore not treated as “nothing to build”: it is treated as “I learned nothing”.
The workflow skips only on an exact docs. An error, an empty line, or some
future third answer all run the build.
The docs site
Section titled “The docs site”docs/ is the source of truth. website/scripts/sync-docs.ts generates the
Starlight content from it, so the Markdown keeps working on GitHub and nothing
is written twice. The site is assembled as the landing page at / with the
rendered chapters beneath it at /docs/.
Three checks run before anything is published, and each exists because of a specific way the site broke:
- the sidebar and
docs/agree, on what is committed — it readsgit ls-files, not the filesystem, because a sidebar entry pointing at a page nobody committed took the site down, and a committed page nobody links is a silent 404 waiting to be found; - the sync script type-checks — it rewrites every link on the site, and a silent failure there publishes dead ones;
- every internal link resolves, against the assembled tree rather than
the build output, because the landing page sits above the docs and a sidebar
link:silently gains the base prefix.
The badge endpoints are written during the same job, from
docs/witnesses.json, which scripts/witnesses.py writes from a real test
run. A badge can therefore never advertise a number nobody proved: if the
recorded count and the suite disagree, CI fails rather than publishing the
prettier one.
Reproducing the quality job
Section titled “Reproducing the quality job”make checkOne command, in CI’s order: ruff, ruff format, the suite, the recorded
witness count, the sidebar, and the compose file. It exists because running a
subset locally is how an unformatted file shipped twice — ruff check passes
where ruff format --check does not, and only the second is what CI asks.
Two things it will catch that are easy to miss:
- ruff formats fenced Python inside Markdown. An example in the docs is held to the same standard as the code, which is the point: a snippet that would not pass the project’s own formatter is a snippet nobody should copy.
website/src/content/docs/is generated bysync-docs.tsfromdocs/and is not tracked. Committing it puts every chapter in the repository twice, and the copy that is stale is the one nobody edits.
Changing documentation only
Section titled “Changing documentation only”Nothing to do. Push, and the gate reports:
Docs-only change: the image jobs are skipped; nothing outsidedocs/, website/, site/ or the top-level documents movedCI finishes in about twelve seconds, the site rebuilds in about thirty, and no image is built. If you also touched anything else — a graph, an extension, the Makefile — the full run happens and nothing needs saying.