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

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

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

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.

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 branch
event.payload.pull_request.base.ref the target branch
event.payload.pull_request.number the PR number
event.payload.pull_request.draft true while it is a draft

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

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.