Backend CI: local-first, GitHub only on the trunk
Backend CI runs the canonical gate — backend/bin/ci → backend/config/ci.rb
(Rails 8.1 ActiveSupport::ContinuousIntegration). There is one definition of
the checks; where it runs depends on the stage:
| Stage | Who runs CI | How |
|---|---|---|
Feature PR → docs/new-schema-design | You, locally, before opening the PR | cd backend && bin/ci |
PR → master (reaching the trunk) | GitHub Actions (final safety net) | .github/workflows/backend-ci.yml |
Everyday development does not spend GitHub-hosted minutes — the runner only
fires on pull requests into master. The trade is intentional: run the suite on
your own machine (it's the same bin/ci), and only pay for cloud CI at the trunk.
Why not a required status check /
gh signoff? Both need GitHub branch protection, which this repo's plan doesn't offer (gh signoff install→ HTTP 403). So the feature-PR gate is local discipline (bin/ci), andmasteris backstopped by the workflow.
Before opening a feature PR
cd backend
bin/ci # canonical gate — must be green before you open the PRbin/ci runs, in order (every step runs even if one fails; non-zero exit if any
failed):
bundle check || bundle installbin/check-structure-sql-current— structure.sql drift guardbin/check-no-domains-rspec— domains/ must stay Minitest-onlybin/rubocop-changed— RuboCop on files changed vs the base (blocking)bin/rails db:test:prepare— load the test DB from structure.sql (RESETS it)bin/ci-test-domains— domains/ Minitest suite, regression-gated ontest/domains_failure_baseline.txt- pricing-catalog gating tests
Prereqs (same as any local dev): mise Ruby 3.4.9, PostgreSQL reachable per
config/database.yml (with a test DB), Redis on localhost:6379.
Tests that use Redis must isolate their keys by process and example. Database isolation does not isolate Redis. The development notification outbox test uses Rails constant stubbing around each run to select a unique test key. It clears only that key and restores the constant, including after a failure. Do not clear the development outbox or flush a shared Redis database to prepare a test. This helper assumes the suite's process workers, not concurrent test threads.
Fast subset while iterating (skip the domains suite):
bin/check-structure-sql-current && bin/check-no-domains-rspec && bin/rubocop-changedPostgres sizing on a many-core machine
The domains suite forks one process per worker, each with its own
kulcare_test-N database and connection pool. test/test_helper.rb caps the
count at min(cores, 16) — not one per core — because two Postgres limits
bite hard on a big box, and both fail in ways that look like a code catastrophe
rather than a config ceiling:
| Symptom | Cause |
|---|---|
FATAL: sorry, too many clients already | workers x pool exceeds max_connections. 128 workers x pool 5 wants ~640 against a stock 100. |
PG::OutOfMemory: out of shared memory ... increase max_locks_per_transaction | Lock slots are one fixed pool sized at startup: max_locks_per_transaction * max_connections = 64 * 100 = 6,400 by default. The test DB has ~4,000 relations, so even six concurrent workers can exhaust it. |
The second one errors every single test (e.g. 8,217 errors out of 8,217 runs), so read the first error before concluding the branch is broken.
If you hit either, raise the limits (they are postmaster-level, so a restart is
required; ALTER SYSTEM is revertible with ALTER SYSTEM RESET <name>):
ALTER SYSTEM SET max_locks_per_transaction = 2048; -- default 64
ALTER SYSTEM SET max_connections = 300; -- default 100sudo systemctl restart postgresqlFor reference, on a 128-core box after tuning: the full domains suite runs in
~1m35s using 9 of 300 connections and peaking at 2 locks per backend. More than
16 workers bought nothing. PARALLEL_WORKERS=N still overrides the cap in either
direction.
Faster local runs on macOS: the sharded domains suite
On macOS, forked parallel workers used to fail. The domains suite uses
parallelize(with: :processes), and on this platform the forked workers could
hang at 0% CPU or crash in libcurl (ethon), even with
OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES. A serial run (PARALLEL_WORKERS=1)
is reliable, but it takes about 25 minutes.
Why the forked workers failed (#2335)
Both symptoms had one cause: the suite talked to the real Ably service.
- A developer machine has
config/master.key, so in test the credentials decrypt and carryably_api_key.config/initializers/ably.rbthen built a live::AblyClient. CI has no master key, so its client was alwaysnil. ably-restsends requests through Faraday's typhoeus adapter, which uses libcurl throughethon. Nothing callscurl_global_initbefore the fork, so the first test that published a realtime event called it inside a forked worker (ethon/curl.rb:61). On macOS, libcurl pulls in the Security and CoreFoundation frameworks, which are not safe to initialise in a forked child. The worker died: the Objective-C runtime aborted it, or, withOBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES, it segfaulted.- A dead worker takes its test with it. Rails' parallel test server waits in
at_exitfor its queue to drain and for every worker to stop. It checks for dead workers only once, when that shutdown begins. So when workers die, the coordinator waits forever at 0% CPU. That is theexec_end_procs_chainhang.
The initializer now always leaves ::AblyClient as nil in the test
environment, whatever the credentials say. Tests that need a client swap the
constant with test/support/realtime_capture.rb. With that, nothing the domains
suite runs reaches libcurl, and Ably behaves the same locally as on CI.
Keep libcurl out of forked workers. ably-rest loads typhoeus, so any HTTP
client that picks its adapter automatically can end up on libcurl.
Elasticsearch::Client is one: ELASTICSEARCH_CLIENT is still built in test on
a machine with a master key, although no domains code uses it today. A test
that needs HTTP should stub the client, as RealtimeCapture does for Ably.
This fix has not yet been confirmed on a Mac. If forked workers still fail
there, use the sharded run below, and add the details to #2335. The earlier
pg crash in forked workers is handled separately: test/test_helper.rb sets
PGSSLMODE and PGGSSENCMODE to disable on macOS.
The sharded run
To run the suite faster without forking, split it into shards:
cd backend
CI_DOMAINS_SHARDS=8 bin/ci # the whole gate, with the domains step sharded
SHARDS=8 bin/ci-test-domains-sharded # the domains suite onlyHow it works:
- The script splits the domain test files round-robin across N separate
rails testprocesses. It never forks them, and each one runs serially. - Each shard uses its own database,
kulcare_test_s<i>, throughDATABASE_URL. The databases load in parallel. - The RLS test-role grants run on one database at a time. The role is global to the cluster, and concurrent grants fail with "tuple concurrently updated".
- The script prints one aggregate Minitest summary line, so the ratchet in
bin/ci-test-domainsworks as before. - The script drops the shard databases when it finishes. Set
KEEP_SHARD_DBS=1to keep them.
Per-shard logs go to tmp/domains_shards/. The script fails when a shard
database cannot be set up, or when a shard ends without a summary line. It
never starts more shards than there are test files.
With 8 shards on a 16-core machine, the suite finishes in a few minutes and
fails the same tests as a serial run. Use the same seed (SEED=51193) when you
compare two runs by failing test name.
Isolated non-Billing checks
Use a unique test database for each concurrent agent session. Do not run the standard shard script in two sessions at once: both runs use the same shard database names and log directory. Run each database's RLS grants in sequence. Apply those grants after the last schema load. A schema reload removes grants.
Test-mode reloading is disabled. Reloading while source files change replaces model classes and invalidates factory, association, and Mocha references.
See the non-Billing QA and storage repair guide for the scoped test command, deployment preflight, manual QA, and rollback. The storage migration refuses damaged UUID links. It does not recover them by guessing an owner or delete the underlying files.
The master gate
.github/workflows/backend-ci.yml triggers on pull_request into master (paths
backend/**) and runs the identical bin/ci on a GitHub-hosted Ubuntu runner with
Postgres/Redis service containers. It's the last line of defense before the trunk —
if your local bin/ci was green, this will be too.
Baseline ratchet
test/domains_failure_baseline.txt is the domains non-passing ceiling. Keep it
cloud-calibrated — read the domains non-passing: N line from a master
runner job (the GitHub-hosted count runs a bit higher than local due to env
differences) and ratchet down as red specs are fixed. The committed ceiling is
zero. A baseline is a limit, not a current result. Use the latest complete
CI run to determine whether the branch passes. Do not raise the ceiling to
hide failures or substitute a passing subset for the complete gate.