Local CI runner (tools/ci/ci_local.sh)¶
When GitHub Actions is unavailable (billing block, outage), verify a branch or
PR before merge with the local Docker CI runner. It reproduces the full backend validation jobs
of .github/workflows/ci.yml, docs.yml and architecture-check.yml against
an exported git ref — your working tree is never used or modified.
Fallback workspaces get a fresh Git index of the exported files before any
dependency install or build. Checks using git ls-files therefore see the
checkout's tracked sources, including committed files matched by .gitignore,
and exclude later build artifacts. The original history and remotes are absent.
tools/ci/ci_local.sh <git-ref> # every job, in parallel (see below)
tools/ci/ci_local.sh origin/feat/x --fetch # fetch first, test the remote head
tools/ci/ci_local.sh pr:55 # a PR head (fetched from origin)
tools/ci/ci_local.sh HEAD --jobs backend-static,docs # some jobs (or job groups)
tools/ci/ci_local.sh HEAD --jobs backend-tests --python 3.12
tools/ci/ci_local.sh HEAD --parallel 1 # sequential
tools/ci/ci_local.sh HEAD --rerun-failed # only what failed last time
tools/ci/ci_local.sh --list # jobs, engine, why
tools/ci/ci_local.sh --check # workflow drift check only
Requirements: Docker Desktop, uv (the script is a uv inline-metadata script,
tools/ci/ci_local.py), and act
(brew install act) — without act, --engine fallback runs every job with
the built-in runner.
Exit code 0 = every selected job passed. Logs, summary.md and
summary.json stay in .ci-local/runs/<run-id>/ (git-ignored).
--report-pr <N> posts summary.md as a PR comment via gh (off by default).
The local runner keeps the full matrix and heavy jobs even though automatic PR CI uses an economical profile. See CI profiles for the difference and the manual umbrella that also runs admin, packaging and extraction checks.
Parallel jobs (the default)¶
--parallel defaults to what the Docker engine (the Docker Desktop VM on
macOS) affords: min(3, CPUs // 3, memory // 2.5 GB), at least 1 — three at
once on the default 10-CPU / 8 GB VM, which takes a full run from ~16 min
(sequential) to ~7 min. The run prints the value it chose; --parallel 1
runs the jobs one by one in workflow order.
- Order. Timing-sensitive jobs (
benchmarks-security: wall-clock cancellations, the load harness's latency checks) start first, then the rest longest-first, by the previous run's durations (.ci-local/runs/*/summary.json, with built-in estimates as a fallback), so the run does not end waiting on one long job. The summary still lists the jobs in workflow order. - CPUs. A container job is pinned with
--cpuset-cpus(--cpus, default 4, as a 4-vCPU GitHub runner). Slot 0 gets CPUs0..3and, on an engine with room for it, no other container job shares them — the timing-sensitive job starts first and takes that slot, so its neighbours cannot starve it. The other slots share the remaining CPUs. act jobs are capped with--cpusbut not pinned. - Isolation. Parallel jobs never share a service: each container job has
its own
postgres/rediscontainers namedsandloom-ci-<run-id>-<job>-…, in the job container's own network namespace (ci.yml'slocalhost:5433/localhost:6380are per job, nothing is published on the host); the microservices stack has its own compose project,/24subnet, image tag and an ephemeral gateway port; act containers are named per job; the docker-build image tag carries the run id. act is never given an artifact server (see Artifact steps); act's cache server binds a free port itself and each act job has its own cache directory. - act container names. act names a job's container after the workflow's
name:and the job only, so every act job thatneeds: changesused to run its ownchangesjob in the same container name — two at once created and removed one container under each other (Error response from daemon: RWLayer of container … is unexpectedly nil). Such a job now runs a private, untracked copy of its workflow (.github/workflows/.ci-local-<job>.ymlin the act checkout) whose only difference is a top-levelname:carrying the local job name. - Docker daemon hiccups. Should act's container setup still fail on a
daemon error, an act job whose attempt died that way —
a daemon error in Set up job, before any
Pre/Main/Poststep of any job ran — is retried once, and its note says so. A failure after a workflow step started is a result and is never retried.
Artifact steps¶
No artifact storage exists locally. act's own artifact server binds a fixed
port and does not serve the results API that actions/upload-artifact
calls, so the step timed out on CreateArtifact and failed the docs job.
The runner therefore emulates artifact actions instead of running them:
- act. A job using
actions/upload-artifactoractions/download-artifact(docs,supply-chain) runs a private, untracked copy of its workflow (.github/workflows/.ci-local-<job>.yml, the same copy as forneeds). Each artifact step there is arun:step; itswith:inputs becomeINPUT_<NAME>variables and every other key (if:,continue-on-error,name) is kept. An upload lists the files it would upload and honoursif-no-files-found(warnby default,errorfails the step); nothing leaves the container. A download fails with an explanation: no workflow downloads artifacts today, and a job that does needs--engine fallbackor GitHub. - Fallback. An upload copies its path into the run directory
(
<job>-artifact-<name>), as before.
tests/architecture/test_ci_local_runner.py checks that the copy differs from
the workflow only in these steps and that the emulated upload behaves as above.
Fail-fast loop: --rerun-failed¶
tools/ci/ci_local.sh <ref> --rerun-failed reruns only the jobs that did not
pass (fail, error, cancelled) in the newest run of the same ref
string under .ci-local/runs/ (--out moves it) — on whatever commit the
ref names now, so the loop is: fix, commit, --rerun-failed, until it prints
that nothing failed. It cannot be combined with --jobs / --python, and,
like any subset, it never writes the full-pass marker: the merge gate stays
a full run.
Two engines¶
| job | engine | why |
|---|---|---|
backend-static, supply-chain, architecture-check, docs, deploy-profiles, docker-build |
act — the real workflow job | — |
backend-tests-3.11/3.12/3.13, benchmarks-security, examples-* |
fallback (container) | ci.yml reaches its services at localhost:5433 / localhost:6380. Under act that only works with host networking, where those ports are the shared dev containers sandloom_postgres_test / sandloom_redis_test (tests would truncate them); backend-tests also starts docker run --name redis -p 6380:6379, which collides by name and port |
quickstart |
fallback (container) | the documented quickstart (tools/smoke/quickstart_check.py) against the job's own Postgres :5433 / Redis :6380, which under act would be the shared dev containers |
app-recipes |
fallback (container) | the sandloom docs recipes run in a generated app (tools/smoke/recipes_check.py), against the job's own Postgres / Redis like quickstart |
microservices-smoke |
fallback (host docker) | the step hard-codes the gateway at localhost:8080 and the stack's default subnet; two stacks would collide |
act runs with catthehacker/ubuntu:act-latest (native arm64), a
pull_request event whose base is the merge base with origin/main (an empty
base when the ref is main, so every path filter is true), the run's own
docker network, and an empty GITHUB_TOKEN (act would otherwise pick up
gh auth token; no credentials are ever passed). The changes job and
dorny/paths-filter therefore run as on GitHub: a job the filter skips is
reported skipped.
The fallback never copies the job commands: it reads the exported ref's
workflow and runs each run: step verbatim — job/step env,
working-directory, if:, continue-on-error, timeout-minutes,
$GITHUB_ENV/$GITHUB_PATH, the matrix and its ${{ }} expressions. It
owns only the runner environment:
- a job container
python:<setup-python>-slim-bookworm+ theuvbinary pinned byastral-sh/setup-uv(UV_PYTHONset like the action), 4 CPUs / 4 GB (--cpus,--memory;-n autois pinned to the CPU budget throughPYTEST_XDIST_AUTO_NUM_WORKERS, like a 4-vCPU GitHub runner); - its own fresh
postgres:16/redis:7containers in the job container's network namespace, each listening on the host-side port of its ci.yml mapping — solocalhost:5433/localhost:6380work unchanged (backend-tests' Redis gets its--databases 256 --save "" --appendonly no). Each Postgres runs withfsync=offandfull_page_writes=off: it lives for one job and no test crashes the server, so flushes only cost time (benchmarks-security's S5 step −18 %).synchronous_commitkeeps its default — a suite test pinsSHOW synchronous_commit=on; - the microservices stack gets its own compose project, free
/24subnet (gateway.10, dynamic poolSANDLOOM_IP_RANGE= the upper/25, as in the profile's defaults) and image tag, and its gateway publishes on an ephemeral loopback port:GATEWAY_PORT=127.0.0.1:0turns the profile's${GATEWAY_PORT:-8080}:8080into127.0.0.1:0:8080, so Docker picks a free port atomically when it binds (the runner never picks one itself — pick-then-bind races other workloads on a shared host with "Address already in use"). Aftercompose upthe runner reads it back withdocker compose port gateway 8080and rewrites the smoke step'slocalhost:8080to it — the only rewrite. The profile's default (8080on all interfaces) is unchanged.
Fallback jobs ignore the changes path filter (they always run: stricter than
CI).
backend-tests matrix: what each Python runs¶
| job | suite |
|---|---|
backend-tests-3.11 |
full (-n auto --dist loadfile) |
backend-tests-3.12 |
full, with coverage (COVERAGE_CORE=sysmon) |
backend-tests-3.13, backend-tests-3.14 |
-m "not slow" |
The slow marker covers the multi-process suites — the split conformance
matrix, module extraction, the generated project, the boot configuration
checks (uv run pytest --collect-only -qq -m slow lists them; about 45 % of
a job's work). They run only on 3.11 and 3.12; 3.13 and 3.14 run every
other test. Owner decision of 2026-10-25 (about 12 % of a full run). The
runner takes this from ci.yml's matrix (slow: true|false) like any other
step, so local and GitHub runs select the same tests.
Overloaded host: SANDLOOM_CI_LOCAL_TIMEOUT_SCALE¶
On a shared machine under heavy load a job can hit its workflow
timeout-minutes although it would pass on a GitHub runner. Opt in to a
multiplier (1–10, default 1) for the fallback runner's job/step
timeouts and act's outer kill timer:
SANDLOOM_CI_LOCAL_TIMEOUT_SCALE=3 tools/ci/ci_local.sh HEAD --jobs benchmarks-security
The workflows' timeout-minutes are never edited. A scale other than 1 is
printed as a warning in the summary (summary.md, summary.json, terminal)
and in every job's note — such a pass does not prove the job fits its CI
timeout. act enforces a job's own timeout-minutes itself, so the scale only
stretches fallback jobs.
Isolation, lock and cleanup¶
- Each run has an id (
YYYYmmdd-HHMMSS-xxxx); containers, the docker network and the compose project are named/labelled with it. The shared dev containers (sandloom_postgres_test,sandloom_redis_test,sandloom_redis_bench) are never used. - The fresh services also satisfy the test harness's guards
(Testing → Isolation guards): their
databases / Redis indexes are unmarked, so the run claims them (no
SANDLOOM_TEST_*needed), and a stockmax_connectionsof 100 affords the 4 pinned xdist workers (4 × 13) plus the 40 the split conformance matrix may hold. Outside the runner, thebackend-full-testspre-push hook needsSANDLOOM_TEST_DATABASE_URL(the sharedsandloom_testis refused). - While running, the runner holds
/tmp/sandloom-ci-local.lock— anfcntl.flockon the file, never the file's mere existence (tools/ci/ci_lock.py, shared with the CPU benchmarks). The file names the run id, pid, ref and start time for humans and is removed on release. A second run waits for the flock (--no-waitfails instead). A crashed holder releases it automatically (the kernel drops the flock), so a leftover file — empty, unparseable or naming a dead pid — is stale and taken over; never delete the file by hand while a holder is alive. CPU benchmarks take the same flock (benchmarks/ladder/http_pipeline.pyrefuses to run beside CI, and CI waits for it) or probe it withci_lock.is_held()(rpc_guard.py,run_rpctx.py) — neveros.path.exists. - On exit — including Ctrl-C / SIGTERM — containers, the network, the compose
stack (
down -v), run-scoped images and the temp export are removed and the lock released; logs are kept. The uv cache (sandloom-ci-local-uv-cachevolume), the runner images (sandloom-ci-local/runner:*) and act's cache (~/.cache/sandloom-ci-local/act-cache) persist for speed.
A full pass skips the duplicate pre-push checks¶
When every job of a run passes (no --jobs / --python subset), the runner
writes a marker named after the tested commit's tree to
~/.cache/sandloom-ci-local/passed/ (SANDLOOM_CI_PASS_DIR overrides it). The pre-push
hooks that mirror CI jobs — backend-full-tests, docs-build,
production-image — run through tools/ci/unless_ci_passed.sh and are skipped
when the pushed commit's tree has such a marker, so a CI-green push does not
run the same suite a second time. The key is the tree, not the commit: any
content change needs a new run. package-distributions is not a CI job and
always runs; SANDLOOM_PREPUSH_IGNORE_CI_PASS=1 git push forces the full hooks.
Which runner version runs¶
tools/ci/ci_local.sh <ref> executes the ref's own tools/ci/ci_local.py
(extracted to a temp file), not the working tree's copy, so a PR that changes
a workflow together with the runner's mapping for it (a new CI step, new
fingerprints) is verified by its own runner. It prints a note when the two
differ. Set SANDLOOM_CI_LOCAL_USE_WORKTREE_RUNNER=1 to force the working-tree
copy (e.g. while developing the runner itself).
When ci.yml changes¶
tests/architecture/test_ci_local_runner.py (run by the
backend-static job) fails when a workflow uses something the runner cannot
map (a new job, uses: action, expression, service image) or when a mapped
job's body changed since the mapping was reviewed. Review CI_JOBS /
KNOWN_USES in tools/ci/ci_local.py, then update FINGERPRINTS with the
values tools/ci/ci_local.sh --check prints.
Not reproduced¶
dependency-review.yml(needs the GitHub dependency graph; see Supply-chain gates).- The non-PR workflows
(
release.yml,packages.yml,benchmarks.yml,extraction-e2e.yml). - Uploading artifacts: act jobs list what they would upload; fallback jobs
(the coverage HTML of
backend-tests-3.12) copy it into the run directory (Artifact steps).actions/cache(mypy cache) is not restored in the fallback. - Docker Desktop on macOS is an arm64 Linux VM; GitHub runners are x86_64. Native wheels and the production image are therefore built for arm64.