AI 编码已使持续集成(CI)成为瓶颈,因此我们重新设计了我们的 CI 流程以跟上进度。
AI coding has made CI a bottleneck, so we reworked ours to keep up

原始链接: https://linear.app/now/ci-bottleneck-reworked

Linear 近期对 CI 流水线进行了全面升级,旨在应对不断上涨的基建成本和开发者等待时间。尽管测试套件的规模扩大了四倍,他们仍成功将合并请求的等待时间缩短了超过一分钟,并将每个测试的运行时间减少了约 50%。 改进主要集中在四个关键领域: 1. **基础设施**:迁移至更快的第三方运行程序并升级了工具链,例如改用 `tsgo` 以加快类型检查速度。 2. **Linting 优化**:重写了自定义规则,改用静态分析而非耗时的 TypeScript 类型信息,显著降低了内存占用和执行时间。 3. **工作流效率**:通过限制抓取深度、消除冗余检出以及批量处理小型任务,优化了“关键路径”作业。他们还通过在基础镜像中预装共享依赖项并避免不必要的缓存,降低了每个作业的设置成本。 4. **测试执行**:增加了测试分片(sharding),并在 Vitest 中引入了受控的模块状态共享,这是性能提升最显著的部分。 通过系统性地最小化设置开销并将非关键任务卸载,Linear 确保了 CI 速度能够随着代码库的快速增长而同步扩展。这些改动每月节省了约 87,000 分钟的运行时间,在测试负载增加的情况下,依然保持了高效的开发周期。

Hacker News 近期的一项讨论指出,AI 辅助编程的兴起已导致持续集成(CI)流水线出现了严重的瓶颈。随着 AI 代理以极快的速度生成代码,传统的 CI 流程难以跟上节奏,这让开发者感到十分困扰。 Linear 团队发布了一篇博客文章,详细介绍了他们如何重新构建 CI 基础设施以适应这些新需求。社区成员也分享了各自的优化策略,并强调必须将构建时间控制在五分钟以内,以保持开发进度。提出的解决方案包括: * **缓存与工具:** 利用 Bazel 等工具,通过热缓存显著缩短构建时间。 * **优化:** 简化繁重的测试套件,例如那些用于视频和截图录制而需启动完整应用程序实例的测试。 * **负载均衡:** 基于实际运行时间而非仅仅根据文件分布来平衡测试执行(例如在 Vitest 中),从而提升性能。 总的来说,人们普遍认为,随着 AI 提高了代码变更的频率,简化 CI/CD 流水线已成为防止开发停滞的关键要务。
相关文章

原文

Earlier this year, I opened Linear to find that Tuomas, our CTO, had assigned an issue to me, titled “CI costs are high.” While I was at it, he also wanted me to make CI faster.

Agents have made it exponentially faster to ship code, but validating those changes hasn’t quite kept up at the same rate. Every PR still has to pass through CI, so as development accelerates, CI becomes a bottleneck, driving up infrastructure costs and leaving developers and agents waiting longer for feedback.

In our pursuit to make CI more performant at Linear, we optimized for how long a PR waits on CI and how much runner time it consumes. Despite our test suites almost quadrupling since the start of the year, we brought pull request wait time down from more than 6 minutes to just over 5, while cutting runner time per test roughly in half.

This is test suite performance indexed to the first week of January. The white line, tracking machine time per test, spikes when we added test shards, which shorten the wait and costs more machine time, and again during checkout stalling issues

Broadly, we improved CI in four ways:

  • Upgraded infrastructure and tooling
  • Optimized the jobs that gate other work
  • Reduced repeated setup
  • Made test execution more efficient

Linear’s codebase is primarily TypeScript, but many of these optimizations apply across languages and toolchains.

Upgraded infrastructure and tooling

Some of our earliest gains required almost no optimization of CI itself. Moving our workloads off GitHub Actions to third-party runners with faster CPUs, higher-performance storage, and better cache infrastructure gave us faster machines to run the same pipeline on. In a like-for-like comparison of the two days either side of the switch, jobs ran 34% faster on average, with some workloads like tsc dropping 52%.

