Repository navigation
ci: run only integration tests in the Integration Tests job, sharded across six runners with per-shard build caches - #1402
Conversation
The Integration Tests job runs `go test -tags=integration ./...`, which also re-runs every untagged unit test that the Unit Tests job already runs with -race -short: 271s of test time on main, next to 536s for the tests in integration-tagged files. This script prints, per package, a -run regex naming only the tests that job skips: top-level tests in files that compile only under the integration tag, plus untagged tests that skip themselves under -short (today only TestDiscoverOrgAccounts_CancelledContext in internal/accounts). Packages with neither are not printed, so they are not compiled either. Build constraints are evaluated by `go list` with and without the tag, so `&&`, `||` and `!` expressions match what go test does. The third column counts the tests the regex runs, which can exceed the names when the in-package and external test packages share a test name, as internal/analytics does. A --shard mode partitions the selection over a shard spec with one catch-all shard and fails if a package lands in no shard or in two. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A test the selector misses is not run by CI at all, since the Unit Tests job skips integration files, so both directions are asserted on fixture packages in a temp dir: tagged and untagged files, in-package and external test packages, `!integration`, `integration && foo`, `integration || foo` and legacy `// +build` constraints, tests gated on testing.Short() directly, through a helper and through testutil.SkipIfShort, packages with nothing to select, and anchoring and escaping of the regex. The regexes are also checked against what `go test -list` actually selects, not only as strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the shard cases (a package in two shards, none, a stale package name, no or two catch-alls, an empty or unknown shard, a multi-line module path) and runs the suite as its own job next to the other script self-tests, with Go set up because the fixtures go through `go list`. CI Success needs it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Integration Tests job took 531s on main: about 45s of setup, 73s of compiling every package with -race and coverage, and a 480s test step holding 856s of package time on one 4-vCPU runner. 271s of that was unit tests the Unit Tests job already runs. Each leg now runs only what scripts/integration-test-selection.sh selects, so packages without integration tests are not compiled, and the legs split the rest by measured package time: migrations alone (190s on main), api + purchase + config, and auth + server + a catch-all for every other package with integration tests. A guard step fails if a package lands in no shard or in two, or if the matrix and the shard list disagree, and the test step fails if fewer top-level tests ran than were selected. Every leg keeps its own Postgres service, the migrations step, the env vars, -race, -tags=integration and -timeout=20m; fail-fast is off. The tests/e2e type-check runs on one leg. Each leg uploads its coverage profile under its own name, and a merge job rebuilds the integration-coverage artifact. The old job's profile included the unit tests it re-ran, so the merge adds the unit job's profile back: the result is 81.6% total, the same as main's. Codecov and the 80% warning read the unit profile, which is unchanged. CI Success needs the matrix job and the merge job; a failed or cancelled leg fails the matrix result, as with terraform-validate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The awk scan took the files as directory/name and split the selectable list on spaces, so a checkout path with a space in it would have dropped every test of the package from the selection. go list already prints the names relative to the package directory, so the scan now runs there on bare file names. The output for this repository is byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 3 billable files and costs up to $0.75. Or wait 5 minutes for your next included review. View limit details
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Test selection and shard assignment scripts/integration-test-selection.sh, scripts/test-integration-test-selection.sh, .github/workflows/ci.yml |
The selector finds qualifying test functions, creates anchored test-name patterns, and assigns selected packages to shards. Its self-tests cover selection rules, regex matching, and shard validation. CI runs the selector self-tests. |
Sharded CI test execution .github/workflows/ci.yml, .github/workflows/README.md |
The workflow runs selected packages across four shards. Each shard validates test results and produces a coverage profile. The e2e type-check runs on one shard. The README describes the selected test groups and sharding script. |
Coverage merge and required CI jobs .github/workflows/ci.yml |
A merge job combines shard profiles with unit coverage and checks profile availability and mode. The merge and selector self-test jobs are required by ci-success. |
Priority:
Estimated code review effort: 4 (Complex) | ~45 minutes
Change: Feature
Sequence Diagram(s)
sequenceDiagram
participant CI as GitHub Actions CI
participant Selector as integration-test-selection.sh
participant Shard as Integration shard
participant Merge as Coverage merge job
CI->>Selector: Generate test selection and assign packages
Selector-->>CI: Return package patterns and test counts
CI->>Shard: Run selected tests with race detection and coverage
Shard-->>CI: Upload shard coverage profile
CI->>Merge: Provide shard and unit coverage profiles
Merge-->>CI: Return merged coverage or validation failure
Suggested reviewers: cristim
Merge Risk: 
Minimal · up to 89de8
-
No merge-blocking issue is established. The CI run still needs to confirm the matrix and artifact behavior.
Pre-merge checks: 
4 · 
1
-
Failed checks: 1 warning Passed checks: 4 passed-
Full details: Title check
-
Explanation
The title clearly identifies the main change: selective integration testing with sharding. The stated six-runner count does not match the objective's three matrix legs, but the title remains related and specific.
-
Full details: Docstring Coverage
-
Explanation
Docstring coverage is 41.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 2 files. (2 skipped: 2 unsupported.)
Resolution
Write docstrings for the functions missing them to satisfy the coverage threshold.
-
Finishing Touches
-
🧪 Generate unit tests (beta)
-
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Comment @coderabbitai help to get the list of available commands.
The first sharded run spent most of each leg compiling, and the leg holding api, config and purchase (87s, 85s and 53s of package time) finished its first package about four minutes into the test step. Give api, config, purchase, auth and server a leg each, and keep migrations with the runtime-computed rest (testhelpers, cleanup-lambda, database, scheduler, analytics, commitmentopts and the small ones), whose 64s and 95s run side by side within api's 87s. api is one package and is never split by test, so it is the floor. Shard names now go into cache keys as well as artifact names, so the spec check rejects names outside letters, digits, '.', '_' and '-'. The self-test covers that and the one-package / catch-all-with-a-name layout ci.yml now uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the first sharded run every leg spent most of its time compiling: the api/purchase/config leg finished its first package about four minutes into its test step. setup-go's cache is one entry for every job, keyed on go.sum and saved by whichever job finishes first, so it never holds this job's -race -tags=integration coverage build (main's entry is under 1 MB today). Each leg now restores and saves its own GOCACHE with actions/cache (same pinned SHA as the security-scan job), keyed on shard, Go version and a hash of go.mod/go.sum, the Go sources and the cmd/, internal/ and iac/ trees (go:embed inputs). On a miss it falls back to the shard's latest entry, then to any shard's: the cache is content-addressed, so a stale entry only recompiles what changed. setup-go's cache is off; the legs share one GOMODCACHE entry keyed on the module files, which the first leg fills with every shard's modules and saves. Restore and save are separate steps so a red run still warms the cache. Before saving, entries the run did not use are dropped (they are backdated on restore and Go bumps the mtime of what it uses), so a fallback restore does not keep growing. Locally the api shard's test binaries took 351s to build cold on 4 cores and 10s with the cache restored; coverage instrumentation is cached too (no compile or cover actions on the warm run). The api cache is 1.7 GB on disk, 213 MB compressed. go test now gets -count=1: -run, -v, -timeout and -coverprofile are cacheable test flags, so a restored cache would otherwise replay earlier results instead of running the tests against the database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in LeanerCloud#1401 (parallel migrations tests) and gives the per-shard build caches a second, warm run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why
On main's CI run 40153816430 (after #1400 put each package's integration tests on one shared Postgres server), the Integration Tests job took 531s:
-raceand coverage: about 73s before the first package finishedOf the test time, 536s was tests in
//go:build integrationfiles. 271s was untagged unit tests, which the Unit Tests job already runs with-race -short. Only two test files checktesting.Short(), so almost every unit test ran twice.Slowest packages: migrations 190s, api 130s, auth 122s, server 92s, purchase 76s, config 70s, testhelpers 25s, secrets 21s (no integration tests at all), database 18s, scheduler 15s, cleanup-lambda 14s, analytics 12s.
What changed
1. The job runs only integration tests.
scripts/integration-test-selection.shprints<package> <tab> ^(TestA|TestB|...)$ <tab> <count>for each package with tests the Unit Tests job doesn't run:Test*functions in*_test.gofiles that compile only with-tags integration. The script doesn't parse build constraints itself. It compares the test files fromgo list -tags integrationwith those from untaggedgo list, so&&,||,!, parentheses and legacy// +buildlines are evaluated exactly asgo testevaluates them. Anintegration && foofile is excluded because CI never setsfoo. A!integrationfile is excluded because CI never compiles it.testing.Short(), either directly, through a package-local helper (followed to a fixed point), or throughtestutil.SkipIfShort. I grepped for them:internal/server/integration_test.gois tagged anyway;internal/testutil.SkipIfShorthas no callers. The only untagged one isinternal/accountsTestDiscoverOrgAccounts_CancelledContext, which-shortskips in the Unit Tests job, so before this PR only this job ran it. It is selected.go test, so they aren't compiled either.internal/secrets, for example, drops out entirely.internal/analyticsdefinesTestPostgresAnalyticsStore_Closein both the in-package untagged file and the external-package tagged file.-runmatches by name, so that package's unit copy still runs here, one test of de-duplication we can't get without renaming a test.2. The job is sharded into six legs (
strategy.matrix,fail-fast: false). Each leg has its own Postgres service container,go installof migrate, the migrations step and the same env vars,-race,-tags=integration,-timeout=20mand-coverprofile. The grouping comes from this PR's first CI run (three legs, run for 89de873):Each leg spent most of its time compiling (point 3), and the package times put api, config, auth, server and purchase each near the old migrations leg on its own. The new legs:
-racetests are CPU-bound, so a merged leg risks becoming the long pole. A separate leg costs runner time, not wall time, so I kept them apart. If runner minutes matter more, server + purchase is the first merge to try.INTEGRATION_SHARDS. The*entry is the catch-all, computed at runtime as every package with integration tests minus the named ones, so a new package lands in "migrations, rest" automatically..,_and-(names go into cache keys and artifact names), or if the matrix legs and theINTEGRATION_SHARDSlines disagree. It checksstrategy.job-indexandstrategy.job-total, so every spec shard has exactly one leg.tests/e2ego vet -tags=e2etype-check runs on one leg (migrations, rest), and it still runs if that leg's tests fail.3. A warm Go build cache per leg. setup-go's
cache: trueis one cache entry shared by every job, keyed on go.sum and saved by whichever job finishes first. It never holds this job's-race -tags=integrationcoverage build, so every leg compiled cold. On main the x64 entry (setup-go-Linux-x64-ubuntu24-go-1.26.10-9e4b…) is 7.6 KB, so in practice it holds nothing at all.go env GOCACHE, keyintegration-gobuild-<os>-<arch>-<go version>-shard-<shard>-src-<hash>. The hash covers go.work, go.work.sum, every go.mod/go.sum, every*.gofile, and the wholecmd/,internal/andiac/trees, which holds thego:embedinputs (openapi.yaml, IaC templates). restore-keys fall back to this shard's latest entry (the PR's own, else main's), then to any shard's. The build cache is content-addressed, so a stale entry is safe: it only recompiles what changed.go env GOMODCACHE, keyintegration-gomod-<os>-<arch>-<hash of go.work/go.work.sum/go.mod/go.sum>, falling back to the latest. On a miss, leg 0 runsgo list -deps -test -tags=integration ./...so its saved set covers every leg, then saves it. No two caches restore the same path.actions/cache/restore/actions/cache/save, the same0057852…v4.3.0 pin the security-scan job uses). The build cache is saved whenever the test step ran, pass or fail, but not on cancel and not on an exact hit.go test -count=1.-run,-v,-timeoutand-coverprofileare all cacheable test flags, and go test can't see that the tests depend on the database. Without-count=1, a restored cache would replay earlier results instead of running the tests.4. Coverage. I grepped all workflows: nothing in CI downloads
integration-coverage. It's a 30-day artifact for people. Codecov and the 80% coverage warning (a::warning::, not a gate) both read the unit job'scoverage.out, which this PR doesn't change. No threshold or gate changes. Each leg uploadsintegration-coverage-<shard>(1-day retention). A newMerge integration coveragejob (needs: [unit-tests, integration-tests]) merges them and uploads the sameintegration-coverage/coverage-integration.outas before. The old job's profile also covered the unit tests it re-ran, so the merge adds the unit job's profile back in. That keeps the artifact's meaning: 81.6% total, the same as main's artifact (see Verification). Without the unit profile it would be about 38%.5. CI Success now
needsintegration-tests(the matrix),integration-coverageandintegration-test-selection. Its check is an allowlist onresult == "success", and a matrix job'sneedsresult isfailureif any leg failed andcancelledif any was cancelled. That's the same mechanismterraform-validateanddocker-buildalready rely on. The required checks on main (rulesets API) are onlyCI SuccessandRun pre-commit hooks, so the renamed per-shard job names don't affect branch protection.6. Self-test:
scripts/test-integration-test-selection.sh(23 cases) runs as its ownIntegration test selection self-testjob, next to the other script self-tests. It needs setup-go (cache off; the fixtures are stdlib-only). The fixture packages in a temp dir cover: tagged files (in-package and external), an untagged file,!integration,integration && foo,integration || foo,(integration && !foo) || ignore, legacy// +build, Short-gated tests (direct, through a two-level helper, throughtestutil.SkipIfShort, inside a subtest closure, and a comment/string mention that must not count), the same test name in both test packages in both tag orders, a package with no integration tests, a package with no test files, and regex anchoring and escaping. The escaping is checked against Go'sregexp, and the regexes againstgo test -list. It also covers every shard-spec failure, shard names unfit for a cache key, and the one-package / catch-all-that-names-a-package layoutci.ymluses. I mutation-checked it: breaking the Short filter, the tag diff, the anchoring or the duplicate-package check each makes 1-5 cases fail.Verification
All of this ran locally on this branch's head (
1bcae5b6):actionlint(pinnedrhysd/actionlint:1.7.12image): clean.pre-commit run --files .github/workflows/ci.yml scripts/integration-test-selection.sh scripts/test-integration-test-selection.sh(trivy skipped there, run below): all hooks pass, including actionlint, zizmor and git-secrets. trivy:docker run aquasec/trivy:0.69.3 config --severity HIGH,CRITICAL --exit-code 1 --skip-dirs terraform/environments/aws --skip-dirs "**/.terraform" --skip-dirs .claude ., exit 0.shellcheck -xon both scripts: clean.bash scripts/test-integration-test-selection.sh:23 passed, 0 failed. Mutation check: dropping the new shard-name check fails exactly the new case (22 passed, 1 failed).bash ~/…/secrets-scan.shprintedclean; planted key flagged.bash scripts/test-git-secrets-allowlist.sh:137 PASS, 0 FAIL.Guard on the real repo with the new spec. I extracted the guard step's
run:block,INTEGRATION_SHARDSand the matrix fromci.ymland ran the block once per leg withSHARD_INDEX/SHARD_TOTALsubstituted. All six legs passed:Run as leg 0 with shard
config, the block fails withmatrix leg 1 of 6 is shard 'config', but INTEGRATION_SHARDS line 1 of 6 is 'api'.Cache effect, simulated locally. I built a shard's test binaries exactly as the test step does (
go test -race -tags=integration -count=1 -coverprofile=… -run '^$' <packages>: full compile, coverage instrumentation and link, no tests run), pinned to 4 cores (taskset -c 0-3) with go1.26.10 and a warm module cache. Then I copied theGOCACHEdir to simulate a restore, ran the workflow's own "Mark restored build cache entries unused" and "Drop build cache entries this run did not use"run:blocks around the build, and built again:GOCACHEgo test -x, 1 linkgo installof golang-migrate, cold / warmCoverage builds are cached. The warm api run had no
compileorcoverinvocations under-x; only the test binary was linked.Cache sizes after one shard's build (a fresh
GOCACHEper shard, so it holds only that shard's build; on CI each leg's defaultGOCACHEstarts empty because setup-go's cache is off):That's about 1.0 GB per full set of six build caches, saved whenever Go inputs change, plus 255 MB when a go.mod/go.sum changes.
Cold builds ran three at a time on a 16-vCPU box (load from the other two), so they were slower than one alone: api 539s in parallel against 351s alone. All the cold/warm comparisons in the table ran alone.
Selection against the real repo (
bash scripts/integration-test-selection.sh, about 7s; regexes elided, count column shown):Before/after test sets. I listed every test with
go test -list '.*' ./...with and without-tags integration, then listed each shard with its exact-runregex and packages under-tags integration:diffof the two unions: empty.internal/analyticsname above, which is in both test packages (5123 lines, 5122 unique).go test -listdifference: identical except the intended Short-gatedinternal/accountstest.Earlier local run of the test steps, on the three-leg layout before this rebalance (the packages and each package's
go testflags are unchanged apart from-count=1). I ran them locally against testcontainers, one leg after another. I extracted the "Select integration tests", guard, "Run integration tests" and e2e steps'run:blocks fromci.ymland ran them withbash -eo pipefail, the job and step env, andstrategy.job-index/job-totalsubstituted. Each leg was pinned to 4 cores (taskset -c 0-3) with a freshGOCACHEholding only pre-built third-party deps (repo packages compiled cold),GOFLAGS=-count=1,CI=true. The Postgres containers weren't pinned. All passed, each reportingran N of N selected:This machine is shared with other agents' test runs (load average 5 to 45 while measuring), so these local times run slower than CI's: migrations took 271s here and 190s on CI. With a fully cold
GOCACHE(third-party deps compiled too) the legs took 351s, 609s and 732s. Those runs were compile-bound and happened under the heaviest load, so they're an upper bound only.Coverage merge, simulated by running the merge job's
run:block on the local shard profiles plus the unit job'scoverage.outfrom run 40153816430: 4 profiles, 31438 blocks, 81.6% total. Main'sintegration-coverageartifact from the same run: 81.6%. Per function, the only lower one isnewMigratorWithRecovery, one block (migrate.go:132, logging whenm.Close()errors after an auto-heal error). It's hit only by the tagged autoheal/idempotency tests, which ran in both, so this is a timing-dependent error path, not a selection gap. The merge step reads everyintegration-coverage-*artifact whatever the shard names are. With six legs it merges seven profiles.Only CI exercises these (not verifiable locally):
strategy.matrixexpansion and job names,strategy.job-index/job-total, and thee2e-typecheckinclude keyactions/cache/restore/save: real hit rates, restore-key fallback across PR and main scopes, upload/download times, and that the backdate/prune steps behave the same on the runner's filesystemintegration-gobuild-*orintegration-gomod-*entry exists yet, so each leg compiles from scratch and then saves. A second push shows the warm numbers. After merge, main's entries become the fallback for every PR.upload-artifact,download-artifactwithpattern/merge-multiple, the merge job, and the CI Success aggregator's view of the matrixExpected effect
Before: main 531s (one job). This PR's first run (three legs, 89de873): 365s wall (legs 178s / 308s / 365s), compile-bound.
After, warm (a later push to this PR, or any PR once main has the entries). Per leg: setup ~45s, which shrinks by roughly 20s because golang-migrate's compile is now cached; ~10-20s to restore ~85-213 MB of build cache plus 255 MB of modules; a few seconds of link; then the slowest package; then ~10-20s to prune and save when the Go inputs changed:
Note for cristim
gh api …/actions/cache/usage), above the 10 GB default. Most of it is 9.6 GB ofbuildkit-blobentries. GitHub evicts least-recently-used entries past the limit. This PR adds about 1.0 GB per push that changes Go inputs (six build caches), plus 255 MB per go.mod/go.sum change. Main's entries stay recent because every PR restores from them; old PR entries and buildkit blobs are what get evicted first. If that churn is too much, addgithub.event_name == 'push'to the two save steps'if:. PRs would then only restore main's entries (a partial hit, ~43s in the simulation instead of 351s cold) and save nothing.integration-gobuild-*entries) falls back to a cold compile; correctness doesn't depend on the cache, and-count=1keeps every test running.INTEGRATION_SHARDSinci.yml, kept in the same order as thematrix.includeentries; the guard fails if they drift. Each leg's log prints per-packageok ... <time>lines for rebalancing.integration-coveragenow includes the unit profile on purpose, so it matches what it measured before (unit + integration, 81.6%). If you'd rather it mean integration-only, drop the "Download unit coverage" step.unit-testsas well. As before, the artifact exists only on green runs, but now a red Unit Tests job also prevents it.SkipIfShortas Short-gated. Over-selection fails loudly, because a selected name that doesn't run makesran < selectedfail the leg. A missed test would not fail anything at runtime: the guard against that is the self-test (now its own CI job) and the before/aftergo test -listdiff above, which is worth re-running if someone adds a new way to skip under-short.permissions: contents: read, like the other jobs. Downloading artifacts from the same run doesn't needactions: read, but the PR's CI run is the first real test of that.🤖 Generated with Claude Code