<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tuist</title>
    <description>The latest articles on DEV Community by Tuist (tuist).</description>
    <link>https://dev.to/tuist</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F15152%2F34b5628a-4d7e-4fa8-bdb9-fc21921f1e3f.jpg</url>
      <title>DEV Community: Tuist</title>
      <link>https://dev.to/tuist</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tuist"/>
    <language>en</language>
    <item>
      <title>Bringing Tuist to Elixir</title>
      <dc:creator>Pedro Piñera Buendía</dc:creator>
      <pubDate>Sat, 31 Oct 2026 21:45:28 +0000</pubDate>
      <link>https://dev.to/tuist/bringing-tuist-to-elixir-1oe7</link>
      <guid>https://dev.to/tuist/bringing-tuist-to-elixir-1oe7</guid>
      <description>&lt;p&gt;We are very fond of &lt;a href="https://elixir-lang.org/" rel="noopener noreferrer"&gt;Elixir&lt;/a&gt;. An idea in &lt;a href="https://erlang.org/download/armstrong_thesis_2003.pdf" rel="noopener noreferrer"&gt;Joe Armstrong's thesis&lt;/a&gt; helped convince us to build &lt;a href="https://tuist.dev/" rel="noopener noreferrer"&gt;Tuist&lt;/a&gt;'s server with it: model software after the real world, as concurrent processes that communicate with each other. To us, software becomes easier to understand and maintain when its structure follows the world it's modelling.&lt;/p&gt;

&lt;p&gt;Since then, we've built much of Tuist in Elixir. &lt;a href="https://www.phoenixframework.org/" rel="noopener noreferrer"&gt;Phoenix&lt;/a&gt; lets us build dynamic experiences and test the UI in the same language, without maintaining a separate single-page application. Hot code reloading makes it easy to iterate. We're a small team, and being able to do all of that in Elixir has mattered a lot. &lt;a href="https://github.com/josevalim" rel="noopener noreferrer"&gt;José Valim&lt;/a&gt; has also been very supportive along the way.&lt;/p&gt;

&lt;p&gt;It's funny that, with agents, we're starting to appreciate many of &lt;a href="https://www.erlang.org/" rel="noopener noreferrer"&gt;Erlang&lt;/a&gt;'s primitives again. An agent might need to keep working for hours, communicate with other agents, and recover from failures. Processes, message passing, and supervision give us a lot to work with. We still need to persist state if we want to recover it after a restart; a supervisor doesn't do that for us. But we don't have to build the whole model ourselves.&lt;/p&gt;

&lt;p&gt;And we can look into the runtime while it's running. Which processes are consuming memory? Where is the CPU time going? Those questions become important when something works for much longer than a web request, and agents can use the same interfaces to investigate. The more we use Elixir, the more convinced we are that we chose well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bringing Tuist to more build systems
&lt;/h2&gt;

&lt;p&gt;That appreciation for Elixir is part of why we're announcing support for it today. The other part has to do with what we're building at Tuist.&lt;/p&gt;

&lt;p&gt;We started with builds and tests: understanding where the time goes, avoiding repeated compilation, and getting test results sooner. We already do that for &lt;a href="https://developer.apple.com/xcode/" rel="noopener noreferrer"&gt;Xcode&lt;/a&gt;, &lt;a href="https://gradle.org/" rel="noopener noreferrer"&gt;Gradle&lt;/a&gt;, and &lt;a href="https://bazel.build/" rel="noopener noreferrer"&gt;Bazel&lt;/a&gt;. Our principle is to bring that work to more toolchains, so teams can benefit from it where they already are.&lt;/p&gt;

&lt;p&gt;Of course, the toolchains don't all have the same capabilities. &lt;a href="https://hexdocs.pm/mix/Mix.html" rel="noopener noreferrer"&gt;Mix&lt;/a&gt; doesn't come with shared compilation caching or remote execution out of the box. Language design decisions also affect what we can offer. But what if we work with those ecosystems to help make more of it possible, including contributing to the toolchains themselves?&lt;/p&gt;

&lt;p&gt;We see ideas moving between build systems already. Working across them lets us participate in that: learn how one solves a problem, then explore whether the same idea makes sense elsewhere. It won't always, and we need to be honest about the limitations. But with agents producing more code to compile and test, we think there's a growing incentive to do that work.&lt;/p&gt;

&lt;p&gt;We're also betting that models will keep getting better at working with each toolchain. They still need infrastructure to query build and test data, reuse compiled work, and distribute execution. That's the part we're building.&lt;/p&gt;

&lt;p&gt;So when we asked ourselves which ecosystem to support next, Elixir felt obvious. We understand it, we use it every day, and we can dogfood the integration on Tuist itself. We also think it deserves more attention when it comes to build and test infrastructure. We'd like to help give it that attention.&lt;/p&gt;

&lt;p&gt;When we see teams like &lt;a href="https://remote.com/blog/partnerships/welcoming-elixir-creator-jose-valim" rel="noopener noreferrer"&gt;Remote&lt;/a&gt;, &lt;a href="https://discord.com/blog/tracing-discords-elixir-systems-without-melting-everything" rel="noopener noreferrer"&gt;Discord&lt;/a&gt;, &lt;a href="https://supabase.com/docs/guides/realtime/architecture" rel="noopener noreferrer"&gt;Supabase's Realtime team&lt;/a&gt;, or &lt;a href="https://jobs.apple.com/en-us/details/200604960/sr-software-engineer-elixir-environmental-systems?team=SFTWR" rel="noopener noreferrer"&gt;Apple's Environmental Systems team&lt;/a&gt; betting on Elixir, we want them to have build and test tooling that grows with their projects. And there's &lt;a href="https://www.erlang-solutions.com/blog/20-years-of-open-source-erlang-openerlang-interview-with-anton-lavrik-from-whatsapp/" rel="noopener noreferrer"&gt;WhatsApp&lt;/a&gt;, which built its messaging backend with &lt;a href="https://www.erlang.org/" rel="noopener noreferrer"&gt;Erlang&lt;/a&gt;, sharing the same runtime as Elixir. We get so much from this ecosystem. Helping organizations move faster with it feels like a good way to contribute.&lt;/p&gt;

&lt;p&gt;Here's what we've built so far. If you'd rather watch, we've recorded a walkthrough on &lt;a href="https://www.youtube.com/watch?v=rTxXBc0Fegc" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/rTxXBc0Fegc" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started with Tuist and Mix
&lt;/h2&gt;

&lt;p&gt;Before going into the features, &lt;a href="https://tuist.dev/users/register" rel="noopener noreferrer"&gt;sign in or create an account&lt;/a&gt;, then create a project in the dashboard and choose &lt;strong&gt;Mix (Elixir)&lt;/strong&gt; as its build system. Its handle, &lt;code&gt;account/project&lt;/code&gt;, connects your local Mix project to the one on Tuist.&lt;/p&gt;

&lt;p&gt;Add our &lt;a href="https://hex.pm/packages/tuist_ex" rel="noopener noreferrer"&gt;Hex package&lt;/a&gt; to your existing dependencies in &lt;code&gt;mix.exs&lt;/code&gt;. You'll need Elixir 1.18 or later, but you don't need the &lt;a href="https://tuist.dev/en/docs/guides/install-tuist" rel="noopener noreferrer"&gt;Tuist CLI&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;deps&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:tuist_ex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 0.10.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;runtime:&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then fetch it, configure it with your project handle, and log in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mix deps.get
mix tuist.setup &lt;span class="nt"&gt;--project&lt;/span&gt; account/project
mix tuist.login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setup updates &lt;code&gt;mix.exs&lt;/code&gt; with the project configuration, enables dependency caching, and installs the compile and test aliases. Login opens your browser to authenticate. Commit the configuration, and keep using &lt;code&gt;mix compile&lt;/code&gt; and &lt;code&gt;mix test&lt;/code&gt; as usual. The &lt;a href="https://tuist.dev/en/docs/guides/get-started/elixir-project" rel="noopener noreferrer"&gt;Elixir guide&lt;/a&gt; covers the details.&lt;/p&gt;

&lt;p&gt;In CI, use a token or authenticate through &lt;a href="https://openid.net/developers/how-connect-works/" rel="noopener noreferrer"&gt;OpenID Connect (OIDC)&lt;/a&gt; with a supported provider; the &lt;a href="https://tuist.dev/en/docs/guides/install-hex-package#authenticate" rel="noopener noreferrer"&gt;authentication guide&lt;/a&gt; walks you through both options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build insights for Elixir
&lt;/h2&gt;

&lt;p&gt;It's easy to look at a slow build and think we need a bigger machine. But if most files are waiting for a handful of modules to compile, more cores won't help much. We need to understand why those dependencies exist.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hexdocs.pm/gettext/Gettext.html" rel="noopener noreferrer"&gt;Gettext&lt;/a&gt; is a good example. Before &lt;a href="https://github.com/elixir-gettext/gettext/blob/v0.26.0/CHANGELOG.md" rel="noopener noreferrer"&gt;version 0.26&lt;/a&gt;, importing an application's Gettext backend to use its macros created a compile-time dependency on that backend. Many views and controllers could end up waiting for the same module, and changing a translation could trigger recompilation across much of the application. Separating the macros from the generated backend removed those dependencies. That improvement didn't require a bigger machine.&lt;/p&gt;

