Skip to main content

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.yaml and 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 exposes Os / 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$myvar Go locals are accepted.

  • Comparison & logic functionseq ne gt lt ge le and or not map to Tera operators (== != > < >= <= and or not).

  • 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 builtins map list printf print println collect their trailing arguments into an array parameter; slice X 0 7 becomes the piped filter X | 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" }} renders 9). 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 if conditions, the collection of a range, 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 indexinglist.0 / a.0.b / a?.0 rewrite to the native list[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 idiomTera equivalentNotes
{{ 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

VariableDescriptionExample
ProjectNameProject name from configmyapp
VersionSemantic version (without v prefix)1.2.3
RawVersionVersion string as-is from Cargo.toml1.2.3-rc.1
TagFull git tagv1.2.3
MajorMajor version component1
MinorMinor version component2
PatchPatch version component3
PrereleasePrerelease suffix (empty if none)rc.1

Git information

VariableDescriptionExample
FullCommitFull commit hashabc123def456...
ShortCommitShort commit hashabc1234
CommitAlias for FullCommitabc123def456...
BranchCurrent git branch namemain
CommitDateISO 8601 author date of HEAD2024-01-15T10:30:00Z
CommitTimestampUnix timestamp of HEAD1705312200
PreviousTagPrevious matching git tagv1.2.2
IsGitDirtytrue if working tree is dirtytrue
GitTreeStateWorking tree stateclean or dirty

Build context

VariableDescriptionExample
OsMapped OS namelinux, darwin, windows
ArchMapped architectureamd64, arm64
Arm32-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
Arm6464-bit ARM feature level (build, makeself, AppImage, sign)v8
Amd64x86-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 suffixv1, v3
MipsAlways empty — Arch carries the full mips token (mips64el), so a suffix would double it(empty)
I38632-bit x86 instruction floor (build, makeself, AppImage, sign)sse2
TargetFull target triplex86_64-unknown-linux-gnu
BinaryCurrent binary namemyapp
ArtifactNameCurrent artifact namemyapp-1.0.0-linux-amd64.tar.gz
ArtifactPathFull path to artifact/path/to/dist/myapp-1.0.0.tar.gz
ArtifactExtArtifact extension (compound-aware).tar.gz, .exe, .deb
ChecksumsCombined checksum file contentsabc123 myapp.tar.gz\n...
SourcePrefixTop-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

VariableDescriptionExample
IsSnapshottrue in snapshot modetrue
IsDrafttrue if draft releasefalse
IsNightlytrue in nightly modefalse
ReleaseURLURL of created GitHub releasehttps://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

VariableDescriptionExample
DateCurrent date2024-01-15
TimestampCurrent Unix timestamp1705312200
NowCurrent UTC time (ISO 8601)2024-01-15T10:30:00Z

Host runtime

VariableDescriptionExample
RuntimeGoosHost OS in Go naming (GoReleaser's {{ .Runtime.Goos }} also works)linux
RuntimeGoarchHost 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 worksamd64
RustcVersionHost rustc release version; empty when rustc is unavailable1.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 | default guard:

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:

VariableEnv channelValue
PublisherANODIZER_PUBLISHERName of the failing / reverted publisher
ErrorANODIZER_ERRORThis publisher's own error message; empty on a clean revert
RollbackFailedANODIZER_ROLLBACK_FAILEDtrue when the revert itself failed (on_rollback)
RolledBackANODIZER_ROLLED_BACKAlways false (on_error) — a release run never withdraws anything on its own
ReasonANODIZER_ROLLBACK_REASONAlways 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:

VariableEnv channelBound inValue
ErrorANODIZER_ERRORon_error:, always:The pipeline error; empty string in always: after a successful run
RolledBackANODIZER_ROLLED_BACKon_error:Always false — a release run never withdraws anything on its own
SuccessANODIZER_SUCCESSalways:Real boolean — {% if Success %} branches correctly
VersionANODIZER_VERSIONbothRelease version (e.g. 0.8.0)
TagANODIZER_TAGbothRelease 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

HelperFormExampleResult
lower / tolowerfilter / fn{{ Os | lower }}linux
upper / toupperfilter / fn{{ Os | upper }}LINUX
titlefilter / fn{{ "hello world" | title }}Hello World
trimfilter / fn{{ " x " | trim }}x
trimprefixfilter / fn{{ Tag | trimprefix(prefix="v") }}1.2.3
trimsuffixfilter / fn{{ File | trimsuffix(suffix=".tar.gz") }}strips suffix
replacefilter / fn{{ Version | replace(from=".", to="_") }}1_2_3
splitfilter / fn{{ "a.b.c" | split(sep=".") }}["a","b","c"]
containsfilter / fn{{ Tag | contains(substr="rc") }}true / false
slicefilter{{ Tag | slice(start=1, end=4) }}1.2 (end-exclusive, Go semantics)
reReplaceAllfilter / fn{{ reReplaceAll(pattern="[^0-9]", input=Tag, replacement="") }}digits only
urlPathEscapefilter / fn{{ urlPathEscape(s=Branch) }}percent-encoded path segment
mdv2escapefilter / fn{{ Body | mdv2escape }}Telegram MarkdownV2-escaped
ruby_escapefilter{{ Desc | ruby_escape }}safe in a Ruby "…" literal

Formatting

HelperFormExampleResult
printffn{{ printf(format="%s-%s", args=[Os, Arch]) }}linux-amd64
printffn{{ printf(format="%04d", args=[Patch]) }}0003
printfn{{ print(args=[Os, Arch]) }}linuxamd64 (Go Sprint)
printlnfn{{ 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

HelperFormExampleResult
dirfilter / fn{{ ArtifactPath | dir }}parent directory
basefilter / fn{{ ArtifactPath | base }}final path component
absfilter / fn{{ "./dist" | abs }}absolute path

List and map

HelperFormExampleResult
listfn{{ list(items=[Os, Arch]) | join(sep="-") }}linux-amd64
list (rendered bare)fn{{ list(items=["a", "b"]) }}["a", "b"] — see the note below
mapfn{% set M = map(pairs=["a", 1]) %}{{ M.a }}1
indexfn{{ index(collection=Parts, key=0) }}element at index
indexOrDefaultfn{{ indexOrDefault(map=M, key="k", default="-") }}value or default
in / contains_anyfilter / fn{{ in(items=["rc", "beta"], value=Prerelease) }}true / false
filterfilter / fn{{ filter(items=Lines, regexp="^v") }}matching lines
reverseFilterfilter / fn{{ reverseFilter(items=Lines, regexp="^#") }}non-matching lines
englishJoinfilter / 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 b

Semver

HelperFormExampleResult
incpatchfilter / fn{{ Version | incpatch }}1.2.4
incminorfilter / fn{{ Version | incminor }}1.3.0
incmajorfilter / fn{{ Version | incmajor }}2.0.0

Environment

HelperFormExampleResult
Env.NAMEvar{{ Env.GITHUB_TOKEN }}env var value
envOrDefaultfn{{ envOrDefault(name="CI", default="local") }}value or default
isEnvSetfn{{ isEnvSet(name="CI") }}true / false

File

HelperFormExampleResult
readFilefn{{ readFile(path="VERSION") }}file contents (empty on error)
mustReadFilefn{{ mustReadFile(path="VERSION") }}file contents (errors if missing)

Time

HelperFormExampleResult
timefn{{ time(format="2006-01-02") }}current date (Go layout accepted)
now_formatfilter{{ Now | now_format(format="%Y-%m-%d") }}current date (chrono format)
datefilter{{ 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, and call builtins 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 Go text/template builtins 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 %}