Skip to content

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:

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

The 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

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.

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/")'

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: ci
if: 'event.ref == "refs/heads/main" || event.ref.startsWith("refs/heads/release/")'
jobs:
build:
steps:
- name: build
run: make release

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

Once a run exists, the trigger envelope is its input. A job reads input.ref and decides for itself:

name: ci
jobs:
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:.

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[].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 instead of becoming a branch that never fires.
  • 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 branches runs once the branches have settled.
  • Several when edges into one node AND together, the way any set of dependencies does.
  • An edge with no when is 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.

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 number
event.payload.pull_request.draft true while it is a draft
event.payload.pull_request.head.repo.fork true for a fork

So “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.

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.