Separately, modernizing our toolchain also paid off. Switching to tsgo, the native TypeScript compiler, cut the weekly median of the tsc check by 73%, large enough to move the bottleneck off of typechecking entirely.

Lint without the type checker

Linting was another early target. A handful of our custom lint rules depended on TypeScript type information, either to enforce a restriction or apply an autofix. That meant every lint run had to build the full type graph before evaluating those rules, making linting one of our most memory-intensive CI jobs.

We rewrote the rules to use static analysis over the abstract syntax tree, identifying function-like constructs and guard patterns without type information. That let ESLint drop TypeScript entirely, reducing API lint time by 68%, and full-repository lint time by 55%. Memory usage dropped substantially as well.

Removing the dependency on type information also made our later move to Oxlint much easier because rules that operate purely on syntax are straightforward to port. Oxlint itself reduced the CI runner-minutes spent on linting.

Optimize the jobs that gate other work

With the underlying infrastructure and individual checks running faster, we zoomed out to look at CI as a system. That drew our attention to the small jobs that sat in front of everything else. Every run starts by checking which paths a PR touched and whether these tests have already passed for the same inputs. We gate on those checks at the job level so skipped work never reserves a runner, but that also puts them directly on the critical path. None of the eight API test shards can start until they finish, making even small delays disproportionately important.

Fetch only what each job needs

Several of our workflows start with a change-detection job that decides what runs next; for instance, it checks whether a diff contains a database migration and outputs a signal used to schedule the relevant database CI checks. These jobs were checking out the full working tree even though they needed only a small subset of it. We capped the fetch depth, which took the slowest of these gates from 94 seconds to 20, and removed checkout entirely from the jobs that never needed a working tree, reducing time spent on those from 27 seconds to 7. For commit push and merge-queue events, where we do have to diff paths, we found that a sparse, blobless checkout with limited history was enough, saving another 11 odd seconds.

The median duration of the change-detection job fell from 26 to 8 seconds, p90 from 31 to 12 seconds, and the slowest run from 138 to 37 seconds.

Make checkout more resilient

After we swapped the underlying runner infrastructure, we noticed that our checkout times (with actions/checkout) in our jobs had gotten longer and would sometimes hang. Because the third-party runners sit outside GitHub’s network, they rely on a direct IP link to reach GitHub. The provider traced the hangs to intermittent degradation on that link. Several of our workflows begin with a checkout, so a stalled fetch could delay the entire CI run.

To be resilient to the network instability, we replaced actions/checkout with a composite action of our own that retried with backoff, and sets GIT_HTTP_LOW_SPEED_LIMIT and GIT_HTTP_LOW_SPEED_TIME so a stalled connection aborts after about 30 seconds instead of hanging and also uses the checkout cache, which keeps a persistent git mirror on a sticky disk. The result was far fewer runs where a critical-path job sat idle waiting for checkout to finish.

Minimize what’s on the critical path

Not every job on the critical path needed to be there. We were writing cache markers as part of the final check before merging, which meant a pull request could sit in the merge queue even after its tests had passed. We moved that write into a job that runs once the test shards finish but gates nothing, shaving 42 seconds from the merge path for every API pull request and merge-queue entry.

Together, these changes took roughly a minute off the required check for API pull requests on cache misses, while also reducing runner starts.

Reduce repeated setup

From there, we turned to the setup cost repeated across every job, like booting a runner, installing packages, and provisioning build dependencies. That overhead means a job that does only seconds of useful work can end up consuming whole minutes of infrastructure time. Here are a few steps we took to work around that issue:

Preinstall shared dependencies in the CI image

Our API test shards each spent 7 to 8 seconds installing the same Postgres client with apt on every run. We moved it into a small CI base image containing Node and the client, so each shard could start from an environment that was ready to run. We later added the required native build headers to the image after discovering that downloading them during setup could occasionally hang, shortening the tail.

Install only the dependencies each job needs

