Xcode and Gradle: Two worlds, one infrastructure
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.
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.
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.
The plugin model, or lack thereof
Gradle 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 BuildCacheService. Want to observe every task as it finishes? Register an OperationCompletionListener. Want test results as they happen? Add a TestListener. The contracts are well-defined, documented, and stable.
Xcode is Apple's IDE for iOS, macOS, and the rest of Apple's platforms. The underlying build system is Swift Build, and xcodebuild 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 SwiftPM, 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. There are no extension points in between.
This single architectural difference shaped everything about how we built our integrations. With Gradle, we wrote a plugin in Kotlin that hooks directly into the build lifecycle. With Xcode, we had to get creative. We built a CLI that wraps xcodebuild (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.
Build caching
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.
For most of Xcode's history, there was no concept of remote build caching. So we built one ourselves.
In the early days of Tuist, we invested heavily in project generation. For context: Xcode projects are stored as .xcodeproj 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 Carthage (a dependency manager for Apple platforms that builds frameworks from source), we built module-level binary caching on top of it. You can see how this looks in practice on our own project's cache dashboard. 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.
Then Apple introduced compilation caching in Xcode 26 using LLVM'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.
So that is what we did. tuist setup cache creates a LaunchAgent daemon (a macOS background process managed by the system) that listens on a local socket. You pass the socket path to xcodebuild 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.
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 gradle.properties, 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.
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 BuildCacheService 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 open source 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 our Android project's cache dashboard.
tuist {
buildCache {
push = System.getenv("CI") != null
}
}
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.
> 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.
Build insights
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. But you can only improve what you can measure, and most teams have no visibility into how their project interacts with their build system. Build insights exist to change that.
Xcode gives you no structured way to observe a build as it happens. Instead, after a build finishes, Xcode writes an .xcactivitylog 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 .gradle and build directories. The activity log is a binary format that contains timing information, errors, warnings, and file-level compilation data. To extract build insights, we parse this file after the build using tuist inspect build, 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 our own project's build dashboard.
The parsing itself is a pain. The activity log format is not publicly documented. Projects like XCLogParser 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.
There is also a race condition that caught us off guard. Scheme post-actions run after the build finishes, but you cannot assume the .xcactivitylog 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.
Gradle makes this dramatically simpler. When we wanted to give teams visibility into their Gradle builds (here is our Android project's build dashboard), 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.
// Gradle calls us. We just collect.
override fun finished(buildOperation: BuildOperationDescriptor, finishEvent: OperationFinishEvent) {
// Record task outcome, duration, cache behavior
}
The data is clean because Gradle gives it to us in a structured form. We know exactly what happened to each task and why. No binary parsing, no race conditions, no reverse engineering.
Test insights, flaky tests, and quarantine
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. Test insights, flaky test detection, and quarantine exist to give teams the data they need to manage this, and to automate the parts that should not require human attention.
With Xcode, test results live in .xcresult 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 tuist inspect test, extract the structured data, and upload it. You can see how this looks on our project's test dashboard.
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.
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 xcodebuild's -skip-testing flag. When you run tuist test, we fetch the list of quarantined tests from our server and pass each one as a -skip-testing argument. If you use xcodebuild directly, you can get the formatted arguments with tuist test case list --skip-testing. It works, but it means quarantine is tied to the command-line invocation rather than being embedded in the build system itself.
In Gradle, we register a TestListener 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 our Android project's test dashboard. If teams use the test-retry plugin 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 excludeTestsMatching filter. The test never runs. No file manipulation, no source control concerns, just a programmatic exclusion at runtime.
tuist {
testQuarantine {
enabled = true
}
}
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.
Bundle analysis and previews
Not every feature requires deep integration with the build system. Bundle analysis and previews 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.
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 .ipa (the archive format for iOS apps), .xcarchive, and .app bundles. You can see this on our project's bundle dashboard. With Gradle, we analyze .aab (Android App Bundle, the format Google Play uses to generate device-specific APKs) and .apk files. Here is our Android project's bundle dashboard. In both cases, tuist inspect bundle breaks down the contents, tracks changes over time, and comments on pull requests with size deltas so the team catches regressions before they ship.
Previews solve a different problem: getting a build into someone's hands without a full release pipeline. On Apple's side, the alternative is TestFlight, 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 tuist share after building, we upload the artifact and give you a link. Anyone with access can run tuist run {url} to launch it on a simulator or device. We support tracks (like beta or nightly), QR codes, and automatic PR comments with preview links.
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.
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.
From CLI dependency to standalone plugin
With Xcode, all Tuist functionality flows through the CLI. tuist auth login handles the OAuth flow, stores credentials, and every command from tuist inspect to tuist setup uses those stored credentials. The CLI is the central orchestration point because there is no plugin system to embed this logic into.
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.
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.
Thanks to that, the plugin is nearly standalone. You add it to settings.gradle.kts, configure it, and we distribute it through the Gradle Plugin Portal, which is exactly where Gradle developers expect to find their tooling. You still need the Tuist CLI for the initial tuist auth login to authenticate via OAuth, but we are considering shipping an auth task directly in the plugin to make it fully standalone.
Looking back
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.
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.
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. Being in both worlds makes us better at each one.
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.
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.
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 Gradle plugin and our Xcode integration are both available today.
2 reactions
Mentions
Package.resolved の originHash は何のためにあるのか Renovate が依存を上げた PR をマージしたあと、tuist install を流すと Package.resolved の 1 行だけが書き換わるようになりました。 Package.resolved { - "originHash" : "b51d86e0d60dc9c07d7da15f1bb995c680b75f60f09f0ee586aea8f14852849d", - "originHash" : "9ea100c33d0673a7cbf638a61115d34ce142720e95d1761c390aa31ae6ec796b", "pins" : [ この originHash が何のための値なのかを、SwiftPM のソースを読み、実際に計測して確かめました。分かったことは次の 4 つです。 originHash は「この Package.resolved がどの Package.swift に対して作られたか」の目印で、改ざん検知の値ではない 一致していると、SwiftPM は依存のバージョンを選び直す計算を省ける。差が出るのは checkout が足りないとき(clone 直後や .build をキャッシュしない CI)で、今回の環境(pin 60 個)では約 20 秒だった SwiftPM 自身は、pin が変わらない限り originHash を書き直さない。だから古いまま残りやすい 実際に解決しないまま originHash だけを今の値にするのは危ない。Tuist は一致した hash を信じて推移的な依存の要求を確かめず、要求を満たさない pin をそのまま使う(再現して確かめた) 後半では、依存を自動で上げる Renovate と Dependabot で、この値の扱いがどう違うかも比べます。調べる途中で見立てを 3 回間違えたので、その話も最後に書きます。 ! SwiftPM のコードは 2026 年 10 月時点の main(7137197)、計測は macOS・Xcode 27.2(Swift 6.4.2)で行いました。 originHash はルートの manifest の SHA-256 originHash は、Package.resolved の書式のバージョン 3 で入った値です。SwiftPM は、ルートの Package.swift の tools-version が 5.10 以上のときにこの書式で書き出します。 中身は、SwiftPM の computeResolvedFileOriginHash を読むと分かります。 Sources/Workspace/Workspace+Dependencies.swift private func computeResolvedFileOriginHash(root: PackageGraphRootInput) throws -> String { var content = try root.packages.reduce(into: "") { partial, element in let path = try ManifestLoader.findManifest( packagePath: element, fileSystem: self.fileSystem, currentToolsVersion: self.currentToolsVersion ) try partial.append(self.fileSystem.readFileContents(path)) } content += root.dependencies.reduce(into: "") { partial, element in partial += element.locationString } return content.sha256Checksum } ルートパッケージの manifest のファイル内容をそのままつなげ、ルートに追加の依存があればその URL を足して、SHA-256 をとっています。ふつうのパッケージでは追加の依存は無いので、Package.swift のファイル内容の SHA-256 そのものになります。手元でも確かめられます。 $ grep originHash Package.resolved "originHash" : "9ea100c33d0673a7cbf638a61115d34ce142720e95d1761c390aa31ae6ec796b", $ shasum -a 256 Package.swift 9ea100c33d0673a7cbf638a61115d34ce142720e95d1761c390aa31ae6ec796b Package.swift ここから 3 つのことが言えます。 pin(各依存のバージョンとコミット)は計算に入っていない。Package.resolved の中身が書き換えられても、originHash では気づけない コメントを 1 文字足しただけでも値が変わる Package@swift-6.0.swift のような Swift のバージョンごとの manifest があると、使っている toolchain が選んだファイルの hash になる 一致すると、SwiftPM はバージョンの選び直しを省く originHash を入れた SwiftPM の PR(swiftlang/swift-package-manager#6698、2023 年 8 月)には、狙いがこう書かれています。 when a package.resolved file exists the dependencies resolution should stick to it, unless requested otherwise 実装では、swift package resolve やビルドが使う標準の解決(コード上は .bestEffort)の中で、この hash を比べて分岐しています(抜粋して簡略化しています)。 let currentHash = try self.computeResolvedFileOriginHash(root: root) guard storedHash == currentHash else { // 'Package.resolved' origin hash does do not match manifest dependencies. resolving and updating accordingly return try await resolveAndUpdateResolvedFile(forceResolution: false) } // 'Package.resolved' origin hash matches manifest dependencies, attempting resolution based on this file let (manifests, precomputationResult) = try await self.tryResolveBasedOnResolvedVersionsFile(...) コメントにしたメッセージは、--very-verbose を付けると実際にログに出ます(「does do not」は原文のままです)。 originHash 通る経路 やること 一致 tryResolveBasedOnResolvedVersionsFile pin のコミットで checkout し、pin が今の要求を満たすかを確かめる 不一致 resolveAndUpdateResolvedFile 足りない checkout があれば resolver を走らせる(今の pin を優先して選ぶ)。揃っていれば pin が要求を満たすかを確かめるだけ どちらの経路でも、pin が今の要求を満たすかは確かめられ(precomputeResolution か resolver)、満たさなければ解決し直します。今の pin が要求を満たしていれば、どちらの経路でもその pin がそのまま使われます。違うのは、そこにたどり着くまでの手間です。後で書く計測でも、hash が一致していてもずれていても、最終的な pin は同じでした。 なお、Package.resolved を正として扱う --force-resolved-versions と、swift package update は originHash を読みません。update は値を計算して書き込むだけです。 差が出るのは checkout が足りないとき 手間の違いがどれくらいかを、仕事で開発しているアプリの依存で測りました。Package.swift に直接書いている依存が 25 個、推移的な依存も含めた pin が 60 個あり、すべて git のリポジトリです。 まず、.build に checkout がある状態です。5 回ずつ流しました。 originHash swift package resolve 一致 0.46〜0.51 秒 古い 0.43〜0.51 秒 差はありませんでした。前の節の分岐のとおり、checkout が揃っていれば、hash がずれていても resolver は走らないからです。checkout が揃った手元の開発では、originHash が古くても遅くなりません。 次に、.build を空にした状態です。git のリポジトリは SwiftPM のキャッシュ(--cache-path)に入っているので、clone の時間はほぼ含みません。順番を入れ替えて 2 回ずつ流しました。 originHash 1 巡目 2 巡目 ログの「Computing version for」 一致 66 秒 29 秒 0 件 古い 87 秒 53 秒 60 件 一致していると、SwiftPM は pin のコミットでそのまま作業コピーを作ります。古いと resolver が走り、60 パッケージすべてについて、git のタグの一覧と、候補になるバージョンの Package.swift を読みます。ログの「Computing version for」がその作業で、これが 2 回とも約 20 秒の差になりました。clone 直後の手元や、.build をキャッシュしない CI は、毎回この分だけ遅くなります。 Tuist ではもっと差が開きます。Tuist の依存解決(SwifterPM)は、hash が一致すると swift package resolve を起動せず、自前のキャッシュから戻すだけで済ませるからです。.build が空の状態で 1 回ずつ測ると、一致で 7.4 秒、古いと 134 秒でした。 ! ネットワークから clone し直す場合は測っていません。その場合は clone の時間が大きく、差の割合は小さくなると考えています。 計測の手順と条件 コマンドは swift package --package-path <dir> --scratch-path <dir>/.build --cache-path <cache> --disable-scm-to-registry-transformation resolve で、時間は /usr/bin/time -p で測りました。 hash を一致させるときは、Package.resolved の originHash を shasum -a 256 Package.swift の値に書き換えました。古い hash は、Package.swift を変える前の値です。pin はどちらも同じです。 checkout がある状態は、同じ .build で続けて 5 回ずつ流しました。 checkout が無い状態は、毎回新しいディレクトリを作り、--scratch-path を空にしました。--cache-path は共通なので、git のリポジトリはキャッシュから取ります。1 巡目は一致→古い、2 巡目は古い→一致の順です。 Tuist の数字は、Tuist の依存解決部分を swifterpm コマンドとしてビルドし、空の --scratch-path で 1 回ずつ測りました。 回数が少なく、同じ条件でも 29 秒と 66 秒のように大きくぶれています。絶対値はマシンや負荷で変わるので、2 巡とも約 20 秒の差が出た、という点だけを読み取ってください。 SwiftPM は pin が変わらないと originHash を書き直さない これだけ効く値なのに、古いまま残りやすい理由は、SwiftPM の保存処理にあります。saveResolvedFile は、保存済みの pin と今の pin を比べて、同じなら何もせずに抜けます(抜粋です。比べている部分は日本語のコメントに置き換えました)。 Sources/Workspace/Workspace+ResolvedPackages.swift // try to load the Package.resolved store from disk so we can compare for any changes // this is needed as we want to avoid re-writing the resolved files unless absolutely necessary var needsUpdate = false if let persistedResolvedPackagesStore = try? self.resolvedPackagesStore.load() { // pin の数と、各 pin の状態を比べる(originHash は比べない) } // exist early is there is nothing to do if !needsUpdate { return } 比べているのは pin だけなので、Package.swift を変えても pin が変わらなければ、originHash は前の値のまま残ります。小さなパッケージで再現できます。 mkdir -p repro/Sources/Repro && cd repro cat > Package.swift <<'EOF' // swift-tools-version: 6.0 import PackageDescription let package = Package( name: "Repro", dependencies: [ .package(url: "apple/swift-algorithms", from: "1.1.0"), ], targets: [ .target(name: "Repro", dependencies: [.product(name: "Algorithms", package: "swift-algorithms")]), ] ) EOF echo 'import Algorithms' > Sources/Repro/Repro.swift swift package resolve grep originHash Package.resolved # shasum -a 256 Package.swift と同じ値 要求だけを変える(今の pin 1.2.1 はどちらでも満たす) sed -i '' 's/from: "1.1.0"/from: "1.2.0"/' Package.swift swift package resolve --very-verbose 2>&1 | grep "origin hash" debug: 'Package.resolved' origin hash does do not match manifest dependencies. resolving and updating accordingly grep originHash Package.resolved # 前の値のまま。何回流しても変わらない swift package update でも、pin が変わらなければ書き直しません。同じ不満は SwiftPM の Issue(#7644「V3 originHash should update more eagerly」、2024 年 6 月)にも出ていて、まだ直っていません。 依存を自動で上げるツールを使っていると、この状態にすぐなります。Renovate の Swift 対応は、Package.swift の要求を上げるときに Package.resolved の該当する pin も書き換えます。ただし originHash には触りません。このことは renovatebot/renovate#41534 に既知の制約として書かれています。マージした後に SwiftPM が解決しても、pin はすでに新しいので、originHash は古いまま残ります。 Dependabot は事情が違います。Dependabot の Swift 対応は、書き換えた Package.swift を作業用のディレクトリに置き、swift package resolve <依存> --version <バージョン> を実際に走らせて、その結果の Package.resolved を commit します(lockfile_updater.rb)。originHash も SwiftPM が計算するので、正しい値になります。以前 Dependabot を使っていたときの commit を見返すと、Package.swift を書き換えた PR でも、originHash は Package.swift の SHA-256 と一致していました。 冒頭の 1 行の diff は、ここに Tuist が絡んで出ていました。Tuist は tuist install で解決するたびに、自分で計算した originHash でファイルを書き直します。SwiftPM と違って値を最新に保つので、Renovate の PR の後に古い値が見つかると、書き直して diff を出すわけです。 解決しないまま originHash だけを直してはいけない この値がただの SHA-256 なら、Renovate が Package.swift を書き換えるついでに計算すればよさそうに見えます。私も最初はそう考えて、Renovate に提案しました(後で取り下げています)。ところが、これは Tuist を使っていると危ない直し方です。 Tuist の依存解決は、hash が一致すると swift package resolve を起動せずに pin をそのまま使います。この速い経路で確かめているのはミラーとローカルパッケージの条件だけで、推移的な依存の要求は確かめません。 一方、Renovate が書き換えるのは、上げた依存の pin だけです。たとえば GoogleSignIn-iOS を 10.0.0 に上げたとします。10.0.0 は AppAuth-iOS に from: "3.0.0" を要求しますが、Package.resolved には AppAuth-iOS 2.1.0 の pin が残ります。実際に私たちのリポジトリでも、この状態の PR がマージされていました。 この状態で originHash だけを今の値にするとどうなるかを、小さな Tuist のプロジェクトで試しました。GoogleSignIn-iOS を from: "9.2.0" で入れた状態から、Renovate と同じように Package.swift の要求と GoogleSignIn-iOS の pin だけを 10.0.0 に書き換えました。そのうえで、Tuist 4.212.0 の tuist install を流しています。 originHash tuist install checkout された AppAuth-iOS GTMAppAuth 古いまま 29 秒、成功 3.0.0 6.0.0 今の値に書き換え 3 秒、成功 2.1.0 5.0.0 古いままだと、Tuist は SwiftPM に解決を任せ、SwiftPM が AppAuth-iOS を 3.0.0 に直しました。今の値にしてあると、Tuist は速い経路で済ませました。checkout されたのは、GoogleSignIn-iOS 10.0.0 の要求(from: "3.0.0")を満たさない AppAuth-iOS 2.1.0 で、エラーも出ませんでした。古い originHash が、ちょうど「確かめ直せ」という合図になっていたわけです。 SwiftPM 自身は、どちらの経路でも pin を確かめます。同じ Package.resolved を素の swift package resolve で解決すると、hash が一致していても(ログは「origin hash matches」)、AppAuth-iOS は 3.0.0 に直りました。 再現の手順 Tuist.swift と、Tuist/Package.swift に .package(url: "google/GoogleSignIn-iOS", from: "9.2.0") だけを書いたプロジェクトで tuist install する。GoogleSignIn-iOS 9.2.0、AppAuth-iOS 2.1.0、GTMAppAuth 5.0.0 が pin される 別のプロジェクトで from: "10.0.0" にして tuist install し、10.0.0 系の依存をキャッシュに入れておく 1 の Package.resolved の GoogleSignIn-iOS の pin だけを、2 のもの(10.0.0)に書き換える。Package.swift は from: "10.0.0" にする originHash を 1 の値のまま残したものと、shasum -a 256 Tuist/Package.swift の値に書き換えたものを用意し、それぞれ空の Tuist/.build で tuist install する 結果の Package.resolved と Tuist/.build/workspace-state.json で、checkout されたバージョンを確かめる 同じ更新を Dependabot がすると、SwiftPM が解決するので AppAuth-iOS も 3.0.0 に上がり、originHash も正しい値になります。pin と originHash を一緒に、実際の解決結果で書き換えるので、この問題は起きません。 ほかにも、自動で上げる PR がそれぞれ originHash を書き換えると、どの PR も Package.resolved の 2 行目を別の値に変えることになります。そのため、1 本マージするたびに残りの PR が conflict します。 originHash を変えてよいのは、その Package.swift で実際に解決して、pin と一緒に書き込むときだけです。 どう付き合うか 使っている道具ごとに、現実的な扱い方が違います。 SwiftPM と Xcode だけを使っている場合は、古い originHash でも結果は変わらず、checkout が無いときに遅くなるだけです。ただ、今の SwiftPM には、pin を変えずに originHash だけを更新するコマンドがありません。Package.resolved を消して作り直せば hash は新しくなりますが、範囲内の新しいバージョンに pin が上がることがあります。 Tuist を使っている場合は、tuist install が書き直した 1 行の diff を、そのまま commit するのがよいと考えています。見た目だけの差分に見えますが、これを入れておくと、clone 直後や CI の install が速い経路に戻ります。 Renovate と Dependabot の違い 依存を自動で上げているなら、どちらのツールかで扱いが変わります。 Renovate Dependabot 方式 SwiftPM を動かさず、pin の文字列を書き換える Linux の上で SwiftPM を実際に動かす originHash 古いまま残る SwiftPM が計算した正しい値になる 推移的な依存の pin 取り残される SwiftPM が揃える Linux でだけ使う依存 入らない 入る PR 同士の conflict originHash の行ではぶつからない Package.swift を変える PR 同士が originHash の行でぶつかる ※ Package.swift を持つパッケージを更新する場合の比較です。Xcode プロジェクトの Package.resolved については後で書きます。 Dependabot の方が Package.resolved を正しく保てますが、代わりに Linux で解決した結果になります。Dependabot は Ubuntu の上の Swift 6.3.1 で解決します(Dockerfile)。Linux で解決するので、Apple のプラットフォームでは使わない依存まで pin に入ることがあります。 私たちのアプリでは OpenCombine がそうでした。依存している combine-schedulers は、Darwin 以外でだけ OpenCombine を使う trait を既定で有効にしています(Package@swift-6.1.swift)。macOS で解決すると OpenCombine は pin に入りませんが、Dependabot の PR には入ってきて、手で消していました。この食い違いを避けるなら、使わない OpenCombine をあえてルートの依存に書いておく手もあります。macOS で解決しても pin に入るので、Linux の結果と揃います(target を持たない manifest で確かめました)。 Dependabot の Swift も、手元の Xcode より古くなりがちです。依存に Package@swift-6.4.swift のような新しい Swift 向けの manifest が増えると、Linux と手元で読むファイルが変わり、結果がずれる可能性があります。 なお、Package.swift を持たない Xcode プロジェクトの Package.resolved を書き換える経路(xcode_lockfile_updater.rb)は、SwiftPM を動かさず、originHash を扱うコードもありません。こちらは Renovate と同じく、pin だけを書き換えます。 どちらのツールでも、いちばんきれいなのは、その PR の中で macOS で実際に解決して、結果を commit することです。たとえば Renovate のブランチで macOS の CI に tuist install を流させ、書き換わった Package.resolved を bot が PR に commit・push します。こうすると originHash だけでなく、取り残された推移的な依存の pin も一緒に直り、Linux でだけ入る依存も混ざりません。ただし swift package resolve は pin が変わらなければ originHash を書き直さないので、Tuist を使わない場合は、この方法でも hash が古いまま残ることがあります。 SwiftPM 側の修正 根本を直すため、SwiftPM に PR(swiftlang/swift-package-manager#10718)を出しました。pin が要求を満たすと確かめた後で、originHash がずれていたら hash だけを書き直す変更です。入れば Xcode でも originHash が最新に保たれるようになります。ただし代わりに、Package.swift を変えた後に 1 行の diff が出るようになり、Package.swift を触ったブランチ同士はこの行でぶつかりやすくなります。checkout が無いときの解決の速さと conflict のどちらを取るか、というトレードオフです。 見立てを 3 回間違えた話 最初は「Tuist が余計な diff を出している」と思いました。調べると、Tuist の値は SwiftPM の定義どおりの SHA-256 で、古い値を残していたのは Renovate の側でした。 次に「Renovate が originHash を計算し直せばいい」と考えました。これは前の節のとおり、Tuist では壊れた pin を通してしまうので取り下げました。 最後に「SwiftPM と Xcode では古い originHash でも遅くならない。書き直して diff を出す Tuist の方を SwiftPM に合わせればいい」と考えて、Tuist に PR(tuist/tuist#14475)を出しました。根拠は checkout がある状態の計測だけでした。checkout が無い状態を測ってみると、Tuist の書き直しこそが速さを保っていたと分かり、PR は閉じました。 近道が効くのは、ふだんの手元ではなく clone 直後や CI です。性能を確かめるときは、効いてほしい状態で測るべきだった、というのがいちばんの反省です。 originHash は「近道を使ってよいか」の目印 originHash は、Package.resolved が今の Package.swift 用かどうかを SwiftPM に伝える目印です。値が正しければ、clone 直後や CI で依存のバージョンの選び直しを省けます。ただ、SwiftPM 自身は pin が変わるまで更新しないので、古くなりがちです(#7644)。 古い値に気づいたら、実際に解決した結果と一緒に commit する。解決しないまま値だけを書き換えない。この 2 つを守れば、diff に紛れ込むこの 1 行が、クリーンな環境での解決を速くしてくれます。依存を自動で上げるなら、Dependabot は実際に解決して書き換えるので前者に当たります。ただし Linux で解決した結果になる点には気をつけてください。
Swift Buildで.swift-version 6.2とかにしてるだいたいの理由がわかった。swiftpm側でCIが過去を引き継ぎ6.2でもテストしててそれにSwift Buildも何となく下げられてる。ただswiftpm側はCIでテストしてるだけでそれは成り行きであり実際.swift-versionは6.4に上がっている swift-package-manager#10017
Xcode and Gradle: Two worlds, one infrastructure 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. 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. 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. The plugin model, or lack thereof Gradle 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 BuildCacheService. Want to observe every task as it finishes? Register an OperationCompletionListener. Want test results as they happen? Add a TestListener. The contracts are well-defined, documented, and stable. Xcode is Apple's IDE for iOS, macOS, and the rest of Apple's platforms. The underlying build system is Swift Build, and xcodebuild 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 SwiftPM, 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. There are no extension points in between. This single architectural difference shaped everything about how we built our integrations. With Gradle, we wrote a plugin in Kotlin that hooks directly into the build lifecycle. With Xcode, we had to get creative. We built a CLI that wraps xcodebuild (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. Build caching 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. For most of Xcode's history, there was no concept of remote build caching. So we built one ourselves. In the early days of Tuist, we invested heavily in project generation. For context: Xcode projects are stored as .xcodeproj 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 Carthage (a dependency manager for Apple platforms that builds frameworks from source), we built module-level binary caching on top of it. You can see how this looks in practice on our own project's cache dashboard. 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. Then Apple introduced compilation caching in Xcode 26 using LLVM'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. So that is what we did. tuist setup cache creates a LaunchAgent daemon (a macOS background process managed by the system) that listens on a local socket. You pass the socket path to xcodebuild 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. 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 gradle.properties, 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. 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 BuildCacheService 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 open source 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 our Android project's cache dashboard. tuist { buildCache { push = System.getenv("CI") != null } } 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. > 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. Build insights 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. But you can only improve what you can measure, and most teams have no visibility into how their project interacts with their build system. Build insights exist to change that. Xcode gives you no structured way to observe a build as it happens. Instead, after a build finishes, Xcode writes an .xcactivitylog 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 .gradle and build directories. The activity log is a binary format that contains timing information, errors, warnings, and file-level compilation data. To extract build insights, we parse this file after the build using tuist inspect build, 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 our own project's build dashboard. The parsing itself is a pain. The activity log format is not publicly documented. Projects like XCLogParser 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. There is also a race condition that caught us off guard. Scheme post-actions run after the build finishes, but you cannot assume the .xcactivitylog 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. Gradle makes this dramatically simpler. When we wanted to give teams visibility into their Gradle builds (here is our Android project's build dashboard), 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. // Gradle calls us. We just collect. override fun finished(buildOperation: BuildOperationDescriptor, finishEvent: OperationFinishEvent) { // Record task outcome, duration, cache behavior } The data is clean because Gradle gives it to us in a structured form. We know exactly what happened to each task and why. No binary parsing, no race conditions, no reverse engineering. Test insights, flaky tests, and quarantine 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. Test insights, flaky test detection, and quarantine exist to give teams the data they need to manage this, and to automate the parts that should not require human attention. With Xcode, test results live in .xcresult 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 tuist inspect test, extract the structured data, and upload it. You can see how this looks on our project's test dashboard. 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. 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 xcodebuild's -skip-testing flag. When you run tuist test, we fetch the list of quarantined tests from our server and pass each one as a -skip-testing argument. If you use xcodebuild directly, you can get the formatted arguments with tuist test case list --skip-testing. It works, but it means quarantine is tied to the command-line invocation rather than being embedded in the build system itself. In Gradle, we register a TestListener 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 our Android project's test dashboard. If teams use the test-retry plugin 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 excludeTestsMatching filter. The test never runs. No file manipulation, no source control concerns, just a programmatic exclusion at runtime. tuist { testQuarantine { enabled = true } } 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. Bundle analysis and previews Not every feature requires deep integration with the build system. Bundle analysis and previews 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. 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 .ipa (the archive format for iOS apps), .xcarchive, and .app bundles. You can see this on our project's bundle dashboard. With Gradle, we analyze .aab (Android App Bundle, the format Google Play uses to generate device-specific APKs) and .apk files. Here is our Android project's bundle dashboard. In both cases, tuist inspect bundle breaks down the contents, tracks changes over time, and comments on pull requests with size deltas so the team catches regressions before they ship. Previews solve a different problem: getting a build into someone's hands without a full release pipeline. On Apple's side, the alternative is TestFlight, 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 tuist share after building, we upload the artifact and give you a link. Anyone with access can run tuist run {url} to launch it on a simulator or device. We support tracks (like beta or nightly), QR codes, and automatic PR comments with preview links. 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. 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. From CLI dependency to standalone plugin With Xcode, all Tuist functionality flows through the CLI. tuist auth login handles the OAuth flow, stores credentials, and every command from tuist inspect to tuist setup uses those stored credentials. The CLI is the central orchestration point because there is no plugin system to embed this logic into. 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. 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. Thanks to that, the plugin is nearly standalone. You add it to settings.gradle.kts, configure it, and we distribute it through the Gradle Plugin Portal, which is exactly where Gradle developers expect to find their tooling. You still need the Tuist CLI for the initial tuist auth login to authenticate via OAuth, but we are considering shipping an auth task directly in the plugin to make it fully standalone. Looking back 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. 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. 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. Being in both worlds makes us better at each one. 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. 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. 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 Gradle plugin and our Xcode integration are both available today.