Expanding Tuist's Cache Infrastructure Beyond Build Systems

We’ve invested in building cache infrastructure designed so teams can optimize latency by bringing it closer to their compute. The infrastructure was purpose-built for one use case: optimizing the compilation of build systems by reusing artifacts from previous builds. That said, the primitives we’ve created are broadly useful, and it’s worth asking whether we should extend the interface so developers can plug into it from other contexts.

Below are some ideas, in no particular order, that we might want to explore.


Caching Through Decoration

The idea of reusing artifacts from previous workflows is not exclusive to build systems. Other use cases, such as dependency resolution, could benefit from the same optimization, ideally with a solution that works consistently across environments.

Drawing inspiration from Mise’s pattern of adding annotations to scripts (for example, to declare a CLI and get arguments parsed and validated), we can bring caching capabilities to scripted workflows. Take the following script to resolve dependencies:

#!/usr/bin/env tuist exec bash
#CACHE input "Package.resolved" hash=content
#CACHE output ".build/"

tuist install

Using a shebang and structured comments, developers can bring caching to their existing scripts. In the example above, we hash the input and resolve the output artifacts through our cache infrastructure. This works for any scriptable runtime. For example, in Ruby:

#!/usr/bin/env tuist exec ruby
#CACHE input "Package.resolved" hash=content
#CACHE output ".build/"

sh("tuist install")

GitHub Actions has a similar concept with its cache actions, but it is tightly coupled to that platform, making it difficult to reuse artifacts in other environments. Nx also has comparable capabilities, but it requires users to declare an automation graph using its DSL. Bringing caching closer to scripts through decoration is a smoother integration: it requires only a few comments in existing scripts and perhaps some light refactoring to make those scripts more atomic.

Portability. The portability of this approach depends on how much business logic is baked into the script. A script that just runs tuist install is highly portable; one that encodes project-specific paths, environment assumptions, or conditional logic is not. Portability is a property of the script, not of the mechanism. That said, the decoration interface at least makes the caching intent explicit and environment-agnostic, which is an improvement over solutions that hard-code caching logic inside CI platform configuration.

Mise integration. Mise has tasks that activate the right tools and add features like argument parsing via usage. Our solution would complement this nicely: adding tuist as a Mise dependency and making a few script changes is all that’s needed to unlock caching superpowers for projects already using Mise. This is a strong basis for a marketing message, since many projects use Mise tasks today.


Caching Through a Shell-Based Runtime API

Decoration solves a lot of use cases, but some scripts need access to caching primitives at runtime, for example to implement control flow based on whether a cached result exists. For this, we can expose an interface through the CLI so any script can shell out to it and parse the exit code to drive logic.

# Key-value store operations
tuist cas keys list                     # list recent key-value mappings
tuist cas keys get <key>                # look up what a key resolves to
tuist cas keys set <key> <value>        # create/update a mapping
tuist cas keys delete <key>             # remove a mapping

$ tuist cas keys get a3f9c1e
  Key:     a3f9c1e8b2d4f6a8
  Value:   d8b2f1a4c6e8b0d2
  Created: 2026-03-09 13:45:00

# Artifact (blob) operations
tuist cas artifacts get <hash>              # inspect metadata
tuist cas artifacts download <hash> <path>  # download blob to a local path
tuist cas artifacts push <path>             # upload content, returns hash
tuist cas artifacts delete <hash>           # remove
tuist cas artifacts list                    # list stored artifacts

$ tuist cas artifacts get d8b2f1a
  Hash:     d8b2f1a4c6e8b0d2
  Size:     12.4 MB
  Type:     xcframework
  Name:     TuistKit
  Stored:   2026-03-09 13:45:00

Here is a concrete example: building DocC documentation for a large target can take over 10 minutes. With this API, we can turn that CPU-bound operation into a network round-trip when the inputs haven’t changed:

#!/bin/bash

INPUT_HASH=$(cat $(find Sources/TuistKit -name "*.swift" | sort) Sources/TuistKit/TuistKit.docc/**/* | shasum | cut -d' ' -f1)

RESULT=$(tuist cas keys get "$INPUT_HASH" --json 2>/dev/null)

if [ $? -eq 0 ]; then
  ARTIFACT_HASH=$(echo "$RESULT" | jq -r '.value')
  tuist cas artifacts download "$ARTIFACT_HASH" docs.doccarchive.tar.gz
  tar xzf docs.doccarchive.tar.gz
  rm docs.doccarchive.tar.gz
  echo "Restored docs from cache."
