Skip to content

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

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

Terminal window
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/:

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

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

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[].name and its default. 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 from is a switch may 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 when edges from the same switch into one node are alternatives: the node runs when the switch picks either case. when edges 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 when is 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.

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 draft
event.payload.body.pull_request.head.repo.fork true when it comes from a fork

So this builds pushes to main, and pull requests into main that aren’t drafts:

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