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 it’s worth keeping them apart because they happen
at different moments.
| Decision | Where it lives | When it happens |
|---|---|---|
| Should a run exist at all? | top-level if: |
at delivery, before any run |
| 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”The top-level if: is 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.
name: ciif: 'event.ref == "refs/heads/main" || event.ref.startsWith("refs/heads/release/")'jobs: build: steps: - name: build run: make releaseThe event is in scope as event:
| Field | Example |
|---|---|
event.type |
push |
event.ref |
refs/heads/main |
event.sha |
the full commit sha |
event.repo |
repo addressing |
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 cryo 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.
Point a trigger node at the github manifest and the delivery becomes
<webhook-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.
One caveat worth knowing before you write the predicate. The
manifest’s shaped fields are push-shaped: it lifts sha from /after,
ref from /ref, repo from /repository/full_name, and sender from
/sender/login. A pull-request body has no /after or /ref, and a
pointer that doesn’t resolve is skipped — so on a PR delivery you get
repo and sender, and the branch is in the payload:
event.payload.pull_request.head.ref the source branchevent.payload.pull_request.base.ref the target branchevent.payload.pull_request.number the PR numberevent.payload.pull_request.draft true while it is a draftSo “run on non-draft PRs targeting main” is:
if: 'event.type.endsWith("pull_request.opened") && !event.payload.pull_request.draft && event.payload.pull_request.base.ref == "main"'Reading through payload is fine, 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.
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.