# Syntax reference (/docs/syntax)



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](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)),
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](/docs/macros/params) are the tutorial-style deep dives.

## Quick reference [#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`](#params)                                    | map of param defs          | stripped; `{{ params.* }}` resolved                |
| [`let`](#let)                                          | map of compile-time consts | stripped; `{{ let.* }}` resolved                   |
| [`reusable`](#reusable)                                | object                     | `on.workflow_call` + `workflow_dispatch`           |
| [`_anchors`](#_anchors)                                | anchor library             | stripped; `- *alias` step lists flattened in place |
| [`templates`](#templates) + [`inject … with`](#inject) | map of param'd step lists  | stripped; expanded in place                        |
| [`fragments`](#fragments) + `inject`                   | map of step lists          | **deprecated** — prefer `_anchors`/`templates`     |
| [`job-defaults`](#job-defaults)                        | map of job keys            | stripped; merged into every job                    |
| [`executors`](#executors)                              | map of presets             | stripped; selected via `executor`                  |
| [`call-templates`](#call-templates)                    | map of call presets        | stripped; merged into `extends:` jobs              |
| [`injection-hoist`](#injectionhoist)                   | enum                       | stripped; controls `${{ }}` hoisting in `run:`     |
| [`coercion`](#coercion)                                | enum                       | stripped; quotes YAML 1.1 coercion traps           |
| [`finally`](#finally)                                  | map                        | teardown jobs with `needs` + outcome guards        |

**Job keys**

| Keyword                                                             | Type                           | Compiles to                                    |
| ------------------------------------------------------------------- | ------------------------------ | ---------------------------------------------- |
| [`executor`](#executor)                                             | string or list                 | preset merged into the job                     |
| [`extends`](#extends)                                               | string or list                 | `call-templates` preset merged into a call job |
| [`dynamic-matrix`](#dynamic-matrix)                                 | map                            | setup job + `fromJSON` matrix wiring           |
| [`expand_matrix`](#expand-matrix)                                   | boolean                        | literal matrix unrolled into named jobs        |
| [`strategy.matrix` comprehension](#matrix-comprehension)            | `"{{ [expr for x in list] }}"` | evaluated to a native `matrix.include`         |
| [`static-if`](#static-if) / [`static-if(<expr>)`](#static-if-merge) | 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`](#inject)                                                          | string                  | fragment/template steps, or an imported step/job       |
| [`for-each`](#for-each)                                                      | map                     | repeated steps (or a matrix)                           |
| [`retry`](#retry)                                                            | integer or map          | chain of conditional attempts                          |
| [`share`](#share)                                                            | map of outputs          | `job.outputs` + `needs` wiring                         |
| [`ref`](#ref)                                                                | optional list or map    | inferred producer; `job.outputs` + `needs` wiring      |
| [`fallback`](#fallback)                                                      | list or map             | try/catch wiring                                       |
| [`soft_fail`](#soft_fail)                                                    | boolean or list         | `continue-on-error`, or a build-time exit-code wrapper |
| [`artifacts`](#artifacts)                                                    | map                     | trailing `actions/upload-artifact` step                |
| [`if-changed`](#if-changed)                                                  | glob string(s)          | `paths-filter` setup job + folded `if:` guard          |
| hoist overrides: [`unsafe`](#unsafe) · [`trust`](#trust) · [`force`](#force) | boolean / paths / paths | keep inline · keep inline · force into `env`           |

**Lifecycle hooks** (job + step)

| Keyword                                                                                                   | Type          | Compiles to                                                            |
| --------------------------------------------------------------------------------------------------------- | ------------- | ---------------------------------------------------------------------- |
| [`ensure`](#ensure) · [`on-success`](#on-success) · [`on-failure`](#on-failure) · [`on-abort`](#on-abort) | list of steps | guarded steps (`always()` · `success()` · `failure()` · `cancelled()`) |

## Interpolation tokens [#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.

<Callout title="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.
</Callout>

***

## Workflow keys [#workflow-keys]

{/* macro:params */}

### `params` [#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

<TypeTable
  type="{
  type: { type: 'string', description: 'Param kind: string, number, boolean, enum, object, stepList, or steps.', required: true },
  values: { type: 'string[]', description: 'Allowed values. Required when type is enum.' },
  default: { type: 'any', description: 'Value used when the param is not supplied at build time.' },
}"
/>

<CodeCompare>
  ```yaml title=".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 }}"
  ```

  ```yaml title="generated .yml"
  jobs:
    deploy:
      runs-on: ubuntu-latest
      steps:
        - run: echo "env=staging retries=3"
  ```
</CodeCompare>

Full guide: [params](/docs/macros/params).

{/* macro:let */}

### `let` [#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`](#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.

```yaml title=".actio.yml"
params:
  base: { type: number, default: 2 }
let:
  doubled: "{{ params.base * 2 }}"
  label: "build-{{ let.doubled }}"
```

{/* macro:reusable */}

### `reusable` [#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.

<CodeCompare>
  ```yaml title=".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 }}"
  ```

  ```yaml title="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 }}"
  ```
</CodeCompare>

Full guide: [reusable](/docs/macros/reusable).

{/* macro:fragments */}

### Step reuse: pick a primitive [#step-reuse]

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:`](#_anchors) + `- *alias`          | Native YAML; the aliased step list is flattened in place at compile time |
| Typed params                 | [`templates:`](#templates) + `inject ... with` | Compile-time args, type-checked                                          |
| Cross-file reuse             | `inject: ./lib#name`                           | Resolves via imported templates/modules                                  |
| Compute a value              | [`let`](#let) / `{{ ... }}`                    | Compile-time expressions                                                 |
| Wire job outputs to `needs`  | [`ref`](/docs/macros/ref)                      | Adds `needs:` + output plumbing                                          |
| (legacy) same-file step list | [`fragments:`](#fragments) + `inject`          | **Deprecated**: prefer `_anchors:`                                       |

### `fragments` [#fragments]

<Callout type="warn" title="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:`](#_anchors) with a
    `&name` anchor and call it with `- *name`.
  * **Params or cross-file:** use [`templates:`](#templates) + `inject ... with`, or
    cross-file `inject: ./lib#name`.

  Compiling a file that still uses `fragments:` emits a `fragment-deprecated`
  warning.
</Callout>

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

```yaml title=".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`](#inject) for the call site. Full guide: [fragments + inject](/docs/macros/fragments).

{/* macro:_anchors */}

### `_anchors` [#_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

```yaml title=".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](/docs/macros/anchors).

{/* macro:templates */}

### `templates` [#templates]

Parameterized, named lists of steps. Like [`fragments`](#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:`

```yaml title=".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`](#params) type system. See [`inject`](#inject) for the
call site. Full guide: [templates](/docs/macros/templates).

{/* macro:job-defaults */}

### `job-defaults` [#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

<TypeTable
  type="{
  if: { type: 'string', description: 'AND-combined with each job if condition.' },
  permissions: { type: 'map', description: 'Replaces the job permissions when the job omits its own.' },
  concurrency: { type: 'map', description: 'Replaces job concurrency on presence.' },
  'continue-on-error': { type: 'boolean', description: 'Inline job value wins when set.' },
  environment: { type: 'string | map', description: 'Inline job value wins when set.' },
  'timeout-minutes': { type: 'number', description: 'Inline job value wins when set.' },
  'runs-on': { type: 'string | string[]', description: 'Inline job value wins when set.' },
  env: { type: 'map', description: 'Deep-merged with job env.' },
  container: { type: 'string | map', description: 'Deep-merged with job container.' },
  services: { type: 'map', description: 'Deep-merged with job services.' },
  defaults: { type: 'map', description: 'Deep-merged with job defaults.' },
}"
/>

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

<CodeCompare>
  ```yaml title=".actio.yml"
  job-defaults:
    runs-on: ubuntu-latest
    timeout-minutes: 30
  jobs:
    test:
      steps:
        - run: npm test
  ```

  ```yaml title="generated .yml"
  jobs:
    test:
      runs-on: ubuntu-latest
      timeout-minutes: 30
      steps:
        - run: npm test
  ```
</CodeCompare>

<Callout title="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.
</Callout>

Full guide: [job-defaults](/docs/macros/job-defaults).

{/* macro:executors */}

### `executors` [#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

<TypeTable
  type="{
  'runs-on': { type: 'string | string[]', description: 'Runner label(s) for jobs using this executor.' },
  'timeout-minutes': { type: 'number', description: 'Default job timeout.' },
  permissions: { type: 'map', description: 'Default GITHUB_TOKEN permissions.' },
  concurrency: { type: 'map', description: 'Default concurrency group.' },
  defaults: { type: 'map', description: 'Default run settings such as shell and working-directory.' },
  container: { type: 'string | map', description: 'Default job container.' },
  services: { type: 'map', description: 'Default service containers.' },
  env: { type: 'map', description: 'Default job-level env.' },
}"
/>

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

See [`executor`](#executor) for the call site. Full guide: [executors](/docs/macros/executors).

{/* macro:call-templates */}

### `call-templates` [#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

<TypeTable
  type="{
  uses: { type: 'string', description: 'Reusable workflow reference. Inline job value wins when set.' },
  with: { type: 'map', description: 'Call inputs, shallow per-key: each inline with.<key> overrides the template; unspecified keys inherit.' },
  needs: { type: 'string | string[]', description: 'Order-preserving union of template and inline needs.' },
  secrets: { type: &#x22;map | 'inherit'&#x22;, description: &#x22;A string on either side (e.g. inherit) replaces; two maps shallow-merge per key.&#x22; },
  if: { type: 'string', description: 'AND-combined with the inline if condition.' },
}"
/>

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.

<CodeCompare>
  ```yaml title=".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
  ```

  ```yaml title="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
  ```
</CodeCompare>

<Callout title="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.
</Callout>

See [`extends`](#extends) for the call site.

{/* macro:injection-hoist */}

### `injection-hoist` [#injectionhoist]

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. &#x2A;*Default.** |
| `warn`  | Leave the value inline, emit a build warning.                                            |
| `error` | Fail the build when an untrusted value is found.                                         |
| `off`   | Disable hoisting entirely.                                                               |

```yaml title=".actio.yml"
injection-hoist: error
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: echo "${{ github.event.issue.title }}"
```

Per-step overrides live in [`unsafe`](#unsafe), [`trust`](#trust), and [`force`](#force).
Full guide: [injection-hoist](/docs/macros/injection-hoist).

{/* macro:coercion */}

### `coercion` [#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. &#x2A;*Default.** |
| `warn` | Leave them unquoted, emit a `yaml-coercion-trap` build warning per scalar.                   |
| `off`  | Disable the guard entirely.                                                                  |

```yaml title=".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
```

{/* macro:finally */}

### `finally` [#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

<TypeTable
  type="{
  'on-success': { type: 'job map', description: 'Teardown jobs that run only when the workflow succeeded.' },
  'on-failure': { type: 'job map', description: 'Teardown jobs that run only when the workflow failed.' },
  'on-abort': { type: 'job map', description: 'Teardown jobs that run only when the workflow was cancelled.' },
}"
/>

```yaml title=".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
```

<Callout title="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.
</Callout>

***

## Job keys [#job-keys]

{/* macro:executor */}

### `jobs.<job_id>.executor` [#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

```yaml title=".actio.yml"
jobs:
  build:
    executor: [base, gpu]
    steps:
      - run: ./build.sh
```

Full guide: [executors](/docs/macros/executors).

{/* macro:extends */}

### `jobs.<job_id>.extends` [#extends]

Name (or ordered list of names) from top-level [`call-templates:`](#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

```yaml title=".actio.yml"
jobs:
  test-unit:
    extends: [base-test, gpu-test]
    with:
      afterBuild: pnpm test-unit
```

See [`call-templates`](#call-templates) for the merge rules.

{/* macro:dynamic-matrix */}

### `jobs.<job_id>.dynamic-matrix` [#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

<TypeTable
  type="{
  script: { type: 'string', description: 'Path or inline command that prints matrix JSON to GITHUB_OUTPUT.', required: true },
  alias: { type: 'string', description: 'Rename the matrix entry key (default is matrix).' },
  mode: { type: 'string', description: 'Fan-out shape: include (raw {include:[...]} for per-item attributes) or alias. Inferred from alias when omitted.' },
  'runs-on': { type: 'string | string[]', description: 'Runner for the generated setup job.', default: 'ubuntu-latest' },
  shell: { type: 'string', description: 'Shell for the script: bash, sh, pwsh, powershell, or python.', default: 'bash' },
  compact: { type: 'boolean', description: 'Collapse the setup job into a compact form.', default: 'true' },
  checkout: { type: 'boolean', description: 'Auto checkout before a local-path script.', default: 'true' },
  'fail-fast': { type: 'boolean', description: 'Cancel sibling legs when one matrix leg fails.', default: 'false' },
  id: { type: 'string', description: 'Explicit id for the generated setup job.' },
}"
/>

For heterogeneous fan-out (per-item `runs-on`/`env`), have `script` print
`{include:[...]}` (include mode); each entry's keys are read as `${{ matrix.<key> }}`.

```yaml title=".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](/docs/macros/dynamic-matrix).

{/* macro:expand_matrix */}

### `jobs.<job_id>.expand_matrix` [#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.

```yaml title=".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`](#dynamic-matrix)), and >256 legs is a compile-time error (GitHub's
per-run cap). Full guide: [expand-matrix](/docs/macros/expand-matrix).

{/* macro:matrix-comprehension */}

### `strategy.matrix` comprehension [#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`](#params) and [`let`](#let), so nothing Actio-specific
survives into the workflow (no setup job, no `fromJSON()`), unlike the runtime
[`dynamic-matrix`](#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

```yaml title=".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`](#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.

{/* macro:static-if */}

### `jobs.<job_id>.static-if` [#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.

<CodeCompare>
  ```yaml title=".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
  ```

  ```yaml title="generated .yml"
  jobs:
    build:
      runs-on: ubuntu-latest
      steps:
        - run: ./build.sh
  ```
</CodeCompare>

<Callout title="Compile-time only">
  `static-if` cannot reference runtime contexts like `github.*`, `env.*`, or
  `secrets.*`, and rejects `${{ }}` wrappers. For runtime conditions, use the
  standard [`if:`](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idif)
  key instead.
</Callout>

Full guide: [static-if](/docs/macros/static-if).

{/* macro:static-if() */}

### `static-if(<expr>)` [#static-if-merge]

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

```yaml title=".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](/docs/macros/static-if).

***

## Step keys [#step-keys]

{/* macro:inject */}

### `jobs.<job_id>.steps[*].inject` [#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`](#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

```yaml title=".actio.yml"
steps:
  - inject: setup
  - inject: greet
    with: { who: world }
  - run: npm test
```

See [`fragments`](#fragments) and [`templates`](#templates). Full guide: [templates](/docs/macros/templates).

#### Cross-file import: `inject: ./lib.actio.yml#name` [#inject-cross-file]

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

```yaml title=".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:

```yaml title=".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).

{/* macro:for-each */}

### `jobs.<job_id>.for-each` / `jobs.<job_id>.steps[*].for-each` [#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. |

```yaml title=".actio.yml"
steps:
  - for-each:
      var: svc
      in: [api, web, worker]
    steps:
      - run: ./deploy.sh {{ svc }}
```

<Callout type="info" title="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.
</Callout>

Full guide: [for-each](/docs/macros/for-each).

{/* macro:retry */}

### `jobs.<job_id>.steps[*].retry` [#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`)

<TypeTable
  type="{
  attempts: { type: 'number', description: 'Total attempts, minimum 2.', required: true },
  delay: { type: 'string | number', description: 'Backoff between attempts, e.g. 10s, 2m, 1h, or seconds as a number.' },
}"
/>

```yaml title=".actio.yml"
steps:
  - run: flaky-command
    retry:
      attempts: 3
      delay: 10s
```

Full guide: [retry](/docs/macros/retry).

{/* macro:share */}

### `jobs.<job_id>.steps[*].share` [#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

<TypeTable
  type="{
  value: { type: 'string', description: 'Static value to publish (value-form).' },
  run: { type: 'string', description: 'Command whose stdout is captured and published (capture-form).' },
  required: { type: 'boolean', description: 'Fail the build if the output is empty.' },
  json: { type: 'boolean', description: 'Parse the captured value as JSON before exposing it.' },
  type: { type: 'string', description: 'Assert the output type: string, number, boolean, enum, or object.' },
}"
/>

```yaml title=".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](/docs/macros/share).

{/* macro:ref */}

### `jobs.<job_id>.steps[*].ref` [#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.

<TypeTable
  type="{
  handle: { type: 'string', description: 'Explicit handle (map form only). Defaults to a slug of the step name, then the step id. A same-job collision is a compile error.' },
  outputs: { type: 'string[]', description: 'Output names this step publishes (explicit form only). The value of the positional shorthand ref: [..].', required: true },
}"
/>

```yaml title=".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](/docs/macros/ref).

{/* macro:fallback */}

### `jobs.<job_id>.steps[*].fallback` [#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

<TypeTable
  type="{
  steps: { type: 'step[]', description: 'Steps to run when the guarded step fails.', required: true },
  recover: { type: 'boolean', description: 'Let the job pass: guarded step gets continue-on-error and the fallback keys off outcome.', default: 'false' },
  retry: { type: 'map', description: 'Re-run the guarded step in a sibling job on a different runner when it fails. Keys: runs-on (required), when-exit-code (run-only, POSIX shell only).' },
}"
/>

```yaml title=".actio.yml"
steps:
  - run: deploy
    fallback:
      recover: true
      steps:
        - run: ./rollback.sh
```

```yaml title=".actio.yml"
steps:
  - run: ./flaky-build.sh
    fallback:
      retry:
        runs-on: ubuntu-latest-8-cores
        when-exit-code: 137
```

Full guide: [fallback](/docs/macros/fallback).

{/* macro:soft_fail */}

### `jobs.<job_id>.steps[*].soft_fail` [#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.

```yaml title=".actio.yml"
steps:
  - run: ./flaky-check.sh
    soft_fail: [0, 42]
```

```yaml title="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](/docs/macros/soft-fail).

{/* macro:artifacts */}

### `jobs.<job_id>.steps[*].artifacts` [#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](/docs/supply-chain) 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

<TypeTable
  type="{
  paths: { type: 'string | string[]', description: &#x22;Glob, or list of globs, to upload. A list joins into upload-artifact's multiline path input.&#x22;, required: true },
  name: { type: 'string', description: 'Artifact name. Omit to let Actio derive a unique, deterministic name from the job id and step.' },
  if: { type: 'string', description: 'Condition on the upload step.', default: 'always()' },
  'retention-days': { type: 'number', description: 'Days to retain the artifact (1-90).' },
}"
/>

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`](/docs/configuration#artifacts).

Full guide: [artifacts](/docs/macros/artifacts).

{/* macro:if-changed */}

### `jobs.<job_id>.steps[*].if-changed` [#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`](https://github.com/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

```yaml title=".actio.yml"
steps:
  - name: Build docs
    run: ./build-docs.sh
    if-changed:
      - 'docs/**'
      - '*.md'
```

Full guide: [if-changed](/docs/macros/if-changed).

{/* macro:unsafe */}

### `jobs.<job_id>.steps[*].unsafe` [#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

```yaml title=".actio.yml"
steps:
  - run: echo "${{ github.sha }}"
    unsafe: true
```

See [`injection-hoist`](#injectionhoist). Full guide: [injection-hoist](/docs/macros/injection-hoist).

{/* macro:trust */}

### `jobs.<job_id>.steps[*].trust` [#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

```yaml title=".actio.yml"
steps:
  - run: echo "${{ github.event.pull_request.title }}"
    trust: [github.event.pull_request.title]
```

See [`injection-hoist`](#injectionhoist). Full guide: [injection-hoist](/docs/macros/injection-hoist).

{/* macro:force */}

### `jobs.<job_id>.steps[*].force` [#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

```yaml title=".actio.yml"
steps:
  - run: echo "$TITLE"
    force: [github.event.issue.title]
```

See [`injection-hoist`](#injectionhoist). Full guide: [injection-hoist](/docs/macros/injection-hoist).

***

## Lifecycle hooks [#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()` |

{/* macro:ensure */}

### `ensure` [#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()`

```yaml title=".actio.yml"
steps:
  - run: ./start-db.sh
    ensure:
      - run: ./stop-db.sh
```

Full guide: [lifecycle](/docs/macros/lifecycle).

{/* macro:on-success */}

### `on-success` [#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`)

```yaml title=".actio.yml"
steps:
  - run: ./build.sh
    on-success:
      - run: ./publish-artifact.sh
```

Full guide: [lifecycle](/docs/macros/lifecycle).

{/* macro:on-failure */}

### `on-failure` [#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)

```yaml title=".actio.yml"
steps:
  - run: ./build.sh
    on-failure:
      - run: ./upload-logs.sh
```

Full guide: [lifecycle](/docs/macros/lifecycle).

{/* macro:on-abort */}

### `on-abort` [#on-abort]

Steps that run on cancellation. At step scope this only sees step-level cancellation;
run-level cancel belongs in a workflow [`finally:`](#finally).

* **Valid in:** `jobs.<job_id>` and `jobs.<job_id>.steps[*]`
* **Type:** list of steps
* **Compiles to:** guarded steps (`cancelled()`)

```yaml title=".actio.yml"
steps:
  - run: ./long-task.sh
    on-abort:
      - run: ./cleanup.sh
```

Full guide: [lifecycle](/docs/macros/lifecycle).

## See also [#see-also]

<Cards>
  <Card title="Macros" href="/docs/macros/params" description="Tutorial-style walkthroughs of each keyword." />

  <Card title="Configuration" href="/docs/configuration" description="Config file, precedence, and custom passes." />

  <Card title="Editor support" href="/docs/editor-support" description="JSON Schema autocomplete + the modeline." />

  <Card title="API reference" href="/docs/api" description="Generated TypeDoc for the compiler internals." />
</Cards>


## Sitemap

Browse the full documentation: [Markdown sitemap](https://austenstone.github.io/actio/sitemap.md) · [XML sitemap](https://austenstone.github.io/actio/sitemap.xml)