Actio

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

KeywordTypeCompiles to
paramsmap of param defsstripped; {{ params.* }} resolved
letmap of compile-time constsstripped; {{ let.* }} resolved
reusableobjecton.workflow_call + workflow_dispatch
_anchorsanchor librarystripped; - *alias step lists flattened in place
templates + inject … withmap of param'd step listsstripped; expanded in place
fragments + injectmap of step listsdeprecated — prefer _anchors/templates
job-defaultsmap of job keysstripped; merged into every job
executorsmap of presetsstripped; selected via executor
call-templatesmap of call presetsstripped; merged into extends: jobs
injection-hoistenumstripped; controls ${{ }} hoisting in run:
coercionenumstripped; quotes YAML 1.1 coercion traps
finallymapteardown jobs with needs + outcome guards

Job keys

KeywordTypeCompiles to
executorstring or listpreset merged into the job
extendsstring or listcall-templates preset merged into a call job
dynamic-matrixmapsetup job + fromJSON matrix wiring
expand_matrixbooleanliteral 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 mapnode kept-or-dropped / patch merged when true

Step keys (most are also valid at job scope)

KeywordTypeCompiles to
injectstringfragment/template steps, or an imported step/job
for-eachmaprepeated steps (or a matrix)
retryinteger or mapchain of conditional attempts
sharemap of outputsjob.outputs + needs wiring
refoptional list or mapinferred producer; job.outputs + needs wiring
fallbacklist or maptry/catch wiring
soft_failboolean or listcontinue-on-error, or a build-time exit-code wrapper
artifactsmaptrailing actions/upload-artifact step
if-changedglob string(s)paths-filter setup job + folded if: guard
hoist overrides: unsafe · trust · forceboolean / paths / pathskeep inline · keep inline · force into env

Lifecycle hooks (job + step)

KeywordTypeCompiles to
ensure · on-success · on-failure · on-abortlist of stepsguarded steps (always() · success() · failure() · cancelled())

Interpolation tokens

Actio distinguishes compile-time from runtime expansion by the brace style:

  • {{ ... }}compile-time. Resolved by actio build and 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

.actio.yml
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 }}"
generated .yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: echo "env=staging retries=3"
.actio.yml
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 }}"
generated .yml
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 and let is 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.

.actio.yml
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, and dispatch keys
  • Compiles to: on.workflow_call (always) plus on.workflow_dispatch (unless dispatch: false); the reusable key 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.

.actio.yml
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 }}"
generated .yml
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 }}"
.actio.yml
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 }}"
generated .yml
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.

NeedUseNotes
Static, same-file, no params_anchors: + - *aliasNative YAML; the aliased step list is flattened in place at compile time
Typed paramstemplates: + inject ... withCompile-time args, type-checked
Cross-file reuseinject: ./lib#nameResolves via imported templates/modules
Compute a valuelet / {{ ... }}Compile-time expressions
Wire job outputs to needsrefAdds needs: + output plumbing
(legacy) same-file step listfragments: + injectDeprecated: 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 &name anchor and call it with - *name.
  • Params or cross-file: use templates: + inject ... with, or cross-file inject: ./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 inject is replaced in place with the fragment's steps
.actio.yml
fragments:
  setup:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - inject: setup
      - run: npm ci

See 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, _anchors is removed
.actio.yml
_anchors:
  setup: &setup
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - *setup
      - run: npm ci

Full 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 inject is replaced in place with the template's steps, with {{ args.* }} substituted from with:
.actio.yml
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.

.actio.yml
job-defaults:
  runs-on: ubuntu-latest
  timeout-minutes: 30
jobs:
  test:
    steps:
      - run: npm test
generated .yml
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - run: npm test
.actio.yml
job-defaults:
  runs-on: ubuntu-latest
  timeout-minutes: 30
jobs:
  test:
    steps:
      - run: npm test
generated .yml
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - run: npm test

Reusable-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

.actio.yml
executors:
  hardened:
    runs-on: ubuntu-latest
    timeout-minutes: 20
jobs:
  test:
    executor: hardened
    steps:
      - run: npm test

See 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.

.actio.yml
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-integration
generated .yml
jobs:
  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-integration
.actio.yml
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-integration
generated .yml
jobs:
  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-integration

Call 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
ModeBehavior
fixHoist untrusted ${{ }} values into env and rewrite the run body. Default.
warnLeave the value inline, emit a build warning.
errorFail the build when an untrusted value is found.
offDisable hoisting entirely.
.actio.yml
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 (nofalse, 1:3090, 2024-01-01 → a date, 1_0001000). 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
ModeBehavior
fixSingle-quote emitted string scalars that a YAML 1.1 consumer would coerce. Default.
warnLeave them unquoted, emit a yaml-coercion-trap build warning per scalar.
offDisable the guard entirely.
.actio.yml
coercion: fix
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: echo hi
        env:
          DEPLOY: no # emitted as 'no' so it stays a string, not false

