Run your GitHub Actions locally. Merge on the green check.
Baste runs the workflows you already have in a fresh Linux VM on your machine every time you git push, then posts each job to GitHub as a commit status that branch protection accepts. No queue, no workflow rewrites, no servers.
Website · Get started · How it works · Compatibility · Architecture
Cloud CI is rented and queued, while your laptop sits mostly idle. Every push waits for a hosted runner, then bills you for the minutes.
- Your real workflow, run faithfully. The jobs in
.github/workflows, in a fresh VM built like GitHub's Ubuntu runner, on the exact commit you pushed. Uncommitted edits never leak into the result. - A check GitHub accepts. One commit status per job, with a stable name like
baste/CI/testthat you can make a required check. Pull requests merge on a local pass. - Never blocks a push.
git pushreturns right away and the run happens in the background, with a pending status on the commit within seconds. - Honest about what it can't run. Windows, macOS and service-container jobs are handed to GitHub before the run starts, never half-run.
- Zero infrastructure. Your machine does the work, GitHub is the only backend, and your logs stay local. Free and open source.
Other local tools solve one half of the problem:
| act | gh-signoff | Baste | |
|---|---|---|---|
| Runs the jobs in your workflow files | ✓ | a script you choose | ✓ |
| Each job in a fresh, runner-like VM | Docker on your host | ✓ | |
| Result shows up on the commit in GitHub | ✓ | ✓ | |
Starts on git push, in the background |
you run it | you run it | ✓ |
A basting stitch is the temporary one sewn before the final seam, the way a local check comes before the merge.
You need macOS 14+ on Apple Silicon, Linux with KVM, or Windows 11 with WSL2, plus the GitHub CLI logged in (gh auth login). Baste uses that login and never stores a token of its own.
1. Install Baste
npm install -g @weftsh/basteOr, without Node.js, with the install script, which verifies the download's checksum and puts the binary in ~/.local/bin:
curl -fsSL https://raw.githubusercontent.com/weftsh/baste/main/install.sh | shEither way you get one native binary for your platform (on macOS, plus the Linux agent that runs inside the VMs). Binaries for every platform are also on the releases page.
2. Set up your machine's VM backend (once)
| Host | Backend | Setup |
|---|---|---|
| macOS 14+, Apple Silicon | Tart, with Rosetta for x86_64 | brew install cirruslabs/cli/tart (or tart.app from its releases, with tart.app/Contents/MacOS/tart on your PATH)softwareupdate --install-rosetta --agree-to-license |
| Linux (x86_64, arm64) | Firecracker | sudo usermod -aG kvm $USER (then log in again)install e2fsprogssudo "$(command -v baste)" setup-network |
| Windows 11 | Firecracker inside WSL2 | Set nestedVirtualization=true under [wsl2] in %UserProfile%\.wslconfig and run wsl --shutdown. Then do every step on this page inside WSL2, including the Linux setup. |
Intel Macs aren't supported.
3. Turn it on in a repository
cd your-repo
baste init # checks virtualization, gh login and status permission, then installs a pre-push hook
baste image prepare # optional: download and provision the pinned VM image now instead of on the first pushbaste init checks everything before it changes anything. If something is missing, like KVM access, gh, or a token that can't write commit statuses, it says what's wrong and changes nothing. Run baste doctor any time to repeat the checks.
Want to try it before anything reaches GitHub? baste run --no-status runs your workflows for HEAD locally and posts nothing.
4. Push
$ git push
baste: running CI for a8d31b7 (feature/login) locally in run q7hz2m; see `baste status`
$ baste status
q7hz2m ● running a8d31b7 feature/login just now push
✓ CI / lint 41s
✓ CI / build 1m02s
● CI / test (node 22) 1m18s running 'npm test'
→ CI / e2e (windows) handed to GitHub: windows-latest jobs run on GitHub
$ baste logs latest # every step's output, live while it runsEach local job shows up on the commit as baste/<workflow>/<job>, pending at first, then green or red with its duration and run id.
5. Merge on a local pass
In branch protection (or a ruleset), require the baste/… checks that baste init listed, and make the GitHub-hosted versions of those jobs optional. That's it: pull requests now merge on a local pass. To also stop spending Actions minutes on jobs that already passed, add the gate action.
git push: the pre-push hook starts a run in the background, and the push completes normally.- A pending status appears on the commit for each job that runs locally, within seconds.
- Baste reads the workflows at the pushed commit and sorts jobs by runner.
ubuntu-*jobs run locally; Windows, macOS and self-hosted jobs are handed to GitHub and listed before the run starts. - Each job boots a fresh VM from a pinned image digest using copy-on-write, and checks out the pushed commit. Uncommitted edits never affect the result.
- The steps run:
run:steps, JavaScript, composite and Docker actions, matrices,needs:,if:,env, outputs and artifacts. - Each job's status flips to success or failure, with the duration and the local run id. The status links to a page that shows the
baste logs <run>command. - Make those contexts required checks in branch protection, and PRs merge on a local pass.
On the first run, Baste downloads the pinned image and verifies it by digest. It then provisions the slim image once (git, build tools, Python, Node.js for JavaScript actions, Docker, and a runner user) and caches it locally. Later runs boot a copy-on-write clone in seconds.
Default: the local status is the required check. No workflow edits are needed. In branch protection (or a ruleset), require the baste/<workflow>/<job> contexts that baste init lists. Then make the GitHub-hosted versions of those jobs non-required, or trim their triggers.
Opt-in: the gate action. Add a first job to a workflow. When Baste already reported a local pass for the commit, the other GitHub-hosted jobs skip and the workflow passes in seconds. With no local status, everything runs on GitHub as usual.
permissions:
contents: read
statuses: read
jobs:
baste-gate:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.gate.outputs.skip }}
steps:
- id: gate
uses: weftsh/baste/gate@v1
build:
needs: baste-gate
if: needs.baste-gate.outputs.skip != 'true'
runs-on: ubuntu-latest
steps: [...]Only gate jobs that Baste runs locally (ubuntu-*), not Windows or macOS ones. baste gate prints this snippet.
| Command | What it does |
|---|---|
baste init [--backend B] |
Check everything, then install the pre-push hook (chaining any existing one) |
baste doctor |
The same checks, changing nothing |
baste status [RUN] [--commit SHA] |
Recent runs with SHA, branch, start, duration and each job's state (queued, running, passed, failed, handed to GitHub) |
baste logs RUN [JOB] [--failed] |
Output per job and step, with exit codes; streams live while the run is in progress |
baste run [--job J] [--workflow W] [--no-status] |
Run the workflows for HEAD now, without pushing |
baste rerun RUN |
Run again on the same commit and update its statuses |
baste cancel RUN |
Stop a run in progress |
baste insights |
Time per step, local vs. GitHub duration, time and Actions minutes saved, flaky jobs |
baste secrets set NAME [--global] / list / rm |
Local secrets in the OS keychain |
baste config [set KEY VALUE] |
Settings in ~/.config/baste/config.toml |
baste image prepare |
Download and provision the pinned VM image now |
sudo baste setup-network |
Linux: tap devices and NAT for Firecracker VMs, persisted with a systemd unit |
baste uninstall |
Remove the hook |
Run ids are short (for example q7hz2m) and appear in each status description. latest and unique prefixes work too.
Secrets come from the OS keychain, never from GitHub: the macOS Keychain, or the Secret Service on Linux and WSL2 (via secret-tool).
baste secrets set NPM_TOKEN # prompts, or reads stdin
baste secrets set SENTRY_DSN --global # for every repositoryA job that references a secret you haven't set fails before any step runs, with a message naming the secret. On macOS, the first time a new version of Baste reads your secrets, macOS asks whether it may use them; choose Always Allow. Values are masked in logs. On headless machines without a keychain, you can opt in to a private 0600 file with BASTE_SECRETS_FILE=~/.config/baste/secrets.json.
GITHUB_TOKEN (and github.token) in local runs is your gh CLI token. It usually has broader scopes than GitHub's per-job token and doesn't expire with the job, so any action in the run can use it. Baste masks it in logs and warns about it at init.
| Supported | Handed to GitHub (flagged before the run) |
|---|---|
push and pull_request triggers, with branch, tag, path and type filters |
Jobs on windows-*, macos-*, self-hosted runners or runner groups |
ubuntu-* jobs |
Service containers (services:), job containers (container:) |
run: steps with bash, sh, python or a custom shell |
Reusable workflows (jobs.<id>.uses) |
| JavaScript (run on Node 24, as on GitHub), composite and Docker actions, pre/post steps | Jobs with environment: or that request OIDC tokens |
needs: ordering and outputs, matrices with include/exclude, fail-fast, max-parallel |
|
env, defaults, expressions and if: (status functions, hashFiles, fromJSON, ...) |
|
GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, step summaries, workflow commands, masking |
|
actions/checkout (from the pushed commit, with any fetch depth), upload-artifact and download-artifact between jobs |
pull_request workflows run on the same test merge commit GitHub would create, when the branch has an open PR. See docs/compatibility.md for details.
Architecture. On Apple Silicon, Linux guests are arm64 while GitHub's ubuntu-latest is x86_64. Baste enables Rosetta for Linux inside the VM, so x86_64 binaries and linux/amd64 images run as they do on GitHub. Docker defaults to linux/amd64 there.
baste config shows the settings; baste config set KEY VALUE changes one.
| Key | Default | Meaning |
|---|---|---|
backend |
auto |
auto, firecracker, tart, or host (no VM; for developing Baste itself) |
max_parallel_jobs |
2 |
VMs running at once, across all runs |
cpus, memory_mb |
auto | Per-VM caps (half the cores up to 4; a quarter of memory, 2–8 GiB) |
disk_gb |
40 |
Sparse writable disk per VM |
pause_on_battery |
false |
Wait for AC power before starting a run |
cancel_superseded |
true |
A new push to a branch cancels its older, still-running run |
notify |
true |
A desktop notification when a run started by a push finishes |
status_wait_minutes |
15 |
How long statuses retry while the pushed commit hasn't reached GitHub yet |
details_url |
GitHub Pages | Link on each status; {run}, {repo}, {sha} are substituted |
BASTE_DISABLE=1 git push (or git push --no-verify) skips the local run for one push.
Baste is trust-based, like gh-signoff: a developer could post a green status without a real run. It's built for small, trusted teams, not for separation-of-duties controls. Everything stays on the developer's machine: logs, run records (.git/baste/) and secrets. Every run records its provenance, meaning where it ran, the backend, the image digest, and the routing policy.
crates/
baste-expr GitHub Actions expressions: ${{ }}, if:, functions
baste-workflow workflow model, trigger filters, matrices, runs-on classification
baste-protocol the runner protocol: a job spec in, a stream of events out
baste-agent the executor that runs a job inside the VM
baste the CLI: hook, worker, statuses, backends, commands
gate/ the opt-in gate action
site/ the website (Tailwind, GitHub Pages), including the page status links point to
Every backend drives the same agent through one runner protocol (a job spec in, a stream of events out), so there are no backend-specific code paths. Routing is a policy ("the pusher's machine runs the push") kept apart from the agent. See docs/architecture.md.
cargo test --workspace # unit, agent and end-to-end tests (fake GitHub API, host backend)
cargo clippy --workspace --all-targets -- -D warnings
gate/test.sh # the gate action
# The VM guest path without KVM (QEMU microvm, software emulation):
cargo build --release --target x86_64-unknown-linux-musl -p baste
scripts/qemu-smoke.sh target/x86_64-unknown-linux-musl/release/baste vmlinux rootfs.squashfs
# The whole Firecracker backend (needs KVM and `sudo baste setup-network`):
scripts/firecracker-e2e.sh target/x86_64-unknown-linux-musl/release/baste
# The website (Node.js 20+): build the Tailwind CSS, check links, then open site/public/index.html
cd site && npm ci && npm run build && npm run check # `npm run dev` rebuilds on changeCI runs all of these, including the Firecracker run on KVM-enabled GitHub runners. The host backend (--backend host) runs jobs directly on your machine with no VM. It's useful for working on Baste, but offers no isolation or fidelity guarantees.
Releases are one click: Actions → Release → Run workflow, then pick patch, minor or major. It bumps the version, builds the Linux and macOS binaries, publishes the GitHub release and the npm packages, and moves the gate action's v1 tag. See CONTRIBUTING.md.
- Service containers (
services:) inside the VM, so jobs that need Postgres or Redis run locally too actions/cachebacked by a local cache- A GitHub App with signed attestation of each run, for teams that need more than trust-based checks
