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).
Run actio build from anywhere — even inside .github/actio/ — and discovery walks
up to the actio.config.ts at the repo root.
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)
});import { defineConfig } from "actio-core";
export default defineConfig({
outDir: ".github/workflows",
validate: true,
header: true,
target: "legacy",
files: ["**/*.actio.yml"],
passes: [],
});{
"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 letslintand runtime annotations blame the.actio.ymlline 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:
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:
executors:
hardened: # actio-keep
runs-on: ubuntu-latestYAML 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:
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
coercionvalue supplied via the CLI (--coercion bogus) or the config file throws — it is a tooling-level error. An invalid in-source rootcoercion:key instead emits acoercion-mode-invalidwarning 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:
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.
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-parserembeds (GitHub's own schema is deliberately loose), solint: erroris 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:
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.
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:
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-jobpermissions:block, and (when the root declares none) a top-levelpermissions: {}deny-all default so unlisted jobs start from zero.check: audit a declared block against the computed minimum. An over-grant raises apermissions-over-grantwarning, escalated to a build-failing error underactio check/--check.checknever edits your output.
Extend or override the bundled action mapping with the object form:
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
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 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.
// 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] });