Template Customization Guide
This guide walks you through creating, testing, and deploying custom templates for the relay. Whether you’re tweaking a Slack message format or building a rich PR summary, this page covers the full workflow.
How templates work
The relay renders templates with Go text/template. Every template receives a data object (usually domain.Event, except for the accumulator which gets SummaryData). The template produces a string that becomes the comment body, message text, or notification content.
Templates are compiled at config load time. If a template has syntax errors, the relay rejects the config during validation or hot-reload. Invalid templates never silently produce empty output.
Three ways to supply a template
1. Inline string
Short templates go directly in your values.yaml or config file:
scm:
github:
- name: github
actions:
- name: pr-summary
type: pr_comment
template: |
## Build {{ .State }}
**Run:** `{{ .RunName }}`
**Commit:** `{{ .CommitSHA | trunc 8 }}`
{{- if .TargetURL }}
[View logs]({{ .TargetURL }})
{{- end }}
Pros: Simple, version-controlled with your config, no extra resources. Cons: Hard to read for long templates, YAML escaping can be tricky.
2. ConfigMap reference
Long templates live in a Kubernetes ConfigMap and are referenced by path:
scm:
github:
- name: github
actions:
- name: pr-summary
type: pr_comment
template:
configmapRef:
name: my-custom-templates # optional; defaults to tekton-events-relay-templates
key: github-pr-summary.tmpl
The Helm chart volume-mounts the ConfigMap at /etc/templates/. The configmapRef form resolves to a file path like /etc/templates/github-pr-summary.tmpl.
Pros: Clean separation of template content from config, easy to iterate, supports multiple templates. Cons: Extra ConfigMap to manage.
To create your template ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: my-custom-templates
data:
github-pr-summary.tmpl: |
## {{ .State }} — {{ .PipelineName | default .RunName }}
| | |
|---|---|
| **Run** | `{{ .RunName }}` |
| **Namespace** | `{{ .Namespace }}` |
| **Commit** | `{{ .CommitSHA | trunc 8 }}` |
{{- if .Results }}
<details><summary>Results ({{ len .Results }})</summary>
| Name | Value |
|---|---|
{{- range .Results }}
| `{{ .Name }}` | `{{ Truncate .Value 120 }}` |
{{- end }}
</details>
{{- end }}
3. Omitted (use shipped default)
Leave the template field out and the chart wires in the default from configmap-templates.yaml. Every templatable action type has a shipped default:
| Action | Default template key |
|---|---|
| GitHub PR comment | github-pr-comment.tmpl |
| GitHub check run | github-checkrun.tmpl |
| GitHub issue comment | github-issue-comment.tmpl |
| GitHub discussion comment | github-discussion-comment.tmpl |
| GitLab note | gitlab-note.tmpl |
| Gitea PR comment | gitea-pr-comment.tmpl |
| Gitea issue comment | gitea-issue-comment.tmpl |
| Bitbucket comment | bitbucket-comment.tmpl |
| Azure DevOps comment | azuredevops-comment.tmpl |
| Slack message | slack-default.tmpl |
| Teams card | teams-default.tmpl |
| Discord message | discord-default.tmpl |
| Email subject | email-subject.tmpl |
| Email body | email-default.tmpl |
| Grafana annotation | deploy-marker.tmpl |
| Jira comment | jira-comment.tmpl |
| Accumulator summary | accumulator-default.tmpl |
Important: PagerDuty, Datadog, Sentry, and Webhook do not support Go templates. PagerDuty/Datadog/Sentry use native API payloads. Webhook uses gojq transform expressions.
Template syntax basics
Accessing fields
All fields from domain.Event are available with a leading dot:
{{ .State }}
{{ .RunName }}
{{ .PipelineName }}
{{ .CommitSHA }}
{{ .Repo.Owner }}
Conditional content
{{- if .CommitSHA }}
Commit: `{{ .CommitSHA | trunc 8 }}`
{{- end }}
The {{- trims leading whitespace. The -}} trims trailing whitespace.
Conditional with else
{{- if eq .State "success" }}✅
{{- else if eq .State "failure" }}❌
{{- else if eq .State "error" }}⚠️
{{- else if eq .State "canceled" }}🚫
{{- else }}⏳
{{- end }}
Loops
{{- range .Results }}
| `{{ .Name }}` | `{{ Truncate .Value 120 }}` |
{{- end }}
Pipes
{{ .CommitSHA | trunc 8 }}
{{ .PipelineName | default .RunName }}
{{ .Description | default "No description" }}
Pointer fields
Pointer fields (*int) must be guarded:
{{- if .PRNumber }}
PR: {{ PRRef .Provider .PRNumber .Repo.Owner .Repo.Name }}
{{- end }}
Time formatting
{{ .StartedAt.Format "2006-01-02 15:04:05 MST" }}
Duration calculation
{{- if and (not .StartedAt.IsZero) (not .FinishedAt.IsZero) }}
Duration: {{ regexReplaceAll "[.][0-9]+s" (toString (.FinishedAt.Sub .StartedAt)) "s" }}
{{- end }}
Step-by-step: Customizing a GitHub PR comment
1. Start with the default template
The shipped github-pr-comment.tmpl shows the basic structure. Copy it as your starting point.
2. Decide what to add or remove
Common customizations:
- Add pipeline results in a collapsible section
- Include commit SHA with a link to the diff
- Show Jira issue link
- Add a custom header with team branding
- Remove fields you don’t need to keep the comment scannable
3. Write the template
template:
configmapRef:
key: my-pr-summary.tmpl
Create the template file:
## {{ if eq .State "success" }}✅{{ else }}❌{{ end }} Pipeline {{ .State }}
| | |
|---|---|
| **Pipeline** | `{{ .PipelineName \| default .RunName }}` |
| **Run** | `{{ .RunName }}` |
| **Namespace** | `{{ .Namespace }}` |
| {{- if .CommitSHA }}
| **Commit** | [`{{ .CommitSHA \| trunc 8 }}`](https://github.com/{{ .Repo.Owner }}/{{ .Repo.Name }}/commit/{{ .CommitSHA }}) |
| {{- end }}
| {{- if and (not .StartedAt.IsZero) (not .FinishedAt.IsZero) }}
| **Duration** | {{ regexReplaceAll "[.][0-9]+s" (toString (.FinishedAt.Sub .StartedAt)) "s" }} |
| {{- end }}
{{- if .PRNumber }}
> PR: {{ PRRef .Provider .PRNumber .Repo.Owner .Repo.Name }}
{{- end }}
{{- if .Description }}
> {{ Truncate .Description 300 }}
{{- end }}
{{- if .Results }}
<details><summary>📦 Results ({{ len .Results }})</summary>
| Name | Value |
|---|---|
{{- range .Results }}
| `{{ .Name }}` | `{{ Truncate .Value 120 }}` |
{{- end }}
</details>
{{- end }}
{{- if .TargetURL }}
🔗 [View logs in Tekton Dashboard]({{ .TargetURL }})
{{- end }}
4. Validate
tekton-events-relay --validate --config ./config.yaml
This catches template syntax errors before deployment.
5. Deploy
Apply the ConfigMap and update your Helm values to reference the new template key.
6. Test with a real event
Trigger a pipeline run and check the PR comment. Adjust the template based on what you see.
Common customizations
Show only on failures
Use the when CEL expression, not the template:
when: 'stateIn("failure", "error")'
Custom commit link
[`{{ .CommitSHA | trunc 8 }}`](https://github.com/{{ .Repo.Owner }}/{{ .Repo.Name }}/commit/{{ .CommitSHA }})
Conditional content by provider
{{- if eq .Provider "github" }}
Full GFM works here.
{{- else if eq .Provider "gitlab" }}
GitLab uses ! for MRs.
{{- end }}
Pipeline results in a collapsible section
{{- if .Results }}
<details><summary>📦 Results ({{ len .Results }})</summary>
| Name | Value |
|---|---|
{{- range .Results }}
| `{{ .Name }}` | `{{ Truncate .Value 120 }}` |
{{- end }}
</details>
{{- end }}
Status-based styling
{{- if eq .State "success" }}
🟢 Pipeline passed
{{- else if eq .State "failure" }}
🔴 Pipeline failed
{{- else }}
🟡 Pipeline {{ .State }}
{{- end }}
Duration with human-readable format
{{- if and (not .StartedAt.IsZero) (not .FinishedAt.IsZero) }}
{{- $dur := .FinishedAt.Sub .StartedAt }}
{{- if gt $dur 3600000000000 }}
{{ printf "%.1fh" (divf ($dur.Seconds) 3600) }}
{{- else if gt $dur 60000000000 }}
{{ printf "%.0fm" (divf ($dur.Seconds) 60) }}
{{- else }}
{{ printf "%.0fs" ($dur.Seconds) }}
{{- end }}
{{- end }}
Template categories
Templates fall into three categories based on whether they’re required or optional:
Category 1: Required (error if empty)
The handler refuses to construct without a template. Empty template means the constructor returns an error.
- Email
subjectandtemplate - Grafana
template - Jira comment action
template
The chart fills omitted fields with shipped defaults from configmap-templates.yaml.
Category 2: Optional with native fallback
The handler accepts an empty template and uses built-in behavior:
- Slack/Teams/Discord — empty template produces a structured card or native message
- Webhook — no Go template; uses gojq transform
- Accumulator — empty template produces a markdown table
- All SCM comment handlers — empty template produces
"Build <State> for <RunName>"
Category 3: Optional skip
Some SCM actions guard with if cfg.Template != "" and skip template compilation when empty.
Troubleshooting
Template syntax errors
Error: template: my-template:5: function "Truncate" not defined
Cause: Using a sprig function in a notifier that doesn’t support sprig (Slack, Teams, Discord, Email).
Fix: Use printf instead. Replace {{ Truncate .Description 200 }} with {{ printf "%.200s" .Description }}.
Empty output
Cause: The template compiles but produces no visible output. Usually a missing {{- end }} or wrong condition.
Fix: Check your if/range blocks. Every {{- if }} needs a matching {{- end }}. Test with a minimal template first:
State: {{ .State }}
Run: {{ .RunName }}
Template not applied
Cause: The configmapRef points to a key that doesn’t exist in the ConfigMap, or the ConfigMap isn’t mounted.
Fix: Verify the ConfigMap exists and has the right key:
kubectl get configmap tekton-events-relay-templates -o yaml
Check that the volume mount is present in the pod:
kubectl describe pod -l app=tekton-events-relay | grep -A5 "Mounts"
Pointer field panic
Error: template: ...: panic: interface conversion: interface {} is nil, not int
Cause: Dereferencing a nil pointer field (PRNumber, IssueNumber, DiscussionNumber).
Fix: Always guard pointer fields:
{{- if .PRNumber }}
PR #{{ .PRNumber }}
{{- end }}
Duration shows N/A
Cause: StartedAt or FinishedAt is zero (not set by the decoder).
Fix: Guard with .IsZero:
{{- if and (not .StartedAt.IsZero) (not .FinishedAt.IsZero) }}
Duration: {{ regexReplaceAll "[.][0-9]+s" (toString (.FinishedAt.Sub .StartedAt)) "s" }}
{{- else }}
Duration: N/A
{{- end }}
Hot-reload not picking up template changes
Cause: The immutable section of the config (server, store, dlq, logging, tracing) was changed, which requires a restart. Or the ConfigMap volume wasn’t updated yet.
Fix: Template changes in scm or notifiers sections are hot-reloadable. Verify the ConfigMap update propagated:
kubectl get configmap tekton-events-relay-templates -o jsonpath='{.metadata.resourceVersion}'
Wait a few seconds for the volume mount to refresh, then check the relay logs for config_reloads_total.