&lt;p&gt;But to find a problem like that in your own project, you need to see more than the build's total duration. Tuist records which files compiled, the modules they define and depend on, and how much time they spent compiling or waiting. You can sort files by compilation time or by how many other files depend on them. A slow file that the rest of the application waits for is worth investigating before one that compiles independently.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffyntao20ibjkw8ipe2qz.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffyntao20ibjkw8ipe2qz.webp" alt="Build trends and Elixir-version configuration insights for a Phoenix project" width="799" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The real Builds dashboard, populated with synthetic data for a Phoenix demo project.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The timeline lets you follow that work alongside type checking, writing modules to disk, and the machine's CPU, memory, network, and disk usage. If the CPU is mostly idle during a long wait, you can look at what the compiler was waiting for. Warnings, errors, and the build's outcome are recorded with it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvw8syp2gl9bpaz0h45u6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvw8syp2gl9bpaz0h45u6.webp" alt="A Mix compilation timeline alongside CPU, memory, network, and disk measurements" width="800" height="817"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A clean compilation in the same synthetic demo, with file compilation, type checking, and machine metrics on one timeline.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We want agents to be able to investigate this too. Build summaries, including durations, warning and error counts, and cache activity, are already available through the API and our &lt;a href="https://tuist.dev/en/docs/guides/features/agentic-coding/mcp" rel="noopener noreferrer"&gt;MCP tools&lt;/a&gt;. The per-file graph and detailed timeline are currently in the dashboard; we still need to expose those for agents to do the same investigation.&lt;/p&gt;

&lt;p&gt;From there, we can connect a file's compilation history with its position in the graph and how often it changes. A frequently edited module that forces half the application to recompile is a different problem from an expensive module that rarely changes. We'd like an agent to identify those cases, suggest a change, and use subsequent builds to check whether it helped.&lt;/p&gt;

&lt;p&gt;For now, the &lt;a href="https://tuist.dev/en/docs/guides/features/build-insights/elixir" rel="noopener noreferrer"&gt;Elixir build insights guide&lt;/a&gt; covers how to collect the data and explore it yourself. We think it's worth doing before build times become a problem for the team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test insights for ExUnit
&lt;/h2&gt;

&lt;p&gt;Tests have a similar problem: the output of one run tells you what happened, but not much about how the suite is changing. Is that test getting slower? Has it failed before with the same code? We collect individual results and durations, including doctests, organized by module and &lt;code&gt;describe&lt;/code&gt; block. Failures keep their message and location, so you and your agents can follow them across runs through the dashboard, API, or MCP.&lt;/p&gt;

&lt;p&gt;Elixir gives us a good starting point for improving the suite, too. Each &lt;a href="https://hexdocs.pm/ex_unit/ExUnit.html" rel="noopener noreferrer"&gt;ExUnit&lt;/a&gt; test runs in its own process. If we avoid shared state, asynchronous test modules can run concurrently, using more of the available cores for CPU-bound work. Databases and external services aren't isolated just because the tests have separate processes, so there's still work to do in how we design them. The &lt;a href="https://tuist.dev/en/docs/guides/features/test-insights/elixir" rel="noopener noreferrer"&gt;test insights guide&lt;/a&gt; covers what we collect to help with that work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test sharding
&lt;/h3&gt;

&lt;p&gt;Once we know how long the tests take, we can use that information to distribute them across CI runners. ExUnit handles concurrency within one machine; sharding lets us spread the suite across several.&lt;/p&gt;

&lt;p&gt;There are already good ways to split a suite. &lt;a href="https://github.com/plausible/analytics/blob/master/.github/workflows/elixir.yml" rel="noopener noreferrer"&gt;Plausible's CI&lt;/a&gt;, for example, uses Mix's built-in partitioning across six runners. Mix sorts the test files and distributes them round-robin, so there's no manual file list to maintain. But an equal number of files doesn't mean an equal amount of work. One runner can still be busy long after the others finish.&lt;/p&gt;

&lt;p&gt;Tuist uses historical timings to assign the files instead. You choose how many shards you want, for example with &lt;code&gt;--shard-total 4&lt;/code&gt;, or set a target maximum duration per shard and a limit on the shard count. As the suite changes, the plan changes with it. New files get estimates until we have measurements. You shouldn't need to keep moving files around to stop one runner holding up the rest.&lt;/p&gt;

&lt;p&gt;We also don't want each runner to compile the same project. Tuist compiles it once, uploads the build, and lets the runners download it and execute their assigned files. Their results appear as a single test run, with each shard's duration available in the dashboard. The &lt;a href="https://tuist.dev/en/docs/guides/features/test-sharding/elixir" rel="noopener noreferrer"&gt;sharding guide&lt;/a&gt; covers the CI setup, including any database preparation your project needs.&lt;/p&gt;

&lt;p&gt;We measured this locally on our own server: &lt;strong&gt;successful full-suite runs took 44.4% less wall-clock time with four shards&lt;/strong&gt;, running the same tests with the same total CPU budget.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj4lfrh0dv1txfps4757a.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj4lfrh0dv1txfps4757a.webp" alt="An Elixir test run combining results from four shards, with their individual durations" width="800" height="583"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Four shards reported as one test run in the real dashboard, using synthetic data for the Phoenix demo project.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Flaky tests
&lt;/h3&gt;

&lt;p&gt;A faster suite isn't much help if you learn to ignore its failures. When the same test keeps passing on a retry, it's tempting to assume the next red build is just that test again. We want to keep the evidence of that flakiness, even when the retry turns CI green.&lt;/p&gt;

&lt;p&gt;Tuist compares passes and failures on the same commit across CI runs. You can also opt in to retries, which report a test that fails and then passes as flaky:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mix &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--retries&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those attempts stay in the test's history. By default, an automation marks the test itself as flaky after three flaky runs in 30 days. You can change the threshold and recovery window in the project's automation settings, or mark a test manually.&lt;/p&gt;

&lt;p&gt;While a fix is in progress, you can &lt;a href="https://tuist.dev/en/docs/guides/features/test-insights/flaky-tests/elixir#quarantining" rel="noopener noreferrer"&gt;quarantine the test&lt;/a&gt; from the dashboard. Muting keeps it running and reporting failures, but those failures don't block an otherwise successful, complete run. Skipping leaves it out of the run. You can set either state manually or have an automation do it when a test becomes flaky, then return it to &lt;strong&gt;Enabled&lt;/strong&gt; once you've fixed it.&lt;/p&gt;

&lt;p&gt;Quarantine requires authentication, even for public projects. For now, muting doesn't apply with &lt;code&gt;--warnings-as-errors&lt;/code&gt; or from an umbrella project's root. Skipping uses a name filter, so it can also exclude a same-named test in another module, and explicit include filters can bring a skipped test back. Those limits matter when deciding how to use it in CI.&lt;/p&gt;

&lt;p&gt;Once a test is flagged, you can notify the team through &lt;a href="https://tuist.dev/en/docs/guides/integrations/slack#flaky-test-alerts" rel="noopener noreferrer"&gt;Slack&lt;/a&gt;. Or, with an event-capable client and a webhook, use the &lt;code&gt;test_case.marked_flaky&lt;/code&gt; event from our &lt;a href="https://tuist.dev/en/docs/guides/features/agentic-coding/mcp#events" rel="noopener noreferrer"&gt;MCP Events interface&lt;/a&gt; to have an agent investigate its history and failures. We'd like to get to a point where those investigations start without someone having to notice the problem in CI first.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://tuist.dev/en/docs/guides/features/test-insights/flaky-tests/elixir" rel="noopener noreferrer"&gt;flaky tests guide&lt;/a&gt; covers detection, retries, quarantine, and notifications.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code coverage
&lt;/h3&gt;

&lt;p&gt;Sharding also changes how we need to collect coverage. Four runners each see only part of the suite. We need to bring those measurements together before saying how much of the code the tests exercised, and make sure restoring compiled code from a cache doesn't leave it out.&lt;/p&gt;

&lt;p&gt;Coverage is in early access and needs to be enabled for your account. Once enabled, the package reports it alongside the test results when you run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mix &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--cover&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can inspect the results by file, function, and OTP application. With sharding, enable &lt;code&gt;--cover&lt;/code&gt; both when building the shards and when running them. After every job measuring coverage for a commit has finished, a final CI job marks it complete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mix tuist.coverage.complete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That tells us when to reconcile the reports for the branch's coverage trend. If you deliberately run only part of the suite, we report the coverage as partial. The &lt;a href="https://hexdocs.pm/tuist_ex/readme.html#report-code-coverage" rel="noopener noreferrer"&gt;package's coverage reference&lt;/a&gt; covers the setup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjb495oa8jh72fz46zb6e.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjb495oa8jh72fz46zb6e.webp" alt="Elixir code coverage trends, covered and executable lines, and changes by source file and OTP application" width="800" height="624"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The real Code Coverage dashboard with synthetic data for the Phoenix demo project: coverage over time, and changes across Elixir files and OTP applications.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We're working on selective testing next, using coverage data to understand which tests exercise the code that changed. That would let us run the affected tests instead of the whole suite for every change. It isn't available for Elixir yet, but we hope to let teams try it soon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Elixir dependency caching
&lt;/h2&gt;

