BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

Go templates + Sprig: the templating you already know

OrchStep's template layer is plain Go templates plus the Sprig function library — default, regexFind, semverCompare and 100+ more — with JavaScript waiting for the logic that templates make ugly.

Jun 24, 2026 OrchStep Team 7 minROLE: Backend DeveloperSCALE: Any

If you have written a Helm chart, a text/template in Go, or a GitHub Actions expression, you already know most of OrchStep's template layer. There is no bespoke DSL to learn. Interpolation is Go templates; the standard library is Sprig v3, the same 100+ functions Helm ships. When a value gets genuinely logic-heavy, you drop into JavaScript — but not before.

This post is a tour of that layer: what {{ }} can do, the Sprig functions worth memorizing, and the exact line where JS takes over.

The basics: {{ }} is Go templates

Anything in {{ }} is a Go template expression, evaluated against a single merged vars namespace (workflow defaults:, task/step vars:, and --var all merge into it):

steps:
  - func: shell
    do: echo "Deploying {{ vars.version }} to {{ vars.target }}"

You also get the standard reference roots — {{ steps.build.image }} for a prior step's output, {{ env.HOME }} for an OS variable, {{ loop.item }} inside a loop.

Sprig: the functions that delete your shell hacks

Every function below is real and verified with orchstep eval (the expression playground — try them yourself). The point is that the messy bits of a bash script — defaulting a value, parsing output, comparing versions — become one piped function call.

Defaults and fallbacks

The single most useful one. No more ${PORT:-8080} scattered through do: blocks:

$ orchstep eval --lang template '{{ "" | default "fallback" }}'
fallback
$ orchstep eval --lang template '{{ coalesce "" "" "from-coalesce" }}'
from-coalesce
$ orchstep eval --lang template '{{ ternary "yes" "no" (eq "prod" "prod") }}'
yes

Extracting values from command output

This is where Sprig earns its keep in an ops tool. regexFind pulls the first capture group out of a step's stdout — replacing a grep | sed | cut pipeline:

$ orchstep eval --lang template '{{ "IMAGE=app:v1.2.3" | regexFind "IMAGE=(.+)" }}'
app:v1.2.3
$ orchstep eval --lang template '{{ regexFind "v([0-9]+)" "release-v42" }}'
42

In a workflow it lives in outputs:, turning unstructured stdout into a named value the next step can use:

- name: extract
  func: shell
  do: |
    echo "Build complete"
    echo "IMAGE=registry.io/my-service:v2.4.0"
  outputs:
    image: '{{ result.output | regexFind "IMAGE=(.+)" }}'
- name: deploy
  func: shell
  do: echo "deploying {{ steps.extract.image }}"

Run it and image resolves to registry.io/my-service:v2.4.0 — the IMAGE= prefix stripped, because regexFind returns the capture group, not the whole match.

Strings, versions, and the rest

$ orchstep eval --lang template '{{ "my-service" | camelcase }}'
MyService
$ orchstep eval --lang template '{{ semverCompare ">=1.2.0" "1.5.0" }}'
true

The full menu — upper/lower/trim, b64enc, sha256sum, toJson, now | date "2006-01-02", list and dict helpers — is in the Templates reference. If Sprig has it, OrchStep has it.

Where JavaScript takes over

Go templates are great for interpolation and one-shot functions. They get ugly the moment you need real boolean logic — nested and/or/eq is hard to read. So OrchStep evaluates un-wrapped expressions (no {{ }}) as JavaScript via an embedded VM. The rule is exact and lives in one place: fully wrapped in {{ }} means Go template; anything else means JavaScript.

$ orchstep eval '2 + 3 > 4'
true
$ orchstep eval --env production 'vars.require_approval === "true" && Number(vars.replicas) > 4'
true

JavaScript is what assert conditions, retry.when, and loop.until use. A condition that would be a thicket of Go-template and calls reads like code:

- func: assert
  args:
    condition: 'steps.extract.image.includes("my-service") && vars.service.length > 0'
    message: "image must reference the service"

That assert passes in a real run — steps.extract.image is a JS string, so .includes() and .length just work.

What you gained

You used to writeNow it is
${VAR:-default} in every block{{ vars.x | default "..." }}
grep ... | sed ... | cut ...{{ result.output | regexFind "..." }}
hand-rolled version string compares{{ semverCompare ">=1.2.0" vars.v }}
unreadable nested and/or/eqa plain JavaScript boolean expression
"what does this resolve to?" guessworkorchstep eval '<expression>'

The honest boundary

This is a templating layer, not a programming language. Go templates have no local variables worth the name and the control flow is awkward; that is why the JS escape hatch exists. If you find yourself writing a 20-line {{ range }}/{{ if }} block inside a do:, that logic probably belongs in a real script that the step calls — OrchStep orchestrates your tools, it does not replace them. Use templates for wiring, JS for conditions, and a script for anything that wants to be a program.

Where to go next

Curious what a specific expression resolves to in your workflow? orchstep eval '<expression>' answers in milliseconds — no run required.

#TEMPLATES#SPRIG#VARIABLES#EXPRESSIONS
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY