Your CI takes as long as its slowest job, not all of them added up. Perseids reads the workflow you already have, starts one Boat machine per job and matrix entry in your own account, runs them in parallel, and deletes every machine when it's done.
Benchmark: pretix's real CI took 15m26s to 22m11s on Perseids, against 25m18s on GitHub, for the same commit.
Before you start
You need a Boat account and API key. Large machines (the default) need Boat's $20 plan, and xlarge machines its $100 plan.
Install
curl -fsSL https://raw.githubusercontent.com/kkeeland/perseids/main/install.sh | sh
It clones Perseids into ~/.perseids, links the command into ~/.local/bin (no sudo), and tells you the exact command
for anything missing. Needs bash, git, jq, curl, ssh and Ruby 2.6+. macOS has all of them except jq
(brew install jq). Run it again to update.
Or by hand:
git clone https://github.com/kkeeland/perseids ~/.perseids
mkdir -p ~/.local/bin && ln -s ~/.perseids/bin/perseids ~/.local/bin/perseids
Add your Boat key
Create an API key in Boat (Settings → API keys), then:
perseids key
It asks once, checks the key with Boat, and saves it to ~/.config/perseids/boat.env with mode 600. Or set
BOAT_API_KEY. The key only ever goes to Boat's API. It's never printed and never copied to a machine.
Run
cd your-repo
perseids run # every job in .github/workflows/ci.yml
perseids run unit e2e # only these jobs
perseids run --dirty # include uncommitted changes
perseids exec 'npm test' # one command on one machine
Split one slow suite across machines
perseids exec --split 8 'npx playwright test --shard={i}/{n}'
perseids exec --split 8 --files 'test/**/*_test.rb' 'ruby -Itest -e "ARGV.each { |f| require \"./#{f}\" }" {files}'
{i} and {n} become each machine's shard number and the count, for tools that shard on their own (jest --shard,
pytest --splits {n} --group {i}). With --files, the files in that commit that match the glob are split into groups
balanced by file size, the same way every time, and {files} becomes one machine's group, quoted (it's added at the
end if the command doesn't have it). The summary shows each shard's time, and the run fails if any shard fails.
Find the commit that broke it
git bisect tests one commit at a time, about five rounds in a row. perseids bisect tests every commit in the range
at once, one machine each, and names the first bad commit in the time of one run.
perseids bisect 'npm test' # HEAD against where it left main (or the last 30 commits)
perseids bisect 'make test' --good v1.4.0 # your own range; --bad defaults to HEAD
It prints each commit with pass or fail, then the first bad one: its SHA, subject and author. If the range is longer
than -n (default 30), it tests an even spread first, then narrows. It tells you when the results don't line up, such
as a flaky test that passes after a failure.
What you get
Use it from your agent
Claude Code
/plugin marketplace add kkeeland/perseids
/plugin install perseids@perseids
Then /ci runs the workflow and reports the verdict.
Codex
codex plugin marketplace add kkeeland/perseids
codex plugin add perseids@perseids
Any agent. Add this to AGENTS.md or CLAUDE.md:
Before saying work is done, commit and run `perseids run`. Report the SHA and the exit code.
Run it from GitHub Actions
Add your Boat key as a repository secret named BOAT_API_KEY, then add this workflow. One small runner orchestrates,
and every job of ci.yml runs on its own cloud machine. The result shows up as a job summary.
# .github/workflows/perseids.yml
name: perseids
on: [push, pull_request]
jobs:
perseids:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: kkeeland/perseids@v1
with:
boat-api-key: ${{ secrets.BOAT_API_KEY }}
workflow: ci.yml
Inputs: workflow, jobs, machine (large or xlarge), max and keep-going.
Exit codes
| Code | Meaning |
|---|---|
0 | Every job passed. |
1 | A job failed. The summary names the step and prints the end of its log. |
2 | Incomplete. Something couldn't run on a machine, such as a macOS job. Never reported as green. |
99 | A machine failed, not your code. Run it again. |
Settings
| Variable | Default | Effect |
|---|---|---|
PERSEIDS_TYPE | large | Machine size: small, default, large (8 vCPU, 16 GB) or xlarge (16 vCPU). |
PERSEIDS_MAX | 10 | Machines at once, across every run on your account. |
PERSEIDS_TTL | 7200 | Seconds before a machine stops itself. |
PERSEIDS_TAIL | 40 | Log lines printed for each failed job. |
PERSEIDS_EVENT | push | The github.event_name that if: conditions see. |
Other commands: perseids ls lists your running machines, and perseids reap deletes any left by a run that was
killed.
What it runs
services: (with health checks), strategy.matrix (include and exclude), needs:, if: and ${{ }}
expressions, env, working-directory, GITHUB_ENV, GITHUB_PATH, GITHUB_OUTPUT, continue-on-error,
timeout-minutes, and the setup-node, setup-go, setup-python, setup-java, setup-ruby, pnpm, bun, deno,
uv and rust actions.
It doesn't run macOS or Windows jobs, container: jobs, reusable workflows, or other marketplace actions. Those are
reported as skipped and the run exits 2. Secrets are never sent to machines.
Custom machines
Need a database server, browsers or a pinned toolchain? Put the setup in .perseids/setup.sh, run
perseids image build once, and commit the .perseids/image file it writes.
Development
ruby test/runner_test.rb # the runner, on your machine
sh test/install_test.sh # install.sh, in a throwaway HOME
ruby test/bisect_test.rb # bisect's logic, on your machine
perseids run # this repo's own CI, on machines
License
MIT © 2026 Kevin Keeland