Different work for different branches
The everyday CI question: run the full pipeline on main, something
lighter on other git branches, and nothing for a docs typo. These are
separate decisions made at different moments:
| Decision | Where it lives | When it happens |
|---|---|---|
| Should this push build at all? | spool repo connect --when |
at delivery, before any run exists |
| Should this job run? | job if: |
during the run, once its dependencies finish |
| Which of several paths? | a switch and its edges |
during the run |
This page assumes a repo connected with spool repo connect, which builds
each pushed commit with the .spool/ci.yaml that commit ships
(CI from a repo).
Don’t start a run at all
Section titled “Don’t start a run at all”--when on repo connect is a CEL expression over the push event,
checked when the delivery arrives. A push it declines starts nothing.
This builds main and release branches only:
spool repo connect https://github.com/me/app --repo-credential gh-read \ --when 'event.ref == "refs/heads/main" || event.ref.startsWith("refs/heads/release/")'And this skips a push that only touches docs/:
spool repo connect https://github.com/me/app --repo-credential gh-read \ --when '!event.paths_known || event.paths.exists(p, !p.startsWith("docs/"))'The !event.paths_known || matters. When the forge sends no file list,
event.paths is empty, and exists over an empty list is false, so
without the guard such a push would never build.
Choose which pushes build
lists every field, for pushes and pull requests.
A --when that fails to evaluate, for example by reading a field the
event doesn’t have, skips the push, and Events in the console shows the
error. A manual spool submit isn’t a delivery, so --when doesn’t
apply to it.
A pipeline’s top-level if:, paths: and paths_ignore: don’t take
part here. A repo connect build is started by the build handler, which
never delivers the push to the pipeline, so they are never evaluated.
They apply only when a handler delivers the event to a pipeline it
stores (YAML reference).
Choose jobs inside one pipeline
Section titled “Choose jobs inside one pipeline”Once a run exists, the pushed commit is its input: input.ref,
input.sha and input.repo. A job reads it and decides for itself:
name: ci
env: SHA: "${{ has(input.sha) ? input.sha : '' }}"
jobs: test: steps: - name: test run: make test
deploy_staging: depends_on: [test] if: 'has(input.ref) && input.ref == "refs/heads/main"' steps: - name: deploy run: ./deploy.sh staging "$SHA"
release: depends_on: [test] if: 'has(input.ref) && input.ref.startsWith("refs/tags/v")' steps: - name: release run: ./release.sh "$SHA"One pipeline, one run per push, and the ref decides which jobs execute.
A pull request build’s input.ref is refs/pull/<n>/head, so it runs
neither job. has() keeps the file runnable by hand, with no input, and
both jobs are then skipped rather than failing.
A skipped job still satisfies its dependents’ depends_on and reports
steps.<name>.status == "skipped", so a later job can react to the skip.
Which to use? If the difference is whether to bother at all, filter with
--when and save the run. If both kinds of push do real work and you
want one run showing what happened, use job if:.
Pick one path with a switch
Section titled “Pick one path with a switch”In a graph, the choice is a node of its own. A switch evaluates its
cases in order and records the one that matched, and each edge leaving
it names the case it belongs to:
{ "version": "hyperspool-graph/v1", "nodes": [ { "id": "route", "type": "switch", "config": { "cases": [{ "name": "big", "when": "input.n > 100" }], "default": "small" } }, { "id": "on_big", "type": "shell", "config": { "run": "./full.sh" } }, { "id": "on_small", "type": "shell", "config": { "run": "./quick.sh" } } ], "edges": [ { "from": "route", "to": "on_big", "when": "big" }, { "from": "route", "to": "on_small", "when": "small" } ]}One of the two shell nodes runs. Neither has a condition of its own; the edges do the routing.
An edge’s when is a case name from the switch it leaves:
- The name is checked when you save, against that switch’s
cases[].nameand itsdefault. A name the switch doesn’t declare fails the save, and the message lists the ones it has, so a misspelled"when": "bg"is caught there. - Only an edge whose
fromis aswitchmay carry one. Anywhere else it’s a save-time error too. - A target the switch didn’t route to is skipped, the same skip a false
if:produces. It still satisfies its own dependents, so a join node below several cases runs once they have settled. - Two
whenedges from the same switch into one node are alternatives: the node runs when the switch picks either case.whenedges from different switches AND together, as any set of dependencies does. - A switch that fails picks no case. Its targets are skipped, and so is
a join below them, even when the switch has
continue_on_error. - An edge with no
whenis always active.
Give the switch a default unless one of its cases is certain to match;
without one, a run where nothing matches fails at the switch.
if: still gates on anything that isn’t a case name, such as a status,
a count or a field of an upstream output:
{ "id": "publish", "type": "shell", "if": "nodes.on_big.status == 'success'", "config": { "run": "./publish.sh" } }The edge decides whether a node is reachable, and the node’s if: then
decides whether it runs.
Routing on edges is for graph documents. A YAML pipeline’s depends_on
becomes plain edges, so a pipeline chooses with job if: as above.
Pull requests
Section titled “Pull requests”With repo connect --pull-requests, pull requests build too. Their
fields differ from a push’s, which matters for --when and for a
handler of your own:
| on a push | on a pull request | |
|---|---|---|
event.type |
github.push |
github.pull_request.opened, .synchronize, .reopened |
event.sha |
the pushed commit | the pull request’s head commit |
event.ref |
refs/heads/main |
absent |
event.number |
absent | the pull request’s number |
event.head_ref |
absent | the source branch (feature-x) |
event.base_ref |
absent | the target branch (main) |
ref stays absent on a pull request so event.ref == "refs/heads/main" never matches one by accident. Anything else is in
the delivery:
event.payload.body.pull_request.draft true while it is a draftevent.payload.body.pull_request.head.repo.fork true when it comes from a forkSo this builds pushes to main, and pull requests into main that
aren’t drafts:
spool repo connect https://github.com/me/app --repo-credential gh-read --pull-requests \ --when 'has(event.number) ? (event.base_ref == "main" && !event.payload.body.pull_request.draft) : event.ref == "refs/heads/main"'The has(event.number) ? comes first because reading event.ref on a
pull request fails the filter, and || doesn’t stop that.
A handler you write yourself with spool handler set sees every repo’s
deliveries on the webhook, so its --when also names the repo:
event.key == "me/app" && ....
A pull request from a fork, or from anyone who isn’t an owner, member or
collaborator, runs untrusted: it gets no project secrets, and a job that
declares credentials: fails. In a pipeline, keep credentials on the
jobs that need them, and mark a job optional_when_untrusted: true to
run it without its credential instead. In a graph, if: "!run.untrusted" skips a node on an untrusted run; pipelines don’t
have run.untrusted, and a job if: that reads it fails.
Who is trusted has the full rules.
See also
Section titled “See also”- CI from a repo: connecting the repo, and handlers
beyond
repo connect. - YAML reference: the trigger predicate’s fields and evaluation rules.
- Authoring on the canvas: drawing the switch and its edges.
- Triggers: webhook trigger steps and verifying deliveries.