Markov uses Pongo2 v6 for template rendering – a Jinja2/Django-compatible template engine for Go. Templates are evaluated at runtime against the workflow context to produce dynamic values for step parameters, conditions, and variable assignments.
# Simple value
command: "echo {{ message }}"
# Nested field access
image: "{{ config.registry }}/{{ config.image_name }}"
# Array index access
first_item: "{{ items.0 }}"
also_works: "{{ items[0] }}"
| Syntax | Description |
|---|---|
{{ variable }} |
Substitute a simple value from context |
{{ variable.field }} |
Access a nested map field |
{{ array.0 }} or {{ array[0] }} |
Access an array element by index |
{{ map[key] }} |
Look up a map entry by a variable key (no further .field after the brackets; bind it with set_fact first) |
Values are inserted as they are. Markov turns off Pongo2’s HTML autoescaping, because templates
render shell commands, script arguments, JSON bodies and paths, where " would corrupt the
value.
Filters transform values using the pipe (|) operator.
# Single filter
greeting: "{{ name | upper }}"
# Filter with argument
label: "{{ description | truncate:50 }}"
# Chained filters
slug: "{{ title | lower | cut:' ' }}"
| Syntax | Description |
|---|---|
{{ value \| filter }} |
Apply a single filter |
{{ value \| filter:arg }} |
Apply a filter with an argument |
{{ value \| filter1 \| filter2 }} |
Chain multiple filters left to right |
Markov registers additional filters on top of Pongo2’s built-in set.
trimRemoves leading and trailing whitespace from a value.
- name: set_issue_key
type: set_fact
vars:
issue_key: "{{ command_result.stdout | trim }}"
csvTurns comma-separated text into a list, trimming each item and dropping empty ones. An empty
string gives an empty list, so default can supply a fallback. Pongo2’s own split filter keeps
spaces and empty items.
for_each: "only_tests | csv" # "S1, S2" -> ["S1", "S2"]
for_each: "variants | csv | default:own" # "" -> own
seqTurns a count into the list 1..n. A numeric string is parsed; an empty value gives an empty list.
for_each: "repeats | seq" # 3 -> [1, 2, 3]
from_json / fromjsonDefined in pkg/template/template.go. Parses a JSON string into a native Go value (map, list, number, bool, etc.).
vars:
parsed_data: "{{ raw_json | from_json }}"
fromjson is supported as a compatibility alias.
Input must be valid JSON. If the input string is not valid JSON, the filter returns an error and the step fails.
to_json / tojsonSerializes a value to a compact JSON string. Prefer the Ansible-style to_json spelling in new workflows; tojson is also supported for Jinja-style compatibility.
params:
command: "echo '{{ config | to_json }}'"
Use to_json when JSON text is needed inside a larger string. For structured fields such as http_request.body, exact value expressions usually do not need this filter because Markov preserves native maps and lists.
params:
body:
config: "{{ config }}" # native object
config_text: "{{ config | to_json }}" # JSON string
When a templated field is exactly a single value expression, Markov preserves native maps and lists instead of rendering them through Pongo2’s string output:
params:
body:
args:
config: "{{ config }}"
If config is a map or list, the rendered parameter remains a map or list. If config is a JSON-looking string beginning with { or [, Markov parses it into the corresponding native value. This is useful for HTTP request bodies, where the executor JSON-encodes the final body.
Any other expression in that position also keeps its native type: filters, arithmetic and variable-key lookups. For example:
vars:
tier_def: "{{ tiers[tier] }}" # the map, not its printed form
names: "{{ variants | csv | default:own_names }}" # a list
next: "{{ round + 1 }}" # an integer
An expression that is undefined still renders as the empty string.
This exact-expression path is triggered only when the whole string is the expression, with no surrounding text. Mixed templates still render to strings:
params:
body:
config: "{{ config }}" # native object
message: "config={{ config | to_json }}" # string
When a set_fact var value is exactly {{ path | from_json }} or {{ path | fromjson }} (no surrounding text), the engine bypasses normal template rendering. Instead, it:
This avoids the flattening that would occur if pongo2 rendered the value to a string first.
# This preserves object structure (direct resolution path)
- name: parse_config
type: set_fact
vars:
config: "{{ raw_config_json | fromjson }}"
# This does NOT get special handling (has surrounding text)
- name: broken
type: set_fact
vars:
config: "prefix {{ raw_config_json | fromjson }} suffix"
The special path is triggered only when the entire value matches the pattern {{ <path> | from_json }} or {{ <path> | fromjson }} with nothing before or after the braces.
These are the most commonly useful filters from Pongo2’s built-in set.
| Filter | Description | Example |
|---|---|---|
length |
Length of string, list, or map | {{ items \| length }} |
default |
Fallback if value is empty/nil | {{ name \| default:"unknown" }} |
upper |
Uppercase string | {{ status \| upper }} |
lower |
Lowercase string | {{ name \| lower }} |
join |
Join list elements with separator | {{ tags \| join:", " }} |
split |
Split string into list | {{ csv_line \| split:"," }} |
first |
First element of list/string | {{ items \| first }} |
last |
Last element of list/string | {{ items \| last }} |
add |
Add a number | {{ count \| add:1 }} |
truncate |
Truncate string to length | {{ desc \| truncate:80 }} |
title |
Title-case string | {{ name \| title }} |
capitalize |
Capitalize first character | {{ word \| capitalize }} |
striptags |
Remove HTML tags | {{ html \| striptags }} |
safe |
Mark as safe (no auto-escaping) | {{ raw_html \| safe }} |
escapejs |
Escape for JavaScript strings | {{ val \| escapejs }} |
floatformat |
Format float precision | {{ price \| floatformat:2 }} |
wordcount |
Count words in string | {{ text \| wordcount }} |
yesno |
Map true/false/nil to strings | {{ flag \| yesno:"yes,no,maybe" }} |
slice |
Slice a list | {{ items \| slice:"1:3" }} |
reverse |
Reverse a list or string | {{ items \| reverse }} |
pluralize |
Pluralize suffix based on count | {{ count \| pluralize:"item,items" }} |
center |
Center-pad a string | {{ name \| center:20 }} |
ljust |
Left-justify (right-pad) string | {{ name \| ljust:20 }} |
rjust |
Right-justify (left-pad) string | {{ num \| rjust:10 }} |
cut |
Remove all occurrences of a string | {{ phone \| cut:"-" }} |
Pongo2 supports Jinja2-style control flow. These are available in any template context, though they are rarely needed because markov provides for_each and when at the step level.
# Conditional
command: >
{% if environment == "prod" %}
deploy --replicas=3
{% elif environment == "staging" %}
deploy --replicas=1
{% else %}
echo "skipping deploy"
{% endif %}
# Loop (prefer markov's for_each for step-level iteration)
script: >
{% for host in servers %}
echo "Checking {{ host }}"
{% endfor %}
# Comments (stripped from output)
value: "{{ name }} {# this comment is removed #}"
Not every field in a step definition goes through template rendering. The following table shows which fields are template-evaluated.
| Field | Evaluated | Notes |
|---|---|---|
params values |
Yes | All string values in the params map |
vars values |
Yes | Both step-level and workflow-level |
when |
Yes | Evaluated as boolean expression |
artifacts[].path |
Yes | Artifact path field |
facts values |
Yes | Gate step fact values |
msg |
Yes | Assert step failure message |
that expressions |
Yes | Assert step conditions |
name |
No | Step name is a static identifier |
type |
No | Step type is resolved, not rendered |
register |
No | Register key is a literal context key |
for_each_key |
No | Field name, not a template |
for_each_sort |
No | Field name, not a template |
as |
No | Iterator variable name is literal |
The EvalBool function (in template.go:57) evaluates a pongo2 expression and returns a boolean result. It works by wrapping the expression:
{% if <expr> %}true{% endif %}
If the rendered output (trimmed) equals "true", the result is true. Otherwise, false.
Used for: when conditions, assert that expressions, and plain string values in set_fact vars (strings without {{ or {% delimiters).
Pongo2 truthiness rules:
| Value | Truthy? |
|---|---|
nil |
false |
"" (empty string) |
false |
0 (zero) |
false |
false |
false |
Empty list [] |
false |
Empty map {} |
false |
| Everything else | true |
After template rendering in set_fact steps, string results are coerced to native Go types by coerceString (in facts.go:146). This ensures that computed values have the expected types downstream.
| Rendered String | Coerced Type | Coerced Value |
|---|---|---|
"true" or "True" |
bool |
true |
"false" or "False" |
bool |
false |
"None" |
string |
"None" (kept as-is) |
"" (empty) |
string |
"" (kept as-is) |
"42" |
int |
42 |
"3.14" |
float64 |
3.14 |
'["a","b"]' |
[]any |
Parsed JSON array |
'{"k":"v"}' |
map[string]any |
Parsed JSON object |
| Anything else | string |
Kept as-is |
Coercion order matters: integer parsing is attempted before float parsing. JSON parsing is only attempted if the string starts with [ or {.
Step when and assert.that support durable jev.noul, jev.choice, and jev.score calls against named definitions. These helpers are not general template filters. See Jev decisions.