Different work for different branches
The everyday CI question: run the full pipeline on main, run something
lighter on a pull request, and don’t run at all for a docs typo. Three
separate decisions, and keeping them apart matters because they happen
at different moments.
This page is about deciding what runs. Getting a push to arrive at all is one command, covered in CI from a repo:
spool repo add https://github.com/me/app --credential gh-readThe pipeline itself stays in the repo at .spool/ci.yaml, and each push
builds the one that commit shipped with.
| Decision | Where it lives | When it happens |
|---|---|---|
| Should a push build at all? | spool repo add --when |
at delivery, before any run |
| Should a run exist for this pipeline? | top-level if: |
at delivery, and only when the event is delivered to the pipeline |
| Should this job run? | job if: |
mid-run, once upstream finished |
| Which of several paths? | a switch and its edges |
mid-run |
Don’t start a run at all
Section titled “Don’t start a run at all”A push reaches you because the repo is connected (spool repo add <url>
creates the webhook and the handler that builds each pushed commit). The
handler is also where you say which pushes are worth a run, as --when:
a CEL predicate over event, evaluated server-side when the event
arrives, before a run exists. A false predicate acknowledges the
delivery and creates nothing - no run in the list, no agent involved,
nothing to explain later.
spool repo add https://github.com/me/app --credential gh-read \ --when 'event.ref == "refs/heads/main" || event.ref.startsWith("refs/heads/release/")'A pipeline can carry the same test itself, as a top-level if:, and
that is the right place for it when a handler delivers the event to
the pipeline rather than spawning it:
name: ciif: 'event.ref == "refs/heads/main" || event.ref.startsWith("refs/heads/release/")'jobs: build: steps: - name: build run: make releaseThe two are not interchangeable. A repo add build is spawned, and a
spawned run is never gated on a trigger predicate, so that if: is
inert in it - silently, since there is nothing to fail. For a connected
repo the branch test belongs in --when.
The event is in scope as event:
| Field | Example |
|---|---|
event.type |
push |
event.ref |
refs/heads/main |
event.sha |
the full commit sha |
event.repo |
the repo, as owner/name |
event.sender |
who pushed |
event.paths |
the files the push touched |
event.paths_known |
false when the forge didn’t report a file list |
event.payload |
the raw delivery, for anything not shaped above |
Skip a docs-only push:
if: 'event.paths_known && event.paths.exists(p, !p.startsWith("docs/"))'Note the paths_known guard. When the forge doesn’t report a file list
the list is empty, and an unguarded exists over an empty list is false
— which would silently stop building. Ask whether the list is real before
trusting it.
A predicate that references a field that doesn’t exist is an error, and an erroring predicate runs the pipeline and logs the failure. A broken guard is a loud misfire rather than a push that vanishes.
Manual runs have no triggering event, so spool submit always runs
regardless of the predicate.
Branch inside one pipeline
Section titled “Branch inside one pipeline”Once a run exists, the trigger envelope is its input. A job reads
input.ref and decides for itself:
name: cijobs: test: steps: - name: test run: make test
deploy_staging: depends_on: [test] if: 'input.ref == "refs/heads/main"' steps: - name: deploy run: ./deploy.sh staging
preview: depends_on: [test] if: 'input.ref.startsWith("refs/heads/pr/")' steps: - name: preview run: ./preview.sh ${{ input.sha }}One pipeline, one run per push, and the branch decides which half
executes. A skipped job still satisfies its dependents’ depends_on and
reports steps.<name>.status == "skipped", so a later job can react to
the skip rather than being blocked by it.
Which to use? If the difference is whether to bother at all, put it in
the top-level if: and save the run. If both branches do real work and
you want one timeline showing what happened, put it in job if:.
Pick one path with a switch
Section titled “Pick one path with a switch”In a graph the fork is a node of its own. A switch evaluates its cases
in order and records the one that won, and each edge leaving it names the
branch it belongs to:
{ "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 carries a condition of its own, because 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 instead of becoming a branch that never fires. - 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 branches runs once the branches have settled. - Several
whenedges into one node AND together, the way any set of dependencies does. - An edge with no
whenis always active, which covers every edge drawn before this existed.
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 branch name - a status, a
count, a field of an upstream output:
{ "id": "publish", "type": "shell", "if": "nodes.on_big.status == 'success'", "config": { "run": "./publish.sh" } }The two work together: the edge decides whether a node is reachable, and
the node’s if: then decides whether it runs.
Before edges could carry a branch, a switch recorded its result and every
target repeated it as if: nodes.route.output.branch == "big". That form
still works and still means what it meant. What changed is the failure
mode: forgetting one of those guards ran every branch, and the run looked
successful.
Branch routing belongs to graph documents. A YAML pipeline’s depends_on
becomes plain edges, so a pipeline keeps branching with the job if:
above.
Pull requests
Section titled “Pull requests”A pull request is a different delivery, not a different branch, so it
arrives as its own event type through a
named webhook rather than as a push.
Give the webhook the github manifest and the delivery becomes
<hook-name>.pull_request.<action> — .opened, .synchronize,
.closed — because the manifest reads the type from the x-github-event
header and refines it with the body’s /action. The hook’s own name is
that first segment, whether you created the hook (spool event-hook set <name> --manifest github) or a graph’s trigger node owns it.
What a PR delivery carries. The manifest lifts the commit from
whichever place the delivery keeps it: /after on a push,
/pull_request/head/sha on a pull request. So event.sha is the thing
to build in both cases, which is what lets one pipeline serve both.
The branch is two fields rather than one, because a PR has two:
| on a push | on a pull request | |
|---|---|---|
event.sha |
the pushed commit | the PR’s head commit |
event.ref |
refs/heads/main |
absent |
event.head_ref |
absent | the source branch (feature-x) |
event.base_ref |
absent | the target branch (main) |
ref stays push-shaped on purpose. A PR’s head is a branch name, not a
full ref, and lifting it into ref would make
event.ref == "refs/heads/main" match a pull request by accident.
Anything else is in the payload:
event.payload.pull_request.number the PR numberevent.payload.pull_request.draft true while it is a draftevent.payload.pull_request.head.repo.fork true for a forkSo “run on non-draft PRs targeting main” is:
if: 'event.type.endsWith("pull_request.opened") && !event.payload.pull_request.draft && event.base_ref == "main"'The target branch is a shaped field now, so that predicate reads
event.base_ref rather than digging. Reading through payload still
works for anything unshaped, with one thing to remember: the shaped
fields survive the input size cap and the raw payload does not. For a
value a job needs later, lift it into the run’s input rather than
re-reading a large body downstream.
Trust, which decides whether the run gets your secrets. A pull
request is trusted when its head is not a fork - a branch in the
repository means whoever pushed it had write access - and on GitHub
also when the author’s association is one the project trusts
(OWNER, MEMBER, COLLABORATOR). A fork PR is never trusted no
matter who opened it, so if: "!run.untrusted" is how a job that needs
a credential opts out of running on one. Gitea and Forgejo send no
association field, so there the fork check decides on its own.
Coming from GitHub Actions
Section titled “Coming from GitHub Actions”| There | Here |
|---|---|
on: push: branches: [main] |
top-level if: 'event.ref == "refs/heads/main"' |
on: push: paths-ignore: [docs/**] |
paths_ignore: [docs/**] |
on: pull_request |
a webhook trigger on <name>.pull_request.opened |
if: github.ref == … on a job |
job if: 'input.ref == …' |
needs: |
depends_on: |
| separate workflow files per trigger | one pipeline with job if:, or separate graphs |
| environment protection rules | an approval: job |
The one real difference in shape: Actions encourages a file per trigger,
so pr.yml and main.yml drift apart. Here the cheap thing is one
pipeline whose jobs disagree about when they run, which keeps the shared
steps shared. Split into separate graphs when the two paths stop having
anything in common, not before.
See also
Section titled “See also”- YAML reference — the predicate’s full field list and evaluation rules.
- CI from a repo — connecting the repo so pushes arrive at all.
- Authoring on the canvas - drawing the switch and its edges.
- Triggers — webhook trigger nodes, manifests, and verifying deliveries.