else
  xcodebuild docbuild \
    -workspace Tuist.xcworkspace \
    -scheme TuistKit \
    -derivedDataPath .build/

  tar czf docs.doccarchive.tar.gz .build/Build/Products/Debug/TuistKit.doccarchive
  ARTIFACT_HASH=$(tuist cas artifacts push docs.doccarchive.tar.gz --json | jq -r '.hash')
  tuist cas keys set "$INPUT_HASH" "$ARTIFACT_HASH"
  rm docs.doccarchive.tar.gz
  echo "Docs built and cached."
fi

A less obvious but important benefit of this API is that it lets teams decouple skip logic from their CI pipelines. Today, a lot of “skip this job if these files haven’t changed” logic is implemented using platform-specific features: GitHub Actions path filters, custom hash-and-compare steps, or bespoke shell scripts embedded in YAML. That logic is hard to test, hard to reuse across pipelines, and completely lost when a team switches CI providers. With a runtime cache API, the same skip logic can live in a plain script that runs identically on any machine. The optimization is no longer a CI concern; it becomes part of the workflow itself.


Caching Through Native Bindings

This is a longer-term direction. Once the shell-based API has matured, we can consider providing native bindings for popular runtimes so teams can build tighter integrations without needing system processes or a separately installed CLI. The DocC example above would become something like this with Node.js bindings:

import { cas } from "@tuist/cas";
import { hash } from "@tuist/cas/hash";
import { exec } from "node:child_process";

const inputHash = await hash.files([
  "Sources/TuistKit/**/*.swift",
  "Sources/TuistKit/TuistKit.docc/**/*",
]);

const entry = await cas.keys.get(inputHash);

if (entry) {
  await cas.artifacts.download(entry.value, ".build/TuistKit.doccarchive", {
    extract: true,
  });
  console.log("Restored docs from cache.");
} else {
  exec("xcodebuild docbuild -workspace Tuist.xcworkspace -scheme TuistKit -derivedDataPath .build/");

  const { hash } = await cas.artifacts.push(".build/Build/Products/Debug/TuistKit.doccarchive");
  await cas.keys.set(inputHash, hash);
  console.log("Docs built and cached.");
}

Additional Considerations

Telemetry and Observability

If we expand caching beyond build systems, we need to invest in telemetry and UI to match. Teams will need to observe how the cache is being used, purge entries, and understand how usage distributes across different scripts and workflows. The interface changes are only part of the investment; the observability layer needs to keep pace.

A Narrow Waist for Build Infrastructure

Rather than positioning this purely as a Tuist feature, we could frame it as an infrastructure-agnostic interface between projects and their cache backends. This approach has strong precedent: OpenTelemetry, Prometheus, and Kubernetes all succeeded by defining standards that allowed users to choose their own providers.

Concretely, we could influence tools like Mise to treat caching as a first-class concept in their task runner, with Tuist as one of several pluggable backends. This would make Mise a meaningful go-to-market channel for us while benefiting the broader ecosystem. It does require giving up some branding control, but the reach and credibility that comes with being a standards-aligned provider could more than compensate.

This is great and the use-case I’d start with since it’s such a common need across teams.

Should input and output be renamed to inputs and outputs? The API should likely take in a variable list of inputs and outputs.

What does the hash=content signal?

Additionally, this will need authentication against the Tuist server. I assume tuist CLI will be required to be installed (at least until this is a concept in Mise) and users would need to have a Tuist project? Since this is meant to be agnostic, integrating this into our dashboard where you need to pick between Gradle or Xcode build systems might be a bit awkward.

Being said, I think for the first iteration, this coupling is fine. If we see traction, we can consider how we can better decouple the cache (and the related analytics, etc.) from the existing projects dashboards.

I’d update the examples to not only model static inputs like Package.resolved, but also dynamic ones, like tuist version or swift --version. The API might be somewhat different in those cases but I think it’s important to piece we shouldn’t gloss over.

Do we actually benefit from the two-tiered approach of key values and artifacts? I wonder if we should have just a single tier with:

  • key being based on the inputs
  • and the value would be a zip of all the outputs

or do you think that parts of the output will be heavily reused across scripts?

If a .build already exists and you re-run a script that causes a new version of .build directory to be downloaded, how do you reconcile the changes between the two?

Yes, I think it would be beneficial in having this as a Mise-first concept. In the end, our primary proposition is to serving the artifacts close to you, not necessarily being the only provider that happens to have this.

So, in general, very aligned, just a couple of quirks to iron out before we go ahead and start implementing this :slightly_smiling_face:

