Continuous Integration Handbook
This handbook explains how continuous integration (CI) works in this
repository, why its platform coverage is arranged as it is, and how to debug it
without repeating expensive or misleading investigations. It complements the
repository testing reference, which remains the command and
suite reference, and the ci-debug skill, which remains the executable
coordination workflow for exact-SHA diagnosis and repair.
The live implementation is authoritative when this handbook and code disagree:
.github/workflows/python-tests.ymlowns GitHub Actions triggers, matrix topology, job timeouts, setup, and sentinels;repo_checks.pyis the only repository-check entry point;src/officina/repository/checks/runner.pyowns suites, tasks, selection, execution order, worker policy, and timing reports;src/officina/repository/checks/remote_macos_windows.pyowns the matrix identity expected by the remote runner;test_support/browser.pyowns Chrome discovery and invocation;docs/testing.mddocuments local suites, repository views, and collection.
Update this handbook when any of those contracts changes. Do not use a dated benchmark below as a substitute for inspecting the current workflow.
The pipeline at a glance
The normal path is:
- A developer runs an appropriate local repository suite.
- The Git hook runs its staged or working-tree gate.
- A pushed commit or pull request starts
Python Testson GitHub Actions. - Eight matrix elements run in parallel on Ubuntu, macOS, and Windows.
- Each element uploads machine-readable repository-check timing evidence.
- A CI repair run uses the exact pushed SHA, isolates a red matrix element, and probes the smallest selector that still reproduces its failure.
- Targeted evidence is followed by a whole-element probe, then a complete exact-SHA matrix. When changes land on the default branch, the ordinary push-triggered workflow is the final integration proof.
Local, probe, matrix, and default-branch results answer different questions. None can silently stand in for another.
Local gates and repository views
The common local gates are:
python3 repo_checks.py --suite precommit
python3 repo_checks.py --suite pre-push
python3 repo_checks.py --suite full --verbose
precommit reads a temporary mirror of the Git index. Unstaged and untracked
files are absent. The other suites use the working tree unless a repository
view is explicitly selected. A new implementation or test therefore must be
staged before the canonical pre-commit gate can exercise it.
The local pre-push suite is broad but is not matrix proof. It cannot reproduce all native runner behavior, it may use a different Python version, and Chrome may skip when the local environment does not promise browser coverage. A local green result means the local gate passed, not that Linux, macOS, and Windows passed the exact pushed commit.
CI triggers and candidate identity
Python Tests runs for:
- pushes to
masterormain; - pull requests targeting
masterormain; - manual
workflow_dispatchrequests made by the repository remote runner.
A dispatched matrix or probe carries a request ID, Git ref, and expected
40-character commit SHA. After checkout, the workflow compares HEAD with the
expected SHA before running tests. This prevents a moving branch from silently
testing a different candidate.
Always record four identifiers together during diagnosis:
- repository and workflow;
- remote ref;
- exact pushed SHA;
- GitHub Actions run ID.
If a branch is rebased or force-pushed, every older run remains evidence only
for its old SHA. Record the new SHA and start a new exact-SHA verification. Use
--force-with-lease, rather than an unconditional force, when rewriting an
authorized non-default branch so unexpected remote work is not overwritten.
Repository tasks
The matrix is expressed in stable repository-check tasks rather than raw test directories.
| Task | Responsibility | Execution policy |
|---|---|---|
validators |
Repository and Officina structural validators | Parallel when the matrix supplies multiple workers |
tests:shared |
Browser-free, non-performance functional tests | Parallel with pytest-xdist work stealing |
tests:performance |
Load-sensitive dispatcher performance invariants | Serial and executed before pooled work in a full suite |
tests:browser |
HTML, SVG, graph projection, inspector, containment, and readability behavior in a real Chrome DOM | Serial, --maxfail=1, and a 30-second bound per Chrome invocation |
tests:portability |
Seven cross-platform boundary sentinels | Serial, outside the main shard |
native:keyring |
Native credential-store behavior | Serial on macOS and Windows |
native:scheduler |
Native recurring-scheduler behavior | Serial on macOS and Windows |
tests:docstrings remains selectable for focused local or probe use. The
matrix topology is intentionally smaller than the selectable-task inventory.
In a full suite, the runner orders performance first, pools validators with shared tests, and runs browser tests afterward. The pooled invocation gives validators and ordinary pytest items one xdist queue and one worker budget. Chrome is kept out of that pool because concurrent browser processes made virtual-time completion unreliable under repository load.
Current matrix
The current matrix has eight elements:
| Operating system | Matrix task | Main evidence | Additional sentinels |
|---|---|---|---|
| Ubuntu | combined |
Performance, pooled validators/shared tests, and browser tests | Portability |
| macOS | validators |
Validators | None |
| macOS | tests:shared |
Shared tests | Portability |
| macOS | tests:performance |
Performance invariants | Native keyring and scheduler |
| Windows | validators |
Validators | None |
| Windows | tests:shared |
Shared tests | Portability |
| Windows | tests:performance |
Performance invariants | Native keyring and scheduler |
| Windows | tests:browser |
Complete browser behavior suite | None |
The Ubuntu combined timing report labels its pooled validators/shared phase
as tests:shared. That label is a reporting normalization, not proof that the
phase contains functional tests only.
The portability and native smoke steps use always() where configured. They
can produce evidence after the main shard is already red. Their later failure
is not automatically the cause of the earlier failure; treat each result as a
separate ledger entry.
Why the operating systems differ
The matrix seeks meaningful platform evidence, not identical job shapes.
- Ubuntu runs the complete suite and provides one stable Chrome gate.
- Windows keeps validators, shared tests, performance tests, and browser tests separate. The browser shard has one worker, while shared tests retain four.
- macOS runs validators, shared tests, performance invariants, portability, keyring, and scheduler behavior. It does not gate Chrome rendering.
Hosted macOS Chrome was observed to produce the expected DOM and then fail to terminate reliably. Accepting a timeout merely because stdout looked complete would turn a process-lifecycle failure into a false pass. Repeated launch flags, a Chrome-for-Testing preference, and output salvage were investigated during the 2026-08-21 repair, but they accumulated special cases without establishing a reliable lifecycle contract. The maintainable resolution was to preserve complete browser behavior coverage on stable Ubuntu and Windows hosts and keep macOS coverage focused on behavior it can gate reliably.
Browser coverage
Browser tests exist because renderer unit tests cannot prove behavior that depends on a real Document Object Model (DOM), browser layout, SVG geometry, event dispatch, or JavaScript mutation. They cover graph projection and arrangement behavior, inspector and Bezier interactions, containment edges, node readability, and the rendered visualization contract.
Every *_browser.py module must:
- call
require_chrome(), so a promised browser gate cannot pass by skipping; - call the shared
run_html()helper, so discovery, flags, temporary profiles, UTF-8 decoding, and timeout behavior remain consistent; - use portable temporary paths rather than host-specific browser URLs.
The browser task itself supplies serialization through jobs=1. Do not add a
second filesystem lock, xdist group, or fixture-level scheduler. Multiple
serialization mechanisms obscure ownership and can deadlock or waste time.
FAMULUS_REQUIRE_BROWSER=1 is set only for the Ubuntu combined job, the
Windows browser job, and browser-bearing probes. Elsewhere a missing Chrome may
skip with documented alternate renderer coverage. A browser gate with that
environment variable must fail if Chrome is absent.
Each Chrome process has a 30-second wall-clock timeout. Exceeding it is red even if partial or apparently complete DOM text was captured. A successful browser assertion includes clean process completion.
Timeouts and failure boundaries
The current limits are:
| Boundary | Limit | Meaning |
|---|---|---|
| Complete matrix job | 20 minutes | Stops an unhealthy OS/task element while leaving room above its healthy duration |
| Targeted probe job | 10 minutes | Bounds an isolated diagnostic run |
| One Chrome invocation | 30 seconds | Bounds browser startup, rendering, DOM dump, and termination |
A timeout is a failure class, not an instruction to enlarge the outer timeout. First identify the last completed selector and the process that remains alive. Then reproduce the smallest containing selector with a bound appropriate to that subprocess. Verify cleanup of the entire process tree and inherited resources before accepting a repair.
The previous 60-minute job limit allowed a normally sub-10-minute suite to sit unproductively for most of an hour. The 20-minute limit leaves substantial headroom while turning a hang into useful evidence sooner. Do not reduce it from one successful observation alone; use the rolling benchmark procedure in Potential improvements.
Artifacts and timing evidence
Every matrix element uploads .repo-checks/*.json, even after failure. The
schema-version-1 timing report contains:
- task wall time;
- pytest setup, call, and teardown totals by file;
- item counts and failure information used by the remote report.
Per-file timing totals are cumulative test durations. With xdist they overlap across workers and can exceed the shard's wall time. Use them to identify expensive test families, not to reconstruct elapsed time by addition.
GitHub job duration includes checkout, language setup, dependency and CLI installation, repository checks, artifact upload, and post-job cleanup. To separate useful test time from setup variance, compare GitHub step timestamps with task wall times in the uploaded artifacts.
Debugging workflow
Use the ci-debug skill when CI is red. This handbook supplies project context;
it does not replace the skill's failure ledger, isolated repair elements, or
exact-SHA orchestration.
1. Establish authority
Before changing code:
- Verify the current branch and worktree status.
- Verify the remote ref and exact pushed SHA.
- Open the run for that SHA, not merely the newest run with a familiar branch name.
- Download its repository-check evidence.
- Record every red matrix element and sentinel independently.
Do not diagnose from a stale checkout, a different worktree, an installed copy of the repository, or a run for a superseded SHA.
2. Classify before repairing
Useful failure classes are:
| Class | Typical evidence | First response |
|---|---|---|
| Product regression | Deterministic assertion or validator failure on multiple hosts | Reproduce the smallest node or file locally, then probe its matrix element |
| Platform portability | Failure exists only on one native OS | Inspect path, encoding, socket, process, timezone, file-mode, or native API boundaries |
| Performance budget | Correct output exceeds a calibrated threshold | Measure the affected platform; change only its budget if the healthy distribution supports it |
| Browser lifecycle | Chrome starts, hangs, times out, or leaves descendants | Bound one browser selector; inspect process completion and cleanup, not only DOM output |
| Infrastructure/setup | Checkout, network install, authentication, or hosted-runner service fails | Retry the unchanged operation or narrowly escalate host access before editing product code |
| Coverage-policy failure | A task skips, disappears, or runs on the wrong host | Repair category routing and its policy test, not one test filename |
Sandbox errors such as repository unavailability, loopback PermissionError,
or SSH owner/permission rejection are not evidence of a source regression.
Retry the unchanged read, socket, or GitHub operation with narrowly scoped host
access before modifying code.
3. Shrink the reproduction
For each red element:
- Prefer the exact failing pytest node.
- If the report lacks a node, use the smallest containing test file.
- Use the same OS and repository task as the failure.
- Use one worker for suspected concurrency or lifecycle failures.
- Keep unprobed failures in the ledger.
A local focused command has this shape:
python3 repo_checks.py --suite full --task tests:browser \
--selector tests/example_browser.py::test_example --jobs 1
Selectors require an explicit task. Do not rerun selectors already known to pass, and do not launch a complete matrix merely to learn whether one edited test still fails.
4. Repair the category
Fix the narrowest root cause, then add the simplest durable guard that prevents the same category:
- fixture behavior for a fixture race;
- shared browser-helper behavior for all browser callers;
- task routing for a missing category;
- one policy test for the matrix contract;
- a platform-specific budget only when platform measurements justify it.
Avoid instance lists when the category can be discovered mechanically. Avoid layers of retries, timeout salvage, locks, flags, and host exceptions that cannot state one coherent contract.
5. Escalate evidence in order
The proof ladder is:
- smallest failing node or file;
- every known selector in that failure class;
- the whole affected matrix element;
- the complete matrix for the exact pushed SHA;
- after integration, the ordinary default-branch push workflow.
Targeted green is not whole-element green. Whole-element green is not matrix green. A matrix on an unpushed or superseded SHA is not integration proof.
If the same failure set repeats without a relevant code or environmental change, stop rerunning and report a concrete blocker. Repetition is not new evidence.
Common pitfalls and lessons
Local success is not native matrix success
The 2026-08-21 repair began from a local pre-push result with thousands of passing tests and a passing local browser phase. Native CI still exposed a Windows loopback reset and a macOS cold-start budget mismatch. Preserve local gates, but require native exact-SHA evidence for native claims.
Do not turn a hang into a pass
A Chrome process that emits expected DOM and then fails to exit is still a
lifecycle failure. Salvaging stdout from TimeoutExpired hid the distinction
between correct rendering and clean completion. The final design rejects that
salvage path.
Do not accumulate speculative Chrome workarounds
Suppressing first-run behavior, adding flags, preferring another Chrome build, or extending timeouts can each be a valid isolated hypothesis. Applying them serially without a confirmed cause produces a fragile browser abstraction. When one host cannot reliably satisfy the lifecycle contract, preserve the same behavior coverage on stable hosts and keep the unreliable host's other native coverage explicit.
One scheduler should own serialization
The browser suite briefly had runner serialization, xdist grouping, and a filesystem lock. Only the runner's one-worker task was necessary. Redundant locks complicate cleanup and can create their own stalls.
Platform budgets need platform measurements
Linux timing is not a universal performance budget. A prior repair measured a normal macOS cold-start median of 132.81 ms, kept Linux at 125 ms and Windows at 175 ms, and changed only macOS from 125 ms to 150 ms. Change budgets from healthy platform distributions, not by copying the slowest host's limit to every platform.
Expected connection resets can be fixture behavior
An intentionally oversized OAuth callback caused Windows to reset the sender connection before the fixture sent its later valid callback. The application was waiting correctly; the test driver had terminated early. The repair made the fixture tolerate the expected reset so it could continue its scenario. Distinguish subject failure from fixture-control-flow failure.
always() creates secondary evidence
A portability or native smoke may execute after the main shard fails. Do not attribute the job's first failure to the last red step. Read timestamps and artifacts in execution order.
Keep unrelated maintenance outside the repair
Do not regenerate blueprints, issue certificates, rewrite standards, or fix unrelated documentation merely because a CI investigation encounters them. Add an independently failing check to the ledger or defer it to its owning workflow. Browser debugging does not authorize certification work.
Optimize the critical path, not the loudest anomaly
A three-minute setup job can be wasteful without delaying the workflow when a ten-minute job runs in parallel. Report both end-to-end wall time and aggregate runner occupancy. Speed work should identify which job determines completion.
Maintaining CI safely
When changing matrix policy:
- Update the workflow and
EXPECTED_MATRIXtogether. - Update policy assertions in
tests/test_repository_test_checks.pyand transport assertions intests/test_repo_checks_remote.py. - Keep category discovery in the runner; do not maintain parallel filename inventories in workflow YAML.
- Preserve
FAMULUS_REQUIRE_BROWSER=1on every browser-promising route. - Preserve one-worker browser execution and the shared helper policy tests.
- Update
docs/testing.mdand this handbook. - Run focused policy tests, the local pre-push gate, an exact-SHA complete matrix, and the normal default-branch workflow when the change lands.
When adding or moving a test, first decide which behavioral category owns it. The prevention mechanism should recognize the category rather than enumerate the particular failure that prompted the change.
Potential improvements
This section records optimization hypotheses, not approved changes. Each proposal should begin with measurement and preserve the behavior boundary the test exists to exercise.
Benchmark timeline
The relevant observations from the 2026-08-21 repair were:
- Healthy per-OS work normally completed in less than ten minutes, while the outer job timeout was 60 minutes. A stuck browser or descendant process could therefore occupy a runner for most of an hour without new evidence.
- Chrome invocation was bounded at 30 seconds and stalls were made explicit failures. The outer matrix and probe limits became 20 and 10 minutes.
- Several macOS Chrome mitigations were tried, but the repeated symptom was correct DOM output followed by unreliable process termination. An architecture audit replaced those layered mitigations with stable-host browser coverage.
- Exact-SHA run
32521019328
at commit
8e62d49c472f58821e5d8cbc543908b0a3bb8e6cpassed all eight elements in 9 minutes 56 seconds. Aggregate job occupancy was 25 minutes 58 seconds.
That successful run is a snapshot, not a statistical baseline:
| Matrix element | Job wall time | Recorded checks | Setup and other overhead |
|---|---|---|---|
| Ubuntu combined | 4m56s | 4m35s | 21s |
| macOS validators | 49s | 21s | 28s |
| macOS shared | 2m43s | 2m22s | 21s |
| macOS performance/native | 40s | 9s | 31s |
| Windows validators | 1m17s | 29s | 48s |
| Windows shared | 9m49s | 8m41s | 1m08s |
| Windows performance/native | 3m17s | 14s | 3m03s |
| Windows browser | 2m27s | 1m40s | 47s |
The Windows shared element was the critical path. Browser execution took 1m19s inside Ubuntu combined and 1m40s on Windows; under healthy conditions it was not the dominant speed bottleneck.
Rolling benchmark procedure
Before changing timeouts or optimizing a shard:
- Use
scripts/benchmark-test-suite.pyfor controlled local cold- or warm-cache measurements; it delegates discovery and policy to the checkout'srepo_checks.pyinstead of duplicating the suite. - Collect at least ten successful CI runs with the same matrix shape and runner images.
- Record workflow wall time, every job's wall time, setup-step time, task wall time, and the slowest per-file cumulative totals.
- Calculate median and p95 separately for each OS/task pair. Do not pool operating systems.
- Mark runs with GitHub service or package-registry incidents instead of silently treating them as product performance.
- Reset the comparison window after a matrix, dependency, runner-image, or major test-inventory change.
- Set outer timeouts above the healthy p95 while keeping subprocess timeouts close to the operation they bound.
For example, a controlled local task benchmark can be started with:
scripts/benchmark-test-suite.py --repo . --suite full \
--task-id tests:shared --output /tmp/famulus-shared-benchmark \
--runs 10 --cache cold --jobs 4 --measure-resources
Bottlenecks, hypotheses, and remedies
| Area | Measured evidence | Hypothesis | Confidence | Simple next step | Plausible effect |
|---|---|---|---|---|---|
| Windows shared certification tests | Shared work took 8m03s; test_certifier.py contributed 12m21s of cumulative per-test time across workers |
Repeated Git, filesystem, hashing, or subprocess setup is amplified on Windows | High that this is the main target; medium on cause | Count expensive operations per test and profile fixture setup before refactoring; reuse only immutable/resettable preparation | 30s-2.5m is plausible; larger gains need evidence |
| Windows portability sentinel | 38s versus 2-4s on Ubuntu/macOS | One or more sentinels exercise Windows process or filesystem boundaries repeatedly | Medium | Inspect its per-test report and optimize only the dominant boundary | At most about 38s on the critical path |
| Per-job CLI installation | One Windows performance job spent 2m27s installing CLIs while its checks took 14s; other installs took 5-19s | Transient registry/network variance, plus installation on shards that may not all consume both CLIs | High for wasted runner time; low for workflow wall time | Audit which tasks import or execute each CLI; skip installation only where absence cannot weaken the test | Up to roughly 2m20s runner occupancy in an anomalous job; little end-to-end gain |
| Windows shared setup | The critical job had about 1m08s outside recorded checks | Checkout and repeated dependency/CLI setup are slower on Windows | Medium | Break out rolling setup medians before adding caches or combining jobs | Likely 10-40s if a stable repeated cost exists |
| Browser projection arrangements | About 43s of 79s on Ubuntu and 54s of 100s on Windows browser execution | Many real-browser cases pay independent Chrome startup cost | High on location; medium on safe remedy | Determine whether cases can share one HTML scenario without losing isolation or failure attribution | Browser job could improve, but current workflow wall time would barely change |
| Performance/native shards | macOS performed 9s of checks in a 40s job; Windows performed 14s in a 3m17s anomalous job | Isolation is diagnostically valuable but has a high setup-to-test ratio | High | Keep isolation unless rolling runner cost justifies a simple merge with an existing non-critical shard | Runner-cost reduction, normally no critical-path reduction |
The first optimization target should be Windows shared certification behavior. Every minute removed from that job currently removes approximately one minute from workflow wall time, until it approaches the Ubuntu combined duration of about five minutes. Chrome optimization should remain secondary unless rolling data shows a regression or lifecycle failures return.
Do not introduce a cache, retry system, persistent browser pool, or new scheduler merely because it might be faster. Prefer removing duplicated work, making existing category ownership explicit, and measuring again.