Expected Behavior
An object param can be supplied partially: the keys a PipelineRun leaves out fall back to the values
in the ParamSpec default. docs/tasks.md states it explicitly:
When providing value for an object param, one may provide values for just a subset of keys in
spec's default, and provide values for the rest of keys at runtime.
Validation implements exactly that. MissingKeysObjectParamNames
(pkg/reconciler/taskrun/validate_taskrun.go#L115)
builds its set of provided keys from the union of the ParamSpec default's keys and the keys the
run supplies, so a partially supplied object is accepted on the understanding that the default covers
the rest.
So for a Pipeline declaring
params:
- name: prepare
type: object
default:
command: echo default-command
image: docker.io/library/busybox:latest
and a PipelineRun supplying only command, both of these should see image come from the default:
$(params.prepare.image) — individual key reference
$(params.prepare[*]) — whole-object reference
Actual Behavior
Neither does. Parameter resolution drops the default for every key the run omitted, and the two
reference forms fail in two different ways.
Case 1 — individual key $(params.prepare.image)
buildUnresolvedObjectParamDefaults
(pkg/reconciler/pipelinerun/resources/apply.go#L249)
skips the whole default as soon as the run mentions the param:
if paramExists(param.Name, resolvedObjectParams) {
continue
}
so the omitted keys are never registered as replacements. The reference survives substitution
untouched and the run only fails later, when the TaskRun is turned into a pod:
PodCreationFailed
failed to create task run pod "partial-object-param-run-build": non-existent variable in
"$(params.prepare.image)": steps[0].image. Maybe invalid TaskSpec
This is a regression. Before f7d3aee, ApplyParameters registered every default key as an
individual $(params.<name>.<key>) replacement first and then let the run's values overwrite them key
by key, so a partial object inherited the rest of its default.
Case 2 — whole object $(params.prepare[*])
The whole-object form resolves to the run-supplied object alone, so the Task receives an object that
is missing the omitted key and the run is rejected before any TaskRun exists:
PipelineValidationFailed
[User error] Validation failed for pipelinerun partial-object-param-star-run with error
invalid input params for task : missing keys for these params which are required in ParamSpec's
properties map[prepare:[image]]
Note what this says: the same PipelineRun is accepted at Pipeline level (validation counts the
default's keys as provided) and then rejected at Task level for missing a key the user did supply
— in the default. Following the documented "supply a subset of keys" pattern produces a Pipeline that
cannot pass its object param on.
This one is not part of the regression above; $(params.<name>[*]) never merged the default. It
is filed here because it is the same contract being broken, from the same root cause (the default
fill-in is not applied to partially supplied object params), and because a fix for Case 1 that leaves
Case 2 alone makes the two reference forms mean different things.
Steps to Reproduce the Problem
- Apply the manifest below. It declares the same object param twice — once referenced by individual
key (Case 1) and once referenced as a whole object (Case 2) — and a PipelineRun for each that
supplies only command.
reproduction manifest
apiVersion: v1
kind: Namespace
metadata:
name: objparam-repro
---
# ---------- Case 1: individual key reference ----------
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: partial-object-param
namespace: objparam-repro
spec:
params:
- name: prepare
type: object
properties:
command:
type: string
image:
type: string
default:
command: echo default-command
image: docker.io/library/busybox:latest
tasks:
- name: build
params:
- name: command
value: $(params.prepare.command)
- name: image
value: $(params.prepare.image)
taskSpec:
params:
- name: command
type: string
- name: image
type: string
steps:
- name: run
image: $(params.image)
script: |
#!/bin/sh
echo "command param resolved to: $(params.command)"
---
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
name: partial-object-param-run
namespace: objparam-repro
spec:
pipelineRef:
name: partial-object-param
# Only `command` is supplied; `image` is expected to come from the Pipeline's default.
params:
- name: prepare
value:
command: echo supplied-command
---
# ---------- Case 2: whole-object reference ----------
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: partial-object-param-star
namespace: objparam-repro
spec:
params:
- name: prepare
type: object
properties:
command:
type: string
image:
type: string
default:
command: echo default-command
image: docker.io/library/busybox:latest
tasks:
- name: build
params:
# No $(params.prepare.<key>) reference appears anywhere in this Pipeline, so this case
# exercises the whole-object path on its own.
- name: prepare
value: $(params.prepare[*])
taskSpec:
params:
- name: prepare
type: object
properties:
command:
type: string
image:
type: string
steps:
- name: run
image: $(params.prepare.image)
script: |
#!/bin/sh
echo "resolved: $(params.prepare.command)"
---
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
name: partial-object-param-star-run
namespace: objparam-repro
spec:
pipelineRef:
name: partial-object-param-star
params:
- name: prepare
value:
command: echo supplied-command
-
kubectl get pipelinerun -n objparam-repro.
Expected: both runs succeed, the step running on busybox from the default.
Actual:
| PipelineRun |
reason |
partial-object-param-run |
PodCreationFailed — non-existent variable in "$(params.prepare.image)" |
partial-object-param-star-run |
PipelineValidationFailed — missing keys ... map[prepare:[image]] |
-
For Case 1, the unresolved reference is visible on the TaskRun itself:
$ kubectl get taskrun -n objparam-repro -o jsonpath='{.items[0].spec.params}'
[{"name":"command","value":"echo supplied-command"},{"name":"image","value":"$(params.prepare.image)"}]
Case 1 also reproduces as a unit test in the existing TestApplyParameters table
(pkg/reconciler/pipelinerun/resources/apply_test.go):
{
name: "object param supplied partially keeps default values for the missing keys",
original: v1.PipelineSpec{
Params: []v1.ParamSpec{
{Name: "prepare", Type: v1.ParamTypeObject, Default: v1.NewObject(map[string]string{
"command": "make build",
"image": "golang:1.24",
})},
},
Tasks: []v1.PipelineTask{{
Params: v1.Params{
{Name: "task-command", Value: *v1.NewStructuredValues("$(params.prepare.command)")},
{Name: "task-image", Value: *v1.NewStructuredValues("$(params.prepare.image)")},
},
}},
},
params: v1.Params{
{Name: "prepare", Value: *v1.NewObject(map[string]string{"command": "make test"})},
},
// expected: task-command "make test", task-image "golang:1.24"
}
On main this fails with task-image still holding $(params.prepare.image); on f7d3aee's parent
(af4c8bd) it passes.
Additional Info
Client Version: v1.36.3
Server Version: v1.34.5-1
Both cases were also reproduced with a controller built from main (a1272d4) — this is not
fixed by upgrading.
Introducing commit
Case 1 was introduced by f7d3aee ("fix: re-work parameter resolution
algorithm", 2025-11-28), so it affects v1.9.0 through v1.15.0 (21 tags). The commit message does
not mention any intended change to object-default semantics, and apply_test.go has no case covering
"partial object + default fill-in", which is likely why it went unnoticed.
Behaviour across trees
Measured, with default = {command: make build, image: golang:1.24} and the run supplying only
command: make test:
| reference |
af4c8bd (before) |
main (today) |
with the proposed fix |
$(params.prepare.image) |
golang:1.24 |
$(params.prepare.image) (unresolved) |
golang:1.24 |
$(params.prepare[*]) |
{command: make test} |
{command: make test} |
{command: make test, image: golang:1.24} |
Only the PipelineRun side is affected
The TaskRun side already implements merge semantics: getTaskParameters
(pkg/reconciler/taskrun/resources/apply.go)
clones the ParamSpec default and layers the run's values onto it key by key, so a partially supplied
object param on a TaskRun behaves as documented. A fix on the PipelineRun side makes the same
expression mean the same thing at both levels.
I have a fix ready (two commits: the Case 1 regression, then the Case 2 alignment) and will open a PR
referencing this issue. If maintainers prefer to keep the change to the strict regression, the second
commit can be dropped — happy to go either way.
Expected Behavior
An object param can be supplied partially: the keys a PipelineRun leaves out fall back to the values
in the
ParamSpecdefault.docs/tasks.mdstates it explicitly:Validation implements exactly that.
MissingKeysObjectParamNames(
pkg/reconciler/taskrun/validate_taskrun.go#L115)builds its set of provided keys from the union of the
ParamSpecdefault's keys and the keys therun supplies, so a partially supplied object is accepted on the understanding that the default covers
the rest.
So for a Pipeline declaring
and a PipelineRun supplying only
command, both of these should seeimagecome from the default:$(params.prepare.image)— individual key reference$(params.prepare[*])— whole-object referenceActual Behavior
Neither does. Parameter resolution drops the default for every key the run omitted, and the two
reference forms fail in two different ways.
Case 1 — individual key
$(params.prepare.image)buildUnresolvedObjectParamDefaults(
pkg/reconciler/pipelinerun/resources/apply.go#L249)skips the whole default as soon as the run mentions the param:
so the omitted keys are never registered as replacements. The reference survives substitution
untouched and the run only fails later, when the TaskRun is turned into a pod:
This is a regression. Before f7d3aee,
ApplyParametersregistered every default key as anindividual
$(params.<name>.<key>)replacement first and then let the run's values overwrite them keyby key, so a partial object inherited the rest of its default.
Case 2 — whole object
$(params.prepare[*])The whole-object form resolves to the run-supplied object alone, so the Task receives an object that
is missing the omitted key and the run is rejected before any TaskRun exists:
Note what this says: the same PipelineRun is accepted at Pipeline level (validation counts the
default's keys as provided) and then rejected at Task level for missing a key the user did supply
— in the default. Following the documented "supply a subset of keys" pattern produces a Pipeline that
cannot pass its object param on.
This one is not part of the regression above;
$(params.<name>[*])never merged the default. Itis filed here because it is the same contract being broken, from the same root cause (the default
fill-in is not applied to partially supplied object params), and because a fix for Case 1 that leaves
Case 2 alone makes the two reference forms mean different things.
Steps to Reproduce the Problem
key (Case 1) and once referenced as a whole object (Case 2) — and a PipelineRun for each that
supplies only
command.reproduction manifest
kubectl get pipelinerun -n objparam-repro.Expected: both runs succeed, the step running on
busyboxfrom the default.Actual:
partial-object-param-runPodCreationFailed—non-existent variable in "$(params.prepare.image)"partial-object-param-star-runPipelineValidationFailed—missing keys ... map[prepare:[image]]For Case 1, the unresolved reference is visible on the TaskRun itself:
Case 1 also reproduces as a unit test in the existing
TestApplyParameterstable(
pkg/reconciler/pipelinerun/resources/apply_test.go):{ name: "object param supplied partially keeps default values for the missing keys", original: v1.PipelineSpec{ Params: []v1.ParamSpec{ {Name: "prepare", Type: v1.ParamTypeObject, Default: v1.NewObject(map[string]string{ "command": "make build", "image": "golang:1.24", })}, }, Tasks: []v1.PipelineTask{{ Params: v1.Params{ {Name: "task-command", Value: *v1.NewStructuredValues("$(params.prepare.command)")}, {Name: "task-image", Value: *v1.NewStructuredValues("$(params.prepare.image)")}, }, }}, }, params: v1.Params{ {Name: "prepare", Value: *v1.NewObject(map[string]string{"command": "make test"})}, }, // expected: task-command "make test", task-image "golang:1.24" }On
mainthis fails withtask-imagestill holding$(params.prepare.image); on f7d3aee's parent(af4c8bd) it passes.
Additional Info
Kubernetes version:
Output of
kubectl version:Both cases were also reproduced with a controller built from
main(a1272d4) — this is notfixed by upgrading.
Introducing commit
Case 1 was introduced by f7d3aee ("fix: re-work parameter resolution
algorithm", 2025-11-28), so it affects v1.9.0 through v1.15.0 (21 tags). The commit message does
not mention any intended change to object-default semantics, and
apply_test.gohas no case covering"partial object + default fill-in", which is likely why it went unnoticed.
Behaviour across trees
Measured, with
default = {command: make build, image: golang:1.24}and the run supplying onlycommand: make test:$(params.prepare.image)golang:1.24$(params.prepare.image)(unresolved)golang:1.24$(params.prepare[*]){command: make test}{command: make test}{command: make test, image: golang:1.24}Only the PipelineRun side is affected
The TaskRun side already implements merge semantics:
getTaskParameters(
pkg/reconciler/taskrun/resources/apply.go)clones the
ParamSpecdefault and layers the run's values onto it key by key, so a partially suppliedobject param on a TaskRun behaves as documented. A fix on the PipelineRun side makes the same
expression mean the same thing at both levels.
I have a fix ready (two commits: the Case 1 regression, then the Case 2 alignment) and will open a PR
referencing this issue. If maintainers prefer to keep the change to the strict regression, the second
commit can be dropped — happy to go either way.