Skip to content

Building a GitHub repo, end to end

From an empty account to a green check on a pull request. The example builds one repo for three targets - Linux x86-64, Linux arm64 and macOS arm64 - because cross-platform is where the interesting decisions are.

Both routes are here: the CLI throughout, and the web UI where it is the better tool. Neither is a subset of the other.

Roughly half a day, and most of that is machines rather than pipelines.

There is no hosted runner pool. Work executes on agents you run, on your own machines, and an agent only claims jobs whose capabilities it advertises. Three targets means three hosts (or VMs, or containers) with your toolchain on them.

If you already run your own CI fleet this is familiar. If you have been using hosted runners, this is the real cost of the move and it is worth deciding on before the rest.

Terminal window
spool login https://api.hyperspool.dev # paste a PAT from Settings
spool where # which server, and why

That writes ~/.config/spool/config.toml with your org and project. Runs, agents, secrets and credentials are all scoped to the project.

Mint a token per machine. The capabilities you name are a ceiling the agent cannot widen at runtime:

Terminal window
spool agents issue linux-amd64-1 --project acme/web --expires-in 90d \
--cap shell --cap polyglot --cap linux-amd64

Repeat for linux-arm64-1 and darwin-arm64-1. The architecture tags are yours to invent - Hyperspool has no built-in notion of one, and linux-amd64 is just a string you will match on in a moment.

On each Linux host:

Terminal window
curl -fsSL https://hyperspool.com/install.sh | sh -s -- \
--service --server https://api.hyperspool.dev \
--id linux-amd64-1 --token csat_… --caps shell,polyglot,linux-amd64

--service writes a systemd unit, wires self-update, and runs as a throwaway DynamicUser. A build that installs its own toolchain needs a persistent home, so pass --user root for that one.

macOS takes longer. --service needs systemd; on darwin the script installs the binary and prints the spool agent run command for you to supervise yourself. You write the launchd plist, and you set HYPERSPOOL_AGENT_UPDATE_URL yourself or the agent never self-updates.

Toolchains must be on the agent’s PATH. Jobs run in a scrubbed environment - a small allowlist plus your pipeline’s env: - so nothing the agent happens to have in its shell leaks in. Widen it per agent with SPOOL_ENV_PASSTHROUGH=MYAPP_*,REGION.

Terminal window
spool agents ls # three agents, their capabilities, last seen

One command. It creates a webhook for the forge, a handler that clones each pushed commit on an agent and runs the pipeline out of that checkout, and a handler that reports the result back to the commit.

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

The Repositories panel on Connections is the same thing with a form. Either way it prints the ingest URL and a signing secret, and the page to paste them into:

Field Value
Payload URL the ingest URL
Content type application/json
Secret the signing secret
Events just the push event, to start

The two credentials are what an agent uses: one to clone, one to post the commit status. Skip --credential for a public repo, and skip --status-credential if you do not want a check on the commit yet - repo add says what you lose either way.

There is no setting that says which repo this project is for. Every push carries its own repo in the delivery, which is why the hook is named for the forge rather than the repo and why the second repo you add reuses it: no new secret, nothing to paste again.

Deliveries are signature-verified and deduped on the digest of the signed body, so a redelivered push resolves to the same event and does not start a second run. The delivery-id header is deliberately not the key: it is not covered by the signature, so dropping it would let a captured body be replayed for ever.

If you would rather not paste, Hyperspool can create the webhook through the forge’s API. That needs a token that may manage hooks (admin:repo_hook on GitHub), stored as a credential, and then Register on forge on the hook’s row does it. More setup than a paste for one repo, and less for twenty: the two ways.

Put it in the repo, at .spool/ci.yaml, so a commit builds the pipeline it shipped with:

name: ci
concurrency:
group: 'ci-${{ has(input.ref) ? input.ref.replace("refs/heads/", "") : "manual" }}'
policy: cancel-running
env:
# The build handler pins the pushed commit into the document as
# `input:` before spawning this. Empty on a manual run, which builds
# the default branch.
SHA: "${{ has(input.sha) ? input.sha : '' }}"
jobs:
build:
matrix:
values:
target:
- { cap: linux-amd64, triple: x86_64-unknown-linux-gnu }
- { cap: linux-arm64, triple: aarch64-unknown-linux-gnu }
- { cap: darwin-arm64, triple: aarch64-apple-darwin }
fail_fast: false
requires: ["${{ matrix.target.cap }}"]
timeout: 45m
env:
TARGET: "${{ matrix.target.triple }}"
steps:
- name: checkout
run: |
set -euo pipefail
git clone https://github.com/me/app app && cd app
# $SHA when a push built it; a `spool submit --ref` sets
# CI_TRIGGER_SHA to that ref instead.
sha="${SHA:-${CI_TRIGGER_SHA:-}}"
if [ -n "$sha" ]; then
git fetch origin "$sha" && git checkout "$sha"
fi
- name: build
run: cd app && cargo build --release --target "$TARGET"

A few things in there are worth naming.

requires: reads the combination, so one job spans three platforms. Each combination claims an agent advertising its own capability.

