Skip to content

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.

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/.

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.

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 reads git 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.

Terminal window
make check

One 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 by sync-docs.ts from docs/ and is not tracked. Committing it puts every chapter in the repository twice, and the copy that is stale is the one nobody edits.

Nothing to do. Push, and the gate reports:

Docs-only change: the image jobs are skipped; nothing outside
docs/, website/, site/ or the top-level documents moved

CI 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.