spindle-cache — an actions/cache for tangled CI, blobs on your own PDS #
handoff spec, written 2026-08-08 from the stream operations session (tangled.org/zat.dev/stream — see its docs/deploying.md and .tangled/workflows/ for the motivating workloads). This is its own project; it landed here because zds is the PDS whose blob store makes the design honest. Whoever picks this up: everything below is design-complete enough to start, and nothing below is sacred.
the problem #
Tangled spindle CI has no artifact/workspace cache. Every push pays the full cost of anything the workflow computes: for zig projects that means the ReleaseSafe compile (~10 min for stream) on every single run. GitHub solved this with actions/cache years ago; spindle has only:
- Nixery layer caching (nixery engine) — dependency images, not build outputs
- Nix binary caches (microvm engine) — nix store paths, not your zig-cache
There is no key-addressed "save this directory, restore it next run".
the insight #
Two existing mechanisms compose into the whole feature:
-
Spindle's plugin system is the nix registry. Any workflow can pull a CLI from any flake ref:
dependencies: git+https://tangled.org/@zat.dev/spindle-cache: - spindle-cachePublishing a repo with a flake IS publishing an action.
-
The PDS is the storage, and eviction falls out of atproto's data model. The spindle docs' own release recipe already uploads blobs via
com.atproto.repo.uploadBloband pins them withsh.tangled.repo.artifactrecords. A cache entry is the same shape: a record keyed by cache key, referencing tarball blob(s). PDS blob GC deletes unreferenced blobs — so overwriting a key's record automatically releases the old blobs. The eviction policy nobody wants to write is free.
UX (mirror actions/cache, people know it) #
steps:
- name: restore build cache
command: spindle-cache restore --key "zig-$(zig version)-$(sha256sum build.zig.zon | cut -c1-16)" --path .zig-cache
- name: build
command: zig build test
- name: save build cache
command: spindle-cache save --key "..." --path .zig-cache
restore: resolve the record (getRecord by rkey = sanitized key) on the configured PDS, fetch blob(s), unpack to --path. Miss = exit 0, empty dir. Optional--restore-keysprefix fallback like actions/cache.save: tar + zstd the path,uploadBlob(chunked, see limits), thenputRecordwith the blob refs. Last writer wins; races are harmless for caches.- Auth:
ATP_IDENTIFIER+ATP_APP_PASSWORDpipeline secrets — the exact pattern the spindle artifact-publishing recipe documents.
the lexicon (strawman) #
dev.zat.spindle.cache (or whatever namespace fits)
key: string (the full cache key, also sanitized into rkey)
createdAt: datetime
toolVersion:string
chunks: blob[] (ordered zstd tarball chunks)
totalBytes: integer
pathHint: string (informational: what was cached)
rkey = a hash of the key so arbitrary key strings stay valid. One record per key; putRecord replaces → old chunks unreference → PDS GC collects.
constraints, named honestly #
- Blob size caps. Hosted PDSes cap blobs (tens of MB typical). The
CLI must chunk (e.g. 32 MiB chunks, blob[] in the record). On zds the
cap is a flag (
blob_upload_limit) — running your own PDS makes big caches a config decision, which is exactly the dogfooding angle: zds is the reference deployment for this tool. - Caches are public. PDS blobs are world-readable. Fine (good, even) for OSS build caches; disqualifying for anything containing secrets. README must say so loudly.
- Bandwidth. A ~300 MB zig-cache round-trips through the PDS each run. Worth it against a 10-min compile; measure and document the break-even. Chunked parallel fetch helps.
- PDS blob GC timing varies by implementation — verify zds actually collects unreferenced blobs (if not, that's a zds issue this project smokes out — also valuable).
- Repo-level trust: anyone can publish records claiming any key; the consumer only ever reads from ITS OWN configured identity's repo, so there is no cross-account cache-poisoning surface as long as the CLI never resolves other identities.
implementation notes #
- Language: go or zig, single static binary, flake packaging it (see tangled's own repos for flake conventions). go is faster to ship; zig keeps the toolchain story consistent with the rest of zat.dev.
- ~600 lines: tar/zstd, chunked uploadBlob with retry, putRecord/ getRecord, key sanitization. No daemon, no state.
- First adopter: stream's
.tangled/workflows/ci.yml(restore/save aroundzig build test -Doptimize=ReleaseSafe) and later its admission workflow. Expected effect: ReleaseSafe test step drops from ~10 min to incremental-compile time on warm keys. - Name ideas: spindle-cache (obvious), stash, hoard.
why this is ecosystem-forward #
Every tangled CI user has this problem and none of them has a cache. This ships as: a repo + a flake + a lexicon, storage the user already owns, zero new infrastructure, and the first real demonstration that "actions marketplace" on tangled is just flakes + PDS records. The feature request we almost filed becomes a thing the ecosystem gets to adopt instead.