Step caching

Skip a step's work when the inputs provably have not changed (#43).

The rule underneath everything: a wrong hit is worse than no hit. Anything the engine cannot read — a cache entry that will not decode, a build context it cannot walk — is a miss, logged as such. Never a guess.

What participates

A step opts in, per run, through StepKind::caching. The default is Caching::Never, so nothing skips unless it says so:

fn caching(&self, inputs: &Inputs) -> Caching {
    Caching::Keyed(vec![CacheInput::File(context)])
}

oci.image opts in — a build of the same context is the same bytes. tofu.plan and deploy never do: the world they read changes between runs, and no key can cover that.

What the key covers

ks_engine::cache::cache_key hashes, with SHA-256: the key domain tag, so an encoding change makes every entry a miss; the step's name, version and STEP_API contract; every resolved input, after defaults are applied, in sorted order; then each declared CacheInput in order — a File (or directory) hashed by relative path, kind and contents, symlinks by target and never followed, a Tool version, or a Value.

Every field is length-prefixed and every sequence counted, so two different inputs can never produce the same bytes, and no map is walked in hash order. The key renders as sha256:<hex>.

Because every resolved input is covered, a step whose inputs include a secret cannot distinguish a safe key from a hash of one: the key is written to disk and logged on every miss. A step that takes secrets therefore has to say so — oci.image returns Caching::Never for a run that mounts any, and keys on its build context for every other run.

Verification before skip

A key match is not enough. Before skipping, the engine hands the cached outputs back to the step through StepKind::verify_cached, with a context holding only the step's declared ports. Only CacheCheck::Valid skips; CacheCheck::Stale rebuilds. The default is Stale, so a step that opts in and forgets to verify never skips.

The engine logs the evidence through the run's redacting logger, which is how a skip says why: Hit logs step skipped with reason="cache hit: inputs unchanged" plus key and evidence; Stale logs cache entry stale with key and evidence, then rebuilding; Miss logs cache miss with key.

Where the local cache lives

DirCache, one file per key, under $XDG_CACHE_HOME/keepshipping/steps (falling back to $HOME/.cache/keepshipping/steps). Entries are written to a temporary file and renamed into place, so a reader never sees a half-written entry; one that will not decode is a miss, not an error. StepCache is engine-local storage, not a port — nothing outside the engine depends on it.

Not done yet