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:

  1. A mixed change takes the union. A model edit that also regenerates headers is a docs and modeling and cpp change, and runs the checks of all three.
  2. 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.
  3. 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 cpp class does the build. A model change whose regeneration is missing fails component-drift first, 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 .tests binary.
  • 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

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.

Emacs 29.3 (Org mode 9.6.15)