finally

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 the when: sugar
  • Compiles to: ordinary jobs with needs + an if: outcome guard

Prop

Type

.actio.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh
finally:
  on-failure:
    page:
      runs-on: ubuntu-latest
      steps:
        - run: ./page-oncall.sh

when: 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
.actio.yml
jobs:
  build:
    executor: [base, gpu]
    steps:
      - run: ./build.sh

Full 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
.actio.yml
jobs:
  test-unit:
    extends: [base-test, gpu-test]
    with:
      afterBuild: pnpm test-unit

See 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 needs and fromJSON() 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> }}.

.actio.yml
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…}; strategy and 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.

.actio.yml
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.sh

include/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.include list of objects; the comprehension source is erased
.actio.yml
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> and jobs.<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.
.actio.yml
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.sh
generated .yml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: ./build.sh
.actio.yml
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.sh
generated .yml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: ./build.sh

Compile-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> and jobs.<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
.actio.yml
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
.actio.yml
steps:
  - inject: setup
  - inject: greet
    with: { who: world }
  - run: npm test

See 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:

.actio.yml
steps:
  - inject: ./lib.actio.yml#setupNode
    with: { node: 20 }
  - run: npm test

Job 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:

.actio.yml
jobs:
  deploy:
    inject: ./lib.actio.yml#deployJob
    with: { env: prod }
    runs-on: ubuntu-24.04   # overrides the imported runner
  • Deep-merge (GitLab include semantics): 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: imported uses: 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.yml or .yaml, case-insensitive); #name is required; no @ref. Remote owner/repo@ref imports 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
OptionTypeDescription
varstringLoop variable name (required).
inlist / object / params.* / output exprSource to iterate (required).
parallelbooleanExpand to a job matrix instead of inline serial steps.
.actio.yml
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 sleep steps for delay)

Prop

Type

.actio.yml
steps:
  - run: flaky-command
    retry:
      attempts: 3
      delay: 10s

Full 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.outputs entries + needs on every consuming job

Prop

Type

.actio.yml
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 optional handle and a required outputs list
  • Compiles to: steps.<id>.outputs.<name> (same job) or needs.<job>.outputs.<name> (cross job), plus synthesized job.outputs and needs

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

.actio.yml
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[*] and jobs.<job_id> (job-level)
  • Type: list of steps (notify mode), or map with recover/steps and/or retry
  • Compiles to: the guarded step plus outcome-gated fallback steps; retry also synthesizes a <job>_retry job (needs: [<job>], if: failure()) that re-runs the step on the fallback runner

Prop

Type

.actio.yml
steps:
  - run: deploy
    fallback:
      recover: true
      steps:
        - run: ./rollback.sh
.actio.yml
steps:
  - run: ./flaky-build.sh
    fallback:
      retry:
        runs-on: ubuntu-latest-8-cores
        when-exit-code: 137

Full 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 (for true), or a wrapper that runs your script in a fresh child shell, captures its aggregate exit code, and remaps the listed codes to 0

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.

.actio.yml
steps:
  - run: ./flaky-check.sh
    soft_fail: [0, 42]
generated workflow
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: bash

Full 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-artifact step

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[*] and jobs.<job_id> (job-level)
  • Type: glob string, or list of glob strings
  • Compiles to: a shared dorny/paths-filter setup job plus a needs + folded if: guard on each gated step/job
.actio.yml
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
.actio.yml
steps:
  - run: echo "${{ github.sha }}"
    unsafe: true

See 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
.actio.yml
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 env and referenced there
.actio.yml
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.

HookStep-level guardJob-level guard
ensureif: always()if: always()
on-successsuccess() && steps.<id>.outcome == 'success'if: success()
on-failure!cancelled() && steps.<id>.outcome == 'failure'if: failure()
on-abortstep-level cancellationif: 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> and jobs.<job_id>.steps[*]
  • Type: list of steps
  • Compiles to: guarded steps with if: always()
.actio.yml
steps:
  - run: ./start-db.sh
    ensure:
      - run: ./stop-db.sh

Full guide: lifecycle.

on-success

Steps that run only when the target step or job succeeded.

  • Valid in: jobs.<job_id> and jobs.<job_id>.steps[*]
  • Type: list of steps
  • Compiles to: guarded steps (success(); step scope also checks outcome)
.actio.yml
steps:
  - run: ./build.sh
    on-success:
      - run: ./publish-artifact.sh

Full 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> and jobs.<job_id>.steps[*]
  • Type: list of steps
  • Compiles to: guarded steps (failure() / outcome check)
.actio.yml
steps:
  - run: ./build.sh
    on-failure:
      - run: ./upload-logs.sh

Full 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> and jobs.<job_id>.steps[*]
  • Type: list of steps
  • Compiles to: guarded steps (cancelled())
.actio.yml
steps:
  - run: ./long-task.sh
    on-abort:
      - run: ./cleanup.sh

Full guide: lifecycle.

See also

On this page