Run Lattice in CI
Running Lattice on a build machine is the same lattice setup and lattice run
you use locally. Two things differ: the output a job produces, and the fact that
its working directory does not survive to the next run.
Install it in a job
A job starts with no lattice binary, so install one first:
curl -fsSL https://latticeandcompany.github.io/lattice/install.sh | sh -s -- --no-modify-path
--no-modify-path skips editing a shell rc file, which a CI step never sources.
The binary lands at .lattice/bin/lattice. Call it by that path, or add
.lattice/bin to the job’s PATH yourself. See
Installation.
On a Windows runner, the same script works in a bash step, and install.ps1
works in a PowerShell one:
$env:LATTICE_NO_PATH = '1'
irm https://latticeandcompany.github.io/lattice/install.ps1 | iex
A CI shell is not interactive, so install.ps1 leaves the user PATH alone
whether or not you set that variable. Setting it says so out loud, and stops the
job depending on how the runner reports interactivity. The binary is at
.lattice\bin\lattice.exe.
Run lattice setup as its own step
lattice run does not install dependencies or provision toolchains. Put
lattice setup before it in every job:
lattice setup
setup provisions everything declared under engines first, so dependency
installers see the pinned PATH, then installs each workspace’s dependencies
with whatever package manager it detected. A fresh checkout has no local marker
to compare against, so the first setup in a job installs every workspace’s
dependencies. Toolchains land under .lattice/toolchains, a separate directory
from the task cache. See Pinning tool
versions.
Skipping it costs more than a missing library. A task that names a tool the
project installed, such as eslint or pytest, finds it because Lattice puts
directories like node_modules/.bin on that task’s PATH — and only the ones
that exist. On a checkout where nothing has been installed, none do, and the
task fails with command not found.
What output a job gets
lattice run and lattice setup pick their output mode the same way
everywhere. Lattice prints raw, line-by-line output when stdout is not a
terminal, when the CI environment variable is set, or when you pass
-v/--verbose. A CI job satisfies the first two at once: a step’s stdout
is not a terminal, and GitHub Actions sets CI in every job.
CI and -v are interchangeable as mode triggers. -v additionally turns on
per-task output and hash trace lines. Somewhere that does not set CI, such as
an SSH session driving a build, pass -v:
lattice run build -v
Color follows the terminal rather than the mode, so a CI log is free of escape codes either way. See Output and logging.
Two lines in that log are worth grepping for. The summary is always the last one a successful run prints:
lattice: 10 tasks, 8 cached, 0 failed, 4.20s, 2m 51s saved
The saved figure is appended after the elapsed time rather than inserted before it, and it is left off when it is zero, so a job that greps the counts reads the same as it always has. It is task time, not wall clock: each hit contributes the time the run that wrote its entry spent, so eight hits on a parallel graph can report more saved time than the job took.
A run where every scheduled task came back from cache adds one more line under the summary:
lattice: full power, nothing to run
That marker needs at least one task scheduled, no failures, and a hit for every
task, so a --filter that matched no workspace never prints it. If a CI check
of yours greps for lattice: full cache, nothing to run, that is the string
this one replaced.
Exit codes
lattice run exits 0 when every task finished successfully, including runs
with nothing to do. No workspaces declared, or a --filter that matched
nothing, prints a line and still exits 0.
| Exit | Means |
|---|---|
0 |
Every task succeeded, or there was nothing to run |
1 |
A task’s command failed, or the config or graph could not be resolved |
130 |
The run was interrupted by SIGINT or SIGTERM |
The 130 is what lets a runner tell a cancelled job from a broken build.
Cancelling a job sends SIGTERM. Lattice passes it on to every running task’s
process group, gives them five seconds, then kills what is left. Without that
pass-on, tasks spawned into their own process groups would outlive the runner’s
shutdown.
SIGTERM ends the run wherever it arrives, including a run a persistent task
is holding open, such as a job that starts a dev server and tests against it.
Such a run used to ignore SIGTERM and hang until the runner force-killed it.
The hang cost a cancelled job the rest of its timeout and left the step’s result
unreported. A cancel now stops the run on the signal itself.
Lattice does not report a task stopped that way as a failure. The log shows the
tasks that were running and then the summary, with no FAILED line for the ones
the cancel stopped. A 0 failed summary next to exit 130 is correct, so read
the exit code rather than the summary to tell a cancel from a failure.
Collect every failure in one run
By default one failure stops the run, so a broken lint keeps test and
build from starting. To keep running everything whose dependencies did not
fail, pass --continue:
lattice run lint test build --continue
The run still exits 1 if anything failed. The summary line carries the failed
count, and each task skipped because a prerequisite failed prints
skipped (dependency failed) under -v. Each failure prints one line naming
the exit code and how long the task ran, api:test: FAILED (code 1) after 12.40s, with the task’s captured output under it. A search for FAILED in the
log finds both the tasks that broke and what they printed.
Cap parallelism on a small runner
Without --concurrency, Lattice uses the number of CPUs the machine reports. On
a 2- or 4-vCPU runner, tasks that are already internally parallel or
memory-heavy compete for the same cores under that default:
lattice run build --concurrency 4
Bound how long a task can run
A task that hangs holds the job until the runner’s own timeout kills it, which
costs the whole remaining budget and saves no cache. Give the tasks that can
hang a timeout:
{
"tasks": {
"test": { "timeout": "10m" },
"build": { "timeout": "20m" }
}
}
An overrun stops the task’s whole process group and counts as a failure, so the
run ends the way any other failure ends and later steps still get to save the
cache. A task stopped that way has no exit code, so its line names only the
time, api:test: FAILED after 10:00. The captured output ends with timed out after 10m and was stopped. timeout accepts ms, s, m, and h, or a bare
integer of seconds. A persistent task ignores it.
Persist the cache between runs
Every job checks out a fresh copy of the repo, so the cache starts empty unless
you restore it. Restore and save one directory: .lattice/cache by default, or
wherever settings.cacheDir points.
{
"settings": {
"cacheDir": ".lattice/cache"
}
}
The cache action’s path and settings.cacheDir must name the same directory.
settings.cacheDir has to name a directory inside the repo, and it cannot be
the repo root, so a path under the runner’s home directory is not an option.
Point the cache action at the repo-relative path instead.
actions/cache’s save step is skipped whenever the restore got an exact key
hit, which would freeze a static key after its first save. Give the save a key
that is always new, and fall back to the newest previous save on restore:
- name: Restore the Lattice cache
uses: actions/cache/restore@v6
with:
path: .lattice/cache
key: lattice-${{ runner.os }}-${{ github.run_id }}
restore-keys: |
lattice-${{ runner.os }}-
- name: Save the Lattice cache
if: always()
uses: actions/cache/save@v6
with:
path: .lattice/cache
key: lattice-${{ runner.os }}-${{ github.run_id }}
if: always() on the save step means a run that fails partway still saves
whatever the cache picked up before the failure.
Restore on a broad, rolling key: by OS, not by lockfile hash. What invalidates an entry inside the directory has nothing to do with the cache action’s key. Each entry is content-addressed, and a lookup is a hit only if that key matches and the stored artifact’s digest checks out. So the directory you restore does not need to match the current commit. Entries left over from an older commit are never looked up again, and they cost storage rather than correctness. See Caching.
The ledger lattice stats reads is a file in that same directory, so a restored
cache brings the run history with it and stats in a job reports across the jobs
that shared it. Jobs that restore the same snapshot in parallel each append to
their own copy and only one save wins, so a CI history is lossier than a single
machine’s.
Keep the saved cache bounded
Every run re-uploads the whole cache directory to the cache action’s storage, so an unbounded cache costs upload time on every job. Set a budget and each run holds itself to it:
{
"settings": {
"maxCacheSize": "2GB"
}
}
Eviction is least-recently-used, so what goes is whatever has not been hit in
the longest stretch of runs. To use a different limit in CI than locally, run
lattice prune before the save step:
lattice prune --max-size 2GB
With neither the setting nor the flag, prune fails rather than guess a limit,
and the cache grows without bound. See
Caching.
A prune here does not reclaim debris the run just left behind. A leftover has
to sit untouched for an hour first, so prune frees only the entries the size
limit evicts. The wait keeps two steps that share one checkout from deleting each
other’s cache writes. If a step runs lattice while another lattice is still
going, neither loses what it stored.
A complete workflow
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install lattice
run: |
curl -fsSL https://latticeandcompany.github.io/lattice/install.sh \
| sh -s -- --no-modify-path
- name: Restore the Lattice cache
uses: actions/cache/restore@v6
with:
path: .lattice/cache
key: lattice-${{ runner.os }}-${{ github.run_id }}
restore-keys: |
lattice-${{ runner.os }}-
- name: Set up toolchains and dependencies
run: ./.lattice/bin/lattice setup
- name: Run the pipeline
run: |
./.lattice/bin/lattice run lint test build \
--continue --concurrency 4
- name: Keep the cache bounded
if: always()
run: ./.lattice/bin/lattice prune --max-size 2GB
- name: Save the Lattice cache
if: always()
uses: actions/cache/save@v6
with:
path: .lattice/cache
key: lattice-${{ runner.os }}-${{ github.run_id }}
if: always() on the last two steps means a failed lint, caught by
--continue, still gets its cache pruned and saved. The job still fails.
lattice run’s non-zero exit propagates out of the run: step regardless of
what runs after it.
Check the runtime an action declares before you pin it. Every major above
(actions/checkout@v7, actions/cache/restore@v6, actions/cache/save@v6)
declares using: node24 in its action.yml. A major still on the Node 20
runtime is deprecated.
Next
- CLI reference for every flag
run,setup, andpruneaccept, and how flag, environment, andsettingsprecedence works. - Output and logging for the rest of the mode model.
- Caching for what feeds a cache key and the integrity rule that makes a stale cache directory safe to restore.