Linear’s codebase is a monorepo managed as a pnpm workspace. Our API test workflow was installing the entire workspace even though it only needed the API package and its dependencies. Restricting the install to our API package cut pnpm install from 44-73 seconds to 16-18 seconds. We applied the same pattern to API-adjacent jobs, which were each installing the full repository and uploading a dependency cache that later runs almost never hit.

Don’t cache when it’s faster to rebuild

We also tested caching node_modules and found it was faster to rebuild. The cache key depended on a frequently changing lockfile, and even a cache hit took about 28 seconds to restore, compared with roughly 7.5 seconds for a filtered install. The cache was adding save time and variability without giving us any discernible advantage.

Together, these three changes reduced per-shard setup time by roughly 44%, from 110-140 seconds to 67-73 seconds.

p95 durations of a test shard

Beyond this, there were other forms of repeated setup we could avoid altogether.

Avoid replaying unchanged setup

Some setup work only needs to be repeated when its inputs change. Our API containers, for example, were replaying the full database migration history on every run, even when a PR hadn’t changed the schema. For those cases, we switched to loading a generated schema snapshot and bootstrap file instead, cutting database setup from roughly 12 seconds to 1-2 seconds per container.

Batch short checks into fewer jobs

Seven independent checks were each starting a runner, checking out the repository, and installing dependencies before doing only seconds of useful work. We consolidated them into two jobs, and then ran the seven tasks concurrently inside them. That reduced the number of times we paid the same setup overhead from seven to two. Based on June usage, the change saved roughly 87,000 runner-minutes per month, equivalent to 11.8% of our total CI usage.

Make test execution more efficient

With the fixed cost of each test shard down, we could afford to parallelize the API suite more aggressively. It was the largest and one of the most frequently executed parts of our workflow, so improvements there had an outsized effect on merge time.

Balance work the way the test runner sees it

Vitest, the test runner we use for our TypeScript test suites, distributes work by file rather than by the duration of individual tests. That meant a few unusually large test files could dominate a shard and effectively hold up completion of the entire suite, even when the other shards finished much earlier.

We split those large files into smaller, more focused files while preserving the structure of the tests, then evaluated different shard and runner configurations. We had already gone from three to four shards earlier in the year; moving to eight made the critical job roughly 19% faster and 19% cheaper in our initial benchmark. A week after the change, the slowest shard dropped from 5.25 minutes to 4.33 minutes.

Share module state only with strict isolation rules

Vitest normally isolates every test file, which for us meant rebuilding the entity, GraphQL, and decorator graph in each test shard. We introduced an opt-in vitest project with isolate: false, allowing safe files to share a module registry within each worker.

This was our largest single performance improvement, worth roughly 17% in monthly savings at our volume. The slowest shard fell from roughly 300-379 seconds to about 195 seconds, while total API-shard runner time dropped from about 32.8 to 22 minutes per run.

It was also the optimization with the highest correctness risk. We made eligibility explicit with an opt-in comment on every file, and added the necessary teardown for shared state. A handful of files used fake timers or shared state in ways we couldn’t untangle safely, so we left them in the isolated project. And because agents now write the majority of our tests, we updated our respective agent skills to account for this performance opt-in as well, so generated tests follow the same constraints by default.

Sharding is limited by setup overhead

Further sharding only pays off when the fixed cost per shard is low, since doubling the shard count also doubles the workflow time spent on setup. The setup optimizations we referred to earlier are what made eight shards practical. At 110-140 seconds per shard, eight shards would have spent 15-19 minutes of runner time on setup alone, more than the tests themselves. Setup is now around 40 seconds, so eight shards spend less total setup time than four did before, while parallelizing the tests twice as far.

Before setup optimizations, the test job used 4 shards and spent 8.3 minutes on setup. Afterward, we could run 8 shards with 7.5 minutes of setup.

Improvements that compound across a system

Had we not made a deliberate effort to improve CI earlier this year, today’s test suite would take roughly 11 minutes, close to double what developers wait now. And the work doesn’t end here. It’s clear that our codebase will continue to grow; we’re currently adding roughly 2,000 tests a week. Keeping CI fast as that happens will be a continued effort, much of it using what we learned through this process to new bottlenecks.

联系我们 contact @ memedata.com