Skip to content

Coming from GitHub Actions, GitLab CI or Buildkite

Tests on every push is spool repo add plus a pipeline in your repo, and nothing on that path involves the canvas. This page maps the words you already have onto the ones used here, then shows the thing you can’t do in a CI system: a pipeline that keeps running after the deploy.

Two pieces: a pipeline in your repo, and spool repo add to connect the repo so pushes run it. The pipeline is YAML at any path you like. .spool/ci.yaml:

jobs:
test:
steps:
- name: install
run: npm ci
- name: test
run: npm test

Run it against a ref without waiting for a push:

Terminal window
spool submit --repo-path .spool/ci.yaml --repo me/app --repo-credential gh-read --ref main

Run it on your own machine, against your working tree, with no server at all:

Terminal window
spool dev # in another terminal
spool run .spool/ci.yaml

To fire it on every push, connect the repo. One command, no canvas:

Terminal window
spool repo add https://github.com/me/app \
--credential gh-read --status-credential github-status

That creates the webhook for the forge, a handler that clones each pushed commit on an agent and runs .spool/ci.yaml out of that checkout, and a handler that puts the result on the commit. It prints the ingest URL and a signing secret to paste into the repo’s webhook settings. CI from a repo has the whole flow, including what to do when you want the dispatch to make a judgement of its own.

Branch and path filters go on that command, as --when. One CEL expression over the event covers both, and it is evaluated server-side when the delivery lands, before any run exists, so a filtered push costs nothing:

Terminal window
spool repo add https://github.com/me/app --credential gh-read \
--when 'event.ref == "refs/heads/main" && event.paths.exists(p, p.startsWith("api/"))'

A pipeline can carry the same predicate itself, as a top-level if:, but only where a handler delivers the event to the pipeline. A repo add build is spawned rather than delivered, and a spawned run is not gated on a trigger predicate, so an if: in .spool/ci.yaml does nothing there. Filter on --when.

Your word Here Worth knowing
workflow file pipeline YAML in your repo, fetched at the commit that triggered it
job job one entry under jobs:
step step entries under steps:; the whole job runs as one activity on one agent
needs: depends_on: same shape
runner agent you run them; each advertises capabilities
runner label requires: a job claims only agents carrying those capabilities
runs-on: ${{ matrix.os }} requires: ["${{ matrix.target.cap }}"] one job across platforms; the capability is read per combination
on: push top-level if: one expression, evaluated before a run is created
paths: / paths-ignore: paths: / paths_ignore: same names; they fail open when the forge reports no file list
concurrency group concurrency: group plus queue, cancel-running, cancel-queued or skip
artifact upload spool artifact put same verb stores a build cache with --key
artifact download spool artifact get exits 1 on a miss, so a cache lookup is plain shell
secret credential resolved to auth headers at dispatch, never stored in the definition
environment approval approval: job pauses at any point in the graph, not only before a deploy
reusable workflow workflow: job runs another pipeline as a durable child
job output outputs: spool output set key=value from a step
matrix matrix: one job per combination, read as ${{ matrix.<name> }}

Jobs can wait. An approval: job parks on a signal and holds no agent while it does, so a pipeline can stop for a person and pick up hours later with its state intact:

promote:
depends_on: [deploy-staging]
approval:
prompt: "Promote to production?"
fields:
- { name: env, type: select, options: [staging, production] }

A wait: job does the same for a timer or a named signal. There is no cap on how long either parks; a run waiting a month occupies a database row.

The agents are yours. An agent claims work matching its capabilities, so build hardware, GPU boxes and network-restricted machines share one queue inside your infrastructure. Nothing is metered per minute.

One history. Runs, schedules, bus events and child runs are the same product with one timeline, so the thing that happens after the deploy doesn’t move to a different tool with its own logs.

The reason to move a pipeline here is usually the deploy that watches itself. One run builds the commit, ships it to staging, sleeps through a soak subscribed to alert events, waits for a human, promotes, and reports on how the release landed a week later. It is one script and one run:

Terminal window
spool step build -- make release
spool step deploy-staging -- ./deploy.sh staging
spool sleep 2w
verdict="$(spool wait-signal ship)"
spool step deploy-prod -- ./deploy.sh production

Kill the agent, restart the server, come back in a fortnight: the run resumes from its last checkpoint. See the authoring model for the rules that make that work, and examples/release-watch for the whole thing including the report at the end.

  • A status badge endpoint.
  • A marketplace. Steps are shell commands and scripts in your language; there is no third-party action to pin to a SHA.