&lt;p&gt;We use a lot of dependencies, and CI keeps compiling the same versions. It feels wasteful, especially when another build has just done that work. But before we reuse a result, we need to know what went into it and what we'd skip by not compiling again.&lt;/p&gt;

&lt;p&gt;The same inputs must produce the same outputs, without side effects that the next build still needs. That's what we mean by hermetic compilation. A macro can read an environment variable, a file outside the dependency, the clock, or a network response while compiling. We can't automatically discover all of those reads. If an input changes without us knowing, we might reuse stale code.&lt;/p&gt;

&lt;p&gt;We started with dependencies because their resolved versions and sources give us a useful starting point. The cache key also includes upstream compiled dependencies and resources, the toolchain, compiler options, effective application configuration, Mix environment and target, and any additional inputs you declare. The setup above enables caching for supported &lt;a href="https://hex.pm/" rel="noopener noreferrer"&gt;Hex&lt;/a&gt; and &lt;a href="https://git-scm.com/" rel="noopener noreferrer"&gt;Git&lt;/a&gt; dependencies using the standard Mix pipeline with Elixir or Erlang sources, and ordinary &lt;a href="https://www.rebar3.org/" rel="noopener noreferrer"&gt;rebar3&lt;/a&gt; compilation. It doesn't cache your application's own source files.&lt;/p&gt;

&lt;p&gt;You'll need to declare additional file and environment inputs where compilation uses them. Dependencies that rely on time, network responses, or side effects that must happen on every build should be excluded. Native and custom builders need an explicit audit and configuration of their build environment, required outputs, and additional inputs. Otherwise they compile normally, as do local dependencies and unsupported dependency chains. Using the standard compiler doesn't, on its own, prove that a dependency is hermetic. The &lt;a href="https://tuist.dev/en/docs/guides/features/cache/elixir-advanced" rel="noopener noreferrer"&gt;advanced cache guide&lt;/a&gt; goes into that in more detail.&lt;/p&gt;

&lt;p&gt;When we can reuse a result, Tuist looks locally first, then in the shared remote cache. A miss compiles normally and stores the result for later builds. If the cache or authentication is unavailable, compilation falls back to Mix. You can see the hits, transfers, and recent builds in the Mix Cache dashboard.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi06yexr9ueyd7egdlkb6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi06yexr9ueyd7egdlkb6.webp" alt="The Mix Cache dashboard showing hit-rate trends, cache transfers, latency, throughput, and recent builds" width="800" height="878"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The real Mix Cache dashboard with synthetic client cache observations. These numbers illustrate the interface, not measured performance or savings.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We tried it on our own server: &lt;strong&gt;compilation took 33.6% less time with a warm local dependency cache&lt;/strong&gt;, while still compiling all of our application's own code.&lt;/p&gt;

&lt;p&gt;Of course, looking up and downloading compiled code takes time too. That's why we've invested in &lt;a href="https://tuist.dev/blog/2026/09/14/kura" rel="noopener noreferrer"&gt;Kura, our distributed cache infrastructure&lt;/a&gt;. It replicates artifacts between cache nodes so builds can fetch them nearby. With our runners, we can place the cache close to the compute and reuse artifacts across jobs. We don't want to save time compiling only to spend it transferring the result. The &lt;a href="https://tuist.dev/en/docs/guides/features/cache/elixir" rel="noopener noreferrer"&gt;Elixir cache guide&lt;/a&gt; covers getting started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Giving something back to Elixir
&lt;/h2&gt;

&lt;p&gt;Elixir has given our small team a lot. We get to build and operate software that would be harder for us to manage otherwise. We're thankful for that, and we'd like this work to be useful to the people building alongside us in the ecosystem.&lt;/p&gt;

&lt;p&gt;We've been using this integration on our own server for weeks. The &lt;a href="https://tuist.dev/tuist/server" rel="noopener noreferrer"&gt;Tuist server project&lt;/a&gt; is public, so you can look at real build and test data, beyond the synthetic examples in this post. There's also a &lt;a href="https://tuist.dev/livebook-dev/livebook" rel="noopener noreferrer"&gt;public project&lt;/a&gt; for &lt;a href="https://livebook.dev/" rel="noopener noreferrer"&gt;Livebook&lt;/a&gt; you can explore.&lt;/p&gt;

&lt;p&gt;If you maintain an open-source Elixir project, our &lt;a href="https://tuist.dev/blog/2024/04/25/tuist-cloud-free-for-open-source" rel="noopener noreferrer"&gt;open-source program&lt;/a&gt; gives you free access to these build and test features. &lt;a href="mailto:contact@tuist.dev"&gt;Send us your repository and Tuist account handle&lt;/a&gt;, and we'll help you get set up.&lt;/p&gt;

&lt;p&gt;And if you're using Elixir at work, we'd like to hear how your builds and tests are going. Where do you spend time waiting? What have you had to build yourselves? You can &lt;a href="https://cal.tuist.dev/team/tuist/tuist" rel="noopener noreferrer"&gt;book a call with us&lt;/a&gt; to talk about it. We have our own experience running Tuist, but there's a lot more for us to learn from yours.&lt;/p&gt;

</description>
      <category>elixir</category>
      <category>tuist</category>
      <category>devops</category>
      <category>testing</category>
    </item>
    <item>
      <title>The physics of build systems</title>
      <dc:creator>Pedro Piñera Buendía</dc:creator>
      <pubDate>Fri, 09 Oct 2026 10:58:05 +0000</pubDate>
      <link>https://dev.to/tuist/the-physics-of-build-systems-i2e</link>
      <guid>https://dev.to/tuist/the-physics-of-build-systems-i2e</guid>
      <description>&lt;p&gt;At first, a build looks simple. Change a file, run a command, and a compiler turns the source into something you can ship or test. On a small project, that is close enough to the whole story. The machine has work to do, it does it, and a few moments later you have an answer.&lt;/p&gt;

&lt;p&gt;Then the project grows. A change that should be local wakes up half the repository. A machine with more processor cores does not make the build noticeably faster. A cache makes some builds fly and seems to do nothing for others. At that point, it is tempting to think there must be one slow compiler, one badly configured cache, or one sufficiently large machine that will make the problem go away. Usually there is not.&lt;/p&gt;

&lt;p&gt;In our &lt;a href="https://tuist.dev/blog/2026/06/30/three-build-systems-one-graph" rel="noopener noreferrer"&gt;last post&lt;/a&gt;, we introduced &lt;a href="https://github.com/tuist/once" rel="noopener noreferrer"&gt;Once&lt;/a&gt;, our prototype for making remote caching and execution accessible without asking people to replace the build systems they already use. Building it has forced us to get more precise about what actually makes a build fast.&lt;/p&gt;

&lt;p&gt;If object storage is cheap, why not put a cache there and call it done? Will adding more processor cores make a build faster? If a continuous integration provider already offers a cache, what is missing? And if a project is already using a remote cache, why can the build still feel slow?&lt;/p&gt;

&lt;p&gt;Start with a graph of tasks. On its own, it is only a description. The moment it meets a machine, the description turns into work, and the machine has only so much capacity to give it. Those constraints are the physics of build systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parameters of a build
&lt;/h2&gt;

&lt;p&gt;Start with computation. We will come back to moving data later. Take a small target with two source files. Before it can produce an executable, it has to compile both files, then hand their object files to the linker. The build engine sees three tasks: two compilations with no relationship to each other, and a link task that needs the output of both. That relationship is the &lt;strong&gt;graph&lt;/strong&gt;. It is not just a convenient way to describe a build after the fact. It decides what the machine is allowed to do at any given moment.&lt;/p&gt;

&lt;p&gt;The simulator below uses generic task names. What matters is not whether a task is a compilation, a link, or something else. It is how much work the task has, what must finish before it can begin, and what the machine has available to run it.&lt;/p&gt;

&lt;p&gt;Try changing the machine and the number of tasks below. Every task in this model takes the same amount of time. It starts with three tasks: two can run immediately, while the third waits for both. As you add tasks, the graph grows wider and deeper. More work becomes available at once, but more layers also have to wait for the work before them. Extra processor cores can help with the first part. They cannot remove the second.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;Interactive simulator:&lt;/strong&gt; this section has a live model you can play with in the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;original post on tuist.dev&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Say each task contains the same amount of &lt;strong&gt;work&lt;/strong&gt;, enough to occupy one processor for ten seconds. On a machine with one processor core, the two initial tasks have to take turns, and the final task runs last. The build takes thirty seconds. Increase &lt;strong&gt;processor speed&lt;/strong&gt; and each task finishes sooner. Add a second processor core and the two initial tasks can run together, so the final task begins after ten seconds and the whole build takes twenty. This is the result people expect when they buy a bigger machine, and it is real, provided the build has enough independent work to keep the machine busy.&lt;/p&gt;

&lt;p&gt;Now give that same build thirty-two &lt;strong&gt;processor cores&lt;/strong&gt;. It still takes twenty seconds. At the start, only two tasks are ready. When they finish, only the final task is ready. The rest of the machine is idle because no scheduler can start a task before the input it needs exists. Large builds have thousands of tasks instead of three, but they are subject to the same limit.&lt;/p&gt;

