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"
SettingDefaultMeaning
workflowsnone, must be set with runner = "act" unless jobs is setWorkflow 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
pathsevery fileGlob patterns. When a change touches no matching file since it left main, the check passes without running and says so
timeout_minutes30A workflow that runs longer fails
retries1How 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_parallelno capThe 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
jobsnoneOne 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_testsfalsetrue 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
daemontruefalse keeps the daemon away from this repository: init and install do not add it, and a running daemon skips it
daemon_authorsnoneGitHub 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.checksnoneWith 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.versionsnoneCommands whose output goes into the cache key, such as node --version, so a new toolchain runs again. A failing one stops the run
host.envnoneNames of variables a check gets from your environment, beyond the base list in modes. Names that carry credentials are refused
act.platformsubuntu-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 digestMaps 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.jobsnoneAn 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_pathsthe four paths above, plus each detected language'sPaths 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_pathsapt's lists, plus each detected language'sPaths 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_homesRUSTUP_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"]

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:

Engines

engine in a [jobs] act entry picks what the job runs on:

[jobs."ci.yml/e2e"]
runner = "act"
engine = "colima"

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:

Previous: Agents Next: Speed