# CI Cache Guide For External Repos

This is a usage guide for anyone wiring `zackees/setup-soldr@v0` into their own GitHub Actions CI. It explains what you get automatically, what the minimum config looks like, and how to confirm warm builds on feature branches are actually restoring from `main`.

If you want the background on why this repository wires its own workflows the way it does, skip to [Why This Repo Uses This Model](#why-this-repo-uses-this-model) at the bottom.

## TL;DR

Add `zackees/setup-soldr@v0` to a normal `push`-triggered workflow:

```yaml
- uses: actions/checkout@v4
- uses: zackees/setup-soldr@v0
- run: soldr cargo build
```

You get, for free:

- branch-agnostic cache keys the action produces on its own
- automatic restore on feature branches from the latest `main` cache on a miss
- no separate `actions/cache` step; the action already runs the setup-state cache internally and also restores and saves the Soldr-owned zccache cache root and the zccache-owned Rust artifact plan cache by default
- `cache-hit`, `build-cache-hit`, and `target-cache-hit` outputs you can read to confirm warm vs cold runs

The rest of this document explains how and why that works.

## How GitHub Actions Cache Scoping Actually Works

A workflow run in GitHub Actions can restore caches from a limited set of scopes, and **not from arbitrary sibling branches**. For any given run, GitHub will consider caches in this order:

1. The run's own branch
2. For `pull_request` events, the PR base branch
3. The repository's default branch (usually `main`)

That means two feature branches cannot share a cache entry directly. The only way to get a shared lineage is to treat the default branch as a shared parent: `main` writes caches, feature branches read them on miss.

Authoritative reference: [Caching dependencies to speed up workflows](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/cache-dependencies).

Two consequences of that scoping rule matter for soldr:

- **`main` is the canonical warm source.** Keep `main` passing so it refreshes its cache entries on every push. A broken `main` pipeline means cold feature-branch builds.
- **Saves are own-branch only.** A run on `feature/x` cannot write into `main`'s cache scope, and it cannot write into `feature/y`'s cache scope. It saves into its own branch scope, and later runs on that same branch restore it first.

## What setup-soldr Does For You Automatically

The `zackees/setup-soldr@v0` action (generated from [`action.yml`](../action.yml)) runs internal cache steps keyed so that the parent-to-child restore works correctly without you configuring anything:

- **Branch-agnostic state-cache keys.** The setup-state cache key is derived from runner OS, runner architecture, the resolved Rust toolchain channel, and the requested soldr version. No branch name is in the key. Two branches with the same toolchain pin produce the same key, so a cache written by `main` is a valid candidate for a run on any feature branch.
- **Restore-keys prefix for partial-match fallback.** The action registers a restore prefix (`setup-soldr-v0-{os}-{arch}-`) so that even if a future toolchain bump changes the exact key, GitHub can still fall back to the most recent compatible cache for the same OS and architecture.
- **Push-only save semantics come for free.** GitHub's cache scoping already prevents feature-branch runs from overwriting `main`'s cache. You do not need to gate `save-if` yourself the way internal Rust caching wrappers usually make you do.
- **Rehydrated state.** On a cache hit, the action restores the soldr root, `CARGO_HOME`, and `RUSTUP_HOME` under the runner-local cache/state root. The resolved Rust toolchain and the `soldr` binary are then provisioned on top of whatever was restored.
- **Build-artifact cache enabled by default.** The action also restores the Soldr-owned zccache cache root with a toolchain-scoped key and saves it at end-of-job, so zccache compilation artifacts survive across runs unless you opt out with `build-cache: false`.
- **Thin Rust artifact cache enabled by default.** The action restores a zccache-owned Rust artifact plan cache when a `Cargo.lock` is present. `soldr cargo ...` generates a `thin` plan by default and asks zccache to restore/save bounded dependency artifacts. It does not use an action-owned full `target/` snapshot unless the workflow explicitly sets `target-cache-mode: full`, which is still executed by zccache from the soldr-generated plan.

Release/LTO musl validation and daemon-failure diagnostics live in
[`docs/DATALAKE_RELEASE_MUSL.md`](DATALAKE_RELEASE_MUSL.md).

## Minimum Config For An External Repo

This is the complete workflow. Copy-paste into `.github/workflows/ci.yml` and adjust the job matrix if you need more than Linux:

```yaml
name: CI

on:
  push:
    branches: ['**']

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: zackees/setup-soldr@v0
        with:
          cache: true

      - run: soldr cargo build
      - run: soldr cargo test
```

That is enough. No separate `actions/cache` step, no `Swatinem/rust-cache`, no manual `save-if` gating. The action handles the cache internally with the key shapes described above.

A slightly fuller example that also demonstrates reading the action's outputs lives in [`examples/ci-minimal.yml`](../examples/ci-minimal.yml).

## Triggering On `push` Vs `pull_request`

Prefer `push` on all branches as your default trigger:

```yaml
on:
  push:
    branches: ['**']
```

Why:

- A `push` run on `feature/x` writes its cache into the `feature/x` scope. The next push on that branch restores it first. PRs from `feature/x` implicitly see those checks because they are tied to the branch head.
- A `pull_request` run builds the synthetic merge commit, writes into a PR-specific cache lineage, and duplicates the work that the branch push already did. You end up with two lineages competing for the same build time.
- Adding `pull_request` on top of `push` roughly doubles CI minutes and creates a second cache entry that does not help feature-branch restore from `main`.

Add `pull_request` only if you explicitly need CI on the PR merge commit (for example, a required merge-ref signal that a forked-branch push cannot produce). Most repos do not need this.

## Verifying The Cache Is Working

After two pushes to the same branch, you should be able to confirm the cache lineage is healthy.

1. **Check the `cache-hit`, `build-cache-hit`, and `target-cache-hit` outputs of the setup step.** Reference them from a later step like this:

   ```yaml
   - id: soldr
     uses: zackees/setup-soldr@v0
     with:
       cache: true
   - run: echo "cache-hit=${{ steps.soldr.outputs.cache-hit }}"
   - run: echo "build-cache-hit=${{ steps.soldr.outputs.build-cache-hit }}"
   - run: echo "target-cache-hit=${{ steps.soldr.outputs.target-cache-hit }}"
   ```

   `true` means the key matched exactly. `false` means either a fresh key (cold) or a restore-keys fallback match (partial). Both `false` cases show the same literal `false`; distinguish them using the raw log.

2. **Open the raw log of the setup step.** Expand the internal cache steps inside the composite action. You want to see either:
   - `Cache restored from key: setup-soldr-v0-...` for an exact setup-state cache hit, or
   - `Cache restored successfully` followed by a key that matches the restore prefix `setup-soldr-v0-{os}-{arch}-` for a partial setup-state restore.

   A line that says no cache was found at all, with no restore match, indicates a cold miss.

   For the build-artifact layer, inspect the `build-cache-restore` step. Its exact keys are `setup-soldr-buildcache-v1-{os}-{arch}-{toolchain-digest}-{github.sha}` and its restore-keys fall back first to the same toolchain lineage, then to any cache for the same OS and architecture.

   For the Rust artifact plan layer, inspect the `target-cache` step. Its default thin-cache keys are `setup-soldr-targetcache-thin-v2-{os}-{arch}-{target-inputs-hash}` and use exact restore only. The target-inputs hash includes the toolchain digest, `Cargo.lock`, workspace manifest hashes, Cargo config, target-dir shape, and relevant Rust flags.

3. **Compare wall-clock.** A warm feature-branch run should not rebuild the toolchain or re-download soldr. A warm build-artifact restore should also reduce downstream compile time once zccache has artifacts to reuse. If you see `rustup` installing, soldr downloading from GitHub Releases, or full recompiles on every run, one of the restore layers is not hitting and something below is wrong.

4. **Inspect zccache stats after the build.** Add a post-build status step when validating a new cache lineage:

   ```yaml
   - run: soldr cache
   ```

   The output includes the embedded zccache cache root and daemon-reported
   counters. For a healthy warm build, look for non-zero cached compilations
   or hit counts, plus the `zccache rust-plan restore/save` JSON summaries
   emitted by `soldr cargo ...`. If `build-cache-hit=true` but zccache still
   reports zero cached compilations, the build-artifact cache restored but did
   not produce compiler-cache reuse; check whether the target-cache layer also
   restored and whether Cargo invalidated fingerprints before zccache could
   hit.

## Debugging Target-Cache Restores That Still Rebuild

A restored Rust artifact plan cache is only a fast path when Cargo still considers the restored fingerprints fresh. Some crates have build scripts that do not declare narrow inputs with `cargo:rerun-if-changed=` or `cargo:rerun-if-env-changed=` lines. For those crates, Cargo can fall back to broad package/source fingerprint inputs. A fresh GitHub checkout may then have different source mtimes than the checkout that produced the restored artifacts, so Cargo rebuilds that package even though `target-cache-hit` is `true`.

Use Cargo's fingerprint diagnostics to confirm this failure mode:

```yaml
- name: Build with Cargo fingerprint diagnostics
  env:
    CARGO_LOG: cargo::core::compiler::fingerprint=info
  run: soldr cargo build
```

Look for lines like:

```text
fingerprint dirty for <crate> ... target="build-script-build"
dirty: PrecalculatedComponentsChanged { ... }
```

That means the cache restored correctly, but Cargo invalidated a build-script fingerprint before zccache had a chance to make the command a no-op. The right fix is usually in the crate that owns the build script: emit precise `cargo:rerun-if-changed=` and `cargo:rerun-if-env-changed=` lines for the real inputs. `setup-soldr` should not hide this by blindly normalizing source mtimes, because that can mask real source changes and make Cargo's invalidation model harder to reason about.

## Debugging Cold Misses

If feature branches keep rebuilding from scratch, check these in order:

- **Has `main` run successfully recently?** The restore fallback only works if the default branch has written a cache. If the main-branch pipeline is red or was never run on this workflow file, there is no parent to restore from. Fix `main` first.
- **Is `Cargo.lock` churning on every push?** Lockfile changes do not change the setup-soldr state-cache key, but they do invalidate the Rust artifact plan cache and can reduce downstream zccache reuse. Check whether your workflow keeps regenerating `Cargo.lock` (for example, because `Cargo.lock` is gitignored in an application repo where it should be committed).
- **Did `rust-toolchain.toml` change?** The resolved toolchain channel is part of both cache key families. Bumping the toolchain channel or the components/targets list invalidates every existing entry. That is expected behavior; the next push to `main` will write a fresh canonical entry.
- **Did you pass a `cache-key-suffix` input?** That value is appended to both cache key families (see `action.yml`). A different suffix on a feature branch produces a different key than `main` writes, and the restore will only succeed through the prefix fallback. Make sure the same suffix is used (or omitted) on every branch you want to share a lineage.
- **Mixed runner OS/arch.** Cache keys are scoped by runner OS and architecture. A cache written on `ubuntu-24.04` will not restore on `macos-15` and vice versa. Each combination needs its own warm lineage from `main`.
- **Did someone opt out of build caching?** If `build-cache: false` is set in the workflow, `build-cache-hit` will be empty and the Soldr-owned zccache cache root will not be restored or saved.
- **Did someone opt out of target caching?** If `target-cache: false` is set in the workflow, `target-cache-hit` will be empty and soldr will not ask zccache to restore or save Rust target artifacts.

---

## Why This Repo Uses This Model

The rest of this document is ancillary context about how this repository's own CI is wired. External consumers do not need any of this.

GitHub Actions caches are not shared across arbitrary sibling branches. A run can restore from:

- its own branch
- the default branch
- for pull requests, the PR base branch

So the right model is not "share caches between feature branches". The right model is:

1. `main` stays warm and acts as the shared parent lineage.
2. A feature branch restores from its own cache if it already has one.
3. Otherwise the feature branch restores from `main`.
4. A feature-branch push may then save a better branch-local cache for later runs of that same branch.

## How This Repo Is Wired

In [`.github/workflows/ci.yml`](../.github/workflows/ci.yml):

- `push` runs on `main` only. Feature branches are covered by the
  `pull_request` trigger instead — soldr#1985 narrowed the push trigger
  because `branches-ignore` matched every PR branch, so each push to an open
  PR ran the whole sweep twice.
- The heavy cache-producing CI workflow therefore *does* run on
  `pull_request`; that is the only way a feature branch gets coverage.
- `Swatinem/rust-cache` is used with `shared-key:` only — no `key:`,
  `prefix-key:`, or `save-if:` anywhere in the tree.

### Cache key scheme (soldr#1978 item 6)

Ordinary CI lanes are keyed on **(profile, target)**, not on the job:

```
shared-key: ws-<profile>-<target>
```

The point is that lanes compiling the same dependency graph, at the same
profile, for the same triple should share one namespace instead of each
owning a private one. `lint` and `build-linux-x64` are the case that matters:
both build ~700 dependencies at the dev profile for
`x86_64-unknown-linux-gnu`.

Two constraints make this less mechanical than it looks:

- **A shared key is worthless unless the sharers write the same directory.**
  Cargo puts a bare build in `target/debug` and a `--target`-qualified build
  in `target/<triple>/debug`. `lint` had no `--target`, so it shared nothing
  with `build-linux-x64` no matter what the key said. Both now pass
  `--target`, and `tests/test_ci_cache_key_scheme.py` pins the flag and the
  key together.
- **Performance and cache-strategy lanes stay off the scheme.**
  `perf-matrix.yml`, `perf-cold-warm.yml`, `parent-cache-bench.yml`, and
  `cache-delta-experiment.yml` keep private, workflow-scoped namespaces. They
  either measure timings that an unrelated lane's writes would contaminate,
  or compare cache strategies. `perf-cold-warm.yml` additionally purges every
  repo cache whose key *contains* `perf-cold-warm`, so its key must keep that
  substring and no shared key may contain it — both pinned by the same test.

Note that `rust-cache`'s key does not include Cargo profile *definitions*, so
a change to a `[profile.*]` table does not invalidate these caches on its own;
that is why the profile is spelled out in the key text.

This repository itself is the reference implementation of that pattern.
