Skip to main content

CI/CD Setup

Bleep's CI story has two parts:

  1. A basic workflow that runs bleep ci on every push. This page covers it.
  2. Two features that make CI fast: build only what changed (--invalidated, see CI project invalidation) and reuse compiled classes across runs and machines (remote build cache). The consolidated workflow at the end of this page combines both.

If your CI is your bottleneck, skip to Build only what changed and Reuse work across runs.

GitHub Actions

Basic CI

name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: bleep-build/bleep-setup-action@v1

- run: bleep --no-color --no-bsp-progress ci --no-tui

bleep ci compiles every project and runs every test — in one build pass, not a compile run followed by a test run. One task graph, one summary, full parallelism, and a library upstream of a test project is compiled once. It does exactly those two things: no formatting check, no publish, nothing you did not ask for.

bleep-setup-action reads $version out of bleep.yaml and installs that exact bleep build. --no-color and --no-bsp-progress make CI logs scriptable and small, details under CI flags below.

Cache the Coursier and JVM downloads

The first CI run downloads every dependency, the JVM bleep manages, and the Scala/Kotlin compilers via Coursier. That's slow, but it's also identical between runs as long as your bleep.yaml doesn't change. Cache it:

- uses: actions/cache@v4
with:
path: |
~/.cache/coursier
~/.bleep
key: ${{ runner.os }}-bleep-${{ hashFiles('**/bleep.yaml') }}

This is not the same thing as bleep's remote build cache (which caches compiled classes, not downloads). See Reuse work across runs below for that.

Build only what changed

--invalidated=<ref> scopes a build to the projects whose source or config changed vs a base commit, plus every project transitively depending on them. CI then does the minimum amount of work:

- name: Build and test only what changed
run: bleep --no-color --no-bsp-progress ci --no-tui --invalidated=origin/${{ github.event.pull_request.base.ref }}

fetch-depth: 0 on the checkout step is required so git diff against the base ref works.

The flag needs no guard around it. When nothing is invalidated it prints Nothing invalidated vs origin/main. Nothing to compile and test. and exits 0.

That is worth stating loudly, because the recipe this replaces did the opposite. It used to read:

# DON'T. This fails open.
- run: bleep build invalidated --base origin/main | xargs bleep compile

With nothing invalidated, bleep build invalidated prints nothing, xargs runs the command anyway with no arguments, and bleep compile with no arguments compiles every project. The pipeline you added to make CI fast turned into a full build exactly in the case where the right answer was to do nothing at all — silently, with a green check at the end. If you ever do pipe project names into a command, use xargs -r, which runs nothing on empty input.

--invalidated works on bleep compile and bleep test too, when you want just one half. For the full pattern (bare --invalidated, GitLab CI example, JSON output for matrix expansion), see CI project invalidation.

Reuse work across runs

The remote build cache stores compiled classes (plus their Zinc analysis) in S3 / R2 / MinIO so a CI runner can pull what's been built elsewhere instead of recompiling. Pair it with invalidated and CI compiles only the projects that genuinely changed and whose digest isn't in the cache.

Whoever can write to the cache decides the compiled classes every later build executes, so pull-request builds get read-only credentials and never push — a PR runs code chosen by whoever opened it, and on GitHub a PR from a branch in the same repository does receive your secrets. Entries are written by builds of the branch you protect. Issue two credential pairs: one whose policy allows only reads, one that may write.

- name: Pull cache
run: bleep remote-cache pull
env:
# Read-only key: safe in any job, including pull requests.
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 }}

# ... compile, test ...

- name: Push cache
# Only post-merge builds of the protected branch write to the cache.
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main'
run: bleep remote-cache push
env:
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 }}

An if: on the step is not by itself enough. On a pull_request event GitHub runs the workflow file from the pull request branch, so a PR can edit the workflow and help itself to any secret the event can reach. Put the pushing steps in their own job and attach a GitHub Environment whose deployment-branch rule allows only your protected branch — that is what actually withholds the write credential from pull requests. The remote cache page shows the full two-job workflow.

