07

Control flow

A Pipeline's DAG says what may run after what. Control flow decides what actually runs. The PipelineRun controller re-evaluates the run on every reconcile: it checks when expressions, notices missing results, counts retries, watches three separate timeouts and honours cancellation requests. Then it either creates the next TaskRuns or records a skip with a reason. Every simulation on this page runs that decision procedure step by step, so you can break things and read the same status Tekton would write.
A when expression compares a substituted string against a list (in / notin) or evaluates a CEL expression. If it is false, the task is skipped. That skip stays local: tasks ordered after it with runAfter still run. Tasks that need its results can't run, though, and their skip does cascade.
params.branch
values:
guard on build-image: false → skip

PipelineRun · DAG

t = 42 / 42s
runAfterIMAGE_DIGESTfetchsucceededlintsucceededunit-testsucceededbuild-imageskippeddeploy-previewsucceededsignskippedpublish-sbomskipped
status.skippedTasks:
- name: build-image
reason: When Expressions evaluated to false
- name: sign
reason: Results were missing
- name: publish-sbom
reason: Parent Tasks were skipped
Succeeded
True
reason
Completed
pipeline.yaml
apiVersion: tekton.dev/v1
kind: Pipeline
spec:
params:
- name: branch
tasks:
- name: fetch
- name: lint
runAfter: [fetch]
- name: unit-test
runAfter: [fetch]
- name: build-image
runAfter: [unit-test]
when:
- input: "$(params.branch)"
operator: in
values: ["main", "release/1.4"]
- name: deploy-preview
runAfter: [build-image] # ordering only
- name: sign
params:
- name: digest
value: $(tasks.build-image.results.IMAGE_DIGEST)
- name: publish-sbom
runAfter: [sign]

Try: set the branch to feature/login. build-image is skipped, but deploy-preview (which only runAfters it) still runs. sign needs build-image's result, so it is skipped too, and publish-sbom is skipped after it. Then make unit-test fail: everything that hasn't started is skipped because the run is stopping.