&lt;p&gt;There are two useful ways to see that limit. One is the total amount of work divided across the processor cores. If enough tasks are ready, more cores lower that number. The other is the longest chain of tasks that must happen in order. More cores cannot shorten that chain. A build cannot finish before either limit allows it to.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The graph is part of the performance budget.&lt;/strong&gt; It decides which tasks can use the machine at the same time and which must wait. Buying a faster machine or adding processor cores is tempting because it is immediate. But if the graph does not expose enough independent work, that extra capacity sits idle. Make the graph easier to run first. Then bigger machines have something to accelerate.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the simulator, a unit of work is one second of processor time on a reference machine. This is only a model. In a real build, recorded processor time is a useful estimate, but wall-clock time also includes waiting for dependencies, memory pressure, scheduling, and eventually moving data. Caches and networks change the picture too. We will get to them, but the graph comes first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the work comes from
&lt;/h2&gt;

&lt;p&gt;A clean build, where every task has to run, is a useful starting point. But most developers do not work that way. They change a file, run the build again, change another file, and run it again. An incremental build looks at that change, finds the part of the graph it can affect, and leaves everything else alone. It still has to answer two questions: what needs to rebuild, and when can the required work run? They are different decisions, a distinction explored in &lt;a href="https://simon.peytonjones.org/assets/pdfs/build-systems-original.pdf" rel="noopener noreferrer"&gt;Build Systems à la Carte&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For one changed file, the affected part of the graph contains the task that reads it and every downstream task whose result could now be different. It is usually much smaller than the graph for the whole project. That is what incremental compilation buys us. It removes tasks from the build, and it can shorten the &lt;strong&gt;longest dependency path&lt;/strong&gt; that remains. It cannot turn a dependency into independent work, but it can make that dependency irrelevant for the change in front of you.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;Interactive simulator:&lt;/strong&gt; this section has a live model you can play with in the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;original post on tuist.dev&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Over time, the number that matters is not the cost of one arbitrary edit. It is the &lt;strong&gt;cost of the edits a team actually makes&lt;/strong&gt;, weighted by &lt;strong&gt;how often they happen&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An incremental build is a distribution, not a single number.&lt;/strong&gt; A slow path that nobody changes is less important than a moderately slow path that developers touch all day.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There are two dimensions to a build: the graph, and the way that graph changes over time. The graph tells us the cost of one change, what has to rebuild, what can run in parallel, and what must wait. History tells us which changes the team actually makes. A shared module with many downstream dependents might be exactly what the project needs. It becomes an everyday cost only when it changes often, or when rebuilding it lands on a long path through the build. Put those two dimensions together and the questions become useful: which modules change most, how far do those changes travel, and which ones add time to a build already waiting on other work?&lt;/p&gt;

&lt;p&gt;Looking at a graph or a build in isolation misses that intersection. A graph can show what might be expensive. An individual build can show what happened once. Neither says whether developers regularly pay that cost. &lt;a href="https://tuist.dev/en/docs/guides/features/build-insights" rel="noopener noreferrer"&gt;Build Insights&lt;/a&gt; keeps the history of local and continuous integration builds, so the graph can be read alongside the way a project is actually used. At Tuist, we do not think caching and execution are enough on their own. Connecting a project should also give a team the data it needs to make better decisions about its setup.&lt;/p&gt;

&lt;p&gt;Architecture turns this into a design problem. Shared foundations need to be small and stable, while fast-moving code needs boundaries that keep its changes from waking up the rest of the project. Then the cache and the extra machines have something worthwhile to accelerate. We will come back to the trade-offs behind those choices in a future post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incrementality across worktrees
&lt;/h2&gt;

&lt;p&gt;Open a feature, a review, and a small fix at the same time. Most files are unchanged between them. So are the dependencies, compiler, and a surprising amount of the work. Worktrees let those copies coexist without stepping on one another, but a build system usually meets each copy as if it were a new project.&lt;/p&gt;

&lt;p&gt;There are two ways to carry work from one build to the next. &lt;strong&gt;Incrementality by location&lt;/strong&gt; keeps mutable state in a build directory and benefits only when the next build returns to that directory. A second worktree has a second directory, so it starts without the first worktree's incremental state. &lt;strong&gt;Incrementality by identity&lt;/strong&gt; names the result of a task by the inputs, toolchain, settings, and dependencies that produced it. Two worktrees can keep their own graphs and bookkeeping, but still ask for the same result.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;Interactive simulator:&lt;/strong&gt; this section has a live model you can play with in the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;original post on tuist.dev&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Xcode is a familiar example of the first approach. Its DerivedData directory holds the build products and bookkeeping that make a later build incremental. Another checkout usually has another DerivedData directory, even when the source trees are nearly identical. &lt;a href="https://www.swift.org/documentation/package-manager/" rel="noopener noreferrer"&gt;Swift Package Manager&lt;/a&gt;, &lt;a href="https://doc.rust-lang.org/cargo/reference/build-cache.html" rel="noopener noreferrer"&gt;Cargo&lt;/a&gt;, and &lt;a href="https://hexdocs.pm/mix/Mix.html" rel="noopener noreferrer"&gt;Mix&lt;/a&gt; also use local build directories. The normal consequence is simple: worktree B rebuilds work already completed in worktree A.&lt;/p&gt;

&lt;p&gt;Sharing one directory changes the failure mode, rather than creating reuse. Two builds that point at the same directory have to coordinate updates to mutable state. The safe response is a lock: one build waits while the other changes the bookkeeping.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A shared build directory is a queue, not a cache.&lt;/strong&gt; It protects mutable state, but the second build still waits for the first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Xcode's compilation cache uses the second approach for cacheable compiler work: it looks up a result by its content-addressed identity rather than through a worktree's DerivedData directory. Bazel, when it uses a disk or remote cache, does the same. Its &lt;a href="https://bazel.build/remote/caching" rel="noopener noreferrer"&gt;action cache and content-addressable storage&lt;/a&gt; keep the lookup from an action to its result separate from the output files themselves. That is why another checkout can reuse the work without borrowing the first checkout's build directory.&lt;/p&gt;

&lt;p&gt;The boundary is not native tools on one side and Bazel on the other. Go and Gradle have forms of action caching too, and Cargo can use &lt;code&gt;sccache&lt;/code&gt; to share built dependencies. Go's &lt;a href="https://pkg.go.dev/cmd/go#hdr-Build_and_test_caching" rel="noopener noreferrer"&gt;build cache&lt;/a&gt;, for example, lives outside the project and is safe for concurrent builds. But unless a build uses &lt;code&gt;-trimpath&lt;/code&gt;, the path of a local package can end up in debug information. Two worktrees with the same source are therefore not always asking Go for the same result. What matters is where a tool puts the state it needs to reuse work.&lt;/p&gt;

&lt;p&gt;For tools that keep that state in a directory, the obvious answer in continuous integration is to keep the directory around. The next job mounts a volume with the last build's outputs and database. From the tool's perspective, it looks like the previous build never ended. We will call this a sticky volume. It makes a directory-scoped build feel incremental. It does not make the work inside that directory broadly reusable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making a build directory stick
&lt;/h2&gt;

&lt;p&gt;The idea is appealing because it can be effective quickly. A branch can start from a fork of the volume that its base commit produced, then keep its own volume from there. Its next build lands on a runner that can mount it. Dependencies are already fetched, generated files are still there, and an incremental compiler sees the state it expects. The difficulty appears when there is more than one plausible volume. Should a pull request inherit its branch's last build, the main branch, or the closest common commit? If every branch gets its own volume, each one starts cold and storage grows with the number of active branches. If they all share one, the build system is back to locking a mutable directory.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;Interactive simulator:&lt;/strong&gt; this section has a live model you can play with in the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;original post on tuist.dev&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The volume also becomes a &lt;strong&gt;scheduling constraint&lt;/strong&gt;. A volume that can be written by one node restricts where a job can run. A shared read-write volume has different storage and concurrency requirements. &lt;a href="https://kubernetes.io/docs/concepts/storage/persistent-volumes/" rel="noopener noreferrer"&gt;Kubernetes makes those choices explicit through volume access modes&lt;/a&gt;. The runner still has to choose the right volume, place the job on a machine that can mount it, and prevent two writers from corrupting the same state.&lt;/p&gt;

&lt;p&gt;That is &lt;strong&gt;operational complexity&lt;/strong&gt;, not cache configuration. Someone has to create and retire volumes, choose which history a job inherits, keep data close to the machine that needs it, and coordinate concurrent jobs. &lt;a href="https://tuist.dev/en/docs/guides/get-started/tuist-runners" rel="noopener noreferrer"&gt;Tuist Runners&lt;/a&gt; handles that runner infrastructure and connects jobs to a cache shared with developer machines. The job can reuse valid results without a team having to select and mount yesterday's mutable build directory.&lt;/p&gt;

