05

Params & Results

Data moves through a Pipeline in two directions. Params flow down: a PipelineRun supplies values, and Tekton substitutes them as plain text into the Pipeline, then the Task, then the step's args and scripts, before any container starts. Results flow back up: a step writes a small file, the entrypoint packs it into the container's termination message, and the controller copies it into the TaskRun status, where other tasks can reference it. This chapter takes both channels apart: validation and types, propagation, the 4 KB ceiling, the built-in context variables, and why splicing a param into a shell script is an injection bug.
Edit the PipelineRun's params and watch every $(params.…) placeholder resolve at each level: the Pipeline maps its params onto the Task's, and the Task's step receives the final strings. Strings substitute directly, arrays expand with [*] into separate arguments, objects are read key by key. Validation runs first, so a missing or ill-typed param fails the run before any Pod exists.
show

PipelineRun params · edit me

git-url string · required
build-args array
image object
registry
name
tag
env string · enum · default dev
pipelinerun.yaml (generated)
kind: PipelineRun
spec:
pipelineRef: { name: build-deploy }
params:
- name: git-url value: https://github.com/acme/shop
- name: build-args value: ["--target=prod", "--snapshot-mode=redo"]
- name: image value: { registry: ghcr.io, name: acme/shop, tag: v1.4.2 }
- name: env value: staging
PipelineRun
Running
validation passed
from defaults
0
params not supplied
build argv
5
separate argv entries
TaskRuns created
1
pipeline.yaml
kind: Pipeline # build-deploy
spec:
params:
- { name: git-url, type: string } # no default → required
- { name: build-args, type: array, default: ["--cache=true"] }
- name: image type: object properties: { registry: {}, name: {}, tag: {} }
- name: env type: string enum: [dev, staging, prod] default: dev
tasks:
- name: build taskRef: { name: kaniko-build } params:
- { name: SOURCE, value: "https://github.com/acme/shop" }
- { name: IMAGE, value: "ghcr.io/acme/shop:v1.4.2" }
- { name: EXTRA_ARGS, value: ["--target=prod", "--snapshot-mode=redo"] }
- { name: ENV, value: "staging" }
task.yaml
kind: Task # kaniko-build
spec:
params: [SOURCE, IMAGE, {name: EXTRA_ARGS, type: array}, ENV]
steps:
- name: build image: gcr.io/kaniko-project/executor args:
- --context=https://github.com/acme/shop
- --destination=ghcr.io/acme/shop:v1.4.2
- --target=prod # from $(params.EXTRA_ARGS[*])
- --snapshot-mode=redo # from $(params.EXTRA_ARGS[*])
- name: announce image: alpine script: |
echo "pushed ghcr.io/acme/shop:v1.4.2 to staging"

what the build container actually receives (argv)

/kaniko/executor--context=https://github.com/acme/shop--destination=ghcr.io/acme/shop:v1.4.2--target=prod--snapshot-mode=redo

blue = items spread from the array param by [*], each its own argument (no shell word-splitting involved)

Try: type a new tag and watch it flow PipelineRun → Pipeline → Task → argv. Add a build-args item to see [*] grow the arg list. Then omit git-url, send build-args as a string, drop the image tag key, or pick env = qa: each fails the PipelineRun with a different reason before anything runs.

Propagated params in inline specs

param-propagated
Declaring a param in the PipelineRun, again in the Pipeline, again in the pipeline task and again in the Task gets old. When the specs are embedded inline, Tekton propagates outer params inward automatically. A referenced Task is a separate object with its own contract, so propagation stops there.
task
pipelinerun.yaml (fully inline)
kind: PipelineRun
spec:
params:
- { name: HELLO, value: "Hello from the PipelineRun!" }
pipelineSpec: # inline, declares no params
tasks:
- name: echo-hello
taskSpec: # inline
steps:
- name: echo image: alpine
script: echo "$(params.HELLO)"

who supplies $(params.HELLO)?

