Syntax reference
Every .actio.yml macro keyword — scope, type, options, and what it compiles to.
Actio is a strict superset of GitHub Actions workflow syntax: every standard
key, event, and value behaves exactly as it does in a normal workflow (see GitHub's
workflow syntax reference),
and a macro-free .actio.yml file is a normal workflow.
On top of that, Actio adds a handful of compile-time macros. actio build expands
them into the verbose YAML you'd otherwise hand-write — zero runtime, zero lock-in —
then strips them, so none survive in the generated .yml.
This page is the canonical keyword dictionary: every macro Actio adds, where it's valid, its options, a minimal example, and what it compiles to. The per-keyword macro guides are the tutorial-style deep dives.
Quick reference
Every keyword Actio adds, grouped by where it lives. Compiles to is what actio build leaves behind.
Workflow keys
| Keyword | Type | Compiles to |
|---|---|---|
params | map of param defs | stripped; {{ params.* }} resolved |
let | map of compile-time consts | stripped; {{ let.* }} resolved |
reusable | object | on.workflow_call + workflow_dispatch |
_anchors | anchor library | stripped; - *alias step lists flattened in place |
templates + inject … with | map of param'd step lists | stripped; expanded in place |
fragments + inject | map of step lists | deprecated — prefer _anchors/templates |
job-defaults | map of job keys | stripped; merged into every job |
executors | map of presets | stripped; selected via executor |
call-templates | map of call presets | stripped; merged into extends: jobs |
injection-hoist | enum | stripped; controls ${{ }} hoisting in run: |
coercion | enum | stripped; quotes YAML 1.1 coercion traps |
finally | map | teardown jobs with needs + outcome guards |
Job keys
| Keyword | Type | Compiles to |
|---|---|---|
executor | string or list | preset merged into the job |
extends | string or list | call-templates preset merged into a call job |
dynamic-matrix | map | setup job + fromJSON matrix wiring |
expand_matrix | boolean | literal matrix unrolled into named jobs |
strategy.matrix comprehension | "{{ [expr for x in list] }}" | evaluated to a native matrix.include |
static-if / static-if(<expr>) | bare expr / keyed map | node kept-or-dropped / patch merged when true |
Step keys (most are also valid at job scope)
| Keyword | Type | Compiles to |
|---|---|---|
inject | string | fragment/template steps, or an imported step/job |
for-each | map | repeated steps (or a matrix) |
retry | integer or map | chain of conditional attempts |
share | map of outputs | job.outputs + needs wiring |
ref | optional list or map | inferred producer; job.outputs + needs wiring |
fallback | list or map | try/catch wiring |
soft_fail | boolean or list | continue-on-error, or a build-time exit-code wrapper |
artifacts | map | trailing actions/upload-artifact step |
if-changed | glob string(s) | paths-filter setup job + folded if: guard |
hoist overrides: unsafe · trust · force | boolean / paths / paths | keep inline · keep inline · force into env |
Lifecycle hooks (job + step)
| Keyword | Type | Compiles to |
|---|---|---|
ensure · on-success · on-failure · on-abort | list of steps | guarded steps (always() · success() · failure() · cancelled()) |
Interpolation tokens
Actio distinguishes compile-time from runtime expansion by the brace style:
{{ ... }}— compile-time. Resolved byactio buildand baked into the output (e.g.{{ params.env }},{{ toJSON(params.matrix) }}).${{ ... }}— runtime passthrough. Emitted verbatim for the GitHub Actions runner to evaluate.
Don't mix them
${{ params.* }} is invalid — params only exist at compile time, so they must
use {{ }}. Conversely, static-if takes a bare compile-time expression
and rejects ${{ }} wrappers.
Workflow keys
params
Typed compile-time parameters. References resolve with {{ params.<name> }} (or
{{ toJSON(params.<name>) }}) and are stripped from the emitted workflow YAML.
- Valid in: top level
- Type: map of name → param definition
- Compiles to: nothing; every
{{ params.* }}token is replaced with its value
Prop
Type
params:
env:
type: enum
values: [dev, staging, prod]
default: staging
retries:
type: number
default: 3
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "env={{ params.env }} retries={{ params.retries }}"jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "env=staging retries=3"params:
env:
type: enum
values: [dev, staging, prod]
default: staging
retries:
type: number
default: 3
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "env={{ params.env }} retries={{ params.retries }}"jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "env=staging retries=3"Full guide: params.
let
File-local compile-time constants. Each value is a literal or a single whole-value
{{ expression }} evaluated at build time by the one compile-time engine; reference
the typed result elsewhere with {{ let.<name> }}. Entries may build on earlier
params and let values, so count: "{{ params.base * 2 }}" stores the
number 4, not a string. Reference an earlier entry with {{ let.<name> }}. The
let key is stripped from the generated workflow.
- Valid in: top level
- Type: map of name → literal or
"{{ expression }}" - Compiles to: nothing;
{{ let.* }}references are resolved andletis removed
Only compile-time expressions are allowed. A ${{ }} runtime expression or a runtime
context head (github.*, matrix.*, secrets.*, ...) is rejected with
let-not-compile-time; a circular reference between let entries is the same error.
params:
base: { type: number, default: 2 }
let:
doubled: "{{ params.base * 2 }}"
label: "build-{{ let.doubled }}"reusable
Declare a workflow as both callable (on: workflow_call) and dispatchable
(on: workflow_dispatch) in one place. Actio emits both trigger blocks, derives
the workflow_dispatch inputs from the workflow_call inputs so you define them
once, and normalizes input references to a single canonical runtime form.
A callable workflow reads an input as ${{ inputs.x }}, but a dispatched one
reads the same value as ${{ github.event.inputs.x }}. Authoring one workflow
that is both forces a hand-written wrapper and careful dual-reference juggling.
With reusable you write ${{ inputs.x }} everywhere; Actio rewrites any
${{ github.event.inputs.x }} it finds back to that canonical form, which is
valid under either trigger.
- Valid in: top level
- Type: object with
inputs,secrets,outputs, anddispatchkeys - Compiles to:
on.workflow_call(always) pluson.workflow_dispatch(unlessdispatch: false); thereusablekey is stripped
Input type is restricted to the workflow_call subset (string, boolean,
number) and defaults to string. Set dispatch: false to emit only the
callable trigger. A hand-written workflow_call or workflow_dispatch trigger
is a compile error, because Actio owns those blocks when reusable is present.
on: push
reusable:
inputs:
target:
type: string
required: true
verbose:
type: boolean
default: false
secrets:
token:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "deploying to ${{ github.event.inputs.target }}"on:
push: null
workflow_call:
inputs:
target:
type: string
required: true
verbose:
type: boolean
default: false
secrets:
token:
required: true
workflow_dispatch:
inputs:
target:
type: string
required: true
verbose:
type: boolean
default: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "deploying to ${{ inputs.target }}"on: push
reusable:
inputs:
target:
type: string
required: true
verbose:
type: boolean
default: false
secrets:
token:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "deploying to ${{ github.event.inputs.target }}"on:
push: null
workflow_call:
inputs:
target:
type: string
required: true
verbose:
type: boolean
default: false
secrets:
token:
required: true
workflow_dispatch:
inputs:
target:
type: string
required: true
verbose:
type: boolean
default: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "deploying to ${{ inputs.target }}"Full guide: reusable.
Step reuse: pick a primitive
Reach for the smallest tool that fits. Native anchors cover the common case; reach
for templates:/inject only when you need params or another file.
| Need | Use | Notes |
|---|---|---|
| Static, same-file, no params | _anchors: + - *alias | Native YAML; the aliased step list is flattened in place at compile time |
| Typed params | templates: + inject ... with | Compile-time args, type-checked |
| Cross-file reuse | inject: ./lib#name | Resolves via imported templates/modules |
| Compute a value | let / {{ ... }} | Compile-time expressions |
Wire job outputs to needs | ref | Adds needs: + output plumbing |
| (legacy) same-file step list | fragments: + inject | Deprecated: prefer _anchors: |
fragments
Deprecated: prefer _anchors: / templates:
fragments still compiles and expands exactly as before, but it is redundant now
that a - *alias flattens an anchored step list in place. Migrate:
- Same-file, no params: move the list under
_anchors:with a&nameanchor and call it with- *name. - Params or cross-file: use
templates:+inject ... with, or cross-fileinject: ./lib#name.
Compiling a file that still uses fragments: emits a fragment-deprecated
warning.
Reusable, named lists of steps. Splice a fragment into a job with - inject: <name>.
The fragments key is stripped from the generated workflow.
- Valid in: top level
- Type: map of name → list of steps
- Compiles to: nothing; each
injectis replaced in place with the fragment's steps
fragments:
setup:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
jobs:
build:
runs-on: ubuntu-latest
steps:
- inject: setup
- run: npm ciSee inject for the call site. Full guide: fragments + inject.
_anchors
A reserved top-level home for native YAML anchor definitions (&name) referenced
elsewhere with *name. Anchors already own no-arg in-file reuse; _anchors: just
keeps anchor libraries out of real workflow keys. A - *alias whose anchor is a
step list is flattened into the surrounding steps at compile time (recursive, with
a depth cap). The _anchors key is stripped from the generated workflow.
- Valid in: top level
- Type: free-form map (anchor library)
- Compiles to: nothing; anchors resolve at parse,
_anchorsis removed
_anchors:
setup: &setup
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
jobs:
build:
runs-on: ubuntu-latest
steps:
- *setup
- run: npm ciFull guide: _anchors.
templates
Parameterized, named lists of steps. Like fragments, but with typed
params:. Splice a template into a job with - inject: <name> and pass a with:
mapping; {{ args.<name> }} references in the body are resolved at compile time and
erased. The templates key is stripped from the generated workflow.
- Valid in: top level
- Type: map of name →
{ params?, steps } - Compiles to: nothing; each
injectis replaced in place with the template's steps, with{{ args.* }}substituted fromwith:
templates:
greet:
params:
who: { type: string }
times: { type: number, default: 1 }
steps:
- run: echo "hello {{ args.who }} x{{ args.times }}"
jobs:
build:
runs-on: ubuntu-latest
steps:
- inject: greet
with: { who: world }Param types reuse the params type system. See inject for the
call site. Full guide: templates.
job-defaults
Default job-level settings merged into every job at compile time. Stripped from the generated workflow YAML.
- Valid in: top level
- Type: map of job-level keys
- Compiles to: nothing; each key is merged into every job per the rules below
Prop
Type
Merge semantics vary by key: if is AND-combined (default && inline);
permissions/concurrency replace on presence; runs-on, timeout-minutes,
continue-on-error, environment are inline-wins; env, container,
services, defaults are deep-merged. strategy is intentionally per-job
only and is rejected in job-defaults.
job-defaults:
runs-on: ubuntu-latest
timeout-minutes: 30
jobs:
test:
steps:
- run: npm testjobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- run: npm testjob-defaults:
runs-on: ubuntu-latest
timeout-minutes: 30
jobs:
test:
steps:
- run: npm testjobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- run: npm testReusable-workflow call jobs
Jobs with uses: only receive the call-compatible subset: if, permissions,
and concurrency. Runner keys are skipped, and executor: is not supported on
call jobs.
Full guide: job-defaults.
executors
Named runner/container/service presets referenced by jobs.<id>.executor. Stripped
from the generated workflow YAML.
- Valid in: top level
- Type: map of name → executor definition
- Compiles to: nothing; the referenced preset is merged into each job that names it
Prop
Type
executors:
hardened:
runs-on: ubuntu-latest
timeout-minutes: 20
jobs:
test:
executor: hardened
steps:
- run: npm testSee executor for the call site. Full guide: executors.
call-templates
Named reusable-workflow call-job templates referenced by jobs.<id>.extends. Lets N
near-identical call jobs collapse to one base template plus per-job deltas (usually a single
with.* value). Stripped from the generated workflow YAML.
- Valid in: top level
- Type: map of name → call-template definition
- Compiles to: nothing; the referenced template is merged under each job that
extends:it, then the job is emitted as an ordinary call job
Prop
Type
Precedence is inline > extends > job-defaults: the template runs before job-defaults, so
a job written as just { extends, with } already carries a real uses before job-defaults
partitions jobs — and any key the template (or inline job) supplied is already present, so
job-defaults won't overwrite it.
call-templates:
reusable-test:
uses: ./.github/workflows/test.yml
with:
testTimingsArtifact: test-timings
jobs:
test-unit:
extends: reusable-test
with:
afterBuild: pnpm test-unit
test-integration:
extends: reusable-test
with:
afterBuild: pnpm test-integrationjobs:
test-unit:
uses: ./.github/workflows/test.yml
with:
testTimingsArtifact: test-timings
afterBuild: pnpm test-unit
test-integration:
uses: ./.github/workflows/test.yml
with:
testTimingsArtifact: test-timings
afterBuild: pnpm test-integrationcall-templates:
reusable-test:
uses: ./.github/workflows/test.yml
with:
testTimingsArtifact: test-timings
jobs:
test-unit:
extends: reusable-test
with:
afterBuild: pnpm test-unit
test-integration:
extends: reusable-test
with:
afterBuild: pnpm test-integrationjobs:
test-unit:
uses: ./.github/workflows/test.yml
with:
testTimingsArtifact: test-timings
afterBuild: pnpm test-unit
test-integration:
uses: ./.github/workflows/test.yml
with:
testTimingsArtifact: test-timings
afterBuild: pnpm test-integrationCall jobs only
extends: is valid only on reusable-workflow call jobs. A job with inline steps:, or one
where no template in the chain provides uses:, is rejected at compile time.
See extends for the call site.
injection-hoist
Controls how Actio handles ${{ }} interpolation inside run: bodies — the script-
injection mitigation that hoists untrusted context values into env: and rewrites the
script to reference them. Set it at the workflow, job, or step level; the narrowest
scope wins.
- Valid in: top level,
jobs.<job_id>,jobs.<job_id>.steps[*] - Type: string enum
- Compiles to: nothing; it only changes how
run:bodies are rewritten
| Mode | Behavior |
|---|---|
fix | Hoist untrusted ${{ }} values into env and rewrite the run body. Default. |
warn | Leave the value inline, emit a build warning. |
error | Fail the build when an untrusted value is found. |
off | Disable hoisting entirely. |
injection-hoist: error
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "${{ github.event.issue.title }}"Per-step overrides live in unsafe, trust, and force.
Full guide: injection-hoist.
coercion
Guards against YAML type-coercion footguns. Actio parses your source with the YAML 1.2
core schema, so tokens like no, on, 1:30, 2024-01-01, and 1_000 survive as
plain strings. A downstream YAML 1.1 consumer — per-action action.yml input parsing,
the truthy on: key, or other tooling that reads the generated workflow — then coerces
them (no → false, 1:30 → 90, 2024-01-01 → a date, 1_000 → 1000). In fix
mode (the default) Actio single-quotes any emitted string scalar a 1.1 consumer would
mis-type, so it stays a string. Genuine booleans and numbers are never touched.
The Actions workflow parser itself largely preserves these as strings in string positions (and rejects an unquoted trap loudly in strictly-typed positions like
timeout-minutes), so this is defensive hardening against 1.1 consumers rather than a fix for the runner silently rewriting your file.
- Valid in: top level
- Type: string enum
- Compiles to: nothing; it only changes how emitted string scalars are quoted
| Mode | Behavior |
|---|---|
fix | Single-quote emitted string scalars that a YAML 1.1 consumer would coerce. Default. |
warn | Leave them unquoted, emit a yaml-coercion-trap build warning per scalar. |
off | Disable the guard entirely. |
coercion: fix
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hi
env:
DEPLOY: no # emitted as 'no' so it stays a string, not falsefinally
Workflow-level teardown. Declares jobs that run after the rest of the workflow,
gated on overall outcome. Actio injects needs: over all real jobs and the matching
outcome guard at compile time.
- Valid in: top level
- Type: map of outcome branch groups (
on-success/on-failure/on-abort), each containing named teardown jobs; named jobs may also be declared directly with thewhen:sugar - Compiles to: ordinary jobs with
needs+ anif:outcome guard
Prop
Type
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
finally:
on-failure:
page:
runs-on: ubuntu-latest
steps:
- run: ./page-oncall.shwhen: sugar
A teardown job declared directly under finally: may use when: to gate on another
job's result (e.g. when: deploy.failed) instead of an outcome branch group.
Job keys
jobs.<job_id>.executor
Name (or ordered list of names) from top-level executors:. Expanded during compile
and stripped from output.
- Valid in:
jobs.<job_id> - Type: string, or list of strings (composed left-to-right; later entries win; inline job keys always win)
- Compiles to: nothing; the preset is merged into the job
jobs:
build:
executor: [base, gpu]
steps:
- run: ./build.shFull guide: executors.
jobs.<job_id>.extends
Name (or ordered list of names) from top-level call-templates:. Materializes
the call-job plumbing (uses/with/needs/secrets/…) during compile and is stripped from
output.
- Valid in:
jobs.<job_id>(reusable-workflow call jobs only) - Type: string, or list of strings (composed left-to-right; later templates win; inline job keys always win)
- Compiles to: nothing; the template is merged into the job
jobs:
test-unit:
extends: [base-test, gpu-test]
with:
afterBuild: pnpm test-unitSee call-templates for the merge rules.
jobs.<job_id>.dynamic-matrix
Generate a job's strategy.matrix at runtime from a script. Splits into a setup job
that prints JSON to $GITHUB_OUTPUT and a matrix job wired with needs + fromJSON()
- an empty-matrix guard.
- Valid in:
jobs.<job_id> - Type: map
- Compiles to: a setup job plus a matrix job with
needsandfromJSON()wiring
Prop
Type
For heterogeneous fan-out (per-item runs-on/env), have script print
{include:[...]} (include mode); each entry's keys are read as ${{ matrix.<key> }}.
jobs:
test:
runs-on: ubuntu-latest
dynamic-matrix:
script: ./scripts/list.sh
alias: shard
steps:
- run: ./run-shard.sh ${{ matrix.shard }}script may be a path or an inline command. Full guide: dynamic-matrix.
jobs.<job_id>.expand_matrix
Unroll a compile-time-known strategy.matrix into one named job per leg, so a
downstream job can needs: a single leg — which a native (anonymous) matrix can't
express. Set expand_matrix: true on a job with a literal matrix.
- Valid in:
jobs.<job_id> - Type: boolean
- Compiles to: one job per matrix leg, slugged
${job}-${values…};strategyand the macro key are stripped and every${{ matrix.* }}reference is resolved to the concrete leg value
Target a leg from another job's needs: with a job(key=value, …) selector. A full
selector resolves to one slug; a partial selector (subset of axes) resolves to
every matching leg; multiple selectors are unioned.
jobs:
build:
expand_matrix: true
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [linux, windows]
arch: [x64, arm64]
steps:
- run: ./build --arch ${{ matrix.arch }}
deploy:
needs: build(os=linux, arch=x64) # → build-linux-x64
runs-on: ubuntu-latest
steps:
- run: ./deploy.shinclude/exclude follow native matrix semantics. The matrix must be literal — a
runtime ${{ fromJSON(...) }} matrix is rejected (use
dynamic-matrix), and >256 legs is a compile-time error (GitHub's
per-run cap). Full guide: expand-matrix.
strategy.matrix comprehension
Write a job's strategy.matrix as the one list comprehension [expr for x in list] and
Actio evaluates it at build time into a concrete, native matrix.include array. Inputs
come from compile-time params and let, so nothing Actio-specific
survives into the workflow (no setup job, no fromJSON()), unlike the runtime
dynamic-matrix. The comprehension runs through the same one
compile-time engine as every other {{ }} expression.
- Valid in:
jobs.<job_id>.strategy.matrix - Type: a whole-value
"{{ [expr for x in list] }}"comprehension - Compiles to: a plain
strategy.matrix.includelist of objects; the comprehension source is erased
params:
versions: { type: object, default: [18, 20, 22] }
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix: "{{ [ { node: v, label: format('node-{0}', v) } for v in params.versions ] }}"
steps:
- run: echo "building ${{ matrix.node }}"A comprehension that does not evaluate to a list is rejected with expr-type-error. A
static literal matrix is left untouched, and ${{ matrix.* }} runtime references survive
verbatim. Use this only when the legs are known at build time; for runtime-generated legs
use dynamic-matrix.
Compile-time {{ }} tokens are erased once resolved; one that survives into emitted
output is a build error. A leftover token in a uses: value is uses-unresolved, and a
stray token anywhere else is expr-stray. Native ${{ }} runtime expressions in
run:/if:/env: are never inspected and always pass through unchanged.
jobs.<job_id>.static-if
Compile-time structural condition. The expression is evaluated by actio build; if it
is false, the entire job (or step) map is omitted from the emitted YAML.
- Valid in:
jobs.<job_id>andjobs.<job_id>.steps[*] - Type: string — a bare compile-time boolean expression
- Compiles to: the node is kept verbatim or dropped entirely
- Expression language: references (
params.*and compile-time symbols), comparisons (==,!=,<,<=,>,>=), boolean operators (&&,||,!), literals, and function calls.
params:
deploy:
type: boolean
default: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: ./build.sh
publish:
static-if: params.deploy
runs-on: ubuntu-latest
steps:
- run: ./publish.shjobs:
build:
runs-on: ubuntu-latest
steps:
- run: ./build.shparams:
deploy:
type: boolean
default: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: ./build.sh
publish:
static-if: params.deploy
runs-on: ubuntu-latest
steps:
- run: ./publish.shjobs:
build:
runs-on: ubuntu-latest
steps:
- run: ./build.shCompile-time only
static-if cannot reference runtime contexts like github.*, env.*, or
secrets.*, and rejects ${{ }} wrappers. For runtime conditions, use the
standard if:
key instead.
Full guide: static-if.
static-if(<expr>)
Conditional merge form. A key shaped like static-if(<expr>) maps to an object that is
merged into the parent only when the expression is true.
- Valid in:
jobs.<job_id>andjobs.<job_id>.steps[*] - Type: keyed map (the key carries the expression; the value is the patch)
- Compiles to: the patch object merged into the parent, or nothing
params:
debug:
type: boolean
default: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: npm test
static-if(params.debug):
env:
RUNNER_DEBUG: "1"Full guide: static-if.
Step keys
jobs.<job_id>.steps[*].inject
Splice in the steps of the named fragment (defined under top-level fragments:) at this
position. Replaced inline by actio build. To pass typed arguments, name a
templates entry instead and add a with: mapping; {{ args.<name> }}
references in the template body are resolved at compile time.
- Valid in:
jobs.<job_id>.steps[*] - Type: string (a fragment or template name)
- Compiles to: the fragment's or template's steps, in place
steps:
- inject: setup
- inject: greet
with: { who: world }
- run: npm testSee fragments and templates. Full guide: templates.
Cross-file import: inject: ./lib.actio.yml#name
inject also imports a step or job from another .actio.yml file by giving it a
path selector: a relative path, then #, then the name of a fragments:,
templates:, or job entry in that file. The imported node is partial-compiled in
its own file's scope and spliced inline, so the generated workflow stays
self-contained with no remaining reference to the source file.
Step form — splice an imported step or step-list at this position. Pass typed
arguments with with: exactly as you would for a local template:
steps:
- inject: ./lib.actio.yml#setupNode
with: { node: 20 }
- run: npm testJob form — set inject as a job body to import a whole job. The local job key
becomes the emitted job id, and any sibling keys deep-merge over the imported job:
jobs:
deploy:
inject: ./lib.actio.yml#deployJob
with: { env: prod }
runs-on: ubuntu-24.04 # overrides the imported runner- Deep-merge (GitLab
includesemantics): maps merge recursively, scalars and arrays replace (last-wins). Local keys override imported ones. - Lexical scope:
with:is the only channel from the importer into the define. Every other{{ }}in the imported body (e.g.{{ let.* }}) resolves in the source file's scope, never the importer's. uses:is never inspected: importeduses:stays native and is SHA-pinned by the terminal pin pass alongside the rest of the assembled workflow.- v1 limits: local relative paths only (must start
./or../and end.actio.ymlor.yaml, case-insensitive);#nameis required; no@ref. Remoteowner/repo@refimports are out of scope.
Errors point at the offending selector: import-module-not-found (file missing),
import-define-not-found (no such name), import-cycle (A imports B imports A, with
the full chain), import-local-ref-version (selector carries an @ref),
import-unknown-param (a with: key the define does not declare), and
import-malformed-module (bad selector, extension, or unparseable source).
jobs.<job_id>.for-each / jobs.<job_id>.steps[*].for-each
Expand one step into many by looping over a list at compile time. Reference the loop
variable with the compile-time token {{ <var> }} (or {{ <var>.<field> }} for object
elements). A parallel loop must be job-level and becomes a matrix.
- Valid in:
jobs.<job_id>,jobs.<job_id>.steps[*] - Type: map
- Compiles to: repeated steps, matrix wiring, or serial job fan-out
| Option | Type | Description |
|---|---|---|
var | string | Loop variable name (required). |
in | list / object / params.* / output expr | Source to iterate (required). |
parallel | boolean | Expand to a job matrix instead of inline serial steps. |
steps:
- for-each:
var: svc
in: [api, web, worker]
steps:
- run: ./deploy.sh {{ svc }}Coexists with strategy.matrix
A job-level parallel loop can sit on a job that also declares its own
strategy.matrix. Instead of overwriting the matrix, Actio fans the job out into one
job per variant (test-turbopack, test-rspack, …), each carrying a clone of the
full author matrix — so you get a variant axis × a shard matrix with clean, separate
status checks. If the loop var (or its as alias) collides with a matrix key, the
build fails with for-each-matrix-key-collision; the matrix's own fail-fast /
max-parallel stay authoritative and any loop-level copies are ignored with a warning.
Full guide: for-each.
jobs.<job_id>.steps[*].retry
Retry a flaky step with optional backoff; expands into a chain of conditional attempts
where attempt N runs only if attempt N-1 failed. Works with run: and uses: steps.
- Valid in:
jobs.<job_id>.steps[*] - Type: integer (≥ 2), or map. Shorthand
retry: 3==retry: { attempts: 3 }. - Compiles to: a chain of conditional attempt steps (plus
sleepsteps fordelay)
Prop
Type
steps:
- run: flaky-command
retry:
attempts: 3
delay: 10sFull guide: retry.
jobs.<job_id>.steps[*].share
Publish step outputs so other jobs can consume them. Reference anywhere with
${{ share.<name> }}; Actio wires job.outputs and needs automatically and strips the
share key from the generated workflow.
- Valid in:
jobs.<job_id>.steps[*] - Type: map of
<name>→ output definition (value-form or capture-form) - Compiles to:
job.outputsentries +needson every consuming job
Prop
Type
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: ./build.sh
share:
version:
run: cat VERSION
deploy:
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh ${{ share.version }}Full guide: share.
jobs.<job_id>.steps[*].ref
Expose a step's outputs by a rename-safe logical handle. ref is optional: a step
becomes a producer the moment a consumer references it with
${{ ref.<handle>.<output> }}. Actio finds the producing step by handle, assigns its
id, synthesizes jobs.<id>.outputs, and adds needs edges. Where share wires
compiler-minted outputs, ref generalizes the same engine to outputs the compiler did
not mint: action uses: outputs, hand-written $GITHUB_OUTPUT writes, and reusable
call-job outputs.
- Valid in:
jobs.<job_id>.steps[*](optional; inference is the default) - Type: list of output names
[a, b](positional shorthand), or a map with optionalhandleand a requiredoutputslist - Compiles to:
steps.<id>.outputs.<name>(same job) orneeds.<job>.outputs.<name>(cross job), plus synthesizedjob.outputsandneeds
The producer is inferred from the reference: the handle matches a step id (preferred)
or a slug of its name. Only referenced handles are resolved, so unrelated named steps
never collide, and omitting ref: is byte-identical to writing the explicit
ref: [outputs]. A run: producer is statically scanned for its $GITHUB_OUTPUT
writes: an authoritative scan makes a reference to an unwritten output a hard
ref-output-unwritten error, while a dynamic or un-dissectable write degrades to a
ref-output-unscannable warning and wires anyway. Action producers are unverifiable
and always wired.
An explicit ref: is the escape hatch for renaming the handle or pinning an explicit
output contract. The positional ref: [a, b] derives the handle from the step
name/id; the { handle, outputs } map form pins the handle. With an explicit ref:,
the declared list is the contract and the run script is not scanned. Cross-job
references resolve to needs.<job>.outputs.<name>; same-job references stay local as
steps.<id>.outputs.<name> with no needs added. A dotted
${{ ref.<handle>.<output>.<field> }} lowers to fromJSON(...). Reusable call-jobs are
referenced as ${{ ref.job.<call_id>.<output> }} and add only a needs edge.
Prop
Type
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
name: node
build:
runs-on: ubuntu-latest
steps:
- run: echo "on ${{ ref.node.node-version }}"Full guide: ref.
jobs.<job_id>.steps[*].fallback
A native try/catch. Default (notify): the fallback runs on failure via if: failure()
but the job still fails. With recover: true the guarded step gets continue-on-error: true and the fallback keys off outcome, so the job can pass. With retry the guarded
step is re-run in a sibling <job>_retry job on a different runner when it fails.
- Valid in:
jobs.<job_id>.steps[*]andjobs.<job_id>(job-level) - Type: list of steps (notify mode), or map with
recover/stepsand/orretry - Compiles to: the guarded step plus outcome-gated fallback steps;
retryalso synthesizes a<job>_retryjob (needs: [<job>],if: failure()) that re-runs the step on the fallback runner
Prop
Type
steps:
- run: deploy
fallback:
recover: true
steps:
- run: ./rollback.shsteps:
- run: ./flaky-build.sh
fallback:
retry:
runs-on: ubuntu-latest-8-cores
when-exit-code: 137Full guide: fallback.
jobs.<job_id>.steps[*].soft_fail
Tolerate failure on a single step without failing the job. soft_fail: true tolerates any
non-zero exit; a list tolerates only the named exit codes (Buildkite-style). true compiles
to continue-on-error: true, so the step still shows as failed but the job continues, and it
works on run and uses steps. A list compiles a run step into a build-time shell wrapper
that maps only the listed codes to success (the step shows green) and re-exits any other code
unchanged. Lists are supported on bash, sh, and pwsh; on any other shell, or on a uses
step, a list is a compile error (use soft_fail: true instead).
- Valid in:
jobs.<job_id>.steps[*] - Type: boolean, or list of integer exit codes (0-255)
- Compiles to:
continue-on-error: true(fortrue), or a wrapper that runs your script in a fresh child shell, captures its aggregate exit code, and remaps the listed codes to0
The wrapper runs your script in a fresh child shell with the same strict flags GitHub uses, so
multi-line scripts, set -e/pipefail fail-fast, and explicit exit N all behave exactly as
they would normally; only the final exit code is remapped.
steps:
- run: ./flaky-check.sh
soft_fail: [0, 42]steps:
- run: |-
__actio_sf_file="$(mktemp)"
printf '%s\n' './flaky-check.sh' > "$__actio_sf_file"
__actio_sf_code=0
bash --noprofile --norc -eo pipefail "$__actio_sf_file" || __actio_sf_code=$?
rm -f "$__actio_sf_file"
case "$__actio_sf_code" in 0|42) exit 0 ;; *) exit "$__actio_sf_code" ;; esac
shell: bashFull guide: soft_fail.
jobs.<job_id>.steps[*].artifacts
Upload a step's build output inline. Expands into a trailing actions/upload-artifact step
right after the one it is attached to, mapping paths → the action's multiline path input.
The upload defaults to if: always() so artifacts publish even when the step failed (handy for
debugging). The emitted uses: flows through the pin pass like any other
action ref.
- Valid in:
jobs.<job_id>.steps[*] - Type: map.
paths(string or list of globs) is required. - Compiles to: the original step plus a trailing
actions/upload-artifactstep
Prop
Type
When name is omitted, Actio derives a deterministic, collision-free name from the job id and
step, so two unnamed uploads can't clash at runtime. Under a matrix, a static name still
collides across legs — carry a per-leg expression like name: build-${{ matrix.os }}.
Compile-time warnings flag both footguns (artifacts-matrix-unnamed,
artifacts-duplicate-name). The configured uploader (default actions/upload-artifact@v4) is
set with artifacts.uploader.
Full guide: artifacts.
jobs.<job_id>.steps[*].if-changed
Gate a step or job on file changes: it runs only when files changed in the pull request
or push match one of the glob patterns. Actio synthesizes one shared setup job that runs
dorny/paths-filter once, publishes a flag
output per unique glob group, and folds if: needs.<setup>.outputs.<flag> == 'true'
(AND-combined with any existing if:) plus needs: into each guarded node.
- Valid in:
jobs.<job_id>.steps[*]andjobs.<job_id>(job-level) - Type: glob string, or list of glob strings
- Compiles to: a shared
dorny/paths-filtersetup job plus aneeds+ foldedif:guard on each gated step/job
steps:
- name: Build docs
run: ./build-docs.sh
if-changed:
- 'docs/**'
- '*.md'Full guide: if-changed.
jobs.<job_id>.steps[*].unsafe
Opt this step out of injection-hoist entirely — ${{ }} is left inline in run:. Use
only when you fully control the interpolated values.
- Valid in:
jobs.<job_id>.steps[*] - Type: boolean
- Compiles to: nothing; the step's
run:body is left untouched
steps:
- run: echo "${{ github.sha }}"
unsafe: trueSee injection-hoist. Full guide: injection-hoist.
jobs.<job_id>.steps[*].trust
Context paths that should not be hoisted out of run: even if Actio would classify
them as untrusted — you vouch for them.
- Valid in:
jobs.<job_id>.steps[*] - Type: list of context paths
- Compiles to: nothing; the listed paths stay inline
steps:
- run: echo "${{ github.event.pull_request.title }}"
trust: [github.event.pull_request.title]See injection-hoist. Full guide: injection-hoist.
jobs.<job_id>.steps[*].force
Context paths to hoist into env: even if Actio would classify them as trusted —
belt-and-suspenders for values you would rather keep out of the script body.
- Valid in:
jobs.<job_id>.steps[*] - Type: list of context paths
- Compiles to: nothing; the listed paths are moved into
envand referenced there
steps:
- run: echo "$TITLE"
force: [github.event.issue.title]See injection-hoist. Full guide: injection-hoist.
Lifecycle hooks
ensure, on-success, on-failure, and on-abort attach teardown/outcome steps to a
step or a job. The steps are spliced in (step scope) or appended (job scope) and
each is wrapped in the matching guard, then the hook key is stripped.
| Hook | Step-level guard | Job-level guard |
|---|---|---|
ensure | if: always() | if: always() |
on-success | success() && steps.<id>.outcome == 'success' | if: success() |
on-failure | !cancelled() && steps.<id>.outcome == 'failure' | if: failure() |
on-abort | step-level cancellation | if: cancelled() |
ensure
Always-run teardown. The listed steps run even if the target step fails or the job is cancelled (bounded by the job timeout).
- Valid in:
jobs.<job_id>andjobs.<job_id>.steps[*] - Type: list of steps
- Compiles to: guarded steps with
if: always()
steps:
- run: ./start-db.sh
ensure:
- run: ./stop-db.shFull guide: lifecycle.
on-success
Steps that run only when the target step or job succeeded.
- Valid in:
jobs.<job_id>andjobs.<job_id>.steps[*] - Type: list of steps
- Compiles to: guarded steps (
success(); step scope also checksoutcome)
steps:
- run: ./build.sh
on-success:
- run: ./publish-artifact.shFull guide: lifecycle.
on-failure
Steps that run only when the target step or job failed. At step scope the guard keys on
outcome (pre-continue-on-error).
- Valid in:
jobs.<job_id>andjobs.<job_id>.steps[*] - Type: list of steps
- Compiles to: guarded steps (
failure()/ outcome check)
steps:
- run: ./build.sh
on-failure:
- run: ./upload-logs.shFull guide: lifecycle.
on-abort
Steps that run on cancellation. At step scope this only sees step-level cancellation;
run-level cancel belongs in a workflow finally:.
- Valid in:
jobs.<job_id>andjobs.<job_id>.steps[*] - Type: list of steps
- Compiles to: guarded steps (
cancelled())
steps:
- run: ./long-task.sh
on-abort:
- run: ./cleanup.shFull guide: lifecycle.