Control flow
when expressions, and what a skip does to dependents
when-expressionwhen-input-operator-valuescel-whenwhen-guards-task-onlyskipping-reasonsPipelineRun · DAG
apiVersion: tekton.dev/v1kind: Pipelinespec:params:- name: branchtasks:- name: fetch- name: lintrunAfter: [fetch]- name: unit-testrunAfter: [fetch]- name: build-imagerunAfter: [unit-test]when:- input: "$(params.branch)"operator: invalues: ["main", "release/1.4"]- name: deploy-previewrunAfter: [build-image] # ordering only- name: signparams:- name: digestvalue: $(tasks.build-image.results.IMAGE_DIGEST)- name: publish-sbomrunAfter: [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.
finally: what always runs, and what it knows
finally-taskstask-execution-statusaggregate-tasks-statusfinally-resultstimeline
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
spec:tasks:- name: clone- name: buildrunAfter: [clone]- name: testrunAfter: [build]finally:- name: cleanup # always runstaskRef: { name: cleanup-workspace }- name: notify-slackwhen:- input: $(tasks.status)operator: invalues: ["Failed"]params:- name: messagevalue: "test is $(tasks.test.status)"- name: report-coverageparams:- name: coveragevalue: $(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.
build → 4 TaskRuns
- name: buildrunAfter: [clone]taskRef: { name: go-build }matrix:params:- name: platformvalue: ["linux/amd64", "linux/arm64"]- name: govalue: ["1.23", "1.24"]include:- name: arm-cgoparams:- name: platformvalue: "linux/arm64"- name: cgovalue: "1"- name: publish-manifestparams:- name: imagesvalue: $(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-timeoutstimeline (minutes)
apiVersion: tekton.dev/v1kind: PipelineRunspec:pipelineRef: { name: ci }timeouts:pipeline: "45m"tasks: "35m"# effective: tasks 35m · finally 10m
events
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.
timeline
spec:tasks:- name: fetch- name: integrationrunAfter: [fetch]retries: 2- name: lintrunAfter: [fetch]onError: continue- name: deployrunAfter: [integration, lint]
events
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.
timeline
same patch at t=18s, all three modes
| task | Cancelled | CancelledRunFinally | StoppedRunFinally |
|---|---|---|---|
| clone | succeeded | succeeded | succeeded |
| build | cancelled | cancelled | succeeded |
| lint | cancelled | cancelled | succeeded |
| deploy | not run | skipped | skipped |
| finally · cleanup | not run | succeeded | succeeded |
| finally · notify | not run | succeeded | succeeded |
# kubectl patch pipelinerun ci-run --type=merge \# -p '{"spec":{"status":"CancelledRunFinally"}}'apiVersion: tekton.dev/v1kind: PipelineRunmetadata:name: ci-runspec:status: CancelledRunFinallypipelineRef: { name: ci }
events
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.
pipeline
sees a taskRef with an apiVersion/kind it doesn't own, creates a CustomRun and waits.
watches CustomRuns of its kind, does the work (collect votes, sleep), writes the Succeeded condition.
reads the condition like a TaskRun's: True unblocks dependents, False fails the run.
approvers
# created by the PipelineRun controllerapiVersion: tekton.dev/v1beta1kind: CustomRunmetadata:name: ci-run-approveownerReferences: [{ kind: PipelineRun, name: ci-run }]spec:customRef:apiVersion: openshift-pipelines.org/v1alpha1kind: ApprovalTaskparams:- name: approversvalue: [alice, bob, carol]- name: numberOfApprovalsRequiredvalue: "2"timeout: 1h# written by the custom controllerstatus:conditions:- type: Succeededstatus: "Unknown"reason: Pendingmessage: 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- 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 - 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 - 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 - 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 - 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 - 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 - $(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 - $(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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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