&lt;p&gt;There is also a &lt;strong&gt;correctness boundary&lt;/strong&gt;. A persistent build directory is not automatically flaky, but it preserves more than source files. It can hold paths, software development kit state, compiler outputs, and assumptions about the machine that last wrote to it. A sound build system notices the difference and rebuilds. A missed input can turn into the kind of failure that only appears on one runner, after one particular sequence of jobs. Absolute paths and undeclared environment variables are common reasons shared caches either miss or reuse the wrong thing. &lt;a href="https://docs.gradle.org/current/userguide/build_cache.html" rel="noopener noreferrer"&gt;Gradle's cache documentation&lt;/a&gt; treats both as part of making a task relocatable.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A sticky volume does not make a build system share work. It makes one runner remember it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Persistent volumes are still useful. They are a good home for downloads, source mirrors, and other state deliberately designed to survive a new checkout. But they leave the platform choosing a history for every job and carrying the cost of that choice. A task-result cache has a narrower question: are these inputs the same? If they are, it can reuse the result wherever the next build happens to run. That is the second model in practice: reuse follows the identity of the work, not the directory that happened to run it last.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distance to a cache
&lt;/h2&gt;

&lt;p&gt;A task-result cache makes reuse portable, but it changes the question. The build no longer needs the previous directory. It needs a result that may be somewhere else. That brings us back to the question from the beginning: if object storage is cheap, why not put the cache there? Object storage can hold the artifacts, and often should. But the price of keeping those bytes says almost nothing about the time it takes to serve a build.&lt;/p&gt;

&lt;p&gt;Cost changes with the traffic shape too. Many managed object stores charge not only for bytes at rest, but also for requests, retrievals, transfer, and sometimes replication. A cache full of small artifacts can turn a modest amount of stored data into a great many operations. That may be immaterial for one team and material for another, but it is another reason to evaluate the cache path, not only the storage price.&lt;/p&gt;

&lt;p&gt;Every remote cache hit has two time components. &lt;strong&gt;Latency&lt;/strong&gt; is the time spent waiting for a request to reach the cache and a response to begin. &lt;strong&gt;Bandwidth&lt;/strong&gt; is how quickly the artifact can move once that response arrives. A useful lower bound for a cache hit is:&lt;/p&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="katex-element"&gt;
  &lt;span class="katex-display"&gt;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;T&lt;/span&gt;&lt;span class="msupsub"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathrm mtight"&gt;hit&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;≥&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;R&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;×&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;L&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;+&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mopen nulldelimiter"&gt;&lt;/span&gt;&lt;span class="mfrac"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;B&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="frac-line"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;S&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mclose nulldelimiter"&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/div&gt;


&lt;p&gt;Here, 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;R&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 is the number of request-response steps that cannot overlap, 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;L&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 is latency, 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;S&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 is the number of bytes to transfer, and 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;B&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 is the effective bandwidth. It is a lower bound, not a prediction. Real cache hits also pay for cache lookup, decompression, and work that cannot be parallelized.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;Interactive simulator:&lt;/strong&gt; this section has a live model you can play with in the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;original post on tuist.dev&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;R&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;×&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;L&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 dominates, the cache is &lt;strong&gt;latency-bound&lt;/strong&gt;. A result split across thousands of small blobs can spend much of its time waiting for requests, unless the cache client batches metadata, reuses connections, prefetches, and downloads in parallel. When 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mopen nulldelimiter"&gt;&lt;/span&gt;&lt;span class="mfrac"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;B&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="frac-line"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;S&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mclose nulldelimiter"&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 dominates, it is &lt;strong&gt;bandwidth-bound&lt;/strong&gt;: artifact size and the available network throughput decide how long the transfer takes. Some lookups still sit on the dependency path, so their latency lands directly on wall-clock time. Concurrent requests can help throughput, and keeping compute in the same region reduces latency, but neither makes a distant object store behave like a local file system.&lt;/p&gt;

&lt;p&gt;That is what &lt;strong&gt;infrastructure collocation&lt;/strong&gt; means here. Cache and execution should sit on a short, high-bandwidth network path. In &lt;a href="https://docs.tuist.dev/guides/features/runners" rel="noopener noreferrer"&gt;Tuist Runners&lt;/a&gt;, compute and cache sit on the same private network. Keeping latency low and bandwidth high is part of the environment we operate, not a setting we ask a team to tune after the fact. But runners are not the whole picture. Developer machines can be spread across regions, and remote coding environments such as &lt;a href="https://openai.com/codex/" rel="noopener noreferrer"&gt;Codex&lt;/a&gt; and &lt;a href="https://www.anthropic.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; may reach a cache over the public internet. They should share the same valid results as the runners, but they cannot all be close to one central cache at once.&lt;/p&gt;

&lt;p&gt;This is why we are building &lt;a href="https://github.com/tuist/tuist/tree/main/kura" rel="noopener noreferrer"&gt;Kura&lt;/a&gt; as a decentralized cache mesh. A Kura node serves hot reads from its own disk and replicates artifacts and metadata to its peers. Kura is designed to sit beside our runners and to be deployed across regions, so each environment can have a nearby cache path while still sharing results with the rest of the team. In terms of the earlier bound, it is designed to reduce 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;L&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 without giving up the shared history that makes a remote cache useful in the first place.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 &lt;strong&gt;Interactive simulator:&lt;/strong&gt; this section has a live model you can play with in the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;original post on tuist.dev&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A remote cache hit is only a win when retrieving the result is faster than producing it again.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The limits of one machine
&lt;/h2&gt;

&lt;p&gt;Collocation makes a cache hit fast. It does not help when there is no result to fetch. New work, invalidated work, and a task nobody has run before still need a machine. The limit returns to where we started: the finite number of processor cores in the environment that started the build. That machine can run the tasks that are ready in the graph, and no more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remote execution&lt;/strong&gt; is the next step. Instead of keeping every cache miss on the machine that started the build, the build system can send a ready task to a pool of workers. Those workers need to be warm: they need the right toolchain, system image, and enough capacity to begin without first reconstructing the environment. They run the task and return the result to the shared cache. More workers help only when the graph has enough ready work to fill them, just as more processor cores help only when one machine has enough independent work. A long dependency chain still runs one step at a time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A remote cache avoids work that has already been done. Remote execution gives the work that remains more places to run.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is not a common capability in native toolchains. &lt;a href="https://bazel.build/remote/rbe" rel="noopener noreferrer"&gt;Bazel's remote execution support&lt;/a&gt; is explicitly designed to distribute build and test actions across multiple machines. &lt;a href="https://buck2.build/docs/users/remote_execution/" rel="noopener noreferrer"&gt;Buck2&lt;/a&gt; can use the same open remote-execution protocol to run actions remotely or locally. In both cases, the build describes enough about an action to move it safely: the inputs, outputs, toolchain, settings, and environment it needs. That is what lets a worker execute a task without borrowing the state of the machine that requested it.&lt;/p&gt;

&lt;p&gt;Most native toolchains can parallelize compiler work on the machine they were started on, but do not offer this as the normal way to run a build. Remote caching can still cut the work dramatically, but cache misses remain limited by the processor cores in one environment. Remote execution is the extra mile: a way to turn a wide part of the graph into work that a fleet can absorb, provided those workers are close to the cache and ready when the work arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for teams
&lt;/h2&gt;

&lt;p&gt;None of this makes optimizing the graph optional. Caching removes repeated work, and remote execution adds capacity for the work that remains. Neither can make a deep chain run in parallel or stop a small change from waking up much of the project. The graph still sets the shape of every build; the history of changes tells us whether developers pay the cost often enough to care. That is why insights over time matter. We need the graph alongside build traces and real changes, not one isolated run, to see what should be fixed before buying faster hardware or more infrastructure.&lt;/p&gt;

&lt;p&gt;Tools that keep incremental state in a directory can be given sticky volumes, and that can be useful. But it trades an ordinary cache question for a scheduling problem: someone must maintain the volumes, decide which history a job inherits, keep it near compatible machines, and prevent concurrent writers. Tools with &lt;strong&gt;incrementality by identity&lt;/strong&gt; can share results through a cache instead. Then latency, bandwidth, and the cost of moving artifacts are the variables to manage. The more granular the artifacts, the more that placement matters: one build can turn into thousands of small lookups and transfers rather than a few large ones.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Infrastructure is a multiplier, not a replacement for a graph that makes useful work available.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Xcode's compilation cache already brings identity-based reuse to native Xcode projects and can deliver cache gains close to the systems built around remote caching. What it does not provide today is distributed execution. That remains the broader gap for native toolchains: most were designed around a developer building on one machine and still have work to do before they can use the amount of parallel build work a modern organization produces. People often ask us whether Xcode's compilation cache will grow into that kind of system. We do not know. It is a toolchain decision, and we do not know when, or whether, Apple will prioritize it.&lt;/p&gt;

