Skip to content

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.

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:

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

The 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.

One command - spool repo add - and the pipeline stays in the repo:

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

It 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 status

What 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 add says 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.

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.

Terminal window
spool event-hook set world --manifest github
spool 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-status

The --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:

Terminal window
# The event, whole. A multi-commit push body is well past what an
# environment variable would hold.
cp "${SPOOL_INPUT_FILE:?}" /tmp/event.json
sha=$(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"' ERR
post_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.yaml
spool 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:

Terminal window
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-status

A 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:

Terminal window
spool handler set notify-on-push ./on-push.yaml --as yaml --on gh.push

That 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.

Terminal window
--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.

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 add gives 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.

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.

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.

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.

  1. 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-admin below.

    Terminal window
    spool credential set forge-admin --kind bearer --token -
    The connections page holding a bearer credential named forge-admin - the forge token Hyperspool uses to create the webhook on your behalf. Only its name and kind are shown; the token itself is never displayed again.
  2. 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.

    The register on forge form with github selected, asking for the repo as owner/name and the bearer credential holding the admin token.

    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.

    The same form with gitea selected: it now also asks for the instance API base, e.g. https://gitea.example.com/api/v1, which forgejo needs too and github does not.

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_hook manages webhooks and nothing else. A token with that and nothing more cannot read your code.
  • Gitea has granular scopes too; write:repository is 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.

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.

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.

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.

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_FILE and the CI_TRIGGER_* env are all there.

  • A handler spawned the pipeline - what spool repo add sets 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 spawn carries no structured input either, so the handler prepends an input: 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 manual spool submit renders 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 workflow node’s from_repo: is the same kind of child, and the env it does get is a manual one: four vars, CI_TRIGGER_EVENT=manual, and CI_TRIGGER_SHA holding 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_FILE in any job, shell step or script: 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 no CI_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 shell or code node that wants one passes it in its own env: from input.*. And a pipeline fetched by a workflow node’s from_repo: is a child run, which gets a manual trigger env: four vars, CI_TRIGGER_EVENT is manual, and CI_TRIGGER_REF holds whatever ref was fetched rather than the branch that was pushed. Read the real values from that child’s input:, 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"
fi

A 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.

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:

Terminal window
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.