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:

ActionDefault template key
GitHub PR commentgithub-pr-comment.tmpl
GitHub check rungithub-checkrun.tmpl
GitHub issue commentgithub-issue-comment.tmpl
GitHub discussion commentgithub-discussion-comment.tmpl
GitLab notegitlab-note.tmpl
Gitea PR commentgitea-pr-comment.tmpl
Gitea issue commentgitea-issue-comment.tmpl
Bitbucket commentbitbucket-comment.tmpl
Azure DevOps commentazuredevops-comment.tmpl
Slack messageslack-default.tmpl
Teams cardteams-default.tmpl
Discord messagediscord-default.tmpl
Email subjectemail-subject.tmpl
Email bodyemail-default.tmpl
Grafana annotationdeploy-marker.tmpl
Jira commentjira-comment.tmpl
Accumulator summaryaccumulator-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:

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")'
[`{{ .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.

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:

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.