&lt;p&gt;That uncertainty matters more now that coding agents can produce changes faster than many projects can validate them. If build feedback is already the bottleneck, &lt;a href="https://bazel.build" rel="noopener noreferrer"&gt;Bazel&lt;/a&gt; and &lt;a href="https://buck2.build" rel="noopener noreferrer"&gt;Buck2&lt;/a&gt; are sensible options because remote caching and remote execution are part of the model today. The trade-off is adoption: both ask a project to describe its build in their rules. If that is more change than a team can take on, we are prototyping &lt;a href="https://github.com/tuist/once" rel="noopener noreferrer"&gt;Once&lt;/a&gt; as a different entry point. Once works with an existing project and the scripts it already has, so a team can describe its automation gradually and connect it to caching and execution without first replacing the build system underneath. It is early, and we are looking for teams that feel this pressure and want to try it. Email us at &lt;a href="mailto:contact@tuist.dev"&gt;contact@tuist.dev&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One condition sits underneath all of this. A remote cache or remote worker is only safe if a task's identity is real. &lt;strong&gt;Hermeticity&lt;/strong&gt; means accounting for every input a task needs. &lt;strong&gt;Deterministic hashing&lt;/strong&gt; means those declared inputs, together with the toolchain and settings, always produce the same cache key. We will look at both in a future post, along with the other constraints that make distributed builds fast without making them wrong.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://tuist.dev/blog/2026/08/25/the-physics-of-build-systems" rel="noopener noreferrer"&gt;Tuist blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildsystems</category>
      <category>performance</category>
      <category>devops</category>
      <category>ios</category>
    </item>
    <item>
      <title>Xcode and Gradle: Two worlds, one infrastructure</title>
      <dc:creator>Pedro Piñera Buendía</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:13:40 +0000</pubDate>
      <link>https://dev.to/tuist/xcode-and-gradle-two-worlds-one-infrastructure-3l09</link>
      <guid>https://dev.to/tuist/xcode-and-gradle-two-worlds-one-infrastructure-3l09</guid>
      <description>&lt;p&gt;At Tuist, we are building a virtual platform team. Not every organization can afford a dedicated team to optimize builds, manage flaky tests, track app size, or streamline developer workflows. Tuist fills that role by going deep into build systems, understanding how they structure and persist data, and plugging into them to optimize workflows like compilations and test runs. Whether developers work alone or alongside coding agents, we want the tooling layer to just work.&lt;/p&gt;

&lt;p&gt;We have been doing this for Xcode for years. Understanding activity logs, result bundles, compilation graphs, the entire landscape of how Apple's toolchain turns source files into apps. Being close to the mobile ecosystem, jumping to Android through Gradle was a natural next step. And we were genuinely struck by how the work turned out. We shipped remote build caching, build insights, test insights, flaky test detection, bundle analysis, and previews for Gradle in about three weeks. Part of that speed came from features like bundle analysis and test insights being largely build-system-agnostic: once the data is extracted, it boils down to the same structures regardless of whether it came from Xcode or Gradle.&lt;/p&gt;

&lt;p&gt;This post is a walkthrough of both build systems from our perspective. If you are an Xcode developer curious about how things work on the Gradle side, or a Gradle developer who has never touched Apple's toolchain, this is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plugin model, or lack thereof
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://gradle.org/" rel="noopener noreferrer"&gt;Gradle&lt;/a&gt; is Android's build system, and more broadly, the dominant build system in the JVM ecosystem. If you have never used it: think of it as a programmable build system where builds are defined in Kotlin or Groovy scripts, and the system is designed from the ground up to be extended. It exposes a rich plugin API where you can hook into nearly every phase of the build lifecycle. You implement an interface, register your plugin, and Gradle calls you at the right time. Want to intercept cache operations? Implement &lt;a href="https://docs.gradle.org/current/javadoc/org/gradle/caching/BuildCacheService.html" rel="noopener noreferrer"&gt;&lt;code&gt;BuildCacheService&lt;/code&gt;&lt;/a&gt;. Want to observe every task as it finishes? Register an &lt;a href="https://docs.gradle.org/current/javadoc/org/gradle/tooling/events/OperationCompletionListener.html" rel="noopener noreferrer"&gt;&lt;code&gt;OperationCompletionListener&lt;/code&gt;&lt;/a&gt;. Want test results as they happen? Add a &lt;a href="https://docs.gradle.org/current/javadoc/org/gradle/api/tasks/testing/TestListener.html" rel="noopener noreferrer"&gt;&lt;code&gt;TestListener&lt;/code&gt;&lt;/a&gt;. The contracts are well-defined, documented, and stable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developer.apple.com/xcode/" rel="noopener noreferrer"&gt;Xcode&lt;/a&gt; is Apple's IDE for iOS, macOS, and the rest of Apple's platforms. The underlying build system is &lt;a href="https://github.com/swiftlang/swift-build" rel="noopener noreferrer"&gt;Swift Build&lt;/a&gt;, and &lt;a href="https://developer.apple.com/library/archive/technotes/tn2339/_index.html" rel="noopener noreferrer"&gt;&lt;code&gt;xcodebuild&lt;/code&gt;&lt;/a&gt; is the command-line executable bundled with Xcode to interact with projects from the terminal, for example to build them. If you come from the Gradle world: think of it as a more closed system where Apple controls both the IDE and the build pipeline, and there is no plugin model to speak of. Apple does not expose a way to say "call me when a compilation finishes" or "let me handle cache storage." Swift Build is open source now, but it is strongly coupled to Xcode and its release cycles. The same applies to &lt;a href="https://github.com/swiftlang/swift-package-manager" rel="noopener noreferrer"&gt;SwiftPM&lt;/a&gt;, Apple's package manager: you can contribute improvements or fixes, but they will not reach developers until Apple ships the next Xcode version. In practice, you feed the build system a project in its intermediate format (PIF, or Project Interchange Format) and it gives you output in the terminal and artifacts on disk. &lt;strong&gt;There are no extension points in between.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This single architectural difference shaped everything about how we built our integrations. With Gradle, we wrote a &lt;a href="https://plugins.gradle.org/plugin/dev.tuist" rel="noopener noreferrer"&gt;plugin&lt;/a&gt; in Kotlin that hooks directly into the build lifecycle. With Xcode, we had to get creative. We built a CLI that wraps &lt;code&gt;xcodebuild&lt;/code&gt; (Apple's command-line build tool), parses its artifacts after the fact, hooks into scheme post-actions to trigger data collection, and in some cases acts as a proxy between the build system and external services. Every feature required finding the right phase in the process where the data we needed was accessible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build caching
&lt;/h2&gt;

&lt;p&gt;Build caching is about not repeating work that has already been done. If a module or a compilation unit has not changed since the last build, there is no reason to compile it again. And if someone else on your team or CI has already compiled it, there is no reason for you to compile it either. The idea is simple, but the integration looks completely different depending on the build system.&lt;/p&gt;

&lt;p&gt;For most of Xcode's history, there was no concept of remote build caching. So we built one ourselves.&lt;/p&gt;

&lt;p&gt;In the early days of Tuist, we invested heavily in project generation. For context: Xcode projects are stored as &lt;code&gt;.xcodeproj&lt;/code&gt; files, a format that is notoriously difficult to manage at scale and causes constant merge conflicts in teams. Tuist lets you define your project in Swift and generates the Xcode project from that definition. Later, drawing ideas from package managers like &lt;a href="https://github.com/Carthage/Carthage" rel="noopener noreferrer"&gt;Carthage&lt;/a&gt; (a dependency manager for Apple platforms that builds frameworks from source), we built &lt;a href="https://tuist.dev/en/docs/guides/features/cache/xcode-cache" rel="noopener noreferrer"&gt;module-level binary caching&lt;/a&gt; on top of it. You can see how this looks in practice on &lt;a href="https://tuist.dev/tuist/tuist/module-cache" rel="noopener noreferrer"&gt;our own project's cache dashboard&lt;/a&gt;. Because we understand the project graph, we can determine which modules have not changed, replace them with pre-compiled binaries, and generate a project that skips compiling them entirely. This happens before the build even starts. It requires deep knowledge of the dependency graph and control over how the project is generated, which is why it is tightly coupled to Tuist's project generation layer.&lt;/p&gt;

&lt;p&gt;Then Apple introduced compilation caching in &lt;a href="https://developer.apple.com/documentation/xcode-release-notes/xcode-26-release-notes" rel="noopener noreferrer"&gt;Xcode 26&lt;/a&gt; using &lt;a href="https://llvm.org/" rel="noopener noreferrer"&gt;LLVM&lt;/a&gt;'s Content Addressable Storage (CAS). For Gradle developers unfamiliar with this: CAS is a storage model where artifacts are stored and retrieved by the hash of their content, similar in spirit to how Git stores objects. The granularity is impressive, sub-function level, meaning Xcode can skip recompiling individual compilation units if their inputs have not changed. But the remote cache interface is a gRPC protocol with no public SDK. The only way to provide a remote cache is to run a service that speaks this protocol and tell Xcode where to find it via a Unix socket.&lt;/p&gt;

&lt;p&gt;So that is what we did. &lt;code&gt;tuist setup cache&lt;/code&gt; creates a &lt;a href="https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html" rel="noopener noreferrer"&gt;LaunchAgent&lt;/a&gt; daemon (a macOS background process managed by the system) that listens on a local socket. You pass the socket path to &lt;code&gt;xcodebuild&lt;/code&gt; via build settings. During compilation, Xcode makes thousands of gRPC calls to this socket, asking "do you have this artifact?" and "store this artifact." Our daemon proxies those calls to our remote infrastructure.&lt;/p&gt;

