Templates
Tera template engine reference — variables, filters, and GoReleaser compatibility
Anodizer uses the Tera template engine (Jinja2/Django-like syntax). Templates can be used in most string fields throughout the configuration: name templates, tag templates, message templates, signing arguments, and more.
Two syntaxes, one engine
Anodizer accepts both template dialects and renders them on the same Tera engine:
- Tera-native, no-dot — the canonical, recommended form. Reference fields by bare name (
{{ Version }}), use Tera operators (==,!=,and,or,not), pipe through filters ({{ Tag | trimprefix(prefix="v") }}), and write control flow with{% %}. - GoReleaser / Go
text/template— paste a snippet straight out of a.goreleaser.yamland it runs unchanged. Anodizer auto-translates Go idioms before rendering, so migrating costs nothing.
# Both forms are equivalent — pick either, mix freely:
name_template: "{{ ProjectName }}-{{ Version }}" # Tera-native (recommended)
name_template: "{{ .ProjectName }}-{{ .Version }}" # GoReleaser/Go (auto-translated)
The docs throughout this site use the Tera-native no-dot form as the canonical idiom; the Go form is documented here for painless migration.
Syntax
Templates use {{ }} for variable interpolation and {% %} for control flow:
name_template: "{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}"Literal template text ({% raw %})
Text between {% raw %} and {% endraw %} reaches the output byte-identical.
That covers the Go→Tera translation too: no preprocessing pass rewrites
anything inside a raw span — not the leading-dot strip, not the block
conversion, not positional-call rewriting, not numeric indexing, not the
string-literal backslash shim. Raw spans are detected once and every pass
consumes that one answer, so the guarantee holds for passes added later.
# A hook that hands a Go template to another tool, unrendered
before:
hooks:
- "helm template ./chart --set tag={% raw %}{{ .Chart.AppVersion }}{% endraw %}"
# A release note that quotes anodizer's own syntax
release:
footer: "Name your artifacts with {% raw %}{{ trimprefix .Tag \"v\" }}{% endraw %}."{% raw %}{{ .Version }}{% endraw %} → {{ .Version }}
{% raw %}{{ if .X }}y{{ end }}{% endraw %} → {{ if .X }}y{{ end }}
{% raw %}{{ list.0 }}{% endraw %} → {{ list.0 }}
{% raw %}grep '\d\+' file{% endraw %} → grep '\d\+' file
{{ .Version }}{% raw %}{{ .B }}{% endraw %} → 1.2.3{{ .B }}
Rewriting resumes at {% endraw %}. A nested {% raw %} has no special
meaning — the inner tag is literal text, so {% raw %}{% raw %}x{% endraw %}
renders {% raw %}x. A {% raw %} that is never closed covers the rest of the
template, which the engine then rejects:
error: failed to parse template: {% raw %}{{ .Version }}Nesting depth
Parenthesised groups in one block may nest at most 64 deep. Every group is rewritten by descending into it, so an unbounded nest exhausts the stack before the engine sees the block at all; the limit turns that into a named error.
# Rejected — 65 nested groups
name_template: "{{ tolower (tolower (tolower (… 62 more …))) }}"
# Accepted — bind the intermediate result instead of nesting further
name_template: "{% set stem = trimprefix Tag \"v\" %}{{ tolower stem }}"error: over-nested expression in template `{{ tolower (tolower (tolower (tolower (tolo…`: parentheses nest 65 deep, past the limit of 64. Every group is rewritten by descending into it, so a deeper nest exhausts memory or the stack before the engine sees the block. Bind an intermediate result to a variable and nest fewer calls.
The quoted block is truncated on a char boundary, so one runaway expression cannot spill a whole template into the message. The limit sits far above any hand-written call — tera's own parser rejects an expression well before this depth.
Undefined variables
Anodizer runs Tera in strict mode: referencing an undefined variable is a render error by default, so a typo in a name template fails the build instead of silently baking a blank into a release artifact.
-
Top-level access errors, naming the missing variable:
name_template: "{{ Typo }}"error: Variable `Typo` is not defined.The error also lists the variables actually available in that rendering context — Tera appends
Available variables: ...with the live context keys. That list varies by stage (a build-stage render exposesOs/Arch; a top-level render adds 20+ git-derived variables) and isn't reproduced here since it isn't a fixed contract. -
An undefined operand inside
~string-concat renders as empty, not an error — this is Tera's own coercion rule for the~operator, not something anodizer configures:name_template: "{{ Typo ~ '-rc1' }}" # -> "-rc1" -
.Env.MISSING(Go-style) renders empty by design — env var references always resolve, defaulting to""instead of erroring, since most templates only reference an env var conditionally:name_template: "{{ .Env.MISSING }}" # -> ""
For an intentional default rather than an accidental empty string, reach for one of these idioms:
# Top-level fallback
name_template: "{{ Typo or \"default\" }}"
# Optional chaining into a (possibly absent) nested field
name_template: "{{ Some?.Missing or \"\" }}"
or short-circuits past an undefined left-hand side the same way ~
coerces one. ?. suppresses the Undefined error at every link of a
dotted path — including a wholly undefined root — not just a missing leaf
field, so it composes with or for a safe default anywhere in a chain.
GoReleaser compatibility
Anodizer auto-translates Go text/template syntax to its Tera equivalent before rendering, so a template copied verbatim from a .goreleaser.yaml works without edits. The translation covers:
-
Leading dots —
{{ .Field }}→{{ Field }}(and{{ .Env.FOO }}→{{ Env.FOO }}). -
Go statement blocks —
{{ if }}/{{ range }}/{{ with }}/{{ end }}become Tera's{% if %}/{% for %}/{% endif %}/{% endfor %}. -
$variables —$myvarGo locals are accepted. -
Comparison & logic functions —
eqnegtltgeleandornotmap to Tera operators (==!=><>=<=andornot). -
len—{{ len .Tags }}becomes{{ Tags | length }}. -
Positional function calls — every helper in the Functions and filters tables accepts its Go-style positional form (
{{ trimprefix Tag "v" }},{{ sha256 ArtifactPath }},{{ envOrDefault "CI" "no" }}) and is mapped to Tera's named-argument form. The variadic builtinsmaplistprintfprintprintlncollect their trailing arguments into an array parameter;slice X 0 7becomes the piped filterX | slice(start=0, end=7). -
Subexpression arguments — a parenthesized Go call used as an argument is rewritten too, at any nesting depth, in every argument slot (including the variadic tail):
{{ trimprefix (base Path) "v" }} → {{ trimprefix(s=(base(s=Path)), prefix="v") }} {{ trimprefix (base (dir Path)) "v" }} → {{ trimprefix(s=(base(s=(dir(s=Path)))), prefix="v") }} {{ printf "%s-%s" (tolower Os) Arch }} → {{ printf(format="%s-%s", args=[(tolower(s=Os)), Arch]) }}A parenthesis inside a string literal is string contents, never nesting, so
{{ trimprefix (base "x/(v9)") "(v" }}renders9). A group that never closes is rejected before rendering, with a diagnostic that quotes the offending block and counts the unclosed groups — rather than the engine's parse error, which points at the following token. Groups that nest past the depth limit are rejected the same way. -
Positional calls in a pipeline — a
|splits the expression into a head and one filter segment per pipe, and a Go positional call is accepted in every one of those slots:{{ trimprefix .Tag "v" | upper }} → {{ trimprefix(s=Tag, prefix="v") | upper }} {{ .Version | replace "v" "" | upper }} → {{ Version | replace(from="v", to="") | upper }} {{ list "a" "b" | join(sep=" ") }} → {{ list(items=["a", "b"]) | join(sep=" ") }}A
|inside a string literal or a sub-expression is string contents or nesting, never a segment boundary. -
Positional calls in statement blocks — a Go call is rewritten wherever a value expression is accepted, not only inside
{{ }}:if/else ifconditions, the collection of arange, and the right-hand side of a$var :=assignment.{{ if contains (tolower .Os) "win" }} → {% if contains(s=(tolower(s=Os)), substr="win") %} {{ range filter .Lines "^v" }} → {% for val in filter(items=Lines, regexp="^v") %} {{ $v := trimprefix (base .Path) "v" }} → {% set v = trimprefix(s=(base(s=Path)), prefix="v") %} -
tera 1.x numeric indexing —
list.0/a.0.b/a?.0rewrite to the nativelist[0]/a[0].b/a?[0]. Numeric segments index arrays: a map key that is the string"0"needs["0"], not.0. Write[N]in new templates.
# Both forms are equivalent:
name_template: "{{ .ProjectName }}-{{ .Version }}" # Go-style (compat)
name_template: "{{ ProjectName }}-{{ Version }}" # Tera-style (native)
You can freely mix both styles in the same config file. The leading dot is stripped before the template is rendered.
Common GoReleaser idiom → Tera mapping
Anodizer preprocesses most Go-template constructs into their Tera equivalents, but a handful of idioms copied verbatim from a .goreleaser.yaml will produce confusing errors. Use this table when migrating.
| GoReleaser idiom | Tera equivalent | Notes |
|---|---|---|
{{ if .IsRelease }}X{{ end }} | {% if IsRelease %}X{% endif %} | Statement tags use {% %}, not {{ }} |
{{ if .IsRelease }}X{{ else }}Y{{ end }} | {% if IsRelease %}X{% else %}Y{% endif %} | {% else %} |
{{ range .Tags }}...{{ end }} | {% for t in Tags %}...{% endfor %} | Tera names the loop variable explicitly |
{{ range $k, $v := .Env }}...{{ end }} | {% for k, v in Env %}...{% endfor %} | Key/value loop |
{{ with .Arm }}v{{ . }}{{ end }} | {% if Arm %}v{{ Arm }}{% endif %} | Tera has no with; reference the field by name |
{{ tolower .Os }} | {{ Os | lower }} — or {{ Os | tolower }} | Filters use |; tolower/toupper aliases provided for parity |
{{ replace .Tag "v" "" }} | {{ Tag | replace(from="v", to="") }} | Tera filters take named args |
{{ trimprefix .Tag "v" }} | {{ Tag | trimprefix(prefix="v") }} | Alias filter registered for parity |
{{ .Env.FOO }} | {{ Env.FOO }} — or {{ .Env.FOO }} | Dot-prefix form is preprocessed away |
{{ default "x" .Tag }} | {{ Tag | default(value="x") }} | Tera pipes the value through a filter |
{{ eq .Os "linux" }} | {% if Os == "linux" %}...{% endif %} | Equality is a normal operator, not a function |
{{ printf "%s-%s" .Os .Arch }} | {{ Os }}-{{ Arch }} | Most printf formats can be inlined; use filters for padding/number formatting |
If you hit a construct not covered here, open an issue with the failing template and the intended output.
Template variables
Project and version
| Variable | Description | Example |
|---|---|---|
ProjectName | Project name from config | myapp |
Version | Semantic version (without v prefix) | 1.2.3 |
RawVersion | Version string as-is from Cargo.toml | 1.2.3-rc.1 |
Tag | Full git tag | v1.2.3 |
Major | Major version component | 1 |
Minor | Minor version component | 2 |
Patch | Patch version component | 3 |
Prerelease | Prerelease suffix (empty if none) | rc.1 |
Git information
| Variable | Description | Example |
|---|---|---|
FullCommit | Full commit hash | abc123def456... |
ShortCommit | Short commit hash | abc1234 |
Commit | Alias for FullCommit | abc123def456... |
Branch | Current git branch name | main |
CommitDate | ISO 8601 author date of HEAD | 2024-01-15T10:30:00Z |
CommitTimestamp | Unix timestamp of HEAD | 1705312200 |
PreviousTag | Previous matching git tag | v1.2.2 |
IsGitDirty | true if working tree is dirty | true |
GitTreeState | Working tree state | clean or dirty |
Build context
| Variable | Description | Example |
|---|---|---|
Os | Mapped OS name | linux, darwin, windows |
Arch | Mapped architecture | amd64, arm64 |
Arm | 32-bit ARM version, set only where Arch is the bare arm (archive asset names split armv7 into Arch="arm" + Arm="7"); empty everywhere Arch carries the composite armv7/armv6 token (build, makeself, AppImage, sign) | 7 |
Arm64 | 64-bit ARM feature level (build, makeself, AppImage, sign) | v8 |
Amd64 | x86-64 micro-architecture level from the binary's build metadata; untagged binaries carry the v1 baseline in every context. Default name templates suppress v1 ({% if Amd64 and Amd64 != "v1" %}), so only tuned v2/v3 builds get a suffix | v1, v3 |
Mips | Always empty — Arch carries the full mips token (mips64el), so a suffix would double it | (empty) |
I386 | 32-bit x86 instruction floor (build, makeself, AppImage, sign) | sse2 |
Target | Full target triple | x86_64-unknown-linux-gnu |
Binary | Current binary name | myapp |
ArtifactName | Current artifact name | myapp-1.0.0-linux-amd64.tar.gz |
ArtifactPath | Full path to artifact | /path/to/dist/myapp-1.0.0.tar.gz |
ArtifactExt | Artifact extension (compound-aware) | .tar.gz, .exe, .deb |
Checksums | Combined checksum file contents | abc123 myapp.tar.gz\n... |
SourcePrefix | Top-level directory inside the source archive (from a source.prefix_template ending in /); empty for a flat archive. Set by the source stage; useful for an SRPM %autosetup -n {{ SourcePrefix }}. | myapp-1.2.3 |
Release state
| Variable | Description | Example |
|---|---|---|
IsSnapshot | true in snapshot mode | true |
IsDraft | true if draft release | false |
IsNightly | true in nightly mode | false |
ReleaseURL | URL of created GitHub release | https://github.com/... |
The Is* flags (IsSnapshot, IsNightly, IsHarness, IsDraft,
IsRelease, IsSingleTarget, IsMerging, IsGitDirty, IsGitClean,
IsPrepare) are real booleans, and NightlyBuild is a real number — use
them directly:
if: "{{ not IsSnapshot }}" # skip on snapshots
if: "{{ IsHarness }}" # only inside the determinism harness
if: "{% if NightlyBuild > 0 %}true{% endif %}"
Comparing them to quoted strings (IsSnapshot == "false") never matches —
Tera does not coerce booleans to strings — so anodizer rejects such
conditions with a hard error instead of silently skipping the stage.
Time
| Variable | Description | Example |
|---|---|---|
Date | Current date | 2024-01-15 |
Timestamp | Current Unix timestamp | 1705312200 |
Now | Current UTC time (ISO 8601) | 2024-01-15T10:30:00Z |
Host runtime
| Variable | Description | Example |
|---|---|---|
RuntimeGoos | Host OS in Go naming (GoReleaser's {{ .Runtime.Goos }} also works) | linux |
RuntimeGoarch | Host architecture in anodizer's arch vocabulary — Go names except the mips family, which keeps the Rust spellings (mipsel/mips64el, not Go's mipsle/mips64le). GoReleaser's {{ .Runtime.Goarch }} also works | amd64 |
RustcVersion | Host rustc release version; empty when rustc is unavailable | 1.96.0 |
# Skip a config on non-amd64 build hosts:
if: '{{ RuntimeGoarch == "amd64" }}'Environment variables
Access environment variables via Env:
name_template: "{{ ProjectName }}-{{ Env.CUSTOM_SUFFIX }}"
You can define custom environment variables in the config:
env:
CUSTOM_SUFFIX: "special"
BUILD_MODE: "production"Pipeline outputs
Stages can write values to the Outputs map, and templates can read them:
# Tera-style
body_template: "Build ID: {{ Outputs.build_id }}"
# Go-style (also supported)
body_template: "Build ID: {{ .Outputs.build_id }}"
Similar to Var.* but for pipeline outputs rather than user config values.
Note: Only reference keys that are actually set by stages. For optional keys, use the
| defaultguard:body_template: "Build: {{ Outputs.build_id | default(value=\"unknown\") }}"
Failure-hook context (on_error / on_rollback)
These variables are bound only inside a publisher's on_error and
on_rollback hooks, which fire when a publish fails or is reverted:
| Variable | Env channel | Value |
|---|---|---|
Publisher | ANODIZER_PUBLISHER | Name of the failing / reverted publisher |
Error | ANODIZER_ERROR | This publisher's own error message; empty on a clean revert |
RollbackFailed | ANODIZER_ROLLBACK_FAILED | true when the revert itself failed (on_rollback) |
RolledBack | ANODIZER_ROLLED_BACK | Always false (on_error) — a release run never withdraws anything on its own |
Reason | ANODIZER_ROLLBACK_REASON | Always empty (on_rollback) — the unwind replays state a prior process persisted, so the trigger cause is not available to it |
Error carries untrusted git/API text — read it from $ANODIZER_ERROR with
--raw rather than splicing it into cmd. See
Release resilience for the
full hook reference.
Run-outcome context (root on_error: / always:)
The root-level hook blocks describe the run as a whole rather than one publisher, so they bind a different, smaller set:
| Variable | Env channel | Bound in | Value |
|---|---|---|---|
Error | ANODIZER_ERROR | on_error:, always: | The pipeline error; empty string in always: after a successful run |
RolledBack | ANODIZER_ROLLED_BACK | on_error: | Always false — a release run never withdraws anything on its own |
Success | ANODIZER_SUCCESS | always: | Real boolean — {% if Success %} branches correctly |
Version | ANODIZER_VERSION | both | Release version (e.g. 0.8.0) |
Tag | ANODIZER_TAG | both | Release tag (e.g. v0.8.0) |
always:
hooks:
# Read the untrusted error text from the env channel, never from `cmd`.
- cmd: './teardown-staging.sh "$ANODIZER_SUCCESS" "$ANODIZER_ERROR"'
See Global Hooks for the lane ordering.
Functions and filters
Tera provides many built-in filters (lower, upper, title, trim, length, default, …). On top of those, anodizer registers a full set of release-oriented helpers. Most are available in both forms — as a filter ({{ X | fn(...) }}) and as a function ({{ fn(s=X, ...) }}) — so the GoReleaser positional form ({{ fn X ... }}) auto-translates onto them.
Examples below use the Tera-native no-dot idiom.
String
| Helper | Form | Example | Result |
|---|---|---|---|
lower / tolower | filter / fn | {{ Os | lower }} | linux |
upper / toupper | filter / fn | {{ Os | upper }} | LINUX |
title | filter / fn | {{ "hello world" | title }} | Hello World |
trim | filter / fn | {{ " x " | trim }} | x |
trimprefix | filter / fn | {{ Tag | trimprefix(prefix="v") }} | 1.2.3 |
trimsuffix | filter / fn | {{ File | trimsuffix(suffix=".tar.gz") }} | strips suffix |
replace | filter / fn | {{ Version | replace(from=".", to="_") }} | 1_2_3 |
split | filter / fn | {{ "a.b.c" | split(sep=".") }} | ["a","b","c"] |
contains | filter / fn | {{ Tag | contains(substr="rc") }} | true / false |
slice | filter | {{ Tag | slice(start=1, end=4) }} | 1.2 (end-exclusive, Go semantics) |
reReplaceAll | filter / fn | {{ reReplaceAll(pattern="[^0-9]", input=Tag, replacement="") }} | digits only |
urlPathEscape | filter / fn | {{ urlPathEscape(s=Branch) }} | percent-encoded path segment |
mdv2escape | filter / fn | {{ Body | mdv2escape }} | Telegram MarkdownV2-escaped |
ruby_escape | filter | {{ Desc | ruby_escape }} | safe in a Ruby "…" literal |
Formatting
| Helper | Form | Example | Result |
|---|---|---|---|
printf | fn | {{ printf(format="%s-%s", args=[Os, Arch]) }} | linux-amd64 |
printf | fn | {{ printf(format="%04d", args=[Patch]) }} | 0003 |
print | fn | {{ print(args=[Os, Arch]) }} | linuxamd64 (Go Sprint) |
println | fn | {{ println(args=[Os, Arch]) }} | linux amd64\n (Go Sprintln) |
printf implements the Go verb subset %s %d %v %x %X %o %b %c %q %f %e %E %g %G %t %% with flags, width, and precision (Go-style exponents). print follows Go's Sprint spacing rule (a space is inserted between two adjacent operands only when neither is a string).
Path
| Helper | Form | Example | Result |
|---|---|---|---|
dir | filter / fn | {{ ArtifactPath | dir }} | parent directory |
base | filter / fn | {{ ArtifactPath | base }} | final path component |
abs | filter / fn | {{ "./dist" | abs }} | absolute path |
List and map
| Helper | Form | Example | Result |
|---|---|---|---|
list | fn | {{ list(items=[Os, Arch]) | join(sep="-") }} | linux-amd64 |
list (rendered bare) | fn | {{ list(items=["a", "b"]) }} | ["a", "b"] — see the note below |
map | fn | {% set M = map(pairs=["a", 1]) %}{{ M.a }} | 1 |
index | fn | {{ index(collection=Parts, key=0) }} | element at index |
indexOrDefault | fn | {{ indexOrDefault(map=M, key="k", default="-") }} | value or default |
in / contains_any | filter / fn | {{ in(items=["rc", "beta"], value=Prerelease) }} | true / false |
filter | filter / fn | {{ filter(items=Lines, regexp="^v") }} | matching lines |
reverseFilter | filter / fn | {{ reverseFilter(items=Lines, regexp="^#") }} | non-matching lines |
englishJoin | filter / fn | {{ englishJoin(items=Names) }} | a, b, and c |
Rendering an array directly
A helper that returns an array — list, split, filter, reverseFilter, or a
structured field — stringifies as a quoted, comma-separated array when it is
interpolated without a joining filter. GoReleaser's Go templates print the same
value as [a b], so a config migrated verbatim produces different text:
{{ list "a" "b" }} → ["a", "b"] (anodizer)
{{ list "a" "b" }} → [a b] (GoReleaser)
The divergence is deliberate. list returns a real collection, which is what
every non-degenerate use needs — {{ if in (list "a" "b") Os }},
{% for x in list("a", "b") %} — and producing Go's spacing would mean
returning a pre-formatted string instead, breaking both. Rendering it as a
collection also keeps it identical to every other array in the engine;
special-casing list alone would leave {{ list "a" "b" }} and
{{ split "a.b" "." }} printing two different shapes.
Choose the separator explicitly whenever the value lands in consumer-visible
text (a changelog entry, a release note, an announcement body). The Go
positional form of the constructor works here too, so a config copied from a
.goreleaser.yaml needs no rewrite:
message_template: "built for {{ list Os Arch | join(sep=\" \") }}" # a b
message_template: "built for {{ list(items=[Os, Arch]) | join(sep=\" \") }}" # a b
message_template: "built for {{ englishJoin(items=[Os, Arch]) }}" # a and bSemver
| Helper | Form | Example | Result |
|---|---|---|---|
incpatch | filter / fn | {{ Version | incpatch }} | 1.2.4 |
incminor | filter / fn | {{ Version | incminor }} | 1.3.0 |
incmajor | filter / fn | {{ Version | incmajor }} | 2.0.0 |
Environment
| Helper | Form | Example | Result |
|---|---|---|---|
Env.NAME | var | {{ Env.GITHUB_TOKEN }} | env var value |
envOrDefault | fn | {{ envOrDefault(name="CI", default="local") }} | value or default |
isEnvSet | fn | {{ isEnvSet(name="CI") }} | true / false |
File
| Helper | Form | Example | Result |
|---|---|---|---|
readFile | fn | {{ readFile(path="VERSION") }} | file contents (empty on error) |
mustReadFile | fn | {{ mustReadFile(path="VERSION") }} | file contents (errors if missing) |
Time
| Helper | Form | Example | Result |
|---|---|---|---|
time | fn | {{ time(format="2006-01-02") }} | current date (Go layout accepted) |
now_format | filter | {{ Now | now_format(format="%Y-%m-%d") }} | current date (chrono format) |
date | filter | {{ Now | date(format="%Y%m%d") }} | 20260703 |
date formats a Unix timestamp (integer), an RFC 3339 datetime string, a naive %Y-%m-%dT%H:%M:%S datetime, or a plain %Y-%m-%d date. format takes chrono strftime specifiers (default %Y-%m-%d). timezone takes an IANA name (timezone="America/New_York") and converts timestamps and offset-carrying RFC 3339 inputs; naive datetime and plain-date inputs format as UTC and ignore it. locale is not supported and errors — output is always POSIX-locale.
Hashing
Fourteen hash functions take a file path argument (s=) and return the lowercase hex digest of that file's contents:
md5 · crc32 · sha1 · sha224 · sha256 · sha384 · sha512 · sha3_224 · sha3_256 · sha3_384 · sha3_512 · blake2b · blake2s · blake3
body_template: "checksum: {{ sha256(s=ArtifactPath) }}"
Not supported (intentionally): Go's
html,js,urlquery, andcallbuiltins are web-escaping / reflection helpers with no role in release templating, so they are not registered. Everything else from GoReleaser's function set — plus the Gotext/templatebuiltins that matter — is present.
Control flow
Tera supports conditionals and loops:
header: |
{% if IsSnapshot %}
**This is a snapshot build — not for production use.**
{% else %}
## {{ ProjectName }} {{ Version }}
{% endif %}# Loops (less common in config, but available)
message_template: |
New release: {{ Tag }}
{% for crate in crates %}
- {{ crate.name }}: {{ crate.version }}
{% endfor %}