pipeline task params (explicit)not set
innermost scope: always wins
PipelineRun paramsWINS
propagated into inline specs
Task param defaultnot set
used only if nothing else supplies a value
step-echo stdout
Hello from the PipelineRun!

Try: with an inline taskSpec, the PipelineRun's HELLO reaches the step even though nothing in between declares it. Switch to taskRef and it breaks. Then turn on the Task default and the pipeline task value to see the precedence order: explicit value, then runtime value, then default.

A result is a file a step writes. Getting it to the next task takes several hops through the Pod, Kubernetes and two controllers. Referencing it from another task is also what creates the dependency between them.
stage 0/7

the result channel

step: build
echo -n sha256:9f2c41e… > $(results.digest.path)
/tekton/results/digest
file in the Pod
·
/tekton/termination
written by entrypoint
·
Pod status
terminated.message
·
TaskRun.status.results
parsed by controller
·
deploy param
$(tasks.build.results.digest)
·
PipelineRun.status.results
image-digest
·
PipelineRun created. build and lint have no dependencies, so both TaskRuns start at once.
pipeline.yaml
kind: Pipeline
spec:
results:
- { name: image-digest, value: $(tasks.build.results.digest) }
tasks:
- name: build taskSpec: results: [{ name: digest }]
steps: - image: gcr.io/go-containerregistry/crane
script: crane digest $IMG | tee $(results.digest.path)
- name: lint taskRef: { name: golangci-lint }
- name: deploy taskRef: { name: kubectl-deploy }
params: [{ name: digest, value: $(tasks.build.results.digest) }]

DAG the controller built

result ref ⇒ edgebuildpendingdeploypendinglintpendingno runAfter anywhere
build result
—
PipelineRun
Pending

Try: step through and follow the digest from a file inside the Pod to the deploy TaskRun's argv. Untick "deploy references the result": the edge disappears and deploy runs alongside build. Tick "build forgets to write it" to see the PipelineRun fail at the hand-off.

Because a result reference is an edge, you rarely need runAfter between producer and consumer. The full DAG scheduler (fan-out, fan-in, runAfter) is in Pipelines & the DAG; a finally task that references a missing result is skipped rather than failing the run (see Control flow).

Array and object results, and step results

result-typedresult-step
Results can be typed like params. The step writes JSON of the declared shape, and consumers index into it with [*], [i] or .key. Inside one Task, step results pass values from step to step without a Task-level name.
result type
step writes
task: build (producer)
results:
- { name: tags, type: array }
steps:
- script: | printf '%s' '…' > $(results.tags.path)

/tekton/results/tags · edit

status.results: [{ name: tags, type: array, value: ["v1.4.2","latest","stable"] }]

consumer task sees

args: ["$(tasks.build.results.tags[*])"]
→ args: ["v1.4.2", "latest", "stable"]
$(tasks.build.results.tags[0])
→ "v1.4.2"
step results: data between steps of one Task
steps:
- name: fetch results: [{ name: sha }]
script: git rev-parse HEAD | tr -d '\n' > $(step.results.sha.path)
- name: build script: echo "building $(steps.fetch.results.sha)"
results:
- { name: commit, value: $(steps.fetch.results.sha) }

inside the Pod

/tekton/steps/step-fetch/results/
sha = 3e9a1c0

Each step gets its own results directory, so steps (often StepActions) can hand values to later steps without both knowing a shared Task result name.

Try: with the array type, click "not JSON": the step wrote plain words, the TaskRun fails. Switch to string and compare printf with echo to see the classic trailing-newline bug. Then step through the step-results panel.

By default results ride in the container termination message, which Kubernetes caps at 4096 bytes. Each step's entrypoint reports every result written so far, so all results of a Task share that one budget. Sidecar-logs mode reads results from logs instead and caps each result separately.
results-from

result sizes · each written by a different step

each step's termination message (written by its entrypoint)

limit 4096 B per message
step-build · ok182 B
step-sbom · ok1,425 B
step-test · ok3,867 B

The entrypoint of every step reports all result files present so far, so the last step's message holds every result. Grey = step metadata. Red line = 4096 B.