&lt;p&gt;Gradle's story could not be more different. It has a built-in concept of remote build caching that has been stable for years. You enable it in &lt;code&gt;gradle.properties&lt;/code&gt;, point it at a remote service, and Gradle handles the rest. The protocol is straightforward HTTP. Before executing a task (Gradle's unit of work, like compiling a set of source files or processing resources), Gradle checks the remote cache with a GET request using the task's cache key. If there is a hit, it downloads the artifact and skips execution. After executing a task, it uploads the outputs with a PUT.&lt;/p&gt;

&lt;p&gt;You might wonder why we built a plugin instead of just pointing Gradle at an existing cache server. The reason is that we need control over the client-side logic. Our authentication model is different from what a plain HTTP cache server expects, and we run client-side logic to resolve the lowest latency endpoint from our global cache network. That forced us to implement Gradle's &lt;code&gt;BuildCacheService&lt;/code&gt; interface ourselves, which turned out to be straightforward. We did not have to intercept anything, parse anything, or run a background daemon. We implemented the contract, handled auth and endpoint resolution on our side, and it worked. The plugin is &lt;a href="https://github.com/tuist/tuist/tree/main/gradle" rel="noopener noreferrer"&gt;open source&lt;/a&gt; if you want to see how it all fits together. The cache key computation, the decision of what is cacheable, the artifact packaging, all of that is Gradle's responsibility. We just provide the storage. You can see how this looks on &lt;a href="https://tuist.dev/tuist/android/gradle-cache" rel="noopener noreferrer"&gt;our Android project's cache dashboard&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;tuist&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;buildCache&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;push&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CI"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caching granularity is worth calling out. Gradle caches at the task level: an entire compilation task, a resource processing task, a code generation task. Xcode's compilation cache operates at the compilation unit level, and our module caching operates at the dependency graph level. This means Xcode's approach is more fine-grained but also more latency-sensitive. When your build system makes thousands of cache lookups per build, every millisecond of network latency matters. With Gradle, a large project might have hundreds of cache lookups per build. The tolerance for latency is still much higher.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We believe caching is a capability that will become increasingly common across build systems. Gradle has had it for years, Xcode added it recently, and others will follow. But caching only works if the infrastructure behind it is fast enough to beat the ceiling of multi-core local compute. If pulling from the cache is slower than just rebuilding, nobody will use it. That is why we are investing in low-latency, globally distributed cache infrastructure that both small teams and large organizations can plug into without having to build or maintain it themselves.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Build insights
&lt;/h2&gt;

&lt;p&gt;The architecture of your project has deep implications in how effectively the build system uses the physical resources available. A poorly structured dependency graph can leave most of your CPU cores idle while one bottleneck module compiles sequentially. A test target that depends on half the project rebuilds far more than it needs to. &lt;strong&gt;But you can only improve what you can measure&lt;/strong&gt;, and most teams have no visibility into how their project interacts with their build system. Build insights exist to change that.&lt;/p&gt;

&lt;p&gt;Xcode gives you no structured way to observe a build as it happens. Instead, after a build finishes, Xcode writes an &lt;code&gt;.xcactivitylog&lt;/code&gt; file to your derived data directory. For Gradle developers: derived data is where Xcode stores all build outputs, indexes, and logs, similar to Gradle's &lt;code&gt;.gradle&lt;/code&gt; and &lt;code&gt;build&lt;/code&gt; directories. The activity log is a binary format that contains timing information, errors, warnings, and file-level compilation data. To extract &lt;a href="https://tuist.dev/en/docs/guides/features/build-insights/xcode" rel="noopener noreferrer"&gt;build insights&lt;/a&gt;, we parse this file after the build using &lt;code&gt;tuist inspect build&lt;/code&gt;, which runs as a scheme post-action (a script Xcode executes after a build or test scheme completes). You can see how this looks on &lt;a href="https://tuist.dev/tuist/tuist/builds" rel="noopener noreferrer"&gt;our own project's build dashboard&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The parsing itself is a pain. The activity log format is not publicly documented. Projects like &lt;a href="https://github.com/MobileNativeFoundation/XCLogParser" rel="noopener noreferrer"&gt;XCLogParser&lt;/a&gt; from the Mobile Native Foundation, which we are now active maintainers of, have done valuable work in this area, but the format changes with new Xcode versions, and every time Apple adds new features to the build system, there is reverse-engineering work needed to parse the new data. Coding agents have made that work easier, but it remains fragile. We extract targets, compiled files with their durations, errors and warnings with file locations, and for Xcode 26 builds, cache hit/miss data for individual compilation units.&lt;/p&gt;

&lt;p&gt;There is also a race condition that caught us off guard. Scheme post-actions run after the build finishes, but you cannot assume the &lt;code&gt;.xcactivitylog&lt;/code&gt; is ready at that point. Xcode generates those artifacts asynchronously, so we had to account for the file not being present when our post-action fires. It is one of those things that works fine in most cases and then fails silently in others.&lt;/p&gt;

