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:
- Macro expansion — the keywords Actio adds (the whole syntax reference) are transformed into plain workflow YAML.
- 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
yamlv2 — permissive, round-trippable, preservesrun: |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
| Package | Description |
|---|---|
actio-core | The engine: parse, passes, emit, validate, diagnostics |
actio-cli | The 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.