CI: a fast check before the merge, everything after it¶
Pull requests get a fast check of what the change can break. Main gets the whole suite on every commit, tracks flaky tests, and when it goes red, hands the bisect and the repair to a hangar job. Two workflows do this:
| Workflow | When | What | Wall time |
|---|---|---|---|
.github/workflows/pr.yml |
every pull request | ruff; the selected tests in 4 shards; the Docker smoke suite, Rust and terraform when their paths change | about 40 s for a one-module change, about 50 s for the whole suite (the required test check) |
.github/workflows/ci.yml |
every push to main; workflow_dispatch on any branch |
ruff; every test in 4 shards; all 12 Docker e2e suites; envd's clippy and tests; the image build; terraform; flake report; bisect hook | about 2 min |
.github/workflows/harness-contract.yml |
every PR (the contract job is skipped, and passes, unless the PR touches harness, driver or isolation code); every push to main |
the real agent CLIs at the locked versions, offline, and the recorded protocols (harnesses.md) | about 1 min with the CLIs cached |
.github/workflows/harness-canary.yml |
nightly | the newest upstream CLIs against the same tests; a lock-bump PR or an issue (harnesses.md) | a few minutes |
A hangar job's own check, scripts/ci/verify.sh, runs a capped selection and counts only new failures
(ci-verify.md).
Pre-merge (pr.yml)¶
lint:ruff check src tests scripts/ci.unit (1/4)…unit (4/4): each shard runsscripts/ci/select_tests.py --base HEAD^1 --shard I/4, thenpytest -n 8on its share.HEAD^1is the base branch the PR merges into, so the diff is exactly the PR's. When nothing is selected (a docs change), the shard stops before installing anything. If envd/ is unchanged, the shards restore main's hangar-envd binary from the Actions cache, so the tests that drive it run too.test: passes whenlintand every shard passed. It is the required check.changes, then, only when needed:e2e-docker:tests/test_e2e_docker.pywhen the change reaches any Docker e2e suite. Main runs all 12.rust: whenenvd/**changes. Runs clippy (-D warnings),cargo test --locked, builds the binary, and runs the Python tests that drive it.terraform: whendeploy/terraform/**changes. Runsinit -backend=false,validateandfmt -check.
A job skipped by its path filter counts as passed, so e2e-docker and terraform stay usable as required checks.
There are no reruns before the merge: a test that fails, fails the PR.
Test selection¶
scripts/ci/select_tests.py builds a static map from imports with ast. Nothing is imported or run.
- A changed hangar module selects the test files that import it, and those that import a module importing it (one hop). Imports inside functions count. hangar's modules import each other in one large cycle, so following imports all the way selects the whole suite for any change. One hop is the useful middle.
- Test helper modules count as part of the tests that import them. For example, many files import
tests/test_jobs.pyfor its fixtures. - Tests that read every module's source (they build
"src" / "hangar", liketest_sweep_caps.py) run for any hangar change. That includestest_scope_parity.py, so a change tomcp_server.py,app.pyor any module that mounts routes runs the REST/MCP scope check (security/front-doors.md). - Non-Python paths map to the tests that read them:
envs/,envd/,scripts/acceptance/,deploy/, the Dockerfile,docs/gke.md, andREADME.mdanddocs/index.md(tests/test_front_page.py). Other docs,*.mdand terraform select no tests. - The whole suite runs for any of these:
- a core module: anything
tests/conftest.pyimports (fleet,models,config,store, …), plus the hubs listed inCORE; tests/conftest.pyortests/fixtures/;pyproject.tomloruv.lock;.github/workflows/orscripts/ci/;- a path the table doesn't know;
- a module no test reaches;
- more than 12 changed modules.
The hubs in CORE are the modules whose one-hop selection was already over 60% of the suite's time.
What it chose for some sample diffs:
| Change | Selected | Share of the suite's time |
|---|---|---|
src/hangar/slack.py |
6 files: slack, front door security, interactions, autofix, setup wizards, sweep caps | 22% |
src/hangar/providers/e2b.py |
4 files | 6% |
src/hangar/sandbox_router.py |
3 files | 8% |
src/hangar/triggers.py |
4 files | 16% |
src/hangar/cli.py |
12 files | 20% |
src/hangar/cache.py |
9 files, 3 e2e suites | 30% |
tests/test_jobs.py |
20 files: it, and the tests that import its fixtures | 60% |
envd/src/main.rs |
7 files and 6 e2e suites, plus the rust job |
15% |
docs/…, README.md, deploy/terraform/… |
nothing (terraform has its own job) | 0 |
src/hangar/fleet.py, tiers.py (no test reaches it), tests/conftest.py, uv.lock |
everything | 100% |
On CI, the slack.py change took 41 s from push to a green test check. A change that selects everything took 50 s.
The selection is a bet, not a proof. A change can break a test that is two imports away. Main's full run catches that within minutes of the merge, and the bisect hook finds and repairs it.
Run the same thing locally¶
scripts/ci/select_tests.py --base origin/main # the files, one per line; why goes to stderr
uv run pytest -n 8 $(scripts/ci/select_tests.py --base origin/main)
scripts/ci/select_tests.py --base origin/main --shard 2/4 # CI's second shard
scripts/ci/select_tests.py --files src/hangar/slack.py # for given paths instead of your diff
scripts/ci/select_tests.py --base origin/main --e2e # the Docker suites your change reaches
--base compares against the merge base, and also counts uncommitted and untracked files.
Shards¶
Shards split whole test files using recorded durations (scripts/ci/durations.json). Files go longest first into the
least-loaded shard. Inside a shard, xdist balances single tests across 8 workers. The workers mostly wait on git, tmux
and subprocesses, so 8 beat 4 on a 4-vCPU runner.
Why not pytest-split:
- Splitting by file keeps each module's fixtures and helpers in one shard.
- The shard's argument list is plain file paths, which the selection already produces.
- No longest file (about 57 s on CI, under xdist) comes near a shard's share (about 140 s), so balance stays within one file.
To rebalance, copy the durations artifact from a recent main run over scripts/ci/durations.json. A new test file
counts at the median duration until then.
Post-merge (ci.yml)¶
envdbuilds hangar-envd for x86_64 once (envd/build.sh) and uploads it as theenvdartifact. The build is cached by the hash ofenvd/**: about 10 s when the cache hits, about 85 s when it misses.unit (I/4)runs every test, sharded, with the envd binary.e2e (<suite>)runs each Docker suite in its own job: the 11test_e2e_*_docker.pyfiles plus the e2e part oftest_acceptance_script.py. The list comes from the selection script, so a new suite joins the matrix automatically.test_e2e_kata.pyneeds a KVM host with Kata, so it isn't included.rustrunscargo clippy --locked --all-targets -- -D warningsandcargo test --locked, withSwatinem/rust-cache.imagebuilds the control-plane image with buildx and the GHA layer cache, without pushing it: about 9 minutes cold, 20 s warm. Main's builds pass the ISO week asAPT_REFRESH, and releases their tag, so the apt layer and its security updates rebuild weekly.apt-versions.ymlreports stale apt pins weekly.terraformvalidates both stacks.reportmerges the per-job reports, uploads theflakesanddurationsartifacts, and runs the bisect hook when anything failed.
One full run goes at a time, and none is cancelled. Pushes that land during a run wait, and only the newest of them runs next. The bisect range from the last green run covers the commits in between.
A full run uses up to 22 jobs at once. On a plan with 20 concurrent jobs, a PR opened during one waits for runners. To leave room, set max-parallel
on the e2e matrix, at the cost of main's time.
workflow_dispatch runs the same on any branch. There, the bisect hook only prints what it would do.
bisect_dry_run: true runs it even when everything passes:
gh api -X POST repos/Sean-Koval/hangar/actions/workflows/ci.yml/dispatches -f ref=<branch> -F 'inputs[bisect_dry_run]=true'
Flakes¶
Main runs pytest with --reruns 1 (pytest-rerunfailures). scripts/ci/flake_report.py, a pytest plugin, writes each
job's reruns as JSON: the test, its first error, and whether the rerun passed (a flake) or failed again (a real
failure). The report job collects them into the flakes artifact and prints them. Across runs:
scripts/ci/flakes.py # the last 30 runs of ci.yml on main, most flaky first
scripts/ci/flakes.py --runs 100 --json
A flake still passes the run. Fix it, not the rerun count. Pull requests never rerun.
Release gate¶
A P1 release needs a green main. Before tagging one, check both:
- The last 10
ci.ymlruns on main passed. A run still in progress doesn't count, either way:
gh api 'repos/Sean-Koval/hangar/actions/workflows/ci.yml/runs?branch=main&status=completed&per_page=10' \
--jq '[.workflow_runs[].conclusion] | (map(select(. == "success")) | length | tostring) + "/10 green"'
- No test "failed twice" in the last 30 runs.
scripts/ci/flakes.py --runs 30prints every rerun test with itsflakedandfailed twicecounts. A test that failed twice turned a run red. Fix it before releasing (or show that the failure was in a commit that has since been reverted). A test that only flaked doesn't block the release, but open an issue for it.
If either check fails, fix the cause and wait for 10 green runs again. Don't rerun failed jobs to make the count. Re-running only repeats the same commit, and it hides the flake from the next check.
The first gate found two flaky tests (#118, #120): the background-process tests in tests/test_streams.py attached
for one fixed second and expected the output to be there. On a loaded runner, the process's first output reached
hangar later than that, and the attach correctly returned nothing. The tests now attach again from next until the
output arrives, within 10 s.
Bisect and repair¶
When any job on main fails, scripts/ci/bisect_hook.py collects three facts:
- the failing test ids, from the junit XML (final outcomes, so a test that passed its rerun doesn't count);
- the failed jobs;
- the last green commit, from the newest successful
ci.ymlrun on main that is older than this one.
From them it builds one check command that fails the way the run did: uv run pytest -x on the failing unit tests,
the same with HANGAR_E2E=1 for failing e2e tests, and each failed non-pytest job's own command (clippy and cargo
test, the envd build, docker build, terraform validate, ruff).
What happens next depends on the repo secrets:
HANGAR_URLandHANGAR_TOKENare set (an admin token, or ajobstoken whoseHANGAR_GITHUB_REPOSmaps this repo): the hook submits a hangar job (POST /api/jobs, jobs/README.md) withdeliver: prand the failing tests asverify.- The agent runs
git bisect runbetween the two commits with that check. It then fixes the culprit, with a regression test, or reverts it, and opens a PR. The check is also the job'sverify. - The idempotency key is the run id, so re-running the failed jobs doesn't start a second bisect.
- Optional:
HANGAR_ENVIRONMENTpicks the environment. - The secrets aren't set, or hangar can't be reached: the hook opens a "CI: main is red" issue with the same instructions. If that issue is already open, it adds a comment instead.
The instructions are built from the run's facts and fixed text only: no commit messages or test output.
Test isolation¶
These changes make the suite safe under xdist, and next to other runs on the same machine:
- Scratch directory. Each pytest process gets its own directory under
HANGAR_TEST_ROOT(default~/.cache/hangar-tests), and removes it when the session ends. Concurrent runs used to collide in the shared one. Fake sandboxes, their tmux sockets (TMUX_TMPDIRper sandbox) and test git remotes all live in it. - Ports.
tests.conftest.free_port()hands out ports from a block of 256 per xdist worker. The blocks sit below the kernel's ephemeral range, so an outgoing connection, or another worker binding port 0, can't take a port before the test server binds it. The e2b code-interpreter test no longer needs the fixed port 49999. - Docker. Unit tests never reach the machine's Docker. Without
HANGAR_E2E=1,DOCKER_HOSTpoints nowhere, so a test that builds a real app behaves as it does on a laptop without Docker. One such test built a real image on CI and took 46 s.
With these changes, the suite passed nine full runs on a 2-vCPU machine without a failure:
- five with
-n 8; - two with
-n auto; - two with
-n 4at the same time, sharing oneHANGAR_TEST_ROOT.
CI's first full runs found two more races, both fixed in the tests:
- an upload cut short by a 413 is cleaned up by envd only after the router answers;
- a pty can echo
^@ahead of a command's output.