Build cache
Bleep can cache compiled classes in S3 (or any S3-compatible storage like MinIO, Cloudflare R2) so CI pipelines skip compilation for unchanged projects, or in a local directory so multiple checkouts and git worktrees on one machine share compiled state.
Setup
Add to your bleep.yaml:
remote-cache:
uri: s3://my-bleep-cache/builds
region: eu-north-1
Add credentials to ~/.config/bleep/config.yaml:
remoteCacheCredentials:
accessKeyId: AKIA...
secretAccessKey: wJal...
Or use environment variables (standard AWS convention):
export BLEEP_REMOTE_CACHE_S3_ACCESS_KEY_ID=AKIA...
export BLEEP_REMOTE_CACHE_S3_SECRET_ACCESS_KEY=wJal...
Usage
# Pull cached classes before building
bleep remote-cache pull
# Build only what changed
bleep build invalidated --base origin/main | xargs -r bleep compile
# Push compiled classes to cache
bleep remote-cache push
How it works
Each project gets a SHA-256 digest computed from:
- The bleep version
- The build-level toolchain JVM —
jvm:inbleep.yaml, name and index url (bump the JDK and every project's digest changes) - Project configuration (dependencies, compiler flags, Scala version, platform, etc.)
- Source file contents (using git blob hashes for speed, with filesystem fallback)
- Resource file contents (affects the digest but resources are NOT cached)
- Transitive dependency project digests (changes propagate downstream)
A build without a jvm: cannot be cached: the compiler would be whatever java is on PATH, which is not something bleep can hash, so remote-cache pull/push fail with an error instead of keying entries on an unknown toolchain.
Cache entries are tar.gz archives stored at s3://bucket/prefix/<project>/<digest>.tar.gz containing the compiled classes directory and zinc incremental analysis.
Pull behavior
- Checks if each project's digest matches a cached archive
- Skips projects that are already compiled locally
- Downloads and extracts matching archives
- Zinc analysis is included so subsequent incremental compilation works correctly
Push behavior
- For each compiled project, checks if the cache already has an entry for that digest
- Uploads tar.gz of classes + zinc analysis for new entries
- Skips projects that aren't compiled or are already cached (
--forceuploads anyway, overwriting the stored entry — see Trust model)
Who is allowed to push decides what everyone else executes: see Trust model before wiring this into CI.
Trust model
Read this before pointing CI at a shared cache. A build cache is a supply-chain component: everything in it is executed, linked into artifacts, or shipped.
How a cache key is computed
Every project gets a SHA-256 digest over its compilation inputs
(ProjectDigest), in this order:
- the build's
$version(different bleep versions produce different output), - the build's
jvm:— the toolchain name and the index url it is looked up in, because a different JDK produces different class files andjvm:appears in no project's configuration, - the project's effective configuration as YAML — templates expanded,
publishexcluded because it doesn't affect compilation, - content hashes of every source file under the project's source directories,
- content hashes of every resource file (resources are not stored in the archive, but they change the key),
- the digests of all transitive project dependencies,
- the digests of the projects backing its sourcegen scripts.
resolvers: is deliberately not part of the key. A coordinate is
expected to resolve to the same bytes from any repository, and an
artifact that is missing from the configured repositories fails
resolution before anything compiles — so a changed resolver list has no
way to silently produce different output. Hashing it would invalidate
every project in the build the moment somebody adds a repository for one
new dependency. Two repositories serving different bytes under one
coordinate is a supply-chain incident, and a cache key is the wrong
place to defend against it.
File content hashes are git blob hashes: git ls-tree -r HEAD supplies
them for directories with no uncommitted changes, and directories that
are dirty or untracked (generated sources) are hashed from the
filesystem with the same SHA-1("blob <size>\0" + content) formula, so
both paths agree on the same content.
The resulting key is
<prefix>/<project>/<digest>.tar.gz (slashes in a cross-project name
become dashes). The archive holds the classes directory and the Zinc
analysis.
The digest describes the inputs, never the bytes stored under it.
bleep remote-cache pull asks the backend whether that key exists,
downloads it, and unpacks it into the project's target directory. There
is no signature, no content hash, and no record of who wrote the entry.
Trust in a cache entry is exactly trust in whoever could write that
key.
What someone who can write to the cache can do
Given write access to the bucket (or cache directory), an attacker picks any key and puts any archive there. Because the key is a pure function of repository content plus bleep version, they can compute the key of any commit they can read — including the main branch, and including commits that haven't been built yet — and place an entry under it ahead of time.
Anyone whose build then produces those same inputs gets a cache hit and never compiles the project:
- the planted classes go on the classpath of every downstream compile, so they can also change what compiles against them,
- tests execute them, and a poisoned entry can make the tests pass,
- packaging and publishing steps ship them,
- developers who run
bleep remote-cache pulllocally get them on their machines, - the Zinc analysis in the archive drives later incremental compiles, so a poisoned entry keeps influencing builds after it lands.
Nothing in the pull path would notice: a poisoned entry looks exactly like a hit. The only checks bleep performs are structural — push refuses to upload analysis containing absolute paths (it would not be portable), and unpacking refuses archive entries whose paths would escape the project's target directory. Neither says anything about whether the contents are trustworthy.
The blast radius is therefore the whole set of consumers of the cache, not just the job that wrote the entry. That is why untrusted builds must not hold write credentials.
Read-only clients are enforced by the backend, not by bleep
Bleep has no read-only mode. There is one credential pair, used for
both pull and push; pull never writes, but nothing stops a client
holding those credentials from running push. If you need a build to
be unable to write to the cache, give it credentials that cannot
write:
- S3 / R2 / MinIO / GCS: issue a second key whose policy allows
only
s3:GetObject(pluss3:ListBucketif your backend needs it) on the cache prefix, and hand that one to untrusted jobs. Keep the read-write key for jobs that only trusted refs can start. file://caches: filesystem permissions — mount or chmod the cache directory read-only for the accounts that must not write.
Keep the two secrets under different names so a copy-paste cannot promote a read-only job to a writer.
bleep remote-cache push --force
Without --force, push checks whether the key already exists and skips
it — an existing entry is left alone. With --force, the upload
happens regardless and overwrites whatever was there.
Use it when you know the stored entry is wrong: it was produced by a broken toolchain, by a bleep version with an output bug, or by an attacker, and you are re-pushing a known-good build from a trusted machine.
Do not put it in a routine pipeline. It turns "first writer wins" into
"last writer wins", so any job that can reach the cache can replace a
good entry at will, and it removes the one accidental brake that makes
poisoning harder — the skip on an existing key. Combined with a write
credential in a pull-request build, --force is a one-line
cache-poisoning primitive.
CI integration
The rule these examples follow: untrusted builds read, trusted builds write. A pull request is untrusted — anyone who can open one chooses the code that runs in the job — so PR builds get the read-only credential and no push step. Entries are written by post-merge builds of the branch you protect, which is exactly the code that already passed review.
Note that "fork PRs don't get secrets" is not the protection here: on
GitHub, a pull request from a branch in the same repository does
receive the repository's secrets, and any contributor who can push a
branch can then run arbitrary code in that job. Nor is an if: on the
push step a control: on a pull_request event the workflow file that
runs is the one on the PR branch, so the PR can simply delete the
guard. What actually withholds the credential is the backend policy on
the key plus a GitHub Environment whose deployment-branch rule admits
only the protected branch (GitLab: protected variables).
GitHub Actions
on:
pull_request:
push:
branches: [main]
jobs:
# Untrusted: runs code from the PR branch. Read-only cache credentials, no push.
pr:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: bleep-build/bleep-setup-action@v1
- name: Pull cache
run: bleep remote-cache pull
env:
# Key whose IAM policy allows GetObject only.
BLEEP_REMOTE_CACHE_S3_ACCESS_KEY_ID: ${{ secrets.BLEEP_CACHE_READ_ACCESS_KEY_ID }}
BLEEP_REMOTE_CACHE_S3_SECRET_ACCESS_KEY: ${{ secrets.BLEEP_CACHE_READ_SECRET_ACCESS_KEY }}
- name: Build changed projects
run: bleep build invalidated --base origin/${{ github.event.pull_request.base.ref }} | xargs -r bleep compile
# Trusted: only reachable after merge to main. This job writes the cache.
main:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
# Scope the write credential to a GitHub Environment restricted to this branch,
# so no other workflow or ref can read it.
environment: build-cache-write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: bleep-build/bleep-setup-action@v1
- name: Pull cache
run: bleep remote-cache pull
env:
BLEEP_REMOTE_CACHE_S3_ACCESS_KEY_ID: ${{ secrets.BLEEP_CACHE_READ_ACCESS_KEY_ID }}
BLEEP_REMOTE_CACHE_S3_SECRET_ACCESS_KEY: ${{ secrets.BLEEP_CACHE_READ_SECRET_ACCESS_KEY }}
- name: Build
run: bleep compile
- name: Push cache
run: bleep remote-cache push
env:
# Read-write key. Only this job's environment can read it.
BLEEP_REMOTE_CACHE_S3_ACCESS_KEY_ID: ${{ secrets.BLEEP_CACHE_WRITE_ACCESS_KEY_ID }}
BLEEP_REMOTE_CACHE_S3_SECRET_ACCESS_KEY: ${{ secrets.BLEEP_CACHE_WRITE_SECRET_ACCESS_KEY }}
workflow_run and pull_request_target are not substitutes for
this split: both run with the base repository's permissions, and a job
that checks out PR code under either of them is as untrusted as the PR
itself.
GitLab CI
Mark the read-write credentials as protected variables, so they are only exposed to jobs on protected branches and tags, and leave the read-only pair unprotected for merge-request pipelines.
# Untrusted: merge request pipelines read the cache.
mr:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
variables:
BLEEP_REMOTE_CACHE_S3_ACCESS_KEY_ID: $CACHE_READ_KEY_ID
BLEEP_REMOTE_CACHE_S3_SECRET_ACCESS_KEY: $CACHE_READ_SECRET
script:
- bleep remote-cache pull
- bleep build invalidated --base origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME | xargs -r bleep compile
# Trusted: only the protected default branch, which is the only place
# the protected write variables resolve.
default-branch:
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
BLEEP_REMOTE_CACHE_S3_ACCESS_KEY_ID: $CACHE_WRITE_KEY_ID # protected variable
BLEEP_REMOTE_CACHE_S3_SECRET_ACCESS_KEY: $CACHE_WRITE_SECRET # protected variable
script:
- bleep remote-cache pull
- bleep compile
- bleep remote-cache push
If you want PR builds to reuse each other's work as well, give them a separate cache — a different bucket that trusted builds never pull from — rather than write access to the shared one, and scope that write credential to that bucket alone. A poisoned entry then reaches only other PR builds, which are untrusted anyway.
Local directory cache
Point the cache at a directory instead of a bucket:
remote-cache:
uri: file:///Users/me/.cache/my-project-bleep-cache
No credentials or region are needed. Cache entries land at <dir>/<project>/<digest>.tar.gz, written atomically so concurrent pushes from different checkouts are safe.
The trust model applies unchanged: the cache is as trustworthy as the set of accounts that can write to that directory. A per-user directory trusts only you; one shared across a machine or an NFS mount trusts everyone who can write there.
This is built for git worktrees: because cache keys are content digests and the shipped zinc analysis is path-portable, a freshly created worktree can bleep remote-cache pull and skip compiling everything its sibling already built:
git worktree add ../feature-x
cd ../feature-x
bleep remote-cache pull # restores classes + analysis for all unchanged projects
S3-compatible services
The remote cache works with any S3-compatible service:
MinIO (self-hosted)
remote-cache:
uri: http://minio.internal:9000/bleep-cache/builds
region: us-east-1
Cloudflare R2
remote-cache:
uri: https://<account-id>.r2.cloudflarestorage.com/bleep-cache/builds
region: auto
AWS S3
remote-cache:
uri: s3://my-bleep-cache/builds
region: eu-north-1
Google Cloud Storage (S3-compatible)
remote-cache:
uri: https://storage.googleapis.com/my-bleep-cache/builds
region: auto
Use HMAC keys for credentials (interoperability API).
Cache expiration
Bleep doesn't manage cache expiration, configure a lifecycle rule on your bucket to automatically delete old entries.
AWS S3
aws s3api put-bucket-lifecycle-configuration \
--bucket my-bleep-cache \
--lifecycle-configuration '{
"Rules": [{
"ID": "expire-cache",
"Status": "Enabled",
"Filter": {},
"Expiration": {"Days": 30}
}]
}'
Google Cloud Storage
gsutil lifecycle set /dev/stdin gs://my-bleep-cache <<'JSON'
{
"rule": [{
"action": {"type": "Delete"},
"condition": {"age": 30}
}]
}
JSON
Cloudflare R2
Set object lifecycle rules in the R2 dashboard under Settings > Object lifecycle rules.