Skip to content

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:

  1. Runs your program under Node — node <file>. Your program must print the canonical workflow JSON to stdout (you get that by calling emit(pipeline) from @tovio/pipeline).
  2. Parses that stdout into the shared Workflow model — the identical model YAML parses to.
  3. Compiles it through the pure tovio-ci-core compiler: validates the DAG (no jobs / duplicate ids / unknown needs / cycles / malformed matrix are all rejected) and content-addresses it — blake3:<hex> over the canonical CBOR of the workflow.
  4. 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 --check and tovio ci check read 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/.yaml from your committed tree). See Generating runnable YAML below. Use --emit both to 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:

  • --out derives 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-out defaults to .github/workflows/<stem>.yml under the repo root (ci.pipeline.mjs → .github/workflows/ci.yml).
  • --name is a fallback, not an override of the pipeline itself. If your program's workflow declares a non-empty name, that name wins. --name (or, absent it, the file stem) is used only when the pipeline is unnamed.

Example

tovio ci compile pipelines/ci.pipeline.mjs
✓ compiled CI · 2 jobs
    lockfile → pipelines/ci.pipeline.lock.json  (blake3:9f3c1e…)

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:

tovio ci compile pipelines/ci.pipeline.mjs --emit both --fix-ignore
✓ 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:

tovio ci compile pipelines/ci.pipeline.mjs --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:

tovio ci compile pipelines/ci.pipeline.mjs --check
✓ pipelines/ci.pipeline.lock.json is up to date (blake3:9f3c1e…)

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:

tovio ci compile pipelines/ci.pipeline.mjs --emit yaml --fix-ignore
✓ compiled CI · 2 jobs
    workflow → .github/workflows/ci.yml

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)) == wf for any SDK pipeline; a model state YAML can't express (reusable-workflow uses:, container/services bodies, an unresolved matrix) fails with TVO-CI-001 rather 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 .yml lacking the marker unless --force.
  • GitHub event names — author .on(...) with GitHub events (pull_request, push, …); the SDK rejects the dotted TOVIO change.* 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:

tovio config set ci.generate on

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 ` (…). Install Node.js (>=18)…`
Program exits non-zero config-as-code program failed → \node ` exited non-zero:` followed by the program's stderr
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 \` needs unknown job ```
Cyclic needs config-as-code pipeline failed to compile (DAG cycle)
Malformed matrix … job \` declares a matrix with no axes/… has a matrix axis with an empty name/… matrix axis `` has no values`

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.json from some other tool (no .pipeline.lock.json name and no tovio_pipeline_lock discriminant) is skipped silently — not your pipeline.
  • A genuine TOVIO lockfile that is corrupt (truncated, merge-conflict markers) is reported, not silently dropped — so ci check never 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

tovio ci check
✓ 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:

No workflows or config-as-code pipelines found (.github/workflows/*.yml or *.pipeline.lock.json)

--json output

tovio ci check --json
{
  "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