# shipcheck.toml

https://shipcheck.run/docs/config/

> Every setting of the one file the repository owner writes.

`shipcheck.toml` lives at the repository root. shipcheck reads it only from the default branch, as [what runs](https://shipcheck.run/docs/how-it-works/#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](https://shipcheck.run/docs/cli.md)).

`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](https://shipcheck.run/docs/how-it-works/#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:

```toml
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:

```toml
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](#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](#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](https://shipcheck.run/docs/limits.md). 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](https://shipcheck.run/docs/speed.md) |
| `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](https://shipcheck.run/docs/how-it-works/#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](#outside-the-sandbox). 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](#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](https://shipcheck.run/docs/how-it-works/#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](https://shipcheck.run/docs/how-it-works/#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`.

```toml
runner = "host"

[host]
checks = { test = "npm test", lint = "npm run lint" }
versions = ["node --version", "npm --version"]
env = ["NODE_OPTIONS"]
```

- Each check runs with `sh -c` in its own copy-on-write clone of the tested tree, never in your worktree, all at once, or up to `max_parallel` when 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_modules` and `target/` 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`, `.gradle` and `.uv-cache`. A Python `.venv` stores absolute paths, so it is not shared: put `uv sync` in the check, and it rebuilds from uv's cache in seconds. `git cow share <dir>` adds a hidden folder to the list and `git config cow.warmInterval 30m` changes how often the template is warmed, for host checks too.
- Each check runs in an OS sandbox: `sandbox-exec` on macOS, bubblewrap on Linux ([install](https://shipcheck.run/docs/install/#set-up-linux)). Without one, host mode refuses to run, and `shipcheck doctor` says so with the fix. A check writes only its clone, a new empty `HOME` and `TMPDIR`, and the repository's tool caches in shipcheck's folder, one for runs of `main`'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/bin` and 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 new `HOME`. It also gets its own rustup home of links and its own copy of Solana's SBF SDK, since `anchor build` relinks both on every build. It can serve and connect to Unix sockets in its clone, `TMPDIR` and caches, such as a test server on a socket in `TMPDIR`, and cannot reach Docker's socket or any other outside them, or start a process outside the sandbox ([security](https://shipcheck.run/docs/security.md)).
- shipcheck points `CARGO_HOME`, `npm_config_cache`, `BUN_INSTALL_CACHE_DIR`, `UV_CACHE_DIR`, `XDG_CACHE_HOME`, `GOPATH` and `GOCACHE` into the tool caches, so the first run downloads dependencies once. `RUSTUP_HOME` and `AVM_HOME` point at your installed toolchains, which a check can read but not change. Your `~/.cargo/config.toml`, `~/.npmrc` and other config in your home do not apply.
- A check gets `PATH`, `USER`, `LOGNAME`, `LANG`, `LC_ALL`, `SHELL` and `TERM` from your environment, the names in `host.env`, and `CI=true`. `PATH` loses 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 this `PATH`. Nothing else, so tokens, `SSH_AUTH_SOCK`, `AWS_*` and the like stay out. `host.env` refuses `SSH_AUTH_SOCK`, `GIT_*`, `SHIPCHECK_*`, `AWS_*`, `GOOGLE_*`, `AZURE_*`, any name with `TOKEN`, `SECRET`, `PASSWORD` or `CREDENTIAL`, 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 as `rust-toolchain.toml` or `.nvmrc`, are in the key through the tree. In both modes the `shipcheck` check names a branch's changes to files that can change what a command does: `package.json` scripts, `Makefile`, `justfile`, `pyproject.toml` tool settings, `Cargo.toml` outside 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/local` or a listed path, or set its variable in `host.env` to a place outside your home, for example `env = ["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.toml` on `main`, 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:

```toml
[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:

```toml
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:

```sh
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:

- `workflows` is empty under act or set under host, both without `jobs`, or names a file that does not exist or names one twice
- a `jobs` key is not `"<a workflow file>/<job>"` or names no job of that file, a host entry's file is not in `workflows`, a host entry has no `host.checks` entry 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 sets `engine`
- `max_parallel` is 0
- an `act.jobs` key is not `"<a file in workflows>/<job>"`
- a `paths` pattern 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_homes` entry is not a variable name with relative paths
- a `shard_tests` number is outside 1 to 32
- host mode has no checks, a check name breaks the rule above, a command is empty, or a `host.env` name is refused
- `[host]` is set under act without `jobs`
- a setting is unknown, so a typo cannot pass unnoticed

## Engines

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

```toml
[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](https://shipcheck.run/docs/install.md)).
- `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. Each `SHIPCHECK_HOME` keeps 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 and `shipcheck clean` delete the `sc-` VMs that this home recorded and a dead run left, and `shipcheck clean --all` deletes 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](https://shipcheck.run/docs/benchmarks/#colima) 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.1` through 192.168.5.2, and the internet, whatever `network` says. 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 doctor` says 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 `sudo` in 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`, plus `self-hosted` and `linux`, run on the VM. Any other label, such as `macos-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-artifact` or `actions/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 doctor` says so.
- A job that a colima job `needs` runs on Docker, so Docker must run too.