The commit comes from the input, and the input comes from the build handler. A spawned pipeline is a run of its own: it never saw the push, so there is no event to read and no CI_TRIGGER_SHA waiting in the environment. What it has is the input: the handler wrote into the document before spawning it, which is why the clone above reads $SHA and falls back to the manual var. Get this wrong and the job clones an empty string.

The same goes for filtering. A top-level if: and paths_ignore: compile into one predicate that the server evaluates when the event arrives, so they gate a pipeline the event delivers to and do nothing at all in a run that was spawned. spool repo add --when is where the path filter goes in this shape - step 5 has it.

concurrency.group is per branch, and the has() in it is not decoration. Every run carries what started it as its input:, so a manual spool submit renders this against {"type": "manual"} - a map with no ref - and reading a key that is not there is an error. A group that fails to render refuses the submit outright, so the bare ${{ input.ref }} form works on every push and rejects the run you were testing with. The replace is only for looks: without it the group renders ci-refs/heads/main, and that is then what a tag filter has to say.

Interpolate the sha into env: and read $SHA in the script, rather than splicing ${{ input.sha }} into the command. A spliced value lands in the script text unescaped, and a branch name is attacker-controlled on a public repo. An env: value is never re-parsed as script text.

Check it before you push:

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

Reading .spool/ci.yaml, validating it offline with spool check, then submitting: the follow view shows three combinations of one job, each claimed by the agent advertising its own capability, all reaching completed with their own target triple.

--follow opens the run’s live view. spool run .spool/ci.yaml runs it against your working tree with no server at all, though jobs pinned to a capability your machine does not advertise will wait rather than run.

Note where TARGET lives. A step’s env: value is a literal: the compiler quotes it into the step script before the expression resolves, so a hole there is refused at submit. Job-level env: renders normally, because each value becomes its own template rather than script text.

That is the whole setup. Commit .spool/ci.yaml, push, and the delivery starts a run: the build handler clones your commit on an agent, reads the pipeline out of that checkout, and runs it. The commit goes green or red when it finishes.

Two things about why it is built that way.

The pipeline is read at the pushed commit, not from a copy stored here. So a branch that changes its own pipeline is built by the definition it shipped with, and editing .spool/ci.yaml takes effect on the next push with no re-registering. The trap that avoids is registering the file itself as the handler:

Terminal window
spool handler set ci .spool/ci.yaml --as yaml --on 'gh.push' # not this

That stores a snapshot of the bytes as they were on your disk when you ran it. Nothing re-reads the file, so every push would build that copy for ever.

The clone happens on your agent, not here. For a repo connected with spool repo add, this server sees the push envelope - repo, ref, sha, sender, the touched paths - and the runs. The agent clones the commit and builds it, so your code never reaches the control plane, which is the point of running your own agents: what that claims exactly.

Two other paths do have the server read one file. spool submit --repo-path and a workflow node’s from_repo: fetch the pipeline at a ref through the forge API, using a credential you stored. That is the pipeline document and nothing else - never a checkout, never your sources - and a repo add build uses neither.

To filter what builds, give repo add a predicate. Skipping docs-only pushes, for instance:

Terminal window
spool repo add https://github.com/me/app --credential gh-read \
--when '!event.paths_known || event.paths.exists(p, !p.endsWith(".md"))'

It is evaluated before any run exists, so a push it declines costs nothing, and it is ANDed with the repo’s own test - a filter cannot widen the handler to another repo’s pushes. The paths_known guard matters: when a forge sends no file list the set is empty, and an unguarded exists over it would stop building rather than build everything.

--status-credential in step 3 already set this up: a handler on run.started and the three terminal types, posting pending when the build starts and success, failure or error when it ends. So a commit shows a check going yellow then green rather than appearing green out of nowhere.

Two details in it matter, because both are places to get it wrong by hand.

The four types are named rather than matched with run.*: the wildcard also delivers run.awaiting and run.wait_expired, so a run parking on an approval would post error on a commit that is doing exactly what it was asked to.

It identifies the commit from the run’s tags (repo: and sha:, stamped when the build handler spawned the pipeline) rather than from the event. A lifecycle payload carries event.payload.trigger.* for a run a delivery started directly, and a run an agent spawned has no trigger fields at all - so tags are the shape that works for both. The shipped github.report_run_status connector action does the same job through trigger, which is why repo add does not use it.

Set HYPERSPOOL_PUBLIC_URL on the server or the check has no link back to the run.

Then require the hyperspool context in the repo’s branch protection, and the check gates merges.

Web UI. The run page is a timeline waterfall with the pipeline strip across the top, live output per step, and any annotations the run attached. The runs list filters by tag, so concurrency-group:ci-main shows one branch’s queue.

CLI.

Terminal window
spool runs ls --limit 10
spool logs <run-id> --follow
spool follow <run-id> # the live TUI
  • Deploying agents - tokens, self-update, draining, and what happens when an agent dies mid-job.
  • Branch routing - PRs, release branches, and deciding what runs where.
  • Coming from GitHub Actions - the vocabulary table and what is deliberately absent.
  • Secrets - project secrets versus connector credentials, and which reaches a job how.

And the reason to be here rather than on a CI system: the same run that builds this commit can ship it, sleep through a soak, wait for a person, and promote - one run, resuming across agent and server restarts. See the tour.