Two kinds of skip. A WhenExpressionsSkip guards one task only. Every other reason (missing results, a skipped parent that wasn't skipped by when, a stopping or timed-out run) passes down to dependents as ParentTasksSkip. To skip a whole branch on a condition, guard each task in it, or pass a result down the chain.
Once every DAG task has finished (succeeded, failed or skipped), all finally tasks start in parallel. Only they can read $(tasks.<name>.status) and the aggregate $(tasks.status). They can also read DAG results, but a finally task whose result never arrived is skipped.
inject

timeline

t = 52 / 52s
0102030405060clonebuildtestfinallycleanupnotify-slackreport-covera…skipped · Results were missing

variables finally can read

$(tasks.clone.status)Succeeded
$(tasks.build.status)Succeeded
$(tasks.test.status)Failed
$(tasks.status)Failed

Values exist only in finally, once every DAG task is done. Aggregate: Succeeded · Failed · Completed (some skipped) · None.

events

28s test started
44s test failed → PipelineRun is stopping: no new DAG tasks
44s all DAG tasks done → finally starts ($(tasks.status) = Failed)
44s cleanup started (finally)
44s notify-slack started (finally)
44s report-coverage skipped: Results were missing
50s notify-slack succeeded
52s cleanup succeeded
52s PipelineRun Failed
phase
done
Succeeded
False
reason
Failed
pipeline.yaml
spec:
tasks:
- name: clone
- name: build
runAfter: [clone]
- name: test
runAfter: [build]
finally:
- name: cleanup # always runs
taskRef: { name: cleanup-workspace }
- name: notify-slack
when:
- input: $(tasks.status)
operator: in
values: ["Failed"]
params:
- name: message
value: "test is $(tasks.test.status)"
- name: report-coverage
params:
- name: coverage
value: $(tasks.test.results.coverage)

Try: with "test fails", cleanup and notify-slack run but report-coverage is skipped, because the result it reads was never written. Switch to "all green": notify-slack's when sees Succeeded and skips. Then make cleanup fail: every DAG task passed, but the PipelineRun still fails.

A matrix turns array params into a cartesian product and creates one TaskRun per combination, all in parallel. include adds params to matching combinations or adds combinations of its own. Each run's string results are gathered into an array for the task that fans in.
platform
go
include
default-max-matrix-combinations-count

build → 4 TaskRuns

t = 36 / 36s
clone done → fan outslowest done → fan inbuild-0 linux/amd64 go1.23build-1 linux/amd64 go1.24build-2 linux/arm64 go1.23cgo=1build-3 linux/arm64 go1.24cgo=1
$(tasks.build.results.IMAGE[*]) →
[0] ghcr.io/acme/app:go1.23-linux-amd64
[1] ghcr.io/acme/app:go1.24-linux-amd64
[2] ghcr.io/acme/app:go1.23-linux-arm64
[3] ghcr.io/acme/app:go1.24-linux-arm64
publish-manifest: succeeded
combinations
4
2 × 2 + include
running now
0
wall time
24s
= slowest run
if serial
78s
sum of runs
pipeline.yaml (excerpt)
- name: build
runAfter: [clone]
taskRef: { name: go-build }
matrix:
params:
- name: platform
value: ["linux/amd64", "linux/arm64"]
- name: go
value: ["1.23", "1.24"]
include:
- name: arm-cgo
params:
- name: platform
value: "linux/arm64"
- name: cgo
value: "1"
- name: publish-manifest
params:
- name: images
value: $(tasks.build.results.IMAGE[*])

Try: tick a third Go version and watch the TaskRun count multiply. Toggle each include: race-detector adds a param to every row, arm-cgo only to the arm64 rows, riscv-preview adds a row of its own (orange). Click a row to make that one combination fail: the whole build task fails and nothing fans in. Set the maximum to 4 to see validation reject the run.

Three timeouts and a budget

pipelinerun-timeouts
timeouts.pipeline bounds the whole run (60 minutes by default, from default-timeout-minutes). timeouts.tasks bounds the DAG part, counted from the start of the run. timeouts.finally bounds the finally part, counted from when it starts. Set two of them and Tekton infers the third. A set where tasks + finally exceeds pipeline is rejected.

timeline (minutes)

t = 43 / 43s
01020304050clonebuildtestcancelledfinallycleanupnotifypipelinetasksfinally
tasks budget
35m
set
finally budget
10m
pipeline − tasks
hit
tasks
reason
PipelineRunTimeout
pipelinerun.yaml
apiVersion: tekton.dev/v1
kind: PipelineRun
spec:
pipelineRef: { name: ci }
timeouts:
pipeline: "45m"
tasks: "35m"
# effective: tasks 35m · finally 10m

events

25s test started
35s timeouts.tasks (35) reached → cancel DAG, run finally
35s all DAG tasks done → finally starts ($(tasks.status) = None)
35s cleanup started (finally)
35s notify started (finally)
38s notify succeeded
43s cleanup succeeded
43s PipelineRun PipelineRunTimeout

Try: untick tasks and push build to 30m. The pipeline deadline cancels test, and cleanup never runs because no time is left for finally. Tick tasks again: DAG tasks are cut at their own deadline and the remaining budget goes to finally. Set tasks + finally above pipeline to see the run rejected outright.

The common surprise: with only timeouts.pipeline, a slow build eats the whole budget and your finally cleanup is skipped as "PipelineRun timeout has been reached". If cleanup matters, set timeouts.tasks lower than timeouts.pipeline. Individual tasks can also carry their own timeout.

Retries and onError: continue

pipeline-task-retriespipeline-task-onerror
By default, the first failed task puts the run into stopping: running tasks finish (and still use up their retries), but nothing new starts. retries re-runs a failed task in a fresh Pod a fixed number of times. onError: continue on a pipeline task records the failure but lets the pipeline carry on as if it had passed.
lint onError

timeline

t = 52 / 52s
01020304050fetchintegrationretry 1retry 2lintdeploy
integration TaskRun · status.retriesStatus:
- attempt 0: Failed (6s → 18s, new Pod)
- attempt 1: Failed (18s → 30s, new Pod)
attempts
3/3
lint
failed, ignored
deploy
succeeded
reason
Succeeded
pipeline.yaml
spec:
tasks:
- name: fetch
- name: integration
runAfter: [fetch]
retries: 2
- name: lint
runAfter: [fetch]
onError: continue
- name: deploy
runAfter: [integration, lint]

events

6s lint started
14s lint failed, onError: continue → pipeline carries on
18s integration failed → retry 1/2
30s integration failed → retry 2/2
42s integration succeeded
42s deploy started
52s deploy succeeded
52s PipelineRun Succeeded

Try: set flaky to 3 with retries at 2. The third failure uses up the retry budget and deploy never starts. Then switch lint to stopAndFail: one failed lint stops the whole run, even though integration recovers.

You stop a PipelineRun by patching spec.status. The three values differ in two decisions: whether running DAG TaskRuns are cancelled or allowed to finish, and whether finally runs afterwards. (PipelineRunPending is a fourth value, set at creation, that holds a run before it starts.)
spec.status

timeline

t = 28 / 28s
01020304050607080clonebuildcancelledlintcancelleddeployskipped · PipelineRun was gracefully cancelledfinallycleanupnotifyCancelledRunFinally
running when patched: build, lint · condition: False/Cancelled

same patch at t=18s, all three modes

taskCancelledCancelledRunFinallyStoppedRunFinally
clonesucceededsucceededsucceeded
buildcancelledcancelledsucceeded
lintcancelledcancelledsucceeded
deploynot runskippedskipped
finally · cleanupnot runsucceededsucceeded
finally · notifynot runsucceededsucceeded
pipelinerun.yaml
# kubectl patch pipelinerun ci-run --type=merge \
# -p '{"spec":{"status":"CancelledRunFinally"}}'
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
name: ci-run
spec:
status: CancelledRunFinally
pipelineRef: { name: ci }

events

10s build started
10s lint started
18s spec.status: CancelledRunFinally
18s all DAG tasks done → finally starts ($(tasks.status) = None)
18s cleanup started (finally)
18s notify started (finally)
24s notify succeeded
28s cleanup succeeded
28s PipelineRun Cancelled

Try: patch at 18s while build and lint are running. Cancelled kills both and skips finally. CancelledRunFinally kills both but still runs cleanup. StoppedRunFinally lets them finish, skips deploy, then runs cleanup. Slide the patch past 40s to catch deploy mid-run.

Custom Tasks: control flow you plug in

custom-runapproval-task
When a pipeline task's taskRef names an apiVersion and kind that Tekton doesn't own, the controller creates a CustomRun and waits for someone else to set its Succeeded condition. That one contract is enough for approval gates, waits, pipelines-in-loops or calls to outside systems.
numberOfApprovalsRequired

pipeline

build
TaskRun · succeeded
approve
CustomRun · running
deploy-prod
waiting on approve
1 · Tekton
sees a taskRef with an apiVersion/kind it doesn't own, creates a CustomRun and waits.
2 · custom controller
watches CustomRuns of its kind, does the work (collect votes, sleep), writes the Succeeded condition.
3 · Tekton
reads the condition like a TaskRun's: True unblocks dependents, False fails the run.

approvers

alicepending
bobpending
carolpending
tkn-approvaltask approve ci-run-approve
Succeeded
Unknown
reason
Pending
pipeline
blocked
customrun.yaml
# created by the PipelineRun controller
apiVersion: tekton.dev/v1beta1
kind: CustomRun
metadata:
name: ci-run-approve
ownerReferences: [{ kind: PipelineRun, name: ci-run }]
spec:
customRef:
apiVersion: openshift-pipelines.org/v1alpha1
kind: ApprovalTask
params:
- name: approvers
value: [alice, bob, carol]
- name: numberOfApprovalsRequired
value: "2"
timeout: 1h
# written by the custom controller
status:
conditions:
- type: Succeeded
status: "Unknown"
reason: Pending
message: 0/2 approvals

Try: approve as alice, then as bob. The CustomRun flips to Succeeded and deploy-prod starts. Reset and have carol reject: one rejection fails the gate. Switch to Wait and press play: same contract, just a different controller writing the condition.

  • takeawayA when skip is contained to its own task. Every other kind of skip passes down to dependents. Read status.skippedTasks[].reason to tell which case you hit.
  • takeawayfinally always gets its turn, unless a hard Cancelled or an exhausted timeouts.pipeline takes it away. Leave room with timeouts.tasks, and use $(tasks.status) to decide what to report.
  • takeawaymatrix and Custom Tasks extend the DAG without new syntax. One adds parallel TaskRuns with array results, the other adds any controller that can write a Succeeded condition.

Key concepts · 21

book chapter 7
  1. when expression

    A guard on a pipeline task: if any expression in its `when` list is false, the task is skipped instead of run.

    It replaces the removed Conditions CRD. Guards are evaluated by the controller, so a skipped task never creates a Pod.

    spec.tasks[].whenspec.finally[].when
    ↑ see it in the visualization
  2. input / operator / values

    The structured form of a when expression: an `input` string (usually a param or result reference), an `operator` (`in` or `notin`) and a non-empty list of `values`.

    The comparison is plain string matching after variable substitution. Referencing `$(tasks.x.results.y)` in `input` also makes the guarded task wait for x.

    input: $(params.branch)operator: inoperator: notin
    ↑ see it in the visualization
  3. CEL in when

    An alternative `cel:` field holding a Common Expression Language expression, e.g. `'$(params.branch)'.startsWith('release/')`.

    It handles ranges, prefixes, regexes and boolean logic that in/notin can't. Behind the `enable-cel-in-whenexpression` feature flag (alpha); params are substituted as text, so string references need quotes.

    when[].celenable-cel-in-whenexpression
    ↑ see it in the visualization
  4. when guards only its task

    When a task is skipped because its when is false, tasks that merely runAfter it still run.

    Tekton used to cascade the skip; now the guard is scoped to the task. To skip a whole branch, repeat the guard on each task, or make dependents consume the guarded task's results.

    ↑ see it in the visualization
  5. Skipping reasons

    Every skipped task is listed in `status.skippedTasks` with a reason, such as when expressions false, parent tasks skipped, results missing, run stopping, or a timeout reached.

    Only a when skip is contained to its task. A task skipped for missing results or a failed run makes its dependents skip too (ParentTasksSkip). The reason tells you which case you hit.

    WhenExpressionsSkipParentTasksSkipMissingResultsSkipIsStoppingSkipPipelineTimedOutSkip
    ↑ see it in the visualization
  6. finally tasks

    Tasks under `spec.finally` that run in parallel once every DAG task has finished, whether it succeeded, failed or was skipped.

    The place for cleanup, notifications and reporting. A failing finally task fails the PipelineRun even if every DAG task passed.

    spec.finally[]
    ↑ see it in the visualization
  7. $(tasks.<name>.status)

    A variable available only to finally tasks: Succeeded, Failed or None (skipped or not run) for one DAG task.

    It lets a finally task report, or use a when guard, on how a specific task went without parsing logs.

    $(tasks.test.status)
    ↑ see it in the visualization
  8. $(tasks.status)

    The aggregate status of all DAG tasks, readable in finally: Succeeded, Failed, Completed (succeeded with some skipped) or None.

    The standard way to send a Slack message only on failure: `when: [{input: $(tasks.status), operator: in, values: [Failed]}]`.

    $(tasks.status)Succeeded | Failed | Completed | None
    ↑ see it in the visualization
  9. Results in finally

    Finally tasks can consume DAG task results; if the producing task failed or was skipped, that finally task is skipped.

    The skip reason is "Results were missing". The other finally tasks still run, so put a guard-free cleanup task separately.

    $(tasks.test.results.coverage)
    ↑ see it in the visualization
  10. matrix

    A field on a pipeline task that fans it out into one TaskRun (or CustomRun) per combination of its array params.

    Build or test across platforms × versions without copy-pasting tasks. All combinations run in parallel, and the pipeline task fails if any of them fails.

    spec.tasks[].matrix.paramsEmptyArrayInMatrixParams
    ↑ see it in the visualization
  11. matrix include

    Named extra entries: params not in the matrix are added to every combination or only to matching ones, and an entry that matches no combination becomes a new one.

    Covers the irregular cases, like one extra flag for arm64 or one experimental platform, without blowing up the cartesian product.

    matrix.include[].namematrix.include[].params
    ↑ see it in the visualization
  12. Matrix results (fan-in)

    Each fanned-out TaskRun emits its own string result; Tekton aggregates them into an array that downstream tasks read whole.

    The usual fan-out/fan-in shape: build N images, then publish a single multi-arch manifest from `$(tasks.build.results.IMAGE[*])`.

    $(tasks.build.results.IMAGE[*])
    ↑ see it in the visualization
  13. default-max-matrix-combinations-count

    A config-defaults setting capping how many combinations one matrix may produce (256 by default).

    A matrix that would exceed it fails validation, which protects the cluster from a typo that creates thousands of Pods.

    config-defaults ConfigMap
    ↑ see it in the visualization
  14. timeouts.pipeline / tasks / finally

    Three budgets on a PipelineRun: the whole run, the DAG tasks (from the run's start), and the finally tasks (from when finally starts).

    With only a pipeline timeout, hitting it skips finally too. Setting `tasks` reserves the rest for cleanup. tasks + finally must not exceed pipeline, and 0 means no limit.

    spec.timeouts.pipelinespec.timeouts.tasksspec.timeouts.finallydefault-timeout-minutes
    ↑ see it in the visualization
  15. retries

    How many times a failed (or timed-out) pipeline task is re-executed in a fresh Pod before it counts as failed.

    It hides flakiness rather than fixing it. Earlier attempts are kept in the TaskRun's `status.retriesStatus` so the history is not lost.

    spec.tasks[].retriesstatus.retriesStatus
    ↑ see it in the visualization
  16. onError: continue (pipeline task)

    Lets a pipeline task fail without stopping the PipelineRun; its TaskRun is still Failed but dependents run.

    Useful for advisory checks like lint or a vulnerability scan that shouldn't block a deploy. The default is stopAndFail. Beta in recent releases.

    spec.tasks[].onError: continuestopAndFail
    ↑ see it in the visualization
  17. spec.status: Cancelled

    Patching a PipelineRun's spec.status to Cancelled cancels every running TaskRun and does not run finally.

    The hard stop. Cleanup in finally won't happen, so prefer the graceful modes when finally matters.

    tkn pipelinerun cancelspec.status: Cancelled
    ↑ see it in the visualization
  18. CancelledRunFinally

    Graceful cancel: running DAG TaskRuns are cancelled, unstarted ones are skipped, then finally tasks run.

    Stop now, but still release locks, delete preview environments and notify.

    spec.status: CancelledRunFinally
    ↑ see it in the visualization
  19. StoppedRunFinally

    Graceful stop: running DAG tasks are allowed to finish, no new ones start, then finally runs.

    Avoids leaving half-done work, such as a deploy cut off midway, while still not running the rest of the pipeline.

    spec.status: StoppedRunFinallyPipelineRunPending
    ↑ see it in the visualization
  20. Custom Task / CustomRun

    A pipeline task whose taskRef names a foreign apiVersion/kind; Tekton creates a CustomRun and an external controller drives it to a Succeeded condition.

    Extends control flow without forking Tekton: waits, approvals, loops over pipelines, or calls to external systems, all scheduled like normal tasks.

    tekton.dev/v1beta1 CustomRuncustomRefwait.testing.tekton.dev Wait
    ↑ see it in the visualization
  21. Approval task

    A custom task (e.g. the manual-approval-gate ApprovalTask) that blocks the pipeline until enough named approvers approve it.

    A human gate before production, enforced inside the PipelineRun instead of by convention. A rejection or timeout fails the CustomRun and stops the run.

    kind: ApprovalTasknumberOfApprovalsRequiredtkn-approvaltask approve
    ↑ see it in the visualization