shipcheck.toml
shipcheck.toml lives at the repository root. shipcheck reads it only from the default branch, as what runs says, so a branch cannot change what it must pass. Its settings follow the inputs of a workflow.
Without it on main there is nothing to run: shipcheck run exits 2 and says to run shipcheck init, the hooks and the daemon start nothing, and shipcheck post refuses. --base <ref> reads it from another ref for a trial, which never posts (commands).
shipcheck init writes host mode with a check for each workflow job that runs on PRs and passes when init tries it, and a [jobs] entry with runner = "act" for each job no host check runs as GitHub does, when Docker is running (which jobs run where). When one of those runs on Docker, it also writes network = "internet" once, at the top. When no job gives one, it uses the checks of a package.json test or lint script, a Cargo.toml, a pyproject.toml or a go.mod at the root. With none of these it writes act mode with every workflow, network = "internet" and shard_tests = true. It writes engine = "colima" only when you move a job to a colima VM on the Checks tab, never by default. It leaves an existing file as it is. It writes runner, so the file says which mode it uses, unless every job it places runs with act through [jobs]. A host mode file:
runner = "host"
on = ["push", "commit"]
max_parallel = 4
[host]
versions = ["cargo --version", "rustc --version"]
[host.checks]
test = "cargo test"
clippy = "cargo clippy --all-targets -- -D warnings"
An act mode file:
runner = "act"
workflows = ["ci.yml"]
on = ["push", "commit"]
paths = ["src/**", "Cargo.*"]
timeout_minutes = 30
retries = 1
max_parallel = 4
network = "internet"
shard_tests = true
[act]
platforms = { "ubuntu-latest" = "catthehacker/ubuntu@sha256:<digest>" }
jobs = { "ci.yml/build" = "catthehacker/ubuntu@sha256:<amd64 digest>" }
cache_paths = ["/home/runner/.shipcheck", "/home/runner/.npm", "/home/runner/.bun/install/cache", "/home/runner/.cache"]
slot_paths = ["/var/lib/apt/lists"]
[act.job_homes]
RUSTUP_HOME = "rustup"
CARGO_HOME = { dir = "cargo", path = "bin" }
GOMODCACHE = "gomod"| Setting | Default | Meaning |
|---|---|---|
workflows | none, must be set with runner = "act" unless jobs is set | Workflow files in .github/workflows that must pass. Every job in them runs under act, also with runner = "host" when jobs is set, except a job a host jobs entry takes out. Workflows not listed run only on GitHub, with their own triggers. Without jobs, empty with runner = "host" |
on | ["push"] | When a run starts on its own. push: the pre-push hook starts one for the tip of each pushed branch, never for a push to the default branch. commit: the post-commit hook starts one for each new commit, so the result is often ready before the push |
paths | every file | Glob patterns. When a change touches no matching file since it left main, the check passes without running and says so |
timeout_minutes | 30 | A workflow that runs longer fails |
retries | 1 | How many times a failed job runs again, in a new checkout, before it counts as failed. A failed attempt's log stays as <job>.attempt-<n>.log, and shipcheck run names it |
max_parallel | no cap | The most act jobs and host checks one run starts at once, counted together. A job starts once every job in its needs has passed. Without a cap every job starts at once, and many jobs share the CPUs and Docker's memory. Set a number to keep part of the machine free |
runner | "act" | host runs the [host] checks natively in a sandbox. act runs the workflows in Docker, with settings under [act]. shipcheck init writes it, see modes. jobs sets it for one job |
jobs | none | One job's mode, as [jobs."<file>/<job>"] with runner = "act" or "host", and engine, "docker" by default or "colima", for act entries only (engines). An act entry runs that job and the jobs it needs under act, beside the host checks, in the same run and under the same shipcheck check. A host entry takes that job out of a listed workflow, and the host.checks entry of the same id runs in its place |
network | "none" | What jobs can reach. none: nothing, not this machine, the local network or the internet. internet: the internet, from a Docker network of the run's own. Jobs also reach the local network, and on a Mac with Docker Desktop services on the Mac's 127.0.0.1 through host.docker.internal, see the limits. Turn it on only for code you trust as much as your own |
shard_tests | false | true splits the pytest step of each act job, or the pytest command of each host check, into parallel pytest processes: as many as the Docker engine's CPUs and memory allow at 1.5 GiB each in act mode, or the machine's CPUs in host mode, at most 6. A number fixes the count, from 1 to 32. It never edits your command or lockfile and installs nothing. A sharded run that fails runs again whole, and that result counts. The cost: a test that fails only after another test's leftovers can pass sharded. See speed up runs |
logs | "all" | Which job checks carry the end of their log. all: every job, passed or failed. failed: only failed jobs. A failed job always carries its log, and no setting hides it. On a public repository every reader of a PR sees this output. See the checks on the PR |
daemon | true | false keeps the daemon away from this repository: init and install do not add it, and a running daemon skips it |
daemon_authors | none | GitHub logins whose PRs and pushes the daemon on any machine may run when a PR's head has no result. Each daemon runs a head when its own user or a listed login opened the PR and pushed the head. Any other head only gets a stored result posted. Forks never run |
host.checks | none | With runner = "host": each check's name and its shell command, or a [host.checks.<name>] table with run and sandbox = false to run it outside the sandbox, below. A name starts with a letter or _, then letters, digits, _ and - |
host.versions | none | Commands whose output goes into the cache key, such as node --version, so a new toolchain runs again. A failing one stops the run |
host.env | none | Names of variables a check gets from your environment, beyond the base list in modes. Names that carry credentials are refused |
act.platforms | ubuntu-latest, ubuntu-24.04, ubuntu-24.04-arm and ubuntu-slim on catthehacker/ubuntu:runner-24.04, ubuntu-22.04 and ubuntu-22.04-arm on catthehacker/ubuntu:runner-22.04, pinned by digest | Maps a runs-on label to an image, pinned by digest. The default images run jobs as runner with sudo, as GitHub does. An image of your own runs as its own user. -self-hosted, which act uses to run a job on the machine itself, is refused here. Only engine = "colima" uses it, inside the VM shipcheck starts for the job |
act.jobs | none | An image for one job, as "<workflow>/<job>", when jobs that share a runs-on label need different images. For example, an amd64 image for a job whose tools ship no arm64 build, on an Apple silicon Mac |
act.cache_paths | the four paths above, plus each detected language's | Paths in the job container kept in a Docker volume per repository, so package caches survive between runs. Parallel jobs share these volumes, so list only caches whose lock lives inside the path. /home/runner/.shipcheck holds the per-job homes of act.job_homes |
act.slot_paths | apt's lists, plus each detected language's | Paths kept in a Docker volume per repository, run slot and job, for caches that break when two jobs use them at once: apt's lists, Maven, Gradle, sbt, yarn v1 and nub. Jobs in one slot run one at a time. Each slot keeps its own copy, so disk grows with the slots a repository has used. Set slot_paths = [], or a shorter list, to drop them, and shipcheck clean to remove the volumes |
act.job_homes | RUSTUP_HOME, CARGO_HOME, RUNNER_TOOL_CACHE and AGENT_TOOLSDIRECTORY, plus each detected language's: pnpm (npm_config_store_dir), Composer (COMPOSER_CACHE_DIR), Bundler (BUNDLE_USER_CACHE), Hex, Mix and rebar3 (HEX_HOME, MIX_HOME, REBAR_CACHE_DIR) | Gives each job its own directory under /home/runner/.shipcheck/<job>/ for each environment variable, so parallel jobs never install into one toolchain. ~/.rustup breaks when two jobs install into it at once, cargo keeps its lock at the root of ~/.cargo, setup actions such as setup-java or setup-zig copy into act's one tool cache for every job, and the package caches lock badly or not at all across containers. path adds a subdirectory to the job's PATH. A job that sets the variable itself keeps its value. job_homes = {} under [act] turns it off |
shipcheck adds a language's cache paths, slot paths and homes only when the tested commit has one of its files: a JavaScript lockfile, go.sum, pom.xml, build.gradle, build.sbt, Gemfile.lock, composer.lock, mix.lock, pubspec.lock or a .csproj, among others. A list you set yourself is used exactly as written, with nothing added.
Write a report to $GITHUB_STEP_SUMMARY and it appears in one shipcheck comment on the PR (the report comment). No setting turns it on.
Modes
runner = "host" runs commands you declare, natively on this machine in an OS sandbox, with no Docker. A pass means your code passed on this machine's toolchain, operating system and system libraries. When the machine where the agent runs is set up like production, with the same tools, dependencies, OS and system libraries (not the same CPU, memory or scale), a host check tests the code where it will run. That can be more faithful than act or GitHub's hosted runner, whose Ubuntu image is not production. It is also the fastest mode, and the one shipcheck init writes when it finds a check, unless the whole repository runs with act. Jobs a host check cannot run as GitHub does then run with act through [jobs], and Windows jobs, macOS jobs off a Mac and jobs that read the PR stay on GitHub (which jobs run where).
runner = "act" runs the GitHub workflows in Linux containers with act, isolated from the machine. It is the mode closest to GitHub's CI: a pass means the workflows as written passed in a container close to GitHub's runner. It needs Docker, and it is the default when shipcheck.toml sets no runner. shipcheck init writes it, with network = "internet", when a job runs a matrix of Linux entries, Docker is running, every job runs on Linux and none runs Docker, since host mode would test only this machine's toolchain. To switch a repository, change runner and, for act, list the workflows in workflows.
runner = "host"
[host]
checks = { test = "npm test", lint = "npm run lint" }
versions = ["node --version", "npm --version"]
env = ["NODE_OPTIONS"]
- Each check runs with
sh -cin its own copy-on-write clone of the tested tree, never in your worktree, all at once, or up tomax_parallelwhen it is set. A check passes on exit 0, and a run passes when every check does. - The clone starts from a template warmed from your checkout at most once an hour, so gitignored directories such as
node_modulesandtarget/are already there. This needs a volume that clones files, such as APFS, btrfs or XFS. Elsewhere a check gets a plain worktree and starts cold. Hidden directories are shared only when they hold build caches, such as.next,.turbo,.gradleand.uv-cache. A Python.venvstores absolute paths, so it is not shared: putuv syncin the check, and it rebuilds from uv's cache in seconds.git cow share <dir>adds a hidden folder to the list andgit config cow.warmInterval 30mchanges how often the template is warmed, for host checks too. - Each check runs in an OS sandbox:
sandbox-execon macOS, bubblewrap on Linux (install). Without one, host mode refuses to run, andshipcheck doctorsays so with the fix. A check writes only its clone, a new emptyHOMEandTMPDIR, and the repository's tool caches in shipcheck's folder, one for runs ofmain's tree and one for other trees. It reads the system and your toolchains, such as~/.cargo/bin,~/.rustup/toolchains,~/.nvm/versions,~/.local/share/uv/python, avm's~/.avm/binand Solana's~/.cache/solana, and nothing else in your home. Toolchains found only through$HOME, Docker's CLI plugins and Solana's platform-tools, are linked into the newHOME. It also gets its own rustup home of links and its own copy of Solana's SBF SDK, sinceanchor buildrelinks both on every build. It can serve and connect to Unix sockets in its clone,TMPDIRand caches, such as a test server on a socket inTMPDIR, and cannot reach Docker's socket or any other outside them, or start a process outside the sandbox (security). - shipcheck points
CARGO_HOME,npm_config_cache,BUN_INSTALL_CACHE_DIR,UV_CACHE_DIR,XDG_CACHE_HOME,GOPATHandGOCACHEinto the tool caches, so the first run downloads dependencies once.RUSTUP_HOMEandAVM_HOMEpoint at your installed toolchains, which a check can read but not change. Your~/.cargo/config.toml,~/.npmrcand other config in your home do not apply. - A check gets
PATH,USER,LOGNAME,LANG,LC_ALL,SHELLandTERMfrom your environment, the names inhost.env, andCI=true.PATHloses every entry in a temp folder or inside a git checkout, except Homebrew's, so a tool an agent wrote there never runs in place of the real one. shipcheck itself starts every process, git, act and bubblewrap included, with thisPATH. Nothing else, so tokens,SSH_AUTH_SOCK,AWS_*and the like stay out.host.envrefusesSSH_AUTH_SOCK,GIT_*,SHIPCHECK_*,AWS_*,GOOGLE_*,AZURE_*, any name withTOKEN,SECRET,PASSWORDorCREDENTIAL, and names ending in_KEY. - A check has the network this machine has, the internet, the LAN and local TCP services. A timeout kills the check's whole process group.
- The cache key holds the tree, the checks and the output of
versions, each of which may take 5 s. Pin files in the tree, such asrust-toolchain.tomlor.nvmrc, are in the key through the tree. In both modes theshipcheckcheck names a branch's changes to files that can change what a command does:package.jsonscripts,Makefile,justfile,pyproject.tomltool settings,Cargo.tomloutside its dependencies, and.cargo/config.toml. - A toolchain in your home outside the sandbox's list cannot be read, so its check fails. Install it under
/opt/homebrew,/usr/localor a listed path, or set its variable inhost.envto a place outside your home, for exampleenv = ["RUSTUP_HOME"]. - After an hourly warm from your checkout, the next run rebuilds what embeds your checkout's path, such as cargo's workspace crates. Later runs reuse the run's own build.
- Commands come only from
shipcheck.tomlonmain, never from the CLI or the branch.
Outside the sandbox
Some checks need a system service the sandbox denies, such as the iOS simulator. A check written as a table with sandbox = false on main can run without the sandbox:
[host.checks.ios]
run = "xcodebuild test -scheme App -destination 'platform=iOS Simulator,name=iPhone 17'"
sandbox = false
It runs outside the sandbox only on a machine where you ran shipcheck trust in the repository. Until then it runs in the sandbox, and every run and shipcheck doctor say this repository asks to run it with sandbox = false, with the command. shipcheck trust --remove takes it back. Trust follows the repository's origin URL, and the daemon and the hooks see it too.
Warning: that check runs any branch's code with your full access: your home, your SSH keys, your gh token and every other secret your user can read. Use it only on a repository where you trust every author the daemon runs PRs for. The check still gets its own clone, a new HOME and TMPDIR, the tool caches and the filtered environment, and shipcheck doctor and every run name it. A PR cannot turn it on, since shipcheck.toml comes from main, and a repository you never trusted cannot turn it on for you.
A jobs entry runs one job under act beside the host checks, such as tests that need Docker:
runner = "host"
[host.checks]
test = "cargo test"
[jobs."ci.yml/e2e"]
runner = "act"
network and [act] apply only when some job runs under act, and setting them when none does is an error. shard_tests applies in both modes.
Host mode runs the branch's code as you, in the sandbox, with your network. Use it only for your own and your agents' work. When the config has host checks, the daemon never checks a fork's PR, not even with a stored result. shipcheck init writes host checks from the workflows' run steps, or else the project files it finds, and prints where each came from. Check them before you commit the file.
To get the digest of one architecture of an image:
docker manifest inspect catthehacker/ubuntu:runner-24.04
Pull an amd64 image once before the first run (docker pull --platform linux/amd64 <image@digest>). Docker runs it under emulation on Apple silicon, which is slower, so keep it to the jobs that need it.
A commit whose message holds [skip ci], [ci skip] or [skip shipcheck] starts no run, and neither does a commit or push made with SHIPCHECK_SKIP=1. A skip never posts a result, so a PR that requires the job checks cannot merge until a later commit runs. shipcheck run ignores skips. Use paths for changes that should pass without running.
Runs started by on never block a commit or a push. They run in the background, and a failure turns the PR's job checks red. A newer commit on the same branch stops the older run first, like cancel-in-progress in a workflow.
shipcheck stops with an error when:
workflowsis empty under act or set under host, both withoutjobs, or names a file that does not exist or names one twice- a
jobskey is not"<a workflow file>/<job>"or names no job of that file, a host entry's file is not inworkflows, a host entry has nohost.checksentry of its id, host entries take every job out of a workflow, an act job needs a job a host entry moves, or a host entry setsengine max_parallelis 0- an
act.jobskey is not"<a file in workflows>/<job>" - a
pathspattern is invalid - an image is not pinned by digest
- a cache or slot path is not an absolute path made of letters, digits and
._/- - a
job_homesentry is not a variable name with relative paths - a
shard_testsnumber is outside 1 to 32 - host mode has no checks, a check name breaks the rule above, a command is empty, or a
host.envname is refused [host]is set under act withoutjobs- a setting is unknown, so a typo cannot pass unnoticed
Engines
engine in a [jobs] act entry picks what the job runs on:
[jobs."ci.yml/e2e"]
runner = "act"
engine = "colima"
docker, the default: a container on the current Docker context, with no Docker socket. It needs act and a running Docker engine (install).colima: a Colima VM of its own, with its own Docker and Linux kernel, for a job that runs Docker or needs a kernel feature a container blocks. It needs colima and lima installed, and also act and a running Docker engine on this machine, since shipcheck checks Docker before every act run and runs the jobs a colima job needs there. The VM runs its own Docker, started with--runtime docker, and its own act. EachSHIPCHECK_HOMEkeeps one stopped base VM,sc-base-<hash>, set up once with the VM's packages and act. Each job gets a copy-on-write clone of that base, so it boots already set up, and shipcheck deletes the VM, with the data disk colima leaves, once the job's result is back. A new colima or lima version, or a new setup, makes a new base and deletes the old one. shipcheck records each VM it starts. While no colima job runs, a run's cleanup andshipcheck cleandelete thesc-VMs that this home recorded and a dead run left, andshipcheck clean --alldeletes the base too. A VM another home or tool made is never touched.
How it was tested: shipcheck's own repository runs its ci.yml/test job with engine = "colima", so the shipcheck/test check of every shipcheck PR comes from a colima VM. Its end-to-end tests run real VMs on a Mac with colima, and passed on 2026-10-30. They check that a job runs in its own VM and leaves no VM behind, that the base VM stays for the next job, that a port the job listens on in the VM does not show up on the Mac's 127.0.0.1, that a run killed while its VM runs still gets that VM deleted, that other colima profiles are left alone, that a failing job and a job past its timeout both fail and leave no VM, and that a job its if: skips boots no VM. Speed has its times.
A colima job has these limits:
- Use it only for code you trust. The VM reaches this machine's
127.0.0.1through 192.168.5.2, and the internet, whatevernetworksays. That includes the artifact servers of Docker runs going at the same time. act runs beside the job's code in the VM, so the job can change its own result.shipcheck doctorsays so. - Secrets reach act once, through a FIFO in the VM that is removed after act reads it, so they never stay on the VM's disk. Steps have
sudoin the VM, and root can still read act's memory. - One VM per 16 GB of this machine's memory at a time, since each VM takes 8 GB, and the CPUs are split between them. A colima job past that waits for a VM and holds its job slot while it waits. The wait counts toward its
timeout_minutes, so at the deadline it fails as timed out, and its log says it waited for another colima job's VM. - Linux labels only. Labels with an image in
[act] platforms, plusself-hostedandlinux, run on the VM. Any other label, such asmacos-latest, fails as it does on Docker. - A colima job cannot pass artifacts to or from other jobs. shipcheck refuses one that uses
actions/upload-artifactoractions/download-artifact, so it fails before it runs. - Each job boots a VM, and no package or build cache crosses VMs, so a colima job is slower than a Docker job. shipcheck fetches the job's actions on this machine, with act, and copies them into the VM, so a job never changes what a later one runs. The cache key holds the colima and lima versions and a hash of the VM's setup. It does not hold the VM image's digest, which colima does not report, so a new image under the same colima version reuses old results.
shipcheck doctorsays so. - A job that a colima job
needsruns on Docker, so Docker must run too.