Yeah, we can rename it to inputs and outputs. Note that they are cumulative, so you can have multiple lines:

# Maybe not the best example for two lines, but you get the idea :)
#CACHE inputs ["Package.resolved"] hash=content
#CACHE inputs ["Package.swift"] hash=content

I placed it as an interface for developers to configure how they’d like to hash the files (e.g. mtime, size) for cases where developers prefer to trade reliability for speed, but we can start with content as the only supported/default, and only add a different method if we are xplicitly asked for.

That’s correct. We can default to tuist.toml as the source of truth for the configuration and give them the option to override at the script level:

#CACHE project "tuist/automation"

If we want to evolve this into a layer (e.g., like MCPs sit between LLMs/agents and the outside world), we might want to iterate on the concept of “project,” since it’s maybe too tied to Tuist. At the end of the day, the project is a piece of information to scope the data & artifacts on our side (we are the ones calling it a project), so we can come up with an interface that makes that concept more generic.

On the auth side, the good thing is that there’s OAuth2, so the authentication flow could be similar to the one that LLM clients follow (i.e. OAuth2 with dynamic client registration).

#FABRIK input files ["Package.resolved"]
#FABRIK input files ["src/**/*.ts"]

# From command output
#FABRIK input command "swift --version"
#FABRIK input command "tuist version"

# From the execution environment
#FABRIK input platform arch
#FABRIK input platform os
#FABRIK input platform os-version

I think what we are indeed generalizing in this RFC is not just our ability to store binaries and serve them with low latency, but also our ability to store key-value pairs. So it’d be more appropriate to extend the title to say that we are “expanding the selection beyond test selection and expanding caching beyond build system caching”.

With that in mind, we have two types of data “key-value pairs” and “key-large-binary pairs”. While we could persist the former in the latter’s storage, I think it makes sense to stick to our model of using ClickHouse for the former and volumes & object storage for the latter.

Nx does a full replacement and Turborepo merges files when globs like build/** are used. I suggest that we start with a full replacement and iterate from there.

The Cache Client Protocol (CCP) :laughing:

Just noted that Bazel calls the key-value piece “action cache”.

Hey, new stuff for the stuff I built!

I think in general having a generalized cache thing, since the nodes already don’t care what it is (to an extent, we still have separate endpoints) on the domain layer.

One thing I’d like to raise is: what’s the business plan for this? I’m already ultra struggling with coming up with a reasonable strategy for the Xcode cache, and if we do something like this it needs

  • authentication like Marek already said
  • maybe some changes to how projects work, or allow cache artifacts that are not attached to a project but an account
  • a billing plan

I think on the implementation side all of the ideas are super simple, in the end it’s just using the existing upload and download logic and event pipeline that we already use; just on the business side I’d like us to preferably have a plan first before launching to not get into the same “what do we do now” situation like with Xcode CAS.

Thanks for the review @cschmatzler

Something like this will likely be scoped to an account instead of a project, since those are bound to a build toolchain/system. We’d definitively need to include it in the billing plan and put some mechanisms in place to prevent an Xcode CAS situation.

I’d suggest that we go with the right limits in place so that we keep the costs under control, offer it for free and understand from our users the value that we are bringing to them, and then put a price on it that feels reasonable to the value that they are getting.

One other piece I’d add here is that this directly relates to our goal of implementing runners. Instead of coming up with a yet-another GH Action, like namespace does for their volumes, this would enable us to have a caching not tied to a specific environment and making the caching more easily translatable between providers. We would probably still to introduce a similar action for teams not using mise, but the majority of teams that we currently work with do use mise, so having a cache integration for mise tasks could really feel nice.

As for pricing, we can start with offering this for free as we did with other features like Gradle remote cache, and then coming up with pricing. Here, whatever pricing we’d land on should imho directly correlate to the infra costs (as opposed to the Xcode Cache where the value we provide does not correlate with the infra costs, here, I’d argue it does).

This will also require quite some changes in the dashboard as it would be an account-level feature that teams need to be able to track, especially once this would become a paid feature.

I’m not opposed to playing with this on the side, but imho this functionality primarily becomes interesting when we have runners, so not sure if I’d prioritize this fully tackling this (including design, billing, etc.) before we progress with that feature.

We can start with compute, and then tackle this after. The good thing is that we have figured out already some pieces that will be needed for this:

  • A granular authorization model and the workflows to generate and scope sessions to a set of permissions.
  • The authentication workflows and the session management such that we don’t need to tell users to generate a token and paste it somewhere in their system :man_facepalming:
  • Users accostumed to have the CLI in their systems.