CI workflows and local checks
Table of Contents
1. Summary
GitHub runs no check on a pull request. The twelve workflows that used to
carry a pull_request trigger lost it on 2026-10-06, and entity-writes.yml
lost it when it was caught growing the trigger back hours later; the earlier
pr.yml gate and the automatic review were already gone. The local check
runner replaced the whole pull-request gate:
./compass.sh check
compass check reads the branch diff against origin/main, classifies every
changed path against the decision tree in build/scripts/local_checks.py,
runs the checks that class needs, and writes
build/output/local_checks/report.md — one table of every check with its
result and time, and a Fix these first section naming each failure, the
command that reproduces it, the command that fixes it, and the tail of its
log. The exit code is 0 when every selected check passed.
The runner is the gate for two reasons. GitHub checks arrived minutes after a push, so a broken change waited behind a queue for a verdict a workstation reaches in seconds. And a workflow could only fire on the paths it named; a change matching no workflow saw no check at all, while the runner's catch-all sends an unclassified path through everything.
2. The decision tree
One table, in build/scripts/local_checks.py, maps a changed path to a
change class. A check runs when its class matched the change and, when the
check declares its own path patterns, one of them matched too.
| Class | Paths | What it covers |
|---|---|---|
| docs | doc/*, assets/*, .claude/*, *.md, *.org, LICENSE* |
the site corpus and the agile record |
| modeling | projects/*/modeling/* |
a component model, which is what codegen reads |
| codegen | projects/ores.codegen/*, projects/ores.http/* |
the generator, its templates and its tests |
| sql | projects/ores.sql/*, projects/ores.seeder/*, *.sql |
the schema, its populate scripts and the migrations |
| cpp | *.cpp, *.hpp, *.h, *.ipp, *.c, *.cc, *.cxx, *.cmake, CMakeLists.txt, CMakePresets.json, vcpkg*.json |
compiled sources, headers and the build description |
| services | projects/*/service/*, projects/ores.service/*, build/config/*, docker/*, compose*.yml |
a service, its launcher and what starts it |
| web | projects/ores.web/*, *.ts, *.tsx, package.json, pnpm-lock.yaml, .prettierrc.yaml |
the ores.web client and its packages |
| plugins | projects/ores.dsh_environment/* |
the DSH profile plugin |
| tooling | projects/ores.compass/*, build/scripts/* |
the project's own tools and their checks |
| ci | .github/*, projects/ores.codegen/library/templates/* |
the check configuration itself |
Three rules follow from the table:
- A mixed change takes the union. A model edit that also regenerates
headers is a
docsandmodelingandcppchange, and runs the checks of all three. - A path no rule claims asks for everything. A change this table cannot classify is a change it cannot vouch for, and the cost of the extra run is smaller than the cost of the break it hides.
- A model or template change does not by itself compile the tree. The
regeneration writes the generated C++ into the tree, so that file appears
in the diff and the
cppclass does the build. A model change whose regeneration is missing failscomponent-driftfirst, which is the point.
The classes and the heavy tier:
| Tier | Checks |
|---|---|
| always | the class's fast checks: compass lint, the drift gates, the codegen and compass suites, the per-language checks |
heavy (cpp, sql, services, unclassified) |
build, then db recreate, then ctest |
sql only, after db recreate |
pgtap |
pgtap runs projects/ores.sql/test/run_tests.sh over the database the
check above it rebuilt. It stays in the sql class alone and stays after
db, because it reads the schema that check recreates. Every suite writes
the reference data it reads and rolls it back, so the corpus passes on a
database that carries no tenant data beyond the seed.
A documentation-only change therefore runs compass site page and the doc
corpus checks and never reaches the compiler.
A change that compiles only into test binaries skips the fleet checks: db,
ticks-smoke and step-consumers. The runner decides this after build
passes. It reads ninja -t deps and finds the targets that compile each
changed path. It skips the three checks only when all of these are true:
- Every path that selects a fleet check is a C++ source or header.
- The dependency log knows every such path.
- Every target that compiles such a path is a
.testsbinary. - No changed path is unclassified.
A new file, a CMakeLists.txt or a SQL script keeps the fleet checks, because
the log cannot vouch for it. The report names each skipped check and the test
binaries that caused the skip. --all and --only skip nothing.
db has one more condition. The runner skips it only when the database's
schema fingerprint equals the checkout's, as compass env status --json
reports them. Main moves under a branch, and a database from before the move
fails the suites that read the new tables. When the runner skips db, ctest
runs with the fleet in the state the developer left it.
3. Running it
| Command | What it does |
|---|---|
./compass.sh check |
classify the branch diff and run what it needs |
./compass.sh check --plan |
print the classes, the checks and the reason for each, without running |
./compass.sh check --list |
print the decision tree and the whole catalogue |
./compass.sh check --only <id> |
run one check (repeatable) |
./compass.sh check --class cpp |
force a class, skipping classification |
./compass.sh check --all |
run every check |
./compass.sh check --exclude-heavy |
never run build, database or ctest |
./compass.sh check --include-heavy |
add the heavy tier to a change that does not need it |
./compass.sh check --json |
also write build/output/local_checks/report.json |
./compass.sh check --base <ref> |
measure against another ref (default origin/main) |
Three details matter when reading the report. The change is measured from the
merge base with origin/main, including uncommitted and untracked files, so
the runner is useful mid-work as well as before a raise, and another author's
merge onto main never reads as this branch's change. The tangle checks
(tangle-templates, tangle-shell, tangle-http) re-run the tangle and then
ask git whether the tree moved: they fail when the committed output is stale,
which is the same contract the retired workflows had. And the drift family
(component-drift, model-drift, the tangles) compares the regenerated tree
against HEAD, so it needs a committed tree — an uncommitted edit to any file
reads as drift. Commit before running the modeling or codegen classes.
component-drift regenerates only the registry components whose models this
branch changed, read from the diff against the base; a change under
projects/ores.codegen/ regenerates them all, because the generator and its
templates decide every component's output. Keeping the rest of the registry
clean belongs to the drift-sweep check, not to this branch's
component-drift.
4. What still runs on GitHub
Nothing on a pull request. Ten workflows keep their own triggers:
| Workflow | When | Why it stays |
|---|---|---|
continuous-linux.yml |
every 3 hours, and on a tag | linux-clang debug and release, the fastest pair the tree builds; submits to CDash |
nightly-macos.yml, nightly-windows.yml |
daily, and on a tag | the only Windows and macOS coverage; submits to CDash |
nightly-linux.yml |
daily | full build and ctest with coverage and memcheck, to CDash |
nightly-format.yml |
daily | rewrites formatting drift and opens a fix PR |
nightly-includes.yml |
daily | misc-include-cleaner fix PR |
build-site.yml and site-cdash.yml |
every 2 hours and manual | the published site, Doxygen and the CDash site build |
stale.yml |
daily | closes stale issues and pull requests |
claude.yml |
an @claude comment |
the on-demand review |
The twelve retired triggers were codegen-drift, codegen-sweep,
doc-lint, dsh-environment,
entity-writes, event-registrar-composition, header-hygiene,
populate-reference-drift, rls-system-tenant-widenings,
shell-script-drift, template-drift and typescript. Each keeps its
push to main trigger, so a defect that escapes the runner still shows on
main the moment it lands.
4.1. The compiler cache has a budget
GitHub gives a repository 10 GB of cache and the Linux legs keep one sccache entry per variant: four variants, so about 8 GB if each is allowed 2 GB. That budget is what decides whether a debug leg finishes, because a cold clang-debug build takes long enough to hit the step's own time limit.
Three rules keep it inside the budget, and all three exist because the obvious order fails:
- Each variant is capped, =SCCACHE_CACHE_SIZE: '2G'. sccache's own default is 10 GB, which is the entire allowance for one variant.
- Space is freed before the save, oldest entries of this variant first. The older order saved first and pruned after verifying the save, so once the allowance filled the save could not fit, the verification found no new entry, the prune deleted nothing, and every later run restored the same stale entry and timed out again. A prune that runs only after a save cannot make room for that save.
- A save that did not store is a warning, not a red job. The build it describes has already passed, and reddening the job hid that it was the cache, not the code, that failed.
entity-writes is the cautionary case. It was merged with the trigger the
same evening the other twelve lost theirs, and nothing failed: the workflow
files are read by nothing but GitHub. A guard now fails on the plain trigger,
build/scripts/check_no_pr_triggers.py, and the runner runs it under the ci
class, so the next one fails locally before it reaches main.
5. See also
- Absorb PR checks locally: targeted checks, local review, CDash submissions — the story this analysis fed.
- Run every check locally in one report — the story that built the runner.
- Analyse all CI workflows and map each check to its local equivalent — the inventory this document replaced.
- PR Raise — the skill that runs
compass checkbefore raising. - Knowledge — the knowledge index.
6. What the heavy tier needs from the machine
The heavy tier is the only part of the runner that is not self-contained, and its three checks do not want the same thing:
| Check | Needs |
|---|---|
build |
nothing beyond the tree |
db recreate |
no service connected to the database |
ctest |
the NATS server up, and a database the db check has just rebuilt |
The two constraints pull against each other, because compass services stop
stops the NATS server as well as the services it fronts. Stopping the target
for db therefore takes down the server ctest needs, and leaving it up means
db recreate --kill loses its race: it terminates the service backends and
they reconnect before the recount, so the recreate refuses on a handful of live
connections.
The environment that satisfies both is the NATS server running with every service stopped:
./compass.sh services stop systemctl --user start nats-server-festive-dijkstra.service # or busctl, see ORES_USE_BUSCTL ./compass.sh check
The runner states none of this and enforces none of it. It is written here because a machine that has just been used for anything else will fail one of the two, and the failure reads as a defect in the change rather than in the environment.