CI from a repo
spool repo add <url> builds every push: the webhook and the handler it
creates run the pipeline the pushed commit shipped with, out of
.spool/ci.yaml in the repo itself, versioned with the code. To run one
on demand instead, spool submit --repo-path fetches a pipeline at the
commit you name.
Run a pipeline from your repo
Section titled “Run a pipeline from your repo”The quickest path needs no canvas. Store a token that can read the repo as a bearer credential, then run any pipeline from it on demand:
spool submit --repo-path .spool/deploy.yaml \ --repo me/private-app \ --repo-credential gh-read \ --ref mainThe server reads the file out of the repo at that ref and runs it, so the
run builds the definition that commit shipped with rather than whatever
was on your disk. A manual submit gets the same CI_TRIGGER_*
environment a push would (see
what a run sees), so a cloning job can check
out the ref.
Naming the repo every time is not boilerplate you can configure away. See where the repo comes from.
Run one automatically on every push
Section titled “Run one automatically on every push”One command - spool repo add - and the pipeline stays in the repo:
spool repo add https://github.com/me/app --credential gh-read \ --status-credential github-statusIt prints the ingest URL and a signing secret, and the page to paste them into. Paste them, push, and the commit goes green or red. The Repositories panel on Connections is the same thing with a form.
push -> webhook -> <hook>.push -> build handler -> your pipeline | final statusWhat it creates, named in its own output so nothing is hidden:
- One event-hook for the forge. The name is the forge, not the repo,
because one hook serves every repo on it: the second repo you add
reuses the hook, so there is no second secret and nothing to paste
again. What keeps them apart is the build handler’s
--when event.key == "me/app", the delivery’s own subject. - A build handler on
<hook>.push. It clones the pushed commit on an agent and runs the pipeline out of that checkout, so a push builds the definition that commit shipped with. The agent does the cloning, which is why this server never needs to read your code. - A status handler, when you give it a credential that may write
commit statuses. Without one, nothing reports back and
repo addsays so.
--credential names a project credential the agent clones with.
Omit it for a public repo. Re-running repo add is how you change the
pipeline path or adopt a rotated secret: the names are derived, so it
converges rather than duplicating.
Then spool repo ls shows what is connected, and where a delivery goes
when it arrives.
Wiring it by hand
Section titled “Wiring it by hand”repo add is a composition over three commands, and they are worth
knowing if you want something it does not do: a different trigger type,
several pipelines per repo, or a dispatch that makes a judgement before
it starts anything.
spool event-hook set world --manifest githubspool handler set world-ci .spool/router.sh --as script \ --on world.push \ --when '!event.paths_known || event.paths.exists(p, !p.endsWith(".md"))' \ --credential GITHUB_STATUS_TOKEN=github-statusThe --when runs before any run exists, so a push it declines costs
nothing. Its only root is event (the envelope below); a predicate
reading anything else is refused when you set the handler.
A script router is the most general form, and the one to reach for when
dispatching a push is a judgement rather than a rule. The whole of one
is in this repo at .spool/router.sh; in outline:
# The event, whole. A multi-commit push body is well past what an# environment variable would hold.cp "${SPOOL_INPUT_FILE:?}" /tmp/event.jsonsha=$(jq -r '.sha // .payload.body.after' /tmp/event.json)repo=$(jq -r '.repo // .payload.body.repository.full_name' /tmp/event.json)
# Red on the commit if anything below fails before the build starts.trap 'post_status failure "CI dispatch failed"' ERRpost_status pending "Build dispatched"
# The pipeline as it existed at THIS commit, so a push builds the# definition it shipped with. A public repo needs no credential:curl -fsSL "https://raw.githubusercontent.com/${repo}/${sha}/.spool/ci.yaml" > /tmp/ci.yaml# A private one clones with a deploy key from a project secret instead.
# `spool spawn` carries no structured input, so pin a minimal one in.printf 'input: {"sha":"%s","repo":"%s"}\n' "$sha" "$repo" | cat - /tmp/ci.yaml > /tmp/run.yamlspool spawn /tmp/run.yaml --tag "sha:${sha}" --tag "repo:${repo}"Three details that are easy to get wrong, and that repo add handles
for you. A branch delete pushes the null sha (0000…), which has no
tree to build, so skip it. Nothing from the delivery may be spliced
into a shell command: a branch name is attacker-controlled on a public
repo, so read it from an environment variable the runtime sets. And
pushing to a PR branch emits both a push and a synchronize, so
admitting pushes only for the default branch keeps each build to one
trigger.
A status closer, shared by every repo whose router tags its runs.
The dispatcher posts pending; this posts the final state when the run
ends, so a build that fails after dispatch turns the commit red instead
of leaving the dispatch-time green:
spool handler set ci-status ./status.sh --as script \ --on run.succeeded,run.failed,run.cancelled \ --when 'has(event.payload.tag.repo) && has(event.payload.tag.sha)' \ --credential GITHUB_STATUS_TOKEN=github-statusA lifecycle payload carries the run’s tags already parsed, as
event.payload.tag.<key>, so the repo and commit come off the run
rather than being baked into the handler and no run lookup is needed.
Name the three terminal types rather than run.*: that wildcard also
matches run.awaiting and run.wait_expired, and a run parking on an
approval is not a failure.
A handler’s own lifecycle events are not re-delivered, so this cannot trigger itself.
When to let the event run the pipeline directly
Section titled “When to let the event run the pipeline directly”A handler’s target can be the pipeline itself rather than a router:
spool handler set notify-on-push ./on-push.yaml --as yaml --on gh.pushThat is the right shape when the work does not depend on the commit’s own definition. The pipeline is stored on the server, the event is its input, and the push is a reason to run rather than something to build:
- react to a push without building it: notify a channel, touch a cache, open a ticket, kick a deploy of something already built
- work whose definition belongs to the project rather than to the repo, so it should not change when somebody edits a branch
- a reaction you want identical for every repo that hook serves
It is the wrong shape for CI, and the trap is specific: --as yaml
stores a snapshot of the file’s bytes as they were when you ran the
command. Nothing re-reads it. Point it at the repo’s own
.spool/ci.yaml and every push will build that copy rather than the
commit’s, and editing the pipeline in the repo changes nothing until
you re-register the handler. Fetching at the commit is the router’s
job.
Two smaller targets exist for the same reason. --node <kind> runs one
node when the reaction is a single call, and --graph <name> runs a
stored graph when it has several steps worth seeing. Both take the
event as their input the same way.
A monorepo puts several routers on one event
Section titled “A monorepo puts several routers on one event”Handlers on a matching type all run, each in its own run, and --when
is what keeps them apart. This repo has two dispatchers on
cloud9k.push, each skipping the paths the other owns, so a push
touching one subproject does not rebuild the other and a mistake in
either cannot break the other’s release. A context: tag on each
spawned run names the commit status it posts to, so the two checks sit
beside each other instead of overwriting.
--when 'has(event.payload.body.commits) && event.payload.body.commits.exists(c, (c.added + c.modified + c.removed).exists(p, p.startsWith("appbuilder/")))'event.paths is the simpler form of the same filter, and it is the one
to reach for first. Read the commits directly when you need per-commit
detail the flattened set has lost. Either way check
event.paths_known: it is false when the forge sent no file list or the
push exceeded the commit cap, and a predicate that treats false as
“nothing relevant changed” silently stops building.
Not this: a graph with a webhook trigger
Section titled “Not this: a graph with a webhook trigger”A graph can own a webhook endpoint of its own, through a webhook trigger node. Do not use that for repo CI, and the reason is not taste:
- It costs an endpoint per graph, each registered with the forge
separately. Ten graphs reacting to one repo is ten forge webhooks,
where
spool repo addgives a forge one. - The routing lives in the document rather than in a handler’s
--when, so which pushes build what is spread across graphs instead of being one predicate you can read. - Nothing about it makes the pipeline easier to write. The pipeline still lives in the repo either way.
What a graph’s webhook trigger is genuinely for is webhook input that is not a repo push: a form post, a device checking in, a vendor callback, a payment provider, anything whose arrival should start that one graph and whose endpoint should disappear when the graph does. That teardown is the thing the shape does better - publishing mints the endpoint and deleting the graph reaps it, where a hook you created outlives every graph and has to be removed by hand.
So: a repo goes through spool repo add. A webhook that starts one
specific graph, and nothing else, can own its endpoint.
Triggers has that shape end to end.
Point the repo at it
Section titled “Point the repo at it”spool repo add prints the ingest URL and the signing secret, and so
does spool event-hook set if you wired it by hand. What is left is
telling the repo where to send. Two ways, and the first needs nothing
but the two values you already have.
Paste it in
Section titled “Paste it in”Both commands print the ingest URL and the signing secret once. The same two values are on the hook’s row on the Event-hooks page (the secret only at the moment you create or rotate it).
In the repo’s Settings -> Webhooks -> Add webhook, on GitHub at
https://github.com/<owner>/<repo>/settings/hooks/new:
| Field | Value |
|---|---|
| Payload URL | the ingest URL |
| Content type | application/json |
| Secret | the signing secret |
| Events | just the push event, to start |
Gitea and Forgejo are the same two values at
/<owner>/<repo>/settings/hooks, with two more fields on the form: set
the HTTP method to POST and the content type to application/json.
Both forges send GitHub-shaped headers and payloads on purpose, so the
github manifest verifies and shapes their deliveries too.
One thing to check first on a self-hosted forge: both Gitea and Forgejo
refuse to deliver to a private or loopback address by default
(ALLOWED_HOST_LIST = external). If nothing ever arrives and the
forge’s own webhook page shows no attempt, that is why, and the fix is
on the forge.
Or let Hyperspool register it
Section titled “Or let Hyperspool register it”Instead of pasting, Hyperspool can create the webhook through the forge’s API. It needs a forge token that may manage hooks, which is more setup than a paste for one repo and less for twenty.
-
Store the forge token. Open Connections and add a bearer credential holding a token that can manage webhooks: on GitHub that is
admin:repo_hook; on Gitea / Forgejo,write:repository. Name it something you will recognise in a dropdown -forge-adminbelow.Terminal window spool credential set forge-admin --kind bearer --token -
-
Register on forge. On the Event-hooks page, the hook’s row has a Register on forge control. It opens a short form: pick the forge, enter the repo as
owner/name, and choose the credential from step 1. Hyperspool creates the webhook on the repo, with the signing secret resolved server-side - nothing to paste, and the secret never goes through the browser.
Gitea and Forgejo ask for one more thing: the instance API base, since there’s no single address to assume. It’s your instance URL plus
/api/v1.
Re-registering is idempotent: it updates the existing hook rather than adding a second one, which is what you want after rotating a secret.
The API base has to be an https address that resolves to a public host,
since registering sends your forge token to it. A forge on a private network is reachable only where the operator has named
it in HYPERSPOOL_FORGE_API_ALLOWLIST.
What the token can do, per forge, before you decide. This is the one thing to weigh, because the token is stored and used later:
- GitHub scopes it:
admin:repo_hookmanages webhooks and nothing else. A token with that and nothing more cannot read your code. - Gitea has granular scopes too;
write:repositoryis the one that covers hooks. - Forgejo does not. Its own documentation says OAuth2 scopes “are not yet implemented”, and that an application obtaining a token for a user “will have administrative rights”. So on Forgejo there is no narrow token to store: the one that can create a hook can do everything that user can.
Which is why pasting stays first-class rather than being a fallback. For one repo it is less work than storing a token at all, and on Forgejo it is the only option that does not hand this server broad access. Auto-registering earns its keep when somebody is wiring twenty repos on a forge they run, where a paste per repo is the slow part.
The same control appears on a graph’s webhook trigger node, for the other shape; it takes either target.
Either way, deliveries are verified against the secret
(X-Hub-Signature-256 HMAC) and deduped on the signed body, so a
redelivered push resolves to the event the first one created rather than
starting a second run.
Check it worked
Section titled “Check it worked”Push something. You should get two runs: the handler that caught the push (or the graph, in the other shape), and the pipeline it started nested under it. If nothing appears at all, the delivery did not reach the endpoint or failed verification - the forge’s own webhook page shows the response code it got, and its Recent Deliveries list shows the body it sent.
Redeliver is for a delivery that failed, not for a re-test. Because the dedup is on the body, redelivering a push that already succeeded returns the same event and starts nothing - it looks like nothing happened, and nothing is meant to. Redeliver when the forge shows a non-2xx response: no run was created then, so the retry makes one. To exercise the path again, push again.
One hook, many repos
Section titled “One hook, many repos”Point several repos at the same hook and you run one set of handlers
for all of them - no per-repo copy. Each push carries the repo as its
subject, so the run knows which repo
it’s building (it’s in the input), and any cancel_on: [pr.closed] in
that run correlates to that repo’s PRs automatically. Ten repos, one
hook, and a closing PR on one never cancels a build for another.
Where the repo comes from
Section titled “Where the repo comes from”There is no “connected repo”. A project doesn’t belong to a repo, and nothing stores which one it builds. That surprises people once, and then explains the rest of this page, so it’s worth a minute.
The repo reaches a run one of two ways, and both of them carry it in.
A push carries it. A delivery arrives at a webhook you registered.
What that webhook stores is an event type and how a delivery proves
itself - the shared secret, the signature scheme - and nothing about a
repo. The manifest shapes the incoming body into an
envelope with repo, ref, sha, sender and paths, and that
envelope becomes the run’s input. So the repo a push builds is whatever
the delivery said it was, read back out as ${{ input.repo }} or
$CI_TRIGGER_REPO. One webhook happily serves many repos; the run knows
which one because the payload told it.
A request names it. spool submit --repo-path and a from_repo:
node both take repo and credential explicitly. Nothing is looked up,
because there is nothing to look up.
What a project does store is credentials by name, webhooks, handlers,
schedules and graphs. A credential named gh-read is a token you can
point at any repo it can read - it isn’t bound to one, which is why
--repo and --repo-credential are separate flags rather than one
setting.
The practical consequence: pointing CI at a different repo is a change to the request or the payload, never to project configuration. There is no page to visit and nothing to migrate.
What a pipeline run sees
Section titled “What a pipeline run sees”Which of these you get depends on what started the run, and the two cases are easy to mix up:
-
The event delivered to the pipeline (a handler whose target is a YAML pipeline - see “when to let the event run the pipeline directly” above). The push envelope is the run’s input, so
${{ input.sha }},$SPOOL_INPUT_FILEand theCI_TRIGGER_*env are all there. -
A handler spawned the pipeline - what
spool repo addsets up, and what both of this project’s own repos run. The spawned pipeline is its own run and inherits none of the push’s env.spool spawncarries no structured input either, so the handler prepends aninput:line holding the sha, ref and repo to the document before spawning it, which is why such a pipeline reads${{ input.sha }}and never$CI_TRIGGER_SHA. A hand-written router spawns the same way.Read those through
has(), which keeps the same file runnable by hand: a manualspool submitrenders it against{"type": "manual"}, and reading a key that is not there is an error rather than an empty string.A pipeline fetched by a
workflownode’sfrom_repo:is the same kind of child, and the env it does get is a manual one: four vars,CI_TRIGGER_EVENT=manual, andCI_TRIGGER_SHAholding whatever ref was fetched rather than a resolved commit.
The input plumbing described next - $SPOOL_INPUT_FILE and the
CI_TRIGGER_* env - is the delivered case. Checking out a commit, at
the end of this section, covers both.
Each run carries what started it as its canonical input:
-
${{ input.<field> }}in a pipeline - the trigger envelope as an expression scope. Uncapped:${{ input.payload.head_commit.message }}reaches into a full push body. -
$SPOOL_INPUT_FILEin any job, shell step orscript:alike - a file holding that same envelope, whole, whatever its size. -
CI_TRIGGER_*env vars for the scalar fields, so a shell can branch without parsing JSON:EVENT(the event type),EVENT_ID,SHA,SHORT_SHA,REF,REPO,SENDER,PATHS_KNOWN. A field that isn’t a scalar (paths,payload) has no flat form and stays in the file. There is noCI_TRIGGER_FORGE: the GitHub manifest shapes no such field.Two carve-outs. A graph handler gets none of these at all - a graph reads the envelope through expressions, and a
shellorcodenode that wants one passes it in its ownenv:frominput.*. And a pipeline fetched by aworkflownode’sfrom_repo:is a child run, which gets a manual trigger env: four vars,CI_TRIGGER_EVENTismanual, andCI_TRIGGER_REFholds whatever ref was fetched rather than the branch that was pushed. Read the real values from that child’sinput:, which the parent passes.
A job that builds the pushed code has to clone it: a pipeline the build handler spawned is its own run with no working tree, and the temporary checkout the handler made was only there to read the pipeline file at that commit.
Which commit to check out depends on which of the two cases started the
run. A pipeline connected with spool repo add is the spawned one, so it
reads input.sha and carries it into the shell through env:. The
fallbacks are what keep the same file runnable by hand, where there is
no push and both are empty:
env: SHA: "${{ has(input.sha) ? input.sha : '' }}" SLUG: "${{ has(input.repo) ? input.repo : '' }}"jobs: test: steps: - name: checkout run: | set -euo pipefail slug="${SLUG:-${CI_TRIGGER_REPO:-me/app}}" sha="${SHA:-${CI_TRIGGER_SHA:-}}" git clone --depth 1 "https://github.com/$slug" app && cd app if [ -n "$sha" ]; then git fetch --depth 1 origin "$sha" && git checkout "$sha" fiA pipeline the event was delivered to directly gets the same two values
from its trigger env, as $CI_TRIGGER_REPO and $CI_TRIGGER_SHA. A
spawned run has neither - not empty, absent - so read them through
${CI_TRIGGER_SHA:-} as above rather than bare, or set -u aborts the
step with unbound variable.
Note the URL either way: the shaped repo is owner/name, so it needs
a host in front of it before git will take it. A private repo clones
with a token from a project credential, the way the build handler does.
When templating a shell body, read the env var inside the script
("$CI_TRIGGER_REF" is quoted by the shell) rather than splicing
${{ env.CI_TRIGGER_* }} into the script text, where a branch or sender
name would land unescaped.
Gate which pushes build
Section titled “Gate which pushes build”Two different gates, and which one you get depends on how the push reaches the pipeline.
A repo connected with spool repo add has its gate on the handler,
as --when. It is a CEL predicate over event, evaluated server-side
at delivery, before any run exists. A push it declines is acknowledged
and costs nothing:
spool repo add https://github.com/me/app --credential gh-read \ --when 'event.ref == "refs/heads/main" || event.ref.startsWith("refs/heads/release/")'Re-run repo add with a different --when to change it; the names are
derived, so it updates the handler rather than adding a second one.
A pipeline’s own top-level if: gates the other case - a handler
whose target is the pipeline itself, so the event is delivered to it
directly. It is evaluated at ingestion too, and a false predicate
creates no run:
if: 'event.ref == "refs/heads/main"'In a spawned run that if: is not evaluated at all. A repo add
build is spawned by the handler (spool spawn), and a spawned run keeps
the input it was given rather than being gated on a trigger it never
saw, so an if: in .spool/ci.yaml is inert there - it does not fail,
it simply never runs. paths: and paths_ignore: are sugar over the
same predicate and are inert in the same way. Put the branch test in
--when, or inside the pipeline as a job if: or a step that exits
early.
The trigger fields available to both are in the YAML reference. A manual submit has no trigger predicate and always runs.
Push and spool submit --repo-path are two ways to start the same run.
Neither reads a stored setting: the push carries its repo in the
delivery, the submit names one, and either way the definition comes out
of the repo rather than off the server.