Tekton Triggers
From git push to PipelineRun
eventlistenertriggers-triggertriggers-interceptortriggerbindingtriggertemplateone HTTP request, every Trigger evaluated independently
PipelineRuns in namespace ci
kubectl get pr -n ciapiVersion: triggers.tekton.dev/v1beta1kind: EventListenermetadata:name: github-listenernamespace: cispec:serviceAccountName: tekton-triggers-satriggers:- name: push-to-maininterceptors:- ref: { name: github }params:- name: secretRefvalue: { secretName: github-secret, secretKey: token }- name: eventTypesvalue: ["push"]- ref: { name: cel }params:- name: filtervalue: body.ref == 'refs/heads/main'- name: overlaysvalue:- key: short_shaexpression: body.head_commit.id.truncate(7)bindings:- ref: push-bindingtemplate:ref: build-and-deploy- name: pull-requestinterceptors:- ref: { name: github }params:- name: secretRefvalue: { secretName: github-secret, secretKey: token }- name: eventTypesvalue: ["pull_request"]- ref: { name: cel }params:- name: filtervalue: body.action in ['opened', 'synchronize', 'reopened']bindings:- ref: pr-bindingtemplate:ref: pr-checks
Try: send a push to main (one PipelineRun), then a push to feature/login (passes the GitHub interceptor, dropped by the CEL filter), a pull_request opened (the other Trigger fires), and the wrong-secret push (both Triggers stop at the signature check). GitHub gets 202 every time; the YAML on the right lights up the lines doing the work.
GitHub signs the raw body
GitHub interceptor recomputes it
apiVersion: v1kind: Secretmetadata:name: github-secretstringData:token: s3cr3t-from-k8s-secret
Try: switch to "tamper: ref" to pretend a feature push is a push to main, and see that the signature no longer matches. Then put the cluster secret out of sync with GitHub (edit it, or pick "GitHub has old secret"). This is the usual reason for "my webhook does nothing" after a secret rotation.
The interceptor chain
triggers-interceptorclusterinterceptortriggers-scm-interceptorscel-interceptorcel-overlaystriggers-webhook-interceptorthe chain (click a hop to see what goes over the wire)
{
"body": "…same body…",
"header": "…same headers…",
"extensions": {},
"interceptor_params": {
"filter": "body.ref.startsWith('refs/heads/release/')",
"overlays": [
{
"key": "short_sha",
"expression": "body.head_commit.id.truncate(7)"
},
{
"key": "branch",
"expression": "body.ref.replace('refs/heads/', '')"
}
]
},
"context": {
"event_url": "http://el-shop-listener.ci.svc:8080",
"event_id": "c2a8f-41d7b",
"trigger_id": "namespaces/ci/triggers/shop-push"
}
}{
"continue": true,
"extensions": {
"short_sha": "e4c1a90",
"branch": "release/1.4"
}
}interceptors:- ref:name: githubkind: ClusterInterceptorparams:- name: secretRefvalue: { secretName: github-secret, secretKey: token }- name: eventTypesvalue: ["push"]- ref: { name: cel }params:- name: filtervalue: >-body.ref.startsWith('refs/heads/release/')- name: overlaysvalue:- key: short_shaexpression: body.head_commit.id.truncate(7)- key: branchexpression: body.ref.replace('refs/heads/', '')
Try: push release/1.4 with the release/* filter and the branch overlay. Then switch the provider: the event header, signature header and JSON paths all change, but the chain works the same way. Turn off "secret matches" and the CEL interceptor is never called. Push a tag with the filter on main and the chain stops at CEL.
Bindings: from JSON to params
triggerbindingtriggers-binding-expressionstriggertemplatetriggers-tt-paramsthe event, as the binding sees it
hover a valueextensions.short_sha was added by a CEL overlay earlier in the chain. Click any value to bind it to git-url.
apiVersion: triggers.tekton.dev/v1beta1kind: TriggerBindingmetadata:name: push-bindingspec:params:# click a field in the payload →
apiVersion: triggers.tekton.dev/v1beta1kind: TriggerTemplatemetadata:name: build-and-deployspec:params:- name: git-url- name: git-revision- name: short-shadefault: latest- name: event-typedefault: pushresourcetemplates:- apiVersion: tekton.dev/v1kind: PipelineRunmetadata:generateName: build-and-deploy-labels:acme.dev/event: $(tt.params.event-type)spec:pipelineRef: { name: build-and-deploy }params:- name: repo-urlvalue: $(tt.params.git-url)- name: revisionvalue: $(tt.params.git-revision)- name: image-tagvalue: $(tt.params.short-sha)
PipelineRun the EventListener would create
apiVersion: tekton.dev/v1kind: PipelineRunmetadata:generateName: build-and-deploy-labels:acme.dev/event: pushspec:pipelineRef: { name: build-and-deploy }params:- name: repo-urlvalue: $(tt.params.git-url)- name: revisionvalue: $(tt.params.git-revision)- name: image-tagvalue: latest
Try: click repository.clone_url, then head_commit.id, and watch the PipelineRun fill in. Unbind git-url: there is no default, so the literal $(tt.params.git-url) goes into the PipelineRun (in red). short-sha and event-type fall back to their defaults until you bind them. Header lookups are case-insensitive.
webhooks hitting the EventListener Pods
Service spreads requests across replicaskubectl get pipelineruns (12)
resourcetemplates:- apiVersion: tekton.dev/v1kind: PipelineRunmetadata:generateName: build-and-deploy-spec: …
Try: switch to a fixed name and replay. Only the first event gets a PipelineRun. Naming by commit SHA drops redeliveries (that is deduplication), but it also blocks you from deliberately re-running a commit. generateName never collides: every event gets its own run, redeliveries included. Adding replicas spreads the load but changes nothing about naming.
Trigger CRDs in the cluster
apiVersion: triggers.tekton.dev/v1beta1kind: EventListenermetadata:name: github-listenernamespace: cispec:serviceAccountName: tekton-triggers-satriggers:- name: push-to-mainbindings: [{ ref: push-binding }]template: { ref: build-and-deploy }- triggerRef: nightlylabelSelector:matchLabels:provider: github
kind: RoleBinding # namespace ci onlymetadata: { name: triggers-sa-eventlistener-binding, namespace: ci }roleRef:kind: ClusterRolename: tekton-triggers-eventlistener-rolessubjects: [{ kind: ServiceAccount, name: tekton-triggers-sa, namespace: ci }]# both setups also bind ...-eventlistener-clusterroles# (ClusterInterceptors, ClusterTriggerBindings)
Try: with only the labelSelector set, the listener picks up the labelled Triggers in its own namespace. Set namespaceSelector to team-a, team-b and those Triggers go red: the ServiceAccount can only read objects in ci. Switch to the ClusterRoleBinding and they are served. Unset the labelSelector to see api-mr (provider=gitlab) join in.
what the EventListener controller creates for you
log
spec:serviceAccountName: tekton-triggers-sacloudEventURI: http://event-display.ci.svcresources:kubernetesResource:replicas: 2spec:template:spec:containers:- resources:requests: { cpu: 100m, memory: 64Mi }
apiVersion: networking.k8s.io/v1kind: Ingressmetadata: { name: github-listener }spec:tls: [{ hosts: [hooks.acme.dev], secretName: hooks-tls }]rules:- host: hooks.acme.devhttp:paths:- path: /pathType: Prefixbackend:service:name: el-github-listenerport: { number: 8080 }
Try: choose "nothing" and send: the listener is running but GitHub can't reach it, which is a common first-day problem. Expose it, raise replicas and send a few times to see requests spread across Pods. Kill pod-0 and the Service stops sending it traffic. With the CloudEvents sink on, every event also produces started / successful / done CloudEvents.
Triggers reference bindings and templates by name
apiVersion: triggers.tekton.dev/v1beta1kind: ClusterTriggerBinding # cluster-scopedmetadata:name: github-push # no namespacespec:params:- name: git-urlvalue: $(body.repository.clone_url)- name: git-revisionvalue: $(body.head_commit.id) # v1
Try: select github-push and press Edit. All four Triggers, across two namespaces, pick up the change on their next event, with nothing to redeploy. Then switch web-push to a plain TriggerBinding: the name now resolves in team-b, where no such binding exists.
- takeawayA 202 from the EventListener only means the request was received. Whether a PipelineRun was created depends on each Trigger's interceptor chain, and a rejected event leaves no trace except in the listener's logs.
- takeawayInterceptors are the security boundary and the router. Validate the HMAC first, filter with CEL, and use overlays to compute what the bindings need.
- takeawayBindings read the event, templates shape the run. Use generateName, give required params a value or a default, and share mappings through ClusterTriggerBindings.
Key concepts · 20
book chapter 11- EventListener
A CRD for a long-running HTTP sink that receives events and evaluates every Trigger it serves against each one.
It is the only part that is always running. It replies 202 Accepted right away and processes the event asynchronously, so a 202 from the listener does not mean a PipelineRun was created.
triggers.tekton.dev/v1beta1spec.triggerstkn eventlistener describe↑ see it in the visualization - EventListener Service & Deployment
For each EventListener the controller creates a Deployment and a Service named el-<name>, listening on port 8080.
The Service is ClusterIP by default, so you must expose it yourself (Ingress, OpenShift Route or a LoadBalancer serviceType) before GitHub can reach it. Replicas scale the sink horizontally, and cloudEventURI makes it emit CloudEvents about each event it handles.
el-<name>:8080spec.resources.kubernetesResource.replicasserviceType: LoadBalancerspec.cloudEventURI↑ see it in the visualization - Trigger
Groups interceptors, bindings and a template into one unit. It can be written inline in an EventListener or as its own CRD.
As standalone CRDs, Triggers can live in team namespaces and be picked up by a shared listener through selectors. Each one can set its own serviceAccountName for the objects it creates.
kind: TriggertriggerRefspec.serviceAccountName↑ see it in the visualization - TriggerBinding
A namespaced list of params whose values are expressions over the event, like $(body.head_commit.id).
It separates reading the payload from the PipelineRun shape, so the same template can serve GitHub, GitLab or a cron job as long as each one has its own binding.
kind: TriggerBindingspec.params[].value↑ see it in the visualization - ClusterTriggerBinding
A cluster-scoped TriggerBinding that any Trigger in any namespace can reference with kind: ClusterTriggerBinding.
Mappings for a provider's payload are the same everywhere, so you define them once. A ref without kind looks for a namespaced TriggerBinding in the Trigger's own namespace, which is a common cause of 'not found'.
kind: ClusterTriggerBindingbindings[].kind↑ see it in the visualization - TriggerTemplate
Declares params (optionally with defaults) and resourcetemplates, the Kubernetes objects to create for each event.
It is the blueprint for a run. The listener substitutes $(tt.params.x) into it and creates the result with its own ServiceAccount, so that account needs RBAC to create PipelineRuns.
kind: TriggerTemplatespec.params[].default↑ see it in the visualization - $(tt.params.…)
The placeholder syntax inside resourcetemplates that is replaced with the bound or default value of a template param.
A param that is neither bound nor defaulted is not an error: the literal placeholder ends up in the created object, which then fails later in a confusing way.
$(tt.params.git-revision)↑ see it in the visualization - resourcetemplates
The list of objects in a TriggerTemplate. Usually one PipelineRun, but any Tekton or Kubernetes resource the ServiceAccount may create.
One event creates every object in the list, every time. Triggers keeps no memory of earlier events, so deduplication has to come from naming or from the pipeline itself.
spec.resourcetemplates[]apiVersion: tekton.dev/v1↑ see it in the visualization - generateName
metadata.generateName asks the API server to add a random 5-character suffix, so every created PipelineRun gets a unique name.
A fixed name works once and then fails with AlreadyExists. A name derived from the commit SHA dedupes redeliveries but blocks reruns. generateName never collides, but builds a redelivered commit twice.
generateName: build-AlreadyExists↑ see it in the visualization - Interceptor
A step in a Trigger's chain that can validate, filter or enrich the event before bindings run. Any interceptor can stop the chain.
Interceptors run in order and each sees the extensions added by earlier ones. The chain stops at the first continue: false, so cheap checks like signature validation should come first.
spec.triggers[].interceptors[]continue: false↑ see it in the visualization - ClusterInterceptor
A cluster-scoped CRD that registers an interceptor as an HTTPS service. The listener POSTs it an InterceptorRequest and reads back an InterceptorResponse.
The built-in github, gitlab, bitbucket, cel and slack interceptors are ClusterInterceptors served by tekton-triggers-core-interceptors. You can register your own the same way via spec.clientConfig.service.
kind: ClusterInterceptorref: { name: cel }InterceptorRequesttekton-triggers-core-interceptors↑ see it in the visualization - GitHub interceptor
Validates X-Hub-Signature-256 against a secret and filters on X-GitHub-Event with an eventTypes list.
Without it, anyone who finds your webhook URL can start pipelines with any payload they like. eventTypes keeps push-only Triggers from firing on ping or issue events.
params: secretRef, eventTypesX-GitHub-Event↑ see it in the visualization - GitLab & Bitbucket interceptors
Provider-specific equivalents of the GitHub interceptor. GitLab compares the X-Gitlab-Token shared token; Bitbucket Server checks an HMAC in X-Hub-Signature.
Each provider authenticates and names events differently (Push Hook, repo:refs_changed). The interceptor hides that so the rest of the chain looks the same.
X-Gitlab-TokenX-Gitlab-EventX-Event-Key↑ see it in the visualization - CEL interceptor
Evaluates a Common Expression Language filter over body and header. The event continues only if it returns true.
Branch, path and action filtering without writing a service. Triggers adds functions such as match, truncate, split, compareSecret and parseJSON to standard CEL.
filter: body.ref == 'refs/heads/main'header.match('X-GitHub-Event','push')↑ see it in the visualization - CEL overlays
Key/expression pairs on the CEL interceptor whose results are added to the event's extensions.
Bindings can only copy fields. Overlays compute new ones (a 7-char SHA, a branch name without refs/heads/) so the template stays simple.
overlays: [{key, expression}]body.head_commit.id.truncate(7)↑ see it in the visualization - Webhook interceptor
The older style of custom interceptor: a plain Kubernetes Service that receives the raw HTTP event and returns 200 to continue.
Its response body and headers replace the event's, so it can rewrite payloads. It doesn't use the InterceptorRequest protocol, and new interceptors should usually be ClusterInterceptors instead.
webhook.objectRef: { kind: Service }↑ see it in the visualization - Webhook secret
A shared secret configured both in the Git provider's webhook settings and in a Kubernetes Secret that the interceptor reads via secretRef.
The two copies have to match. After a rotation that misses one side, every event is dropped silently while the provider still sees a 202.
secretRef: { secretName, secretKey }↑ see it in the visualization - HMAC signature
sha256=HMAC-SHA256(secret, raw body), sent in X-Hub-Signature-256 and recomputed by the interceptor.
It proves the sender knows the secret and that the body wasn't changed in transit. One changed byte gives a completely different digest, and the comparison is constant-time so timing doesn't leak the expected value.
X-Hub-Signature-256hmac.Equal↑ see it in the visualization - $(body…), $(header…), $(extensions…)
The three roots a binding value can read: the JSON body (JSONPath-like, with [i] indexes), HTTP headers (case-insensitive), and interceptor extensions.
Values can be mixed with literals ("pr-$(body.number)"), and $(body) on its own gives the whole payload as a JSON string. A path that doesn't exist fails the binding for that event.
$(body.head_commit.id)$(header.X-GitHub-Event)$(extensions.short_sha)↑ see it in the visualization - namespaceSelector, labelSelector & listener RBAC
Selectors that let one EventListener serve Trigger CRDs from other namespaces (matchNames, "*") and/or only those with matching labels.
This is how platform teams run one listener for many teams. The listener's ServiceAccount must be allowed to read Triggers, bindings and templates in those namespaces, or they are silently skipped and the errors show up only in its logs.
namespaceSelector.matchNameslabelSelector.matchLabelstekton-triggers-eventlistener-rolestekton-triggers-eventlistener-clusterroles↑ see it in the visualization