TaskRun
Succeeded
last message
3,867 B
all results + metadata
headroom
229 B
for every result combined
Results are for small strings: a digest, a commit SHA, a URL, a version. A test report or an SBOM belongs in a file on a workspace (or pushed to a registry), with a result carrying only its path or digest.

Try: drag test-report up. Its own step overflows first, even though each result alone is under 4 KB, because the message carries everything written before it. Switch to sidecar-logs: the same sizes pass, until a single result passes max-result-size.

Context variables: what you can reference, and where

param-context-variables
Besides params and results, Tekton substitutes a set of built-in variables: names and UIDs of the run, retry counts, workspace paths, task outcomes. Each one is only legal in certain places. A step can see its TaskRun's name but not $(tasks.status); a finally task can.
I'm writing a
build task

test an expression

✗ known variable, but not available in a Task step. Valid in: Pipeline task, finally task

variable reference

variablehere
$(params.<name>)v1.4.2
$(params.<name>[*])["--target=prod", "--cache=true"]
$(params.<name>.<key>)ghcr.io
$(results.<name>.path)/tekton/results/digest
$(step.results.<name>.path)/tekton/steps/step-fetch/results/sha
$(steps.<step>.results.<name>)3e9a1c0
$(steps.step-<step>.exitCode.path)/tekton/steps/step-unit/exitCode
$(tasks.<task>.results.<name>)n/a here
$(tasks.<task>.results.<name>[*])n/a here
$(tasks.<task>.results.<name>.<key>)n/a here
$(tasks.<task>.matrix.length)n/a here
$(tasks.<task>.status)n/a here
$(tasks.<task>.reason)n/a here
$(tasks.status)n/a here
$(workspaces.<name>.path)/workspace/source
$(workspaces.<name>.bound)true
$(workspaces.<name>.claim)pvc-5c2f1e
$(workspaces.<name>.volume)ws-8k2lp
$(context.taskRun.name)build-deploy-r7x2k-build
$(context.taskRun.namespace)ci
$(context.taskRun.uid)0d4b1f5e-9c3a-4a8e-b1f7-2e6d8c90aa13
$(context.task.name)kaniko-build
$(context.task.retry-count)0
$(context.pipelineRun.name)n/a here
$(context.pipelineRun.namespace)n/a here
$(context.pipelineRun.uid)n/a here
$(context.pipeline.name)n/a here
$(context.pipelineTask.retries)n/a here
$(credentials.path)/tekton/creds

Try: switch between Task step, Pipeline task and finally task and watch rows light up or grey out. $(tasks.status) only exists in finally. Click a row to test it, or try the misspelt $(context.taskRun.nmae).

Substitution is not escaping: script injection

param-script-injection
Tekton pastes the value into the script text before the shell ever sees it. If the value contains a quote, a semicolon or $(…), it becomes code. The fix is to keep the script constant and hand the value over as an environment variable.
pass the param
branch =

PipelineRun param (attacker controls this, e.g. a branch name from a webhook)

task.yaml
steps:
- name: deploy image: alpine/git
script: |
echo "Deploying $(params.branch)"
git checkout "$(params.branch)"
script file after substitution (what sh reads)
#!/bin/sh
echo "Deploying "; rm -rf / #"
git checkout ""; rm -rf / #"

commands the shell runs

  1. 1.echo "Deploying "
  2. 2.rm -rf /
  3. 3.git checkout ""
  4. 4.rm -rf /
intended commands
2
injected commands
2
attacker code runs with the step's credentials

Try: pick "; rm -rf / #: the quote closes the string, ; starts a new command and # comments out the rest. The $(cat creds) payload runs even inside double quotes. Switch to "via env var": the script text never changes, the shell expands ${BRANCH} as a single word, and every payload stays harmless data.

