How it works

A tool reports the result, never the agent

The agent can start a run. Only shipcheck turns a run into a check, from facts it reads itself: the exit codes of the processes it started, the git tree of the commit, and the config on main. An agent that says the tests passed changes nothing.

What runs

runner in shipcheck.toml picks the mode of every job, and [jobs] moves one job to the other mode in the same run (modes). shipcheck init writes host mode when it finds checks in the workflows or a project file it knows, with a [jobs] act entry for each job no host check runs as GitHub does. It writes act mode for the whole repository when a job runs a Linux matrix, every job runs on Linux and Docker is running, and when it finds nothing (which jobs run where).

shipcheck reads shipcheck.toml and .github/workflows/ from the base branch, found in one order for every command: the base install stored for this clone, then the default branch the remote reports (git ls-remote --symref origin HEAD), then origin/main with a warning. The hooks never ask the remote, so they skip the second step. init refuses when the remote does not answer, and nothing posts from the origin/main fallback. These docs call that branch main. shipcheck never reads them from the branch or from your working copy, so a PR that edits a workflow to exit 0 or empties workflows changes nothing until it merges.

When paths is set and a change touches none of those files since it left main, nothing runs and every job check is skipped with that reason. shipcheck works this out again on each run, because it depends on where main is.

In act mode, before a run, shipcheck edits its own copy of each workflow it runs. The repository's files stay as they are.

A workflow passes when every job that started reached its end with status success, and no job was skipped for an unsupported platform. As on GitHub, a failed job with continue-on-error: true does not fail the workflow. act runs a PR's checks as workflow_dispatch, so when it skips a job by its if:, that skip does not tell whether GitHub would run the job on the PR. So a job act skips by its if: fails, with failure, act skipped it by its `if:`, which may hold on a PR, and only GitHub can tell, unless that if: is false on every PR:

A condition counts when it ands one of these terms with the rest. An ||, or a ! other than !=, could make it hold on a PR, so a condition with one never counts. A job whose if: reads the PR, such as github.event.pull_request.draft == false, or reads vars, fails too. A job that needs a skipped job fails as well when its if: uses always(), failure() or cancelled(), since GitHub would run it anyway.

Steps get the same check. A step whose if: reads something workflow_dispatch and a PR answer differently, the event, github.ref, head_ref, base_ref, the actor or vars, gets a shipcheck if step before it in shipcheck's copy of the workflow. That also counts when the if: reads an env. value whose step, job or workflow env reads one of them. When act skips the step, the guard fails the job with act skipped the step '<name>' by its if:, which may hold on a PR, and only GitHub can tell. A step whose if: is false on every PR, or that runs only on failure() or cancelled(), gets no guard. The guard is part of the workflow as it runs, so the cache key holds it.

The step check has two gaps. It does not see steps inside a composite action. It does not catch a step that act runs but GitHub would skip on the PR.

To fix such a job or step, keep the job on GitHub by leaving its workflow out of workflows, or make the if: false on every PR with one of the terms above.

Which jobs run where

shipcheck init places each job of a workflow GitHub runs for feature branches:

JobFor a PR it runs
A host check runs it as GitHub doesHere, as a host check
No host check runs it as GitHub does, such as a job with a heredoc or \ step, sudo or apt, an action no command stands for, a container:, a JDK this machine lacks, or a Linux matrix, Docker runningHere, with act, as a [jobs."<file>/<job>"] entry with runner = "act". init also writes network = "internet", since such jobs download what their steps set up
The same, no DockerOn GitHub Actions
A Linux matrix, Docker running, every job on Linux and none running DockerHere, with act. The whole repository uses act mode, with network = "internet" and shard_tests = true
A matrix with macOS or Windows entriesOn GitHub Actions
On macOSHere as a host check on a Mac, on GitHub Actions elsewhere
On WindowsOn GitHub Actions
It reads the PR: through its if:, a step's if:, an env, secrets, vars or ref value, a script, Makefile, justfile or package.json its command reads, or HEAD in a PR workflowOn GitHub Actions
It uses a PR-aware action, such as one that lints only the changed lines, or an action that reports to GitHub or publishesOn GitHub Actions
Its runs-on label has no act image, or it uses a local actionOn GitHub Actions
It runs Docker or changes the kernel, such as mount, sysctl, bwrap or modprobeOn GitHub Actions. colima could run it, and you can move it to a colima VM on init's Checks tab. init never picks that by default, since the VM reaches this machine's loopback (engines)
Its host check fails when init tries itOn GitHub Actions, and init prints the end of its log and the fix

A job goes to act only when every job it needs can run there too. A job a job on GitHub needs stays on GitHub with it. A Rust toolchain or Ruby that this machine lacks does not keep a job off: its check runs on this machine's version, and init lists it under "run on this machine's version, not the workflow's".

A matrix of one combination is a plain job. A matrix exists to test several toolchains or systems, and host mode tests only the one on this machine, so a matrix job runs with act or stays on GitHub. That holds for a matrix such as express's, whose run steps never name the matrix, since its setup actions do. When the whole repository uses act mode, a workflow with a job act cannot run as GitHub does stays on GitHub as it is, since act runs a listed workflow whole.

shipcheck does not support Windows yet, so Windows jobs stay on GitHub Actions (limits).

