# cacher S3-backed CI cache helper. A single static Go binary that downloads, uploads, lists, and invalidates cached build artifacts in any S3-compatible bucket. Built for [builds.sr.ht](https://builds.sr.ht) but works anywhere you can run a binary and reach an S3 endpoint. Replaces the typical CI cache shell loop: ```sh # before — install awscli, write ~/.aws/config, then in every task: if aws s3api head-object --bucket "$B" --key "$K" >/dev/null 2>&1; then aws s3 cp "s3://$B/$K" "$out" else curl -sSL "$url" -o "$out" aws s3 cp "$out" "s3://$B/$K" fi ``` ```sh # after — one binary, one config, one command: cacher download "$key" "$out" --url "$url" ``` Same collapse for docker images: ```sh cacher docker download "$key" "$image:tag" --pull ``` …and for a directory you build yourself, where the fallback is your own build script: ```sh cacher dir download "$key" ~/scss --exec 'git clone … && cp -r …' ``` Releases and changelog: [bigbes.pages.srht.bigb.es/ci-cacher](https://bigbes.pages.srht.bigb.es/ci-cacher/). ## Install ```sh # One-liner: detects the platform, verifies against checksums.txt, # installs into ~/.local/bin (override with CACHER_BINDIR). On # builds.sr.ht it also appends the PATH export to ~/.buildenv, so the # whole bootstrap task is this single line. curl -sSL https://bigbes.pages.srht.bigb.es/ci-cacher/install.sh | sh ``` ```sh # Pre-built binary (latest tag). Substitute your platform: # cacher-linux-amd64, cacher-linux-arm64, # cacher-darwin-amd64, cacher-darwin-arm64 wget https://bigbes.pages.srht.bigb.es/ci-cacher/cacher-linux-amd64 -O ~/.local/bin/cacher chmod +x ~/.local/bin/cacher # Pin to a known sha256 (checksums.txt lives next to the binaries): wget https://bigbes.pages.srht.bigb.es/ci-cacher/cacher-linux-amd64 -O ~/.local/bin/cacher echo " ~/.local/bin/cacher" | sha256sum -c # From source: go install go.bigb.es/cacher@latest ``` Verify with `cacher version`. ## Setup `cacher init` writes `~/.config/cacher/config.toml` and runs a smoke test to fail fast on bad credentials. Run it once per CI job after secrets are mounted: ```sh cacher init \ --endpoint https://s3.example.com \ --region us-east-1 \ --bucket ci-cache \ --prefix my-project \ --key-file ~/.s3-cache-key-id \ --secret-file ~/.s3-cache-key-secret ``` Credentials resolve as **`CACHER_S3_KEY_ID` / `CACHER_S3_SECRET` env vars > `--key-file` / `--secret-file` > files recorded in config**. Use the env vars for local-dev runs; use the files in CI where secrets are mounted. `cacher doctor` repeats the smoke test (HEAD bucket + 1-byte write/read/delete canary) and prints credential-length diagnostics without leaking the secret. ## Usage ### Single file — fetch-or-download ```sh cacher download go-1.26.3.tar.gz /tmp/go.tar.gz \ --url https://go.dev/dl/go1.26.3.linux-amd64.tar.gz \ --sha256 abc123… ``` Cache HIT: pulls from S3. Cache MISS: GETs the URL, verifies the optional sha256, writes to the destination, **and uploads to S3** so the next run hits. When the artifact isn't a URL away but a command away, `--exec` is the same shape — the script runs only on a miss and must leave the destination behind, which is then cached: ```sh cacher download "bin/tool-$VER" ~/.local/bin/tool \ --exec 'go build -o ~/.local/bin/tool ./cmd/tool' ``` `--optional` turns a miss into exit 0 instead of an error, for caches whose absence just means a cold build: ```sh cacher dir download "$KEY_GOC" ~/.cache/go-build --optional ``` ```sh cacher upload my-key /path/to/artifact # skip if present cacher upload my-key /path/to/artifact --force # overwrite cacher exists my-key # exit 0 hit, 1 miss cacher list my-prefix # /-delimited (aws s3 ls) cacher list my-prefix --recursive # flat cacher list --root # ignore configured prefix cacher delete my-key # invalidate ``` ### Docker images — streamed save/load For an image pulled from a registry, the cache-or-pull pattern is a single command: ```sh # Cache HIT → docker load from S3. MISS → docker pull + seed S3 + tag stays local. cacher docker download "docker/dxflrs-garage-v2.3.0.tar.zst" \ dxflrs/garage:v2.3.0 --pull ``` For images you build locally, `--exec` replaces `--pull` — key by Dockerfile content, and the build only runs on a miss: ```sh cacher docker download "images/{hash}.tar.zst" myimage:latest \ --hash-from Dockerfile \ --exec 'docker build -t myimage:latest .' ``` The primitives are still there if you want the branch by hand: ```sh KEY=$(cacher key "images/{hash}.tar.zst" --hash-from Dockerfile) if ! cacher docker exists "$KEY"; then docker build -t myimage:latest . cacher docker upload "$KEY" myimage:latest else cacher docker download "$KEY" myimage:latest fi ``` The save/load pipeline is fully streamed — `docker save | zstd | s3` and the inverse, no on-disk tempfile. The zstd codec is pure Go ([klauspost/compress](https://github.com/klauspost/compress)), so no external `zstd` binary is needed. ### Directory caching — the real CI speedup Cache resolved trees keyed by a lockfile hash. Skip resolution entirely when nothing changed: ```sh # Go module cache, keyed by go.sum: cacher dir download "go-mod/{hash}.tar.zst" ~/go/pkg/mod \ --hash-from go.sum --exec 'go mod download' # Lua rocks tree, keyed by rockspec: cacher dir download "rocks/{hash}.tar.zst" .rocks \ --hash-from project-scm-1.rockspec --exec 'tt rocks install ...' ``` `--exec` runs only on a miss, with `sh -c`, stdout and stderr wired straight to the build log. The destination directory is created before the script starts, so the script can write into it without its own `mkdir -p`. On success the tree is packed and uploaded; if the upload fails the build still continues, because the content the script produced is already on disk. If the script itself fails, `cacher` exits with the script's own status and nothing is cached. When restore and save can't be one command — because the tree is only final several tasks later, as with a Go build cache — use the pair: ```sh KEY=$(cacher key "gocache/{hash}.tar.zst" --hash-from go.sum) cacher dir download "$KEY" ~/.cache/go-build --optional # miss is fine ... build, test, package ... cacher dir upload "$KEY" ~/.cache/go-build # no-op if present ``` ### Key derivation `--hash-from ` is repeatable; files are hashed by content, directories are hashed by recursively walking entries in sorted relative-path order. The resulting hex digest is truncated to `--hash-length` characters (default 16, matching the `sha256sum file | cut -c1-16` convention). For a single file path, the digest equals `sha256sum file | head -c 16` exactly — so you can migrate existing keys without recomputing them. Substitution into the key template: | Template | --hash-from | Result | |--------------------------------|---------------|-------------------------------------| | `img/{hash}.tar.zst` | `Dockerfile` | `img/abcd1234….tar.zst` | | `img/build.tar.zst` | `Dockerfile` | `img/build-abcd1234….tar.zst` | | `bin/cacher` + `--arch-suffix` | (none) | `bin/cacher-linux-amd64` | `cacher key