Treat every param that can come from outside (branch names, PR titles, commit messages arriving via Triggers) as untrusted. Passing values in args to a non-shell binary is safe from this, since each arg is one argv entry. Scripts are not. Enums are a good extra line of defence for values with a known set.
  • takeawayParams are substituted as text into the spec before Pods exist. Validation of presence, type, enum and object keys happens first, so bad inputs fail fast with a named reason.
  • takeawayResults are files that ride the termination message into TaskRun.status. Referencing one creates the DAG edge. Keep them tiny: the whole Task gets about 4 KB unless you switch to sidecar logs, and big data belongs on a workspace.
  • takeawayNever splice a param into a script. Map it to an env var and quote it, so the script text stays fixed and the value stays data.

Key concepts · 20

book chapter 5
  1. Param

    A named input declared in a Task or Pipeline spec and given a value by the TaskRun, PipelineRun or pipeline task that uses it.

    Params are how one Task definition serves many runs: the repo URL, the image tag, the target environment. They are declared with a name, a type and optionally a default and description.

    spec.params[]PipelineRun spec.params[].valuetkn pipeline start -p git-url=…
    ↑ see it in the visualization
  2. Variable substitution

    Tekton replaces $(params.x) and other $(…) expressions with their values in the spec before it creates TaskRuns and Pods.

    It is text replacement, not a template language: no loops, no conditionals, no escaping. The container receives the final strings, which is both simple and the root of the injection problem.

    $(params.x)$(params["x"])$(params['x'])
    ↑ see it in the visualization
  3. Param types

    Every param is a string, an array of strings or an object of string values; string is the default.

    The declared type is checked against what the run supplies, and it decides which reference forms are legal: .key for objects, [*] or [i] for arrays.

    type: stringtype: arraytype: object
    ↑ see it in the visualization
  4. Default value

    The value a param takes when the run does not supply one; a param without a default is required.

    Defaults keep PipelineRuns short and make Tasks reusable. Leaving a default off is how a Task says "you must tell me this", and the run fails validation if nobody does.

    default: devdefault: ["--cache=true"]
    ↑ see it in the visualization
  5. Param enum

    A list of allowed values for a string param; any other value fails the run before it starts (beta).

    It moves input checking from a shell `case` statement into the spec, where it is visible, documented and enforced before a Pod is ever scheduled.

    enum: [dev, staging, prod]feature-flags: enable-param-enumreason: InvalidParamValue
    ↑ see it in the visualization
  6. Array param

    A param holding a list of strings, expanded into separate list items with $(params.name[*]) or indexed with $(params.name[i]).

    [*] only works as a whole element of a list field like args or command, and each item becomes its own argv entry. That is safer than joining flags into one string and letting a shell split it.

    args: ["$(params.flags[*])"]$(params.flags[0])
    ↑ see it in the visualization
  7. Object param

    A param with named string keys declared under properties, read one key at a time with $(params.name.key).

    It groups related values (registry, name, tag) into one input. The run must supply every declared key, or the PipelineRun fails with ObjectParameterMissKeys.

    properties: {registry: {}, tag: {}}$(params.image.tag)$(params.image[*])
    ↑ see it in the visualization
  8. Param validation

    Before creating any TaskRun, the controller checks that required params are present, types match, enum values are allowed and object keys are complete.

    A bad input fails fast with a specific reason instead of a confusing error ten minutes into a build. No Pod, no wasted node time.

    ParameterMissingParameterTypeMismatchInvalidParamValueObjectParameterMissKeys
    ↑ see it in the visualization
  9. Propagated params

    With inline specs (pipelineSpec, taskSpec), params of the outer run are visible to inner specs without being declared or passed at each level.

    It removes boilerplate from fully inline PipelineRuns. It does not cross a taskRef: a referenced Task only sees params it declares and that are passed to it. Inner explicit values win over outer ones, and runtime values win over defaults.

    pipelineSpectaskSpectaskRef
    ↑ see it in the visualization
  10. Task result

    A small named output a Task declares and its steps write to a file, which Tekton copies into TaskRun.status.results.

    Results are how tasks hand values to each other without sharing a volume: a digest, a commit SHA, a URL. Declaring a result does not force a step to write it.

    spec.results[]TaskRun status.resultstkn taskrun describe
    ↑ see it in the visualization
  11. Result path

    $(results.name.path) resolves to /tekton/results/name, a file on a volume shared by all steps of the Pod.

    Writing the file is the whole API: any language, any tool. Use printf or echo -n, because a trailing newline becomes part of the value.

    $(results.digest.path)/tekton/results/digest
    ↑ see it in the visualization
  12. Termination message

    The Kubernetes mechanism Tekton uses by default to ship results: the entrypoint writes them as JSON to /tekton/termination, and the kubelet copies it into the Pod status.

    It needs no extra permissions, but Kubernetes caps a container's message at 4096 bytes and each step reports all results written so far, so a Task's results share about 4 KB. Overflowing fails the step and the TaskRun.

    /tekton/terminationstate.terminated.messageresults-from: termination-message
    ↑ see it in the visualization
  13. Sidecar logs results

    An alternative transport where a sidecar prints result files to its log and the controller reads them from the Pod logs.

    It lifts the shared 4 KB ceiling, at the cost of the controller needing pods/log access and extra work per TaskRun. Turned on with results-from: sidecar-logs in the feature-flags ConfigMap.

    feature-flags: results-from: sidecar-logssidecar-tekton-log-results
    ↑ see it in the visualization
  14. max-result-size

    The per-result byte cap that applies in sidecar-logs mode, 4096 by default.

    Raising it allows bigger results, but everything still lands in the TaskRun object in etcd, which is limited to about 1.5 MB. A result over the cap fails the TaskRun with TaskRunResultLargerThanAllowedLimit.

    feature-flags: max-result-sizeTaskRunResultLargerThanAllowedLimit
    ↑ see it in the visualization
  15. Result reference

    $(tasks.build.results.digest) in another pipeline task's params or when expressions, resolved by the PipelineRun controller.

    Referencing a result creates a DAG edge: the consumer waits for the producer, no runAfter needed. If the producer never wrote it, the PipelineRun fails with InvalidTaskResultReference.

    $(tasks.build.results.digest)InvalidTaskResultReference
    ↑ see it in the visualization
  16. Pipeline results

    Outputs a Pipeline declares from its tasks' results, surfaced in PipelineRun.status.results.

    They make a whole Pipeline usable like a function: whatever triggered it (a human, Triggers, Chains, another system) can read the image digest without digging into TaskRuns.

    spec.results[].value: $(tasks.build.results.digest)PipelineRun status.results
    ↑ see it in the visualization
  17. Array and object results

    Results declared as type array or object, written as JSON and consumed with [*], [i] or .key.

    They avoid ad-hoc comma-separated strings. The step must write valid JSON of the declared shape (strings only, every declared key), or the TaskRun fails.

    type: array$(tasks.t.results.tags[*])$(tasks.t.results.image.url)
    ↑ see it in the visualization
  18. Step results

    Results declared on a single step, written to $(step.results.name.path) and read by later steps as $(steps.step-name.results.name).

    They let steps, especially reusable StepActions, pass values within a Task. A Task result can be filled from one: value: $(steps.fetch.results.sha).

    steps[].results[]$(step.results.sha.path)$(steps.fetch.results.sha)
    ↑ see it in the visualization
  19. Context variables

    Built-in substitutions describing the run itself, such as $(context.taskRun.name), $(context.pipelineRun.uid) and, in finally tasks, $(tasks.status).

    They give unique names, retry counts and outcomes without passing them as params. Each is only legal in certain places, and misspelt ones are rejected by validation.

    $(context.taskRun.name)$(context.pipelineRun.uid)$(context.task.retry-count)$(tasks.status)
    ↑ see it in the visualization
  20. Script injection

    Because substitution is raw text, a param value spliced into a script becomes shell code if it contains quotes, ;, && or $(…).

    Branch names and PR titles from webhooks are attacker-controlled. Pass params into scripts through env vars (env: value: $(params.x)) and quote "$X": the script text never changes and the value stays data.

    env: [{name: BRANCH, value: $(params.branch)}]"${BRANCH}"
    ↑ see it in the visualization