Actio

Architecture

The compiler pipeline, the typed IR, provenance, and packages.

Actio is a compiler, not a runtime. You author a .actio.yml source — a strict superset of GitHub Actions workflow syntax — and actio build expands it into the verbose standard workflow YAML you'd otherwise hand-write. Two phases, both at build time:

  1. Macro expansion — the keywords Actio adds (the whole syntax reference) are transformed into plain workflow YAML.
  2. Compile-time interpolation{{ ... }} tokens are resolved and baked into the output; ${{ ... }} is left verbatim for the runner to evaluate. See interpolation tokens.

Nothing Actio adds survives into the generated file: zero runtime, zero lock-in. A macro-free .actio.yml is already a valid workflow, and you can walk away at any time by keeping the generated .yml. The rest of this page is the pipeline that does the compiling.

How it works

actio build [globs]
  discover .actio.yml files
  per file:
    1. parse with eemeli `yaml` (comment- and position-preserving)
    2. run transform passes in dependency order
    3. resolve final compile-time text boundaries (`{{ ... }}` interpolation)
    4. serialize back to standard YAML (block scalars preserved)
    5. prepend a "generated by Actio" header
    6. validate the OUTPUT with @actions/workflow-parser (official GHA schema)
    7. write .github/workflows/<name>.yml + <name>.yml.map  (or --stdout / --check)
  • Front-end: eemeli yaml v2 — permissive, round-trippable, preserves run: | block scalars. (The official parser validates during parse and would reject our macro keywords.)
  • Output validation: @actions/workflow-parser — the same parser behind the GitHub Actions language server. Every generated workflow is fed back through it, so Actio only ever emits a legal workflow.

Each transform is a pass — a { name, runsAfter?, apply } descriptor. The pipeline order is derived by topologically sorting each pass's runsAfter, not hand-maintained, so adding a feature is: drop in a new file, declare what it runs after, register it. PassRegistry (exported from actio-core) lets external code add or remove passes without editing core.

Built-in pass order today: params → call-templates → job-defaults → for-each → when-compile → fragments → share → retry → fallback → dynamic-matrix → lifecycle → if-changed → injection-hoist.

After every registered pass has run, Actio performs one final compile-time text boundary phase. This is where leftover {{ params.* }} and {{ toJSON(params.*) }} tokens in ordinary scalar text are turned into emitted literals. transpile(), runPasses(), and PassRegistry.run() all execute this complete public pipeline. applyPasses() is the lower-level escape hatch for tests or tooling that intentionally needs the raw pass-only stage; it can leave {{ ... }} tokens unresolved.

Typed IR and provenance

Passes operate on a small typed IR that wraps ctx.data rather than replacing it — the emitted YAML stays byte-for-byte identical. workflow(ctx), visitJobs, visitSteps, and the in-place transformSteps fan-out helper give passes (and third-party plugins) typed Job/Step views instead of raw Record<string, any>.

Every node can carry an origin — the source path/range it came from — held in a WeakMap side-table (ctx.origins) keyed by object identity, so it survives index shifts and never serializes. The visitor records origins on first sight; cloneNode and deriveNode propagate them so generated nodes (retry attempts and sleep steps, the dynamic-matrix setup job) map back to their macro source. This is the hook source maps build on. Look up an origin with originOf(ctx, node).

Packages

PackageDescription
actio-coreThe engine: parse, passes, emit, validate, diagnostics
actio-cliThe actio binary (wraps core)

actio-core exposes the programmatic TypeScript API. Prefer transpile() when you want generated workflow YAML, and prefer runPasses() or PassRegistry.run() when you already have a parsed context and want the complete transformed workflow model.

On this page