Actio

Configuration

Config file, precedence, and custom transform passes.

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 — no build step).

actio.config.ts
ci.actio.yml

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

actio.config.ts
import {  } from "actio-core"; // also re-exported from "actio-cli/config"

export default ({
  : ".github/workflows",
  : true,
  : true,
  : "legacy",
  : ["**/*.actio.yml"], // `include` is accepted as an alias
  : [],                // custom transform passes (see below)
});
actio.config.js
import { defineConfig } from "actio-core";

export default defineConfig({
  outDir: ".github/workflows",
  validate: true,
  header: true,
  target: "legacy",
  files: ["**/*.actio.yml"],
  passes: [],
});
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.

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:

Prop

Type

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 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, injectionHoist is a mode (fix (default) | warn | error | off), not a boolean — it sets the global default for the injection-hoist security pass that defuses ${{ }} script-injection in run: blocks. Per-block injection-hoist: knobs override it.

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

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:

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:

.actio.yml
executors:
  hardened: # actio-keep
    runs-on: ubuntu-latest

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 (nofalse, 1:3090, 2024-01-01→a date, 1_0001000). 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:

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

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:

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: + *alias (plain YAML anchors, valid 1.2.2), templates:, 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)

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

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)

actio build automatically freezes mutable uses: refs to immutable SHAs and docker:// tags to digests — see supply-chain pinning 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:

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)

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

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

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

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:

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

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.

Prop

Type

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.

Traversing jobs and steps?

Prefer the typed-IR visitor helpersworkflow, 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.

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] });

On this page