init prints each job that stays on GitHub and why. A workflow with such a job is kept whole: its triggers stay, and each job shipcheck runs gets an if: that runs it on GitHub only on the default branch, on the other branches the workflow lists for pushes, on PRs into other branches, for Dependabot and for a fork's PR, and-ed with an if: it already has. On a PR into the default branch, GitHub then runs only the jobs that stay, and on the default branch it runs them all. A job that a job on GitHub needs, directly or through others, stays on GitHub too, since GitHub skips a job whose needs it skipped. If you decline the edit, the workflow keeps running fully on GitHub for PRs, beside shipcheck's checks.

The ruleset init offers requires shipcheck/<job> from the shipcheck App and, from GitHub Actions, the checks of the jobs that stay on GitHub. When their names depend on a matrix include or an expression, init says so and you add them once a PR shows them. Jobs that stay on GitHub still use Actions minutes.

To move a repository to act anyway, set runner = "act" and list the workflows in workflows in shipcheck.toml (modes). To give a job back to GitHub, remove its check from [host.checks] or its [jobs] entry, and its if: from the workflow.

When it runs

Runs start from the hooks, as on in shipcheck.toml says, or from shipcheck run.

shipcheck run tests the working copy, so the test run an agent does before it commits becomes the check. shipcheck builds a tree from the working copy in a copy of the index: tracked files with their edits and untracked files that are not gitignored. Your branch, index and stash stay as they are.

--staged builds it from the index instead. When you commit that same tree, with shipcheck commit or with git, the commit hook and shipcheck post find the result by tree and nothing runs again. A commit with any other tree, such as one with a file left out, runs again.

When a PR changes on GitHub

A rebase or an update on GitHub, such as the Update branch button, makes a new head commit with no local push, so no hook runs. The daemon covers that case. shipcheck init and shipcheck install start it as a background service.

daemon = false in shipcheck.toml keeps it off a repository, and shipcheck daemon --uninstall removes it from the machine.

The daemon's settings live in ~/.local/state/shipcheck/daemon.toml, next to the repositories it watches. fetch = "https" sends shipcheck's own fetches of a GitHub remote over HTTPS with git's credential helper, such as gh auth git-credential: the daemon's, a posting run's and the one that finds the default branch. An SSH key that asks for a touch then never signs them. Your remotes, your own git and every push stay as they are.

Where it runs

In act mode each job gets a new checkout of the tested tree, cloned with copy-on-write from its own warm template, so it starts with the build output of its earlier runs and costs no disk. A working copy is checked out from a commit no ref names, with HEAD as parent, as git stash create makes. A run never touches your own checkout. Jobs never share a checkout, as on GitHub. A job starts once the jobs in its needs pass, and gets their outputs. Gitignored files are not in it.

The checkout sits at a fixed path per repository, slot and job, because builds remember absolute paths. Toolchains and package caches live in Docker volumes per repository, removed after 14 days without a run that uses them. apt's package lists get a volume per slot and job, and .venv a new volume each run, off the macOS bind mount. setup-uv steps get prune-cache: false in the job's copy, so uv's cache keeps its wheels, and pnpm/action-setup's run_install becomes plain pnpm install steps after it, since the action prunes the store after an install. Both are fed only by shipcheck's own runs, never by builds on your machine, which would not run in a Linux container.

act runs each job in Docker, never on the machine itself. A job gets its checkout, its cache volumes, no Docker socket, no host token or environment, and by default no network.

A job with engine = "colima" runs on a Colima VM that shipcheck starts for that job and deletes after it. The VM shares no folder with this machine. It gets the checkout's tracked files and a .git of its own over colima ssh, and act runs inside it with -self-hosted, so the steps run on the VM, with its own Docker and kernel. The VM reaches this machine's 127.0.0.1 and the internet whatever network says, so it is for code you trust (engines).

In host mode each check gets the same kind of clone, but its template is warmed from your checkout at most once an hour, so gitignored node_modules and target/ are there. It runs in sandbox-exec on macOS or bubblewrap on Linux: it writes only its clone, a new home and the repository's tool caches, reads the system and your toolchains, and has the network (config).

When a stored result counts

shipcheck stores each result under a key made of:

Two commits with the same tree share a result, so a rebase or a squash that changes nothing needs no new run. A change to the config or the workflows on main changes the key, so old results stop counting.

Only a pass is reused. A failure runs again next time, so a flaky failure cannot stick.

Two runs of the same tree at once share one run. The second waits, then reads the result.

The checks on the PR

shipcheck posts one check per job, shipcheck/test for job test, as GitHub Actions does, and branch protection requires them (require the checks). Each is queued until a machine runs the commit, in progress while it runs, then green or red. A paths skip marks every job skipped. A shipcheck check beside them sums up the run, with each failed workflow's log tail.

Each job check shows how long the job took and, as logs in shipcheck.toml says, the end of its log (config):

The report comment

Write a report to $GITHUB_STEP_SUMMARY and it appears in one shipcheck comment on the PR. This replaces a step that posts a coverage report with gh pr comment, which cannot run under shipcheck: a job has no GitHub token and, by default, no network.

It works in both modes. In act mode, act gives each step the file. In host mode, each check gets GITHUB_STEP_SUMMARY set to a file in its sandbox. The App needs Pull requests: write for the comment. Until it has it, the checks still post, and shipcheck post prints one line saying there is no comment.

Previous: Commands Next: What a green check proves