Script Steps in Workflows, Custom Commands, and Hooks
A script step runs embedded Starlark inside Atmos:
steps:- name: summarizetype: scriptinterpreter: starlarkworking_directory: components/terraform/vpcenv:REGION: us-east-2script: |print("region", env["REGION"])
interpreter and script are required; never put command on a script step. See the
script step reference for the full field list and
the Language reference.
Templates in script source
The script body is rendered as a Go template before Starlark runs. This applies to inline
script: | bodies and to files loaded with script: !include scripts/x.star. Consequences:
{{and}}in Starlark source are template delimiters. A literal{{in a string fails with a template error (for examplefunction "x" not defined). Tag the script with!literalto skip rendering:script: !literal |runs the body exactly as written, in sequential steps,parallel/matrixchildren, custom commands, workflows, and hooks. Prefer it for any script that contains braces, including"{}".format(...)next to{{.!literalalso works oncommand,interpreter,working_directory, and individualenvvalues, and only the tagged field skips rendering.- Fallback when
!literalis not an option, such as a script loaded with!include, which is still rendered: avoid the sequence by splitting it, for example"{" + "{ value }}". Escape sequences such as{{"{{"}}do not survive, because the rendered result is rendered again (workflows and custom commands run more than one render pass). - Sprig and Gomplate functions (
{{ upper "abc" }}) work the same in sequential steps and inparallel/matrixchild steps: children render with the renderer and pass count of the context that contains them. - Only the values declared under the step's
env:are rendered. The ambient process environment passes through to the script and its child processes verbatim, so an inheritedFOO='{{ bad'neither fails the step nor gets evaluated. A declaredenvvalue written with!literalis not rendered either. - A template error names the step and field (
step "fmt" field script) and, for a script read through!include, the source file. A hint points at!literalwhen the body contains{{. - Do not template user input into script source. Read inputs through
ctx.flags,ctx.arguments, andenv: they are plain values, so quotes and braces in them cannot break the script or inject code. - Standalone scripts (
atmos ./x.star) are not templated.
Inputs by context
| Context | ctx.flags | ctx.arguments | ctx.args | ctx.component |
|---|---|---|---|---|
| Custom command | Typed: string, bool, and type: int flags as integers | Named command arguments (strings); an omitted optional argument without a default is "" | Empty; trailing args after -- are not exposed | The command's component: (lazy), else None |
| Workflow | String map that always has stack (for example {"stack": ""}) | Empty | Empty | None; use components.get(name, stack, type) |
| Hook | Not part of the hook contract | Empty | Empty | The hook's component, plus ctx.hook and ctx.operation |
parallel/matrix child | Inherited from parent | Inherited | Empty | Inherited, with components.get |
env holds only the step's declared env (resolved), not the ambient process environment. In a
custom command it holds only the step's own env: entries, not command-level env: entries.
ctx.script exposes .path and .directory for file-backed included steps and standalone
scripts; it is None for inline steps. Child processes started with exec.run,
component.exec, or atmos.* still inherit the effective process environment, plus
per-call env={...} overrides.
A workflow has no component in scope: ctx.component.stack fails because ctx.component is None.
Resolve one explicitly with components.get("vpc", "dev", "terraform").
Output and labels
printwrites data to stdout.ui.info,ui.success, andui.warningwrite to stderr.log.*goes through the Atmos logger withstep(andtask) fields.- Script steps default to raw output with no
[step]or✓ step completedlabels, the same as shell steps.show: {labels: true}on the step restores the labels. - The step
output:field selects the display mode:raw,log,viewport, ornone. Any other value, such asoutput: capture, fails validation before the step runs and the error lists the valid modes. The same check applies toshell,atmos, andcontainersteps and to the workflow-leveloutput:. (Subprocess capture isexec.run(..., output="capture")inside the script.) Container steps now default to raw output without labels, like the other command steps. - A top-level
output = valuein the script becomes the step value for later steps: strings as-is, other values JSON-encoded. Without it, the captured stdout is the step value.{{ .steps.<name>.value }}renders a dict or list as JSON text ({"n":3}), never as Go map syntax. JSON text contains double quotes, so put it in single quotes in a shell command (echo '{{ .steps.first.value }}') or pass it throughenvandjson.decodeit. Anoutputthat cannot be encoded fails withoutput` must be a string or JSON-encodable value: <reason>.
Timeouts and containers
- A
timeout:on the script step is enforced: the interpreter runs under a deadline, the Starlark thread and its subprocesses are canceled when it elapses, and the step fails withstep timed out. An invalid value (not a positive duration) fails withinvalid step timeout.shellandatmossteps enforcetimeout:the same way in workflows and custom commands. Per-task limits inside the script still usesteps.task(name, fn, timeout="30s")andsteps.parallel. - Embedded Starlark runs inside Atmos, so a step under an enabled workflow or step
containerfails validation before the workflow starts. Setcontainer: falseon that step. atmos workflow --dry-runparses top-level script steps (reporting syntax errors) and runs no code or processes. Script steps insideparallel/matrixrun nothing either.
Files and paths
Use script: !include scripts/deploy.star for external source, or include a YAML step
definition through the existing include mechanism. load() and fs.read_file accept relative
and absolute paths.
!includein a workflow manifest or custom command:./xand../xresolve against the file that contains the tag, barex/yagainst the projectbase_path, absolute paths as written; never against the directory Atmos runs from.load()in a script read from a file (!include,!include.raw): relative to that file's directory, bare or./or../, also inside loaded modules. Tracebacks name that file and line.- In a stack-manifest hook (
kind: step/steps), a local!include/!include.rawscript(no YQ expression, never remote) next to aninterpretergets an Atmos-recordedscript_sourcesibling key, so hookload()also resolves next to the included file.script_sourceis provenance, visible indescribe componentanddescribe stacksoutput; never write it by hand. A siblingscript_source_sha256validates it: if a child stack overrides onlyscript(for example with an inline body), the inheritedscript_sourceno longer matches and is ignored, so no manual cleanup is needed. load()in an inlinescript: |body: relative toworking_directory.fs.read_fileandexec.run: relative toworking_directory, which must exist.
Imports are local, cached per invocation, and reject cycles. Imported functions work in tasks just like inline functions.
Retries and errors
A step-level retry: re-runs the whole script. Make side effects repeatable, or put retries
around a narrower function with steps.task(..., retry={...}).
Script errors are single-line messages followed by the Starlark traceback. A subprocess failure includes the last lines of its stderr. Runaway recursion stops at a depth limit with a "recursion depth exceeded" error and a collapsed traceback.