Configuration (the bucket URI plus credentials) lives in bleep.yaml and ~/.config/bleep/config.yaml, see Remote build cache for setup, S3-compatible backends (MinIO / Cloudflare R2 / GCS), bucket-lifecycle expiration commands, and the trust model — read that before handing any job a write credential.

Consolidated workflow: invalidation + remote cache

The two features compose. Pull the cache first, compute the invalidated set, compile/test against it (cache hits skip work, genuine changes recompile), push the cache so the next run picks up the new artifacts.

Two jobs, not one: the pull-request job reads the cache with a read-only key, and the post-merge job — the only one that runs code that has been reviewed — is the one that writes.

name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
# Untrusted: builds the PR branch. Reads the cache, never writes it.
pr:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required for `--invalidated`

- uses: bleep-build/bleep-setup-action@v1

- uses: actions/cache@v4
with:
path: |
~/.cache/coursier
~/.bleep
key: ${{ runner.os }}-bleep-${{ hashFiles('**/bleep.yaml') }}

- name: Pull remote 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 and test invalidated projects
run: bleep --no-color --no-bsp-progress ci --no-tui --invalidated=origin/${{ github.event.pull_request.base.ref }}

# Trusted: reachable only after merge to main, and the write credential
# lives in an Environment whose deployment-branch rule allows only main.
main:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: build-cache-write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: bleep-build/bleep-setup-action@v1

- uses: actions/cache@v4
with:
path: |
~/.cache/coursier
~/.bleep
key: ${{ runner.os }}-bleep-${{ hashFiles('**/bleep.yaml') }}

- name: Pull remote 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: Compile and test
run: bleep --no-color --no-bsp-progress ci --no-tui

- name: Push remote cache
if: success()
run: bleep remote-cache push
env:
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 }}

Release workflow

Tag-driven publish:

name: Release

on:
push:
tags: ['v*']

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # dynver reads tag history

- uses: bleep-build/bleep-setup-action@v1

- name: Publish to Sonatype
env:
SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
PGP_SECRET: ${{ secrets.PGP_SECRET }}
PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
run: bleep publish --assert-release sonatype

--assert-release fails the job if dynver would emit a snapshot version (uncommitted changes, commits past the tag. No tags at all). See Publish to Maven Central for the full release-pipeline story.

CI flags

bleep --no-color --no-bsp-progress ci --no-tui
FlagEffect
--no-colorStrip ANSI codes from log output.
--no-bsp-progressDrop the live BSP progress display so log files stay small.

Both are appropriate for any non-interactive run.

On compile, test, run, link and dist, three flags control how much of the run is printed:

FlagEffect
--no-tuiFull streaming trace without the TUI: one log line per project and test event. For CI logs you want to read afterwards.
--quiet, -qOnly failures and the final summary. Per-event streaming and daemon-connection chatter are suppressed; a red build still prints the full failure section and exits non-zero.
--summary-onlyAlias for --quiet (test only).

Quiet success, loud failure: --quiet is the right default for agents and for CI jobs where the log only matters when something broke. Combined with --diff it prints just the summary and what changed — nothing else.

Matrix builds

Test multiple Scala versions in parallel jobs:

jobs:
test:
strategy:
matrix:
scala: [2.13, 3]
steps:
- uses: actions/checkout@v4
- uses: bleep-build/bleep-setup-action@v1
- run: bleep test mylib@jvm${{ matrix.scala }}*

For invalidation-driven matrix expansion (build the matrix dynamically from what's actually changed), pipe bleep build invalidated --output json through jq into the matrix. See CI project invalidation for that pattern.

Other CI systems

Bleep works with any CI that can run shell commands. Install via the curl installer or Coursier:

curl -fsSL https://bleep.build/install | sh
# or
cs install --channel https://raw.githubusercontent.com/oyvindberg/bleep/master/coursier-channel.json bleep

Remote build cache and CI project invalidation include GitLab CI examples alongside the GitHub Actions ones.

See also