Caching
KiCI ships a general-purpose cache for any files or directories your workflow produces — compiled artifacts, downloaded toolchains, package manager stores, build outputs. A cache entry is keyed, immutable once written, and shared across runs of the same repository so a later run can restore what an earlier run produced instead of recomputing it.
Two surfaces drive the same cache:
- Declarative — a
cachefield on a job or a step. The runtime restores before the work runs and saves after it succeeds, with no code in your step body. - Imperative —
ctx.cache.restore(spec)/ctx.cache.save(spec)inside a step body, for fine-grained control over when restore and save happen.
The cache is backed by the orchestrator’s object storage. Entries are isolated per organization and per ref scope (see Isolation); no other tenant can read your cache, and an untrusted/fork ref can never poison the cache a trusted branch reads.
CacheSpec
Section titled “CacheSpec”Both surfaces take the same shape:
interface CacheSpec { /** Exact cache key. First save wins; re-saving an existing key is a no-op. */ key: string; /** Files/directories to cache. Repo-root-relative or `~`-prefixed. */ paths: string[]; /** Ordered prefix fallbacks for partial restore; newest matching entry wins. */ restoreKeys?: string[];}keyis the exact cache key. It is immutable — the first save under a given key wins, and any later save under the same exact key is a no-op (the existing entry is never overwritten). Build keys from inputs that change when the cached content should change, e.g. a hash of your lockfile:key: `deps-${await ctx.$`sha256sum pnpm-lock.yaml`}`.pathsare the files and directories to archive, repo-root-relative or~-prefixed (the agent expands~to the workspace home). At least one path is required.restoreKeysare ordered prefix fallbacks tried only when the exactkeymisses on restore. Each prefix is matched against existing entries; the newest matching entry wins. This lets a run that changed its lockfile still restore the closest previous cache and rebuild incrementally.
Declarative cache
Section titled “Declarative cache”Add a cache field to a job or a step. It accepts one CacheSpec or an array of them. The runtime restores every spec before the job/step runs (surfaced as a cache:restore pseudo-step) and saves every spec after it completes successfully (surfaced as a cache:save pseudo-step):
import { job } from '@kici-dev/sdk';
job('build', { runsOn: 'linux-x64', cache: { key: 'mise-tools-v1', paths: ['~/.local/share/mise'], }, steps: [ step('install-tools', async (ctx) => { await ctx.$`mise install`; }), step('build', async (ctx) => { await ctx.$`mise exec -- pnpm build`; }), ],});Step-level cache scopes the restore/save to a single step:
step('deps', { cache: { key: `npm-${lockfileHash}`, paths: ['node_modules'], restoreKeys: ['npm-'] }, run: async (ctx) => { await ctx.$`pnpm install --frozen-lockfile`; },});On a cache hit, the archived paths are restored before the step body runs, so pnpm install sees a warm node_modules. On a miss, the step runs cold and the resulting paths are saved under the exact key for the next run.
Imperative cache (ctx.cache)
Section titled “Imperative cache (ctx.cache)”When you need to decide at runtime whether to restore or save — for example, save only when a build actually changed something — use the imperative API on the step context:
step('build', async (ctx) => { const result = await ctx.cache.restore({ key: `build-${sourceHash}`, paths: ['dist'], restoreKeys: ['build-'], });
if (result.hit) { ctx.log.info(`restored cache (matched ${result.matchedKey})`); }
await ctx.$`pnpm build`;
await ctx.cache.save({ key: `build-${sourceHash}`, paths: ['dist'] });});restore(spec) returns { hit, matchedKey? }:
hitistruewhen the exactkeymatched or arestoreKeysprefix matched.matchedKeyis the full key that actually matched — the exact key on a direct hit, or the full key of the matched prefix entry on a fallback hit.
save(spec) archives spec.paths under spec.key. Like the declarative surface, it is immutable: the first save under an exact key wins, and re-saving the same key is a no-op.
Restore semantics
Section titled “Restore semantics”A restore resolves in this order:
- Exact key. If an entry exists under the exact
key, it is restored andmatchedKey === key. - restoreKeys prefix fallback. Each
restoreKeysprefix is tried in order. Within a prefix, the newest matching entry wins;matchedKeyis that entry’s full key. - Miss. If nothing matches,
hitisfalseand no paths are restored.
This mirrors the familiar lockfile-hash pattern: key the entry on the exact lockfile hash, and add a restoreKeys prefix so a changed lockfile still restores the most recent prior cache to rebuild from.
Immutability
Section titled “Immutability”Cache keys are write-once. The first save under an exact key wins; every subsequent save under that same exact key is a no-op and the original bytes are preserved. To publish new content, use a new key (typically by including a content hash in the key). Immutability is what makes a cache hit safe to trust — the bytes behind a given key never change after they are first written.
Isolation
Section titled “Isolation”Each cache entry is scoped to your organization and to the ref’s trust level:
- Trusted refs (your repository’s own branches, default branch) read and write a shared scope visible to the whole org for that repository.
- Untrusted / fork refs read the shared scope as a fallback but write to an isolated per-run scope. A fork build can therefore benefit from a warm cache the trusted branch produced, but can never write into the shared scope — so a malicious fork cannot poison the cache a trusted branch later restores.
No tenant can read another tenant’s cache; the org boundary is enforced in the cache key namespace.
Eviction
Section titled “Eviction”Cache storage is bounded per organization. Two mechanisms keep it bounded:
- Quota — when a save pushes the org over its byte quota (
KICI_USER_CACHE_QUOTA_BYTES, default 5 GiB), the oldest entries are evicted until the org is back under quota. - TTL — entries unused for
KICI_USER_CACHE_TTL_MS(default 7 days) expire. The TTL refreshes on read (touch-on-read), so an actively used cache stays warm.
Both knobs are operator-configured on the orchestrator — see orchestrator storage layout.
Observability
Section titled “Observability”Each cache restore and save surfaces in the run timeline as a cache:restore / cache:save pseudo-step, reporting the outcome (hit/miss/saved, the matched key, bytes). The same outcomes are recorded as cache.restore / cache.save run events. See data flows for the restore/save protocol.
See also
Section titled “See also”- Core —
job()/step()factories thecachefield attaches to - Runtime —
StepContext, wherectx.cachelives - Orchestrator storage layout — cache prefix, quota, TTL, and eviction
- Data flows — restore/save protocol and trust→scope mapping