# Configuration (/docs/configuration)



For anything beyond flags — and to register **custom passes** — drop an
`actio.config` file in your project. Actio auto-discovers it by walking up from the
current directory, so it works from any subfolder. Point at a specific file with
`--config <file>` to skip discovery (handy when the config lives somewhere
discovery won't reach, like alongside your sources). Supported formats:
`actio.config.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs`, `.json` (TS/ESM are
loaded at runtime via [jiti](https://github.com/unjs/jiti) — no build step).

<Files>
  <Folder name="my-repo">
    <File name="actio.config.ts" className="text-fd-primary! bg-fd-primary/10!" />

    <Folder name=".github">
      <Folder name="actio">
        <File name="ci.actio.yml" />
      </Folder>
    </Folder>
  </Folder>
</Files>

Run `actio build` from anywhere — even inside `.github/actio/` — and discovery walks
up to the `actio.config.ts` at the repo root.

<Tabs items="['ts', 'js', 'json']">
  <Tab value="ts">
    ```ts twoslash title="actio.config.ts"
    import { defineConfig } from "actio-core"; // also re-exported from "actio-cli/config"

    export default defineConfig({
      outDir: ".github/workflows",
      validate: true,
      header: true,
      target: "legacy",
      files: ["**/*.actio.yml"], // `include` is accepted as an alias
      passes: [],                // custom transform passes (see below)
    });
    ```
  </Tab>

  <Tab value="js">
    ```js title="actio.config.js"
    import { defineConfig } from "actio-core";

    export default defineConfig({
      outDir: ".github/workflows",
      validate: true,
      header: true,
      target: "legacy",
      files: ["**/*.actio.yml"],
      passes: [],
    });
    ```
  </Tab>

  <Tab value="json">
    ```json title="actio.config.json"
    {
      "outDir": ".github/workflows",
      "validate": true,
      "header": true,
      "target": "legacy",
      "files": ["**/*.actio.yml"]
    }
    ```

    JSON can't express `passes` (transform functions) — reach for `.ts`/`.js` when you
    need custom passes.
  </Tab>
</Tabs>

`defineConfig()` is an identity helper — it exists purely for type-safe authoring
and autocompletion.

The full set of options, generated straight from the `ActioConfig` type in source so
this table can never drift from the code:

<AutoTypeTable path="../packages/core/src/config.ts" name="ActioConfig" />

### Output toggles [#output-toggles]

Three booleans control the extra material Actio emits alongside the workflow, each
with a matching `--no-*` CLI flag and each defaulting to **on**:

* **`header`** — write the generated-by banner comment at the top of each file
  (`--no-header`).
* **`sourceMap`** — emit the [source map](/docs/source-maps) sidecar next to the
  workflow, the file that lets `lint` and runtime annotations blame the `.actio.yml`
  line instead of the generated YAML (`--no-source-map`).
* **`annotate`** — inject the runtime annotation job that surfaces failures back on
  your source lines in the Actions UI; it requires the source map (`--no-annotate`).

Separately, &#x2A;*`injectionHoist`** is a *mode* (`fix` (default) | `warn` | `error` |
`off`), not a boolean — it sets the global default for the
[injection-hoist](/docs/macros/injection-hoist) security pass that defuses
`${{ }}` script-injection in `run:` blocks. Per-block `injection-hoist:` knobs
override it.

## Precedence [#precedence]

Settings resolve **explicit CLI flag → config file → built-in default**, in that
order. Passing `--out-dir build/` always wins over the config's `outDir`, which in
turn wins over the `.github/workflows` default. Positional file globs on the CLI
override `files`/`include` from the config.

## Dead-code diagnostics [#dead-code-diagnostics]

Actio flags `params`, `fragments`, and `executors` that are declared but never
referenced — the copy-paste smell that `actio check` is built to catch. Each unused
symbol gets a diagnostic pointing at its declaration. Tune the severity with
`unusedSymbols`:

```ts title="actio.config.ts"
export default defineConfig({
  unusedSymbols: "error", // off | warn (default) | error
});
```

`off` disables the check, `warn` reports a warning, and `error` fails the build —
handy in CI alongside `actio check`. The analysis is deliberately conservative: a
symbol referenced inside a `static-if` branch, injected into another step list, or
on any conditional path counts as used. Keep an intentionally-unused symbol by adding
a `# actio-keep` comment on its declaration:

```yaml title=".actio.yml"
executors:
  hardened: # actio-keep
    runs-on: ubuntu-latest
```

## YAML coercion guard [#yaml-coercion-guard]

Actio parses your source with the YAML **1.2** core schema, so bare scalars like
`no`, `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`). A string you wrote stays a string
in Actio yet can flip type once a 1.1 consumer reads it. The guard force-quotes
exactly the emitted scalars a 1.1 consumer would mis-type. Tune it with `coercion`:

```ts title="actio.config.ts"
export default defineConfig({
  coercion: "warn", // off | warn | fix (default)
});
```

`fix` single-quotes trap scalars on emit, `warn` leaves them unquoted and reports a
`yaml-coercion-trap` warning pointing at the offending value, and `off` disables the
guard. An in-source root `coercion:` key overrides the config. Genuine booleans and
numbers are never touched — only JS **strings** whose text matches a 1.1 trap
(the Norway booleans, binary `0b…`, sexagesimal `1:30`, ISO timestamps, and
underscore-grouped numerics like `1_000`).

This is defensive hardening against 1.1 consumers, not a fix for the Actions
*workflow* parser — that parser largely preserves these as strings in string
positions and rejects an unquoted trap loudly in strictly-typed positions (e.g.
`timeout-minutes`) rather than silently coercing your file.

> **Invalid-mode asymmetry.** An invalid `coercion` value supplied via the CLI
> (`--coercion bogus`) or the config file throws — it is a tooling-level error. An
> invalid in-source root `coercion:` key instead emits a `coercion-mode-invalid`
> warning and falls back to the inherited mode, so one stray source file never
> breaks a whole build.

## Strict YAML 1.2.2 source [#strict-yaml-122-source]

Actio opts into YAML **1.1** merge keys (`<<:`) when parsing, so you can fold one
mapping into another. They resolve and are erased at parse, so the emitted workflow
is already a pure YAML 1.2.2 subset with no `<<:` left in it. If you want your
`.actio.yml` *source* to stay pure 1.2.2 as well, turn on `strict`:

```ts title="actio.config.ts"
export default defineConfig({
  strict: true, // off by default
});
```

When enabled, every `<<:` merge key in source gets a `yaml-merge-key` warning that
points at the line and suggests a native reuse primitive in its place —
[`_anchors:`](/docs/syntax#_anchors) + `*alias` (plain YAML anchors, valid 1.2.2),
[`templates:`](/docs/syntax#templates), [`job-defaults`](/docs/macros/job-defaults),
`executors`, or `extends`. The `--strict` CLI flag turns it on for a single run and
overrides the config value.

This is a source linter only: it never changes the emitted YAML, and the default
stays permissive, so `<<:` keeps working unless you opt in. Plain YAML anchors and
aliases (`&name` / `*name`) are valid 1.2.2 core and are never flagged. Only the
1.1 merge key is.

## Output linting (`lint` → actionlint) [#output-linting-lint--actionlint]

`validate` checks the generated workflow against GitHub's JSON Schema (a structural
gate, on by default). `lint` adds a second, deeper pass: it runs the emitted YAML
through [actionlint](https://github.com/rhysd/actionlint) and maps every finding
back to the `.actio.yml` line that produced it, using the same source map that powers
runtime annotations. This is the "compiler, not text templating" promise. Actio
validates its own output and blames the source, not the generated file.

```ts title="actio.config.ts"
export default defineConfig({
  lint: "error", // off (default) | warn | error
});
```

`off` skips the lint entirely, `warn` reports findings as warnings (exit 0), and
`error` fails the build (pair it with `actio check` in CI). It is independent of the
schema-gating `validate` boolean, so you can run either, both, or neither.

actionlint is shelled out to from your `PATH`. If the binary isn't installed the
lint is skipped with a single informational note (never a hard failure), so a missing
linter can't break a build. Install actionlint to opt every contributor in. The
findings carry `actionlint-<rule>` codes and the original actionlint message.

> **Scope.** v1 relies on a locally installed `actionlint`. A bundled
> actionlint-wasm build (for the in-browser playground) is a tracked follow-up, not
> part of this release. Schema strictness is whatever `@actions/workflow-parser`
> embeds (GitHub's own schema is deliberately loose), so `lint: error` is the way to
> catch issues the schema waves through.

## Pinning (`uses:` → SHA) [#pinning]

`actio build` automatically freezes mutable `uses:` refs to immutable SHAs and
`docker://` tags to digests — see [supply-chain pinning](/docs/supply-chain) for how
resolution, the `actio.lock` lockfile, registries, and `--offline` behave. Configure
it with the `pin` block (on by default for third-party actions and `docker://`
images; `actions/*` and `github/*` stay on their tag) or the
`--pin`/`--no-pin`/`--pin-github`/`--offline` flags:

```ts title="actio.config.ts"
export default defineConfig({
  pin: {
    enabled: true,      // master switch
    thirdParty: true,   // pin non-first-party actions
    github: false,      // also pin actions/* and github/*
    docker: true,       // pin docker:// images
    allow: [],          // globs left unpinned, e.g. ["my-org/*"]
    comment: "tag",     // "tag" | "tag+date" | "none"
  },
});
```

Sugar: `pin: "all"` pins everything (first-party included); `pin: "off"` disables
pinning entirely.

## Artifact uploads (`artifacts.uploader`) [#artifacts]

The [`artifacts:`](/docs/syntax#artifacts) step macro expands into a trailing
`actions/upload-artifact` step. `artifacts.uploader` sets which action ref that
expanded step uses. The default is `actions/upload-artifact@v4`.

```ts title="actio.config.ts"
export default defineConfig({
  artifacts: {
    uploader: "actions/upload-artifact@v4", // action ref the macro emits
  },
});
```

The ref is emitted verbatim, then flows through [pinning](/docs/supply-chain) like
any other `uses:`, so under a pin policy it is rewritten to a SHA. Pin a tagged ref
(not a bare `actions/upload-artifact`): a versionless `uses:` floats to the action's
default branch and cannot be pinned. Point `uploader` at a fork or a mirror when you
need a different uploader, and it will be pinned the same way.

## Least-privilege permissions (`permissions`) [#permissions]

The [`permissions`](/docs/macros/permissions) policy computes the minimal
`GITHUB_TOKEN` scopes each job needs from its steps. It is **off by default** so it
never silently rewrites your output. Turn it on with a mode:

```ts title="actio.config.ts"
export default defineConfig({
  permissions: "infer", // off (default) | infer | check
});
```

* **`off`** (default): no-op. The pass does nothing and the generated YAML is byte
  for byte what it was before.
* **`infer`**: emit the computed per-job `permissions:` block, and (when the root
  declares none) a top-level `permissions: {}` deny-all default so unlisted jobs
  start from zero.
* **`check`**: audit a declared block against the computed minimum. An over-grant
  raises a `permissions-over-grant` warning, escalated to a build-failing error
  under `actio check` / `--check`. `check` never edits your output.

Extend or override the bundled action mapping with the object form:

```ts title="actio.config.ts"
export default defineConfig({
  permissions: {
    mode: "infer",
    inferRunScopes: false, // opt-in gh/GITHUB_TOKEN run: heuristic (default off)
    actions: {
      "my-org/deploy-action": { deployments: "write", "id-token": "write" },
      "actions/checkout": { contents: "read" }, // override a bundled entry
    },
  },
});
```

See the [permissions macro page](/docs/macros/permissions) for the full mapping
table, the diagnostics, and the safety invariant (unknown actions are never
silently narrowed). The CLI mirrors the mode with `--permissions <mode>`.

## Custom passes [#custom-passes]

The `passes` field is the headline feature: a pass you supply is **merged** into
the built-in pipeline (not replacing it), and the final order is still derived by
topologically sorting every pass's `runsAfter` — so your pass slots in wherever its
dependencies say it should. A pass is a `{ name, runsAfter?, apply }` descriptor;
`apply(ctx)` mutates `ctx.data` (the parsed workflow object) in place.

<AutoTypeTable path="../packages/core/src/passes/registry.ts" name="Pass" />

```ts title="actio.config.ts"
import { defineConfig, type Pass } from "actio-core";

// Stamp a global env var onto every generated workflow.
const stampEnv: Pass = {
  name: "stamp-env",
  runsAfter: ["fragments"],
  apply: (ctx) => {
    ctx.data.env = { BUILT_BY: "actio", ...(ctx.data.env as object) };
  },
};

export default defineConfig({
  passes: [stampEnv],
});
```

Now `actio build` runs `stampEnv` as part of the pipeline — no fork of core
required. Pass names must be unique (Actio throws on a collision with a built-in or
another custom pass).

After the last built-in or custom pass runs, Actio resolves final compile-time text
boundaries. That means custom passes can still emit scalar strings containing
`{{ params.* }}` or `{{ toJSON(params.*) }}` and let the public pipeline turn them
into literals. If you call lower-level helpers directly, use `transpile()`,
`runPasses()`, or `PassRegistry.run()` for complete compilation; `applyPasses()` is
only the raw pass-only stage and may leave compile-time interpolation unresolved.

<Callout title="Traversing jobs and steps?">
  Prefer the typed-IR **visitor helpers** — `workflow`, `visitJobs`, `visitSteps`,
  `transformSteps`, all exported from `actio-core` — over poking `ctx.data` by
  hand. They give you typed nodes and handle the awkward shape-checking for you.
</Callout>

```ts title="actio.config.ts"
// Preferred form once the typed-IR API is available.
import { defineConfig, transformSteps, type Pass } from "actio-core";

// Pin every actions/checkout to a specific SHA.
const pinCheckout: Pass = {
  name: "pin-checkout",
  runsAfter: ["fragments"],
  apply: (ctx) =>
    transformSteps(ctx, (step) =>
      step.uses?.startsWith("actions/checkout@")
        ? { ...step, uses: "actions/checkout@<sha>" }
        : step,
    ),
};

export default defineConfig({ passes: [pinCheckout] });
```


## Sitemap

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