&lt;p&gt;Gradle makes this dramatically simpler. When we wanted to give teams visibility into their &lt;a href="https://tuist.dev/en/docs/guides/features/build-insights/gradle" rel="noopener noreferrer"&gt;Gradle builds&lt;/a&gt; (here is &lt;a href="https://tuist.dev/tuist/android/builds" rel="noopener noreferrer"&gt;our Android project's build dashboard&lt;/a&gt;), the plugin API made it almost trivial. We register a listener that gets called as each task finishes. We collect the task path, its outcome (cache hit, executed, failed, skipped), the cache key, artifact size, and duration. At the end of the build, we bundle everything into a JSON payload and send it to our server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Gradle calls us. We just collect.&lt;/span&gt;
&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;finished&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buildOperation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BuildOperationDescriptor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finishEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OperationFinishEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Record task outcome, duration, cache behavior&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data is clean because Gradle gives it to us in a structured form. We know exactly what happened to each task and why. &lt;strong&gt;No binary parsing, no race conditions, no reverse engineering.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Test insights, flaky tests, and quarantine
&lt;/h2&gt;

&lt;p&gt;Tests are one of the biggest sources of friction in any development workflow, regardless of platform. A slow test suite delays every pull request. A flaky test, one that passes sometimes and fails other times with the same code, erodes trust in the entire pipeline. And when a flaky test blocks a merge, someone has to investigate whether the failure is real or noise. Multiply that by dozens of developers and hundreds of test runs per day, and the cost adds up fast. &lt;a href="https://tuist.dev/en/docs/guides/features/test-insights/xcode" rel="noopener noreferrer"&gt;Test insights&lt;/a&gt;, &lt;a href="https://tuist.dev/en/docs/guides/features/test-insights/flaky-tests/xcode" rel="noopener noreferrer"&gt;flaky test detection&lt;/a&gt;, and &lt;a href="https://tuist.dev/en/docs/guides/features/test-insights/flaky-tests/xcode#quarantining" rel="noopener noreferrer"&gt;quarantine&lt;/a&gt; exist to give teams the data they need to manage this, and to automate the parts that should not require human attention.&lt;/p&gt;

&lt;p&gt;With Xcode, test results live in &lt;code&gt;.xcresult&lt;/code&gt; bundles. For Gradle developers: these are structured archive files that Xcode produces after a test run, containing test case hierarchies, timing data, failure messages, and attachments like screenshots and crash reports. After the test run, we parse the result bundle using &lt;code&gt;tuist inspect test&lt;/code&gt;, extract the structured data, and upload it. You can see how this looks on &lt;a href="https://tuist.dev/tuist/tuist/tests" rel="noopener noreferrer"&gt;our project's test dashboard&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The result bundle format is richer than what Gradle gives us. It can contain screenshots from UI tests, crash logs, performance metrics. But accessing that richness requires navigating a nested archive format that Apple controls and occasionally changes between Xcode versions.&lt;/p&gt;

&lt;p&gt;Once we have the data, we detect flaky tests by comparing results across runs. If a test passes in one CI run but fails in another on the same commit, we flag it. When a test is identified as flaky, you can quarantine it so it does not block CI while you work on a fix. With Xcode, quarantine works through &lt;code&gt;xcodebuild&lt;/code&gt;'s &lt;code&gt;-skip-testing&lt;/code&gt; flag. When you run &lt;code&gt;tuist test&lt;/code&gt;, we fetch the list of quarantined tests from our server and pass each one as a &lt;code&gt;-skip-testing&lt;/code&gt; argument. If you use &lt;code&gt;xcodebuild&lt;/code&gt; directly, you can get the formatted arguments with &lt;code&gt;tuist test case list --skip-testing&lt;/code&gt;. It works, but it means quarantine is tied to the command-line invocation rather than being embedded in the build system itself.&lt;/p&gt;

&lt;p&gt;In Gradle, we register a &lt;a href="https://docs.gradle.org/current/javadoc/org/gradle/api/tasks/testing/TestListener.html" rel="noopener noreferrer"&gt;&lt;code&gt;TestListener&lt;/code&gt;&lt;/a&gt; on every test task. As each test case finishes, Gradle calls us with the test descriptor (module, suite, name) and the result (pass, fail, skip, duration). We collect everything and upload it when the test task completes. You can see this on &lt;a href="https://tuist.dev/tuist/android/tests" rel="noopener noreferrer"&gt;our Android project's test dashboard&lt;/a&gt;. If teams use the &lt;a href="https://github.com/gradle/test-retry-gradle-plugin" rel="noopener noreferrer"&gt;test-retry plugin&lt;/a&gt; to rerun failed tests, we see each attempt and can detect flakiness by comparing results across retries. For quarantine, our plugin fetches the list of quarantined tests from our server before each test task runs and adds them to Gradle's &lt;code&gt;excludeTestsMatching&lt;/code&gt; filter. The test never runs. No file manipulation, no source control concerns, just a programmatic exclusion at runtime.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;tuist&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;testQuarantine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data is simpler than what Xcode gives us, but it is immediately available, stable, and designed for tool integration. The flaky detection and quarantine logic on the server side is identical for both ecosystems. The difference, again, is entirely in how we collect the data and how we act on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bundle analysis and previews
&lt;/h2&gt;

&lt;p&gt;Not every feature requires deep integration with the build system. &lt;a href="https://tuist.dev/en/docs/guides/features/bundle-size" rel="noopener noreferrer"&gt;Bundle analysis&lt;/a&gt; and &lt;a href="https://tuist.dev/en/docs/guides/features/previews" rel="noopener noreferrer"&gt;previews&lt;/a&gt; are both CLI features that operate on build artifacts after they are produced. The difference across platforms is just the object types they work with.&lt;/p&gt;

&lt;p&gt;Bundle analysis tracks app size over time. App size tends to grow silently, a new dependency here, an uncompressed asset there, and before you know it your app is 50% larger than it was six months ago. With Xcode, we analyze &lt;code&gt;.ipa&lt;/code&gt; (the archive format for iOS apps), &lt;code&gt;.xcarchive&lt;/code&gt;, and &lt;code&gt;.app&lt;/code&gt; bundles. You can see this on &lt;a href="https://tuist.dev/tuist/tuist/bundles" rel="noopener noreferrer"&gt;our project's bundle dashboard&lt;/a&gt;. With Gradle, we analyze &lt;code&gt;.aab&lt;/code&gt; (Android App Bundle, the format Google Play uses to generate device-specific APKs) and &lt;code&gt;.apk&lt;/code&gt; files. Here is &lt;a href="https://tuist.dev/tuist/android/bundles" rel="noopener noreferrer"&gt;our Android project's bundle dashboard&lt;/a&gt;. In both cases, &lt;code&gt;tuist inspect bundle&lt;/code&gt; breaks down the contents, tracks changes over time, and comments on pull requests with size deltas so the team catches regressions before they ship.&lt;/p&gt;

&lt;p&gt;Previews solve a different problem: getting a build into someone's hands without a full release pipeline. On Apple's side, the alternative is &lt;a href="https://developer.apple.com/testflight/" rel="noopener noreferrer"&gt;TestFlight&lt;/a&gt;, Apple's beta distribution service that requires provisioning profiles, App Store Connect processing, and often a multi-minute wait. On Android's side, it is generating a signed APK and sending it over Slack. With Tuist, you run &lt;code&gt;tuist share&lt;/code&gt; after building, we upload the artifact and give you a link. Anyone with access can run &lt;code&gt;tuist run {url}&lt;/code&gt; to launch it on a simulator or device. We support tracks (like &lt;code&gt;beta&lt;/code&gt; or &lt;code&gt;nightly&lt;/code&gt;), QR codes, and automatic PR comments with preview links.&lt;/p&gt;

&lt;p&gt;Both features required minimal platform-specific work. The CLI handles different file formats, but the server infrastructure for tracking, diffing, sharing, and access control is completely shared between Xcode and Gradle.&lt;/p&gt;

&lt;p&gt;One thing that expanding to Gradle made clear is that our CLI was too coupled to macOS. Gradle projects build on Linux and Windows, not just Mac. So we invested in loosening that dependency, starting with Linux support. We are not fully there yet, Windows is still on the roadmap, but the groundwork is laid. It was one of those changes that the Gradle work forced us to confront earlier than we would have otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  From CLI dependency to standalone plugin
&lt;/h2&gt;

&lt;p&gt;With Xcode, all Tuist functionality flows through the CLI. &lt;code&gt;tuist auth login&lt;/code&gt; handles the OAuth flow, stores credentials, and every command from &lt;code&gt;tuist inspect&lt;/code&gt; to &lt;code&gt;tuist setup&lt;/code&gt; uses those stored credentials. The CLI is the central orchestration point because there is no plugin system to embed this logic into.&lt;/p&gt;

&lt;p&gt;When we first built the Gradle plugin, we took the same approach. The plugin shelled out to the CLI for responsibilities like refreshing an OAuth2 session or fetching the cache endpoint with the lowest latency. But it quickly became clear that requiring developers to install the Tuist CLI just to use a Gradle plugin added unnecessary friction. It felt wrong for the Gradle ecosystem, where plugins are expected to be self-contained.&lt;/p&gt;

&lt;p&gt;So we decided to break that dependency. The scope of the logic was well-defined: token management, OAuth2 refresh, endpoint resolution. Coding agents turned out to be great at porting this kind of solution across languages, and since it is a piece of code we do not change often, we re-implemented all of it in Kotlin to run as part of the plugin itself.&lt;/p&gt;

&lt;p&gt;Thanks to that, the plugin is nearly standalone. You add it to &lt;code&gt;settings.gradle.kts&lt;/code&gt;, configure it, and we distribute it through the &lt;a href="https://plugins.gradle.org/plugin/dev.tuist" rel="noopener noreferrer"&gt;Gradle Plugin Portal&lt;/a&gt;, which is exactly where Gradle developers expect to find their tooling. You still need the Tuist CLI for the initial &lt;code&gt;tuist auth login&lt;/code&gt; to authenticate via OAuth, but we are considering shipping an auth task directly in the plugin to make it fully standalone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Looking back
&lt;/h2&gt;

&lt;p&gt;Three weeks from first line of Kotlin to a working plugin with remote caching, build insights, test insights, and flaky test detection. Our Xcode integration took months to reach the same feature set. Part of that is because we were building the infrastructural pieces, the server, the analytics pipeline, the dashboard, that we would later reuse with Gradle. But a large part of the time went into understanding Xcode internals and proprietary formats. Gradle was designed to be extended. Every feature we wanted to build had a corresponding interface or hook point in Gradle's API. We spent our time on our logic, not on figuring out how to extract data from a system that does not want to share it.&lt;/p&gt;

&lt;p&gt;It is empowering to see coding agents lowering the cost of expanding our value to new ecosystems. The work that used to take weeks of reverse engineering now takes days with the right agent and the right context. That changes the calculus of which ecosystems are worth investing in.&lt;/p&gt;

&lt;p&gt;It was also a great exercise in learning how another build system thinks. Understanding Gradle's design decisions, its plugin model, its approach to caching, puts us in a position to cross-pollinate 🐝 ideas between ecosystems. Some patterns from Gradle inform how we approach Xcode tooling, and some of the depth we have built in the Apple ecosystem gives us perspective that Gradle-only teams might lack. &lt;strong&gt;Being in both worlds makes us better at each one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Gradle builds and Xcode builds land in the same analytics pipeline. The dashboard shows the same kinds of insights. The flaky test detection uses the same algorithms. That said, we deliberately chose not to unify all data structures or force a common denominator. The data that build toolchains work with is heterogeneous, and trying to flatten Gradle tasks and Xcode compilation units into the same model would lose the nuance that makes the insights useful. But the patterns, the pipelines, the algorithms, those are shared. The toolchain layer is where the ecosystem-specific work happens, and once you get past it, the problems become universal.&lt;/p&gt;

&lt;p&gt;If you are a Gradle developer, the extensibility of your build system is a genuine advantage that you might take for granted. The ecosystem of plugins, the clean lifecycle hooks, the first-class support for remote caching, these are design decisions that make the entire tooling ecosystem richer. If you are an Xcode developer, the lack of extensibility is a real limitation that shapes the tools available to you. Every tool that integrates with Xcode has to work around the same constraints we do. Parsing binary logs, reverse-engineering formats, running daemons as proxies. This is not a criticism of Apple, they have their reasons, but it is a reality that makes the ecosystem harder to improve from the outside.&lt;/p&gt;

&lt;p&gt;For us, the Gradle experience validated our architecture. Our infrastructure is build-system agnostic. The ecosystem-specific work is a plugin, an adapter, that translates between the build system's world and ours. Gradle's adapter was thin and fast to build. Xcode's was thick and took months. But both connect to the same infrastructure, and that is the point. We will keep going deeper into both ecosystems, and if you are interested in trying it out, our &lt;a href="https://plugins.gradle.org/plugin/dev.tuist" rel="noopener noreferrer"&gt;Gradle plugin&lt;/a&gt; and our &lt;a href="https://tuist.dev/en/docs/guides/install-tuist" rel="noopener noreferrer"&gt;Xcode integration&lt;/a&gt; are both available today.&lt;/p&gt;

</description>
      <category>xcode</category>
      <category>gradle</category>
      <category>android</category>
      <category>ios</category>
    </item>
  </channel>
</rss>
