CLI reference — tovio ci compile and tovio ci check¶
Two subcommands live under tovio ci. tovio ci compile turns a config-as-code pipeline program into a committed lockfile and — with --emit yaml — a real runnable workflow; tovio ci check reports how your pipelines — YAML and config-as-code — classify on TOVIO compute. Both accept the global --json and --quiet flags.
The design guarantee behind both commands: the Forge never runs your pipeline code. The one and only place your program executes is your own machine, under tovio ci compile. What the Forge schedules is a .github/workflows/*.yml — either one you hand-write, or one tovio ci compile --emit yaml generates from your SDK program (the compiled lockfile is the client-side staleness record, not what the Forge runs). Both are the same structural Workflow model. (For what the @tovio/pipeline SDK program looks like, see the SDK section. For how jobs and steps are graded native / shimmed / container / unsupported, see the compatibility section.)
tovio ci compile <file>¶
Compiles a @tovio/pipeline SDK program into a lockfile. Concretely, it:
- Runs your program under Node —
node <file>. Your program must print the canonical workflow JSON to stdout (you get that by callingemit(pipeline)from@tovio/pipeline). - Parses that stdout into the shared
Workflowmodel — the identical model YAML parses to. - Compiles it through the pure
tovio-ci-corecompiler: validates the DAG (no jobs / duplicate ids / unknownneeds/ cycles / malformed matrix are all rejected) and content-addresses it —blake3:<hex>over the canonical CBOR of the workflow. - Writes the lockfile (unless
--check, which compares instead of writing — see below).
Because compilation happens entirely client-side, no server-side sandbox is needed; the Forge never runs your program.
It can emit two artifacts, chosen with --emit:
- the lockfile (default) — the deterministic content-addressed record
--checkandtovio ci checkread to prove your source and committed artifacts agree. The Forge does not schedule from the lockfile. - a real GitHub-Actions workflow (
--emit yaml) —.github/workflows/<stem>.yml, byte-for-byte the same file a YAML author would write. This is the artifact the Forge actually schedules and runs (its scheduler reads only.yml/.yamlfrom your committed tree). See Generating runnable YAML below. Use--emit bothto write and gate both at once.
Flags¶
| Flag | Type | Default | Meaning |
|---|---|---|---|
<file> |
path (positional) | — | The SDK pipeline program, e.g. pipelines/ci.pipeline.mjs. Must print the canonical workflow JSON to stdout. |
--emit <target> |
lockfile | yaml | both |
lockfile |
Which artifact(s) to produce. yaml/both serialize the compiled model to a real .github/workflows/*.yml. |
--out <path> |
path | source path with its final extension replaced by .lock.json |
Where to write the lockfile. |
--yaml-out <path> |
path | .github/workflows/<stem>.yml |
Where to write the generated workflow (with --emit yaml/both). |
--check |
flag | off | Recompile and verify the committed artifact(s) are current. Writes nothing; a stale or missing artifact is an error. Honors --emit. |
--fix-ignore |
flag | off | If .github/ is ignored (the default), add !.github/ to .tovioignore so the generated workflow is tracked, instead of only warning. |
--force |
flag | off | Overwrite a same-path .yml even if it lacks the # Generated by tovio ci compile marker (a hand-authored workflow is otherwise never clobbered). |
--name <name> |
string | the source file stem | The name used when the pipeline declares no name: of its own. |
Notes on the defaults:
--outderives from the source path:pipelines/ci.pipeline.mjs→pipelines/ci.pipeline.lock.json(only the last extension —.mjs/.ts/.js— is swapped for.lock.json).--yaml-outdefaults to.github/workflows/<stem>.ymlunder the repo root (ci.pipeline.mjs→.github/workflows/ci.yml).--nameis a fallback, not an override of the pipeline itself. If your program's workflow declares a non-emptyname, that name wins.--name(or, absent it, the file stem) is used only when the pipeline is unnamed.
Example¶
With --emit both, a second line names the generated workflow. Anything the .github/ ignore check has to say prints first, before the compiled block — here, --fix-ignore re-including the directory:
✓ re-included `.github/` in .tovioignore
✓ compiled CI · 2 jobs
lockfile → pipelines/ci.pipeline.lock.json (blake3:9f3c1e…)
workflow → .github/workflows/ci.yml
Without --fix-ignore, that first line is instead two ⚠ advisories naming the fix — see Generating runnable YAML. Either way the check runs only when you are emitting YAML; a lockfile-only compile never touches .tovioignore.
With --json:
The document is wrapped in the CLI's result envelope, keys sorted, and carries both artifact paths — the one you did not emit is null:
{
"result": {
"compiled": true,
"content_address": "blake3:9f3c1e…",
"jobs": 2,
"lockfile": "pipelines/ci.pipeline.lock.json",
"name": "CI",
"source": "pipelines/ci.pipeline.mjs",
"yaml": null
}
}
The lockfile¶
The lockfile is deterministic JSON. Commit it — it is the staleness-and-provenance record that lets --check (below) and tovio ci check prove your source and your compiled artifacts agree. (It is not what the Forge schedules — that is the generated .yml; see Generating runnable YAML.) Its fields:
| Field | Meaning |
|---|---|
tovio_pipeline_lock |
Lockfile schema version (currently 1). Also the discriminant that marks a .lock.json as a TOVIO pipeline lock. |
name |
The workflow name — the pipeline's own name, or the --name/stem fallback. |
source |
The SDK file path that produced this lock, repo-relative with forward slashes. Provenance only — not part of the content address. |
content_address |
blake3:<hex> over the canonical CBOR of workflow. Recompiling the same pipeline yields the same address, so drift is detectable. It addresses the compiled model, not the .mjs source text — formatting of your program is irrelevant to the DAG. |
workflow |
The canonical structural workflow — the exact model the Forge lowers to a DAG. |
Abbreviated — the file on disk serializes the model's fields including their defaults, so each trigger carries its empty filter arrays and each job and step its nulls. (The exception is a step's with:/env:, which are skipped when empty so that adding the fields left every pre-existing model byte-stable.) The shape is:
{
"tovio_pipeline_lock": 1,
"name": "CI",
"source": "pipelines/ci.pipeline.mjs",
"content_address": "blake3:9f3c1e…",
"workflow": {
"name": "CI",
"triggers": [{ "event": "push", "branches": [], "…": [] }],
"jobs": [
{ "id": "build", "runs_on": ["ubuntu-latest"], "steps": [{ "run": "cargo build", "…": null }] },
{ "id": "test", "needs": ["build"], "steps": [{ "run": "cargo test", "…": null }] }
]
}
}
verify() — the content address binds the body. Every reader of an on-disk lockfile re-derives the address from workflow and confirms it equals the stored content_address (the repo's "re-hash on read" invariant). A hand-edited lockfile — where someone swaps the workflow body but keeps the old address — fails this check and is rejected with TVO-CI-001, rather than being trusted downstream. Both --check and tovio ci check run verify() before they trust a lock.
--check: the pre-commit / CI gate¶
tovio ci compile <file> --check recompiles from source and compares against the committed lockfile. It writes nothing and exits non-zero (with TVO-CI-001) if:
- the lockfile is missing,
- the committed lockfile is corrupt or tampered (fails
verify()— its stored address does not bind its body), or - the lockfile is stale — the source now compiles to a different content address than the committed lock.
On success it exits zero:
A stale lock looks like this (non-zero exit):
TVO-CI-001: lockfile is out of date
pipelines/ci.pipeline.lock.json is stale (committed blake3:9f3c1e…, source now
compiles to blake3:4b7a02…) — run `tovio ci compile`
Wire it into CI (or a pre-commit hook) so a pipeline can never be edited without its artifacts being regenerated. Gate both the lockfile and the generated workflow with --emit both --check:
# .github/workflows/pipeline-lint.yml — hand-authored, independent of the generated files
name: pipeline-lint
"on": [pull_request, push]
jobs:
pipelines-fresh:
runs-on: ubuntu-latest
steps:
- run: tovio ci compile pipelines/ci.pipeline.mjs --emit both --check
- run: tovio ci compile pipelines/deploy.pipeline.mjs --emit both --check
Once a runner is connected (see the CI/CD overview), a required pipeline-lint check blocks a land whenever a committed .yml or lockfile drifts from its source. Keep this file hand-authored so it can gate the generated ones. Note that only the pull_request half of that "on": fires today — nothing schedules a run for a land.
Generating runnable YAML (--emit yaml)¶
The lockfile proves your source is intact, but the Forge schedules from .github/workflows/*.yml, not the lockfile. To make a config-as-code pipeline actually run, emit a real workflow:
The generated file is a normal GitHub-Actions workflow — the identical artifact a YAML author would hand-write — that flows through the unchanged YAML path (trigger mapping, the compatibility ladder, scheduling, gates), opening with a provenance marker:
# Generated by `tovio ci compile` from pipelines/ci.pipeline.mjs — do not edit; edit the source and recommit.
name: CI
"on":
pull_request: ~
push: ~
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo build
Four properties make it safe to commit and regenerate:
- Byte-deterministic — re-emitting an unchanged pipeline is byte-identical (fixed key order), so the file's content address is a genuine staleness signal and regeneration never triggers a spurious re-run.
- Lossless for config-as-code, loud otherwise —
parse(generate(wf)) == wffor any SDK pipeline; a model state YAML can't express (reusable-workflowuses:, container/services bodies, an unresolved matrix) fails withTVO-CI-001rather than emitting a lossy file. It is not a general round-trip of an arbitrary hand-written workflow. - Never clobbers hand-written YAML — refuses to overwrite a same-path
.ymllacking the marker unless--force. - GitHub event names — author
.on(...)with GitHub events (pull_request,push, …); the SDK rejects the dotted TOVIOchange.*names, which would parse but map to no triggers and silently never run.
TOVIO ignores dot-directories by default, so .github/ is untracked until you add !.github/ — --fix-ignore does that for you (otherwise --emit yaml warns and points at the fix).
Auto-regenerate on commit (ci.generate)¶
Enable per clone so tovio commit regenerates every config-as-code workflow automatically, keeping the .yml in lock-step with its source:
Each commit — before the working-copy snapshot — recompiles every tracked *.pipeline.mjs into its .yml and captures the fresh file in the same change (source + generated workflow commit atomically). It is fail-closed: a pipeline that fails to compile aborts the commit; a target under an ignored .github/ is a hard commit error naming the remedy. tovio commit --no-generate (or TOVIO_NO_GENERATE=1) skips it, and it is skipped for agent (--token) commits. Because ci.generate is repo-local (not synced), it is a per-developer convenience — the pipeline-lint CI gate above is the cross-team enforcement.
Error cases¶
Every failure from tovio ci compile surfaces the code TVO-CI-001 with a non-zero exit and a specific remediation:
| Situation | Message (summary → detail) |
|---|---|
| Source file absent | pipeline source not found → no file at <path> |
| Node.js not installed | Node.js is required to compile a config-as-code pipeline → could not run \node |
| Program exits non-zero | config-as-code program failed → \node |
stdout isn't a workflow (e.g. you forgot emit()) |
config-as-code program did not emit a valid pipeline → the program's stdout is not a workflow JSON document (…). Did you call \emit(pipeline)` from @tovio/pipeline?` |
| No jobs | config-as-code pipeline failed to compile → config-as-code pipeline declares no jobs |
| Empty / duplicate job id | … a job has an empty id / … duplicate job id \ |
needs a job that doesn't exist |
… job \ |
Cyclic needs |
config-as-code pipeline failed to compile (DAG cycle) |
| Malformed matrix | … job \/… has a matrix axis with an empty name/… matrix axis ` |
The matrix and unknown-needs checks are deliberately stricter than YAML: config-as-code is a fresh typed surface, so a typo'd needs or an empty matrix axis fails loudly at compile time instead of silently stranding or dropping a job at run time.
tovio ci check¶
Reports the compatibility of every pipeline in your working copy — both .github/workflows/*.yml|*.yaml and every committed config-as-code lockfile — and grades each job and step. This command is read-only; it takes no repo lock.
Lockfiles are discovered (non-recursively) in the natural places tovio ci compile writes them: .github/workflows/, pipelines/, .tovio/pipelines/, and the repo root. Because a lockfile lowers to the same Workflow model YAML does, it is classified exactly as YAML is — same compat report, front-end-agnostic.
Robustness of the lockfile pass:
- A
.lock.jsonfrom some other tool (no.pipeline.lock.jsonname and notovio_pipeline_lockdiscriminant) is skipped silently — not your pipeline. - A genuine TOVIO lockfile that is corrupt (truncated, merge-conflict markers) is reported, not silently dropped — so
ci checknever falsely claims "no pipelines" over a broken one. - A tampered lockfile (body edited, address preserved) is caught by
verify()and reported, never trusted.
An unparseable pipeline is always reported-and-skipped, never fatal.
tovio ci check takes no positional args and no flags of its own — only the global ones (--json, --quiet, and the flags every command carries).
Human output¶
✓ CI (.github/workflows/ci.yml)
[ok ] job build
native step 0
[ok ] job test
native step 0
✓ CI (pipelines/ci.pipeline.lock.json) · config-as-code
[ok ] job build
native step 0
⚠ pipelines/deploy.pipeline.lock.json — TVO-CI-001: corrupt pipeline lockfile: … (skipped) · config-as-code
A step's label is its name: when it declares one and step <index> when it doesn't; a shimmed step appends the resolved action with its @version stripped ([actions/checkout]), a container step appends [needs a DinD runner], and an unsupported step appends — <reason>. A job's advisory notes print above its steps, each on a · line.
Config-as-code pipelines are tagged · config-as-code; YAML stays untagged. When nothing is found:
--json output¶
{
"result": {
"workflows": [
{
"fully_supported": true,
"kind": "yaml",
"path": ".github/workflows/ci.yml",
"report": { "workflow": "CI", "jobs": [ … ], "unsupported_triggers": [] }
},
{
"fully_supported": true,
"kind": "config-as-code",
"path": "pipelines/ci.pipeline.lock.json",
"report": { "workflow": "CI", "jobs": [ … ], "unsupported_triggers": [] }
},
{
"error": "TVO-CI-001: corrupt pipeline lockfile: …",
"kind": "config-as-code",
"path": "pipelines/deploy.pipeline.lock.json"
}
]
}
}
Each entry in workflows[] carries:
| Field | Meaning |
|---|---|
path |
Repo-relative path (forward slashes). |
kind |
"yaml" or "config-as-code". |
report |
The compat report (present when the pipeline parsed and verified). See the compatibility section for its shape. |
fully_supported |
true only when every job's runner is supported, every step is anything but unsupported, and there are no unsupported triggers. Any one of the three takes a workflow out of the green. |
error |
Present instead of report when the pipeline failed to parse or verify — the reason string. |
Last reviewed September 9, 2026
Suggest an improvement to this page Not for security reports — see disclosure