Skip to content

CI/CD

TOVIO models your CI/CD as a durable graph of jobs, defined either in familiar GitHub Actions YAML or as typed config-as-code with the @tovio/pipeline SDK — both compile to the same pipeline and appear in the same web UI.

Read this first — what actually runs today.

No executor is connected by default. A TOVIO Forge ships with no CI job dispatcher provisioned, and there is no hosted executor to fall back on. The front half is real — workflows are discovered, parsed, content-addressed, classified, mapped onto change events, and built into a needs DAG whose checks gate the land — but until you connect a customer-operated (BYO) runner, nothing executes a job: every scheduled CI check resolves to failure on the first worker tick, and because the land gate is default-closed that blocks every land on a lane with a required CI check. Keep CI checks out of your lanes' required_checks until a runner is connected.

A CI runner is not a sandbox. The customer-operated (BYO) runner executes run: steps as an ordinary child process, under the runner's own user account, with unrestricted network access — no VM, container, namespace, or egress control. The step's environment is built from a small allowlist rather than inherited from the runner's shell, so a credential in the operator's environment does not reach a step — but a credential on the host's filesystem does. Give it a dedicated host and no credential you would not hand to the author of every change it builds. A step that backgrounds a process (run: some-server &) leaves it running after the step and after the job, so recycle the host or use a disposable one per job.

No manual deploy gate is attached yet. The pipeline model has a ManualApproval gate and the Forge serves approve/reject routes for a parked gate, but the scheduler that turns a proposal into a run admits every node Automatic today — a job with an environment: is scheduled like any other job, and nothing parks it for approval. Read the gate material below as the model's design; the web UI's demo data shows the intended behaviour.

A supported subset, not GitHub parity. ${{ }} expressions are not evaluated, with exactly one exception: a bare ${{ secrets.NAME }} in a run: script is substituted by the runner after a clearance check. Everywhere else a secret reference fails the step closed rather than being interpolated or dropped — inside a compound expression, and in a step's env: or with:, which is the shape most real workflows use. if: conditions are captured but never evaluated, the $GITHUB_OUTPUT / $GITHUB_ENV / $GITHUB_PATH / $GITHUB_STEP_SUMMARY protocol is absent, only pull_request triggers fire, and checkout and the setup-* actions are recognized but do nothing in the step itself. See YAML & compatibility.

Use the pages in this section to get started:


A TOVIO pipeline is a directed acyclic graph (DAG) of jobs. Each job declares which other jobs it needs, and a job becomes runnable only once every job it needs has concluded success. Jobs with no unmet dependencies run in parallel; the graph advances one frontier at a time until every node is terminal. This is the whole scheduling model — there is no linear script, just dependency edges.

TOVIO runs your pipeline as a durable, orchestrated DAG rather than an in-memory loop: the run's state is journaled to a completion ledger, so an orchestrator crash resumes from the persisted frontier instead of restarting, and a job that already finished is never re-run.

The DAG, concretely

Every job in the graph becomes a node with a stable check-name:

  • "<workflow>/<job>" for an ordinary job — e.g. CI/build.
  • "<workflow>/<job> (<matrix_key>)" for a matrix instance — e.g. CI/build (node=18,os=ubuntu-latest).

That check-name is load-bearing wire identity: it is what gets posted back as a required check and what gates the land, so it must stay stable across a run.

A matrix job fans out into one independent node per axis-value combination. A job with node: [18, 20] and os: [ubuntu-latest] produces two nodes, each scheduled, retried, and concluded on its own. A downstream job that needs a matrix job waits for all of its instances. Matrix keys are order-independent — {os, node} and {node, os} render the identical name=value,name=value key — so the same matrix always produces the same node set.

A dependency cycle is rejected at build time as TVO-CI-001. A needs pointing at a job that does not exist is not rejected: it simply strands the dependent node Pending forever, which keeps the land blocked — the fail-safe direction.

Two front-ends, one DAG

A pipeline has two co-equal definition front-ends. Both are just producers of the same structural workflow model, which lowers through the identical Pipeline::build path to the same DAG:

Front-end What you write When it fits
Declarative YAML .github/workflows/*.yml (GitHub Actions format) The on-ramp: imported GitHub Actions repos, simple pipelines, the beginner surface
Config-as-code SDK A TypeScript program using @tovio/pipeline Typing, reuse/composition, loops, functions, shared constants, programmatically generated matrices

The two compile to the same DAG, the same durable runtime, and the same CI/CD UI — the config-as-code DAG is indistinguishable from a YAML DAG once registered. Pick per taste, and mix them freely in one repo: a config-as-code pipeline and its equivalent YAML produce a byte-identical effect (same needs graph, same matrix fan-out, same gates).

YAML is permanent and co-equal. It is never removed or degraded — imported GitHub Actions YAML must always keep working (see ADR-0287). Config-as-code is an addition, not a replacement.

Which front-end should I use?

  • Reach for YAML when you are importing an existing GitHub Actions repo, when the pipeline is small and static, or when you just want the simplest possible on-ramp.
  • Reach for the @tovio/pipeline SDK when you want real code around your pipeline definition: type-checked jobs, functions and constants shared across pipelines, for-loops that emit families of jobs, or a matrix generated from data at definition time.

Both land in the same place, so this is a preference about how you author, not about what the pipeline can do at runtime.

Core concepts

Concept One-line meaning
Job The unit of scheduling — one node in the DAG.
needs The dependency edges: a job runs only after every job it needs concludes success.
Matrix fan-out A strategy.matrix job expands into one independent node per axis-value combination.
environment A named deployment target on a job — the deploy-gate target in the model. Today's scheduler still admits it Automatic (see the notice above).
Gate How a node is admitted once its needs are met: Automatic (the CI default, and the only gate the scheduler assigns today) or ManualApproval.
Durable run The journaled record (PipelineRun) that survives an orchestrator crash and resumes from its ledger.
Completion ledger The persisted per-node state; a concluded node is never re-run on resume.
Lockfile For config-as-code: the compiled, committed, content-addressed artifact the Forge registers.
Control plane vs executor The orchestrator schedules/gates/journals; untrusted job code runs in an isolated runner.

Node lifecycle. Each node moves through Pending → Running → a terminal state. Terminal is either Concluded(<conclusion>) (only a Success conclusion unblocks dependents) or Blocked (a required upstream permanently failed, so this node can never run). A manual-approval gate adds a fourth, non-terminal state, WaitingApproval — parked, not runnable, and not failed. A run is complete once every node is Concluded or Blocked.

Gates & approvals. By default a node runs automatically the moment its needs conclude success. In the model, a job with an environment: (the "Ship" deploy gate) instead parks at a ManualApproval gate even after its needs are satisfied, and stays parked until an authenticated approval clears it; rejecting the gate concludes it Failure and blocks its dependents — fail-closed. As the notice above says, the scheduler does not assign that gate yet, so on a Forge today an environment: job is admitted Automatic. A required check that is cancelled or blocked does not satisfy the land gate, so the land stays blocked rather than defaulting to a pass. (Approving and rejecting deploy gates is covered in the deployments/approvals material.)

Durability. The run's ledger is the source of truth. Each job dispatch has a stable, nonce-free identity — (run, check-name, workflow content address, revision) — so a crash-replay re-dispatches the same identity and the substrate dedupes rather than double-running. A job stranded Running by a crash is resumed back onto the frontier under that same identity; a job already Concluded is never re-offered.

Content-addressed lockfiles (config-as-code). The SDK program runs client-side — on your machine or a build sandbox, never on the Forge. tovio ci compile runs it, emits the canonical model, content-addresses it (BLAKE3, exactly like a .yml), and commits it as a tracked lockfile artifact — the deterministic staleness record. The same command's --emit yaml serializes that model to a real .github/workflows/*.yml, and that generated YAML — not the lockfile — is what the Forge schedules, through the identical YAML path, never evaluating your pipeline code. Because the content address is a deterministic function of the compiled model, a re-compile is byte-stable and the address doubles as a real staleness guard — tovio ci compile --check fails when a committed artifact is stale or missing. (The SDK and tovio ci compile workflow are covered in the config-as-code authoring and CLI sections.)

Control plane vs executor. The orchestrator is deliberately not an executor: it schedules jobs, retries transient faults, journals state, and enforces gates, but it never runs untrusted run: code. That code runs in a separate runner process. The design target is a per-job kernel (microVM or single-tenant VM) that never sees platform secrets; no such isolation is implemented today — see the notice at the top of this page. What is true regardless of the runner is the config-as-code half: the only user code involved runs on your machine at compile time, so the Forge never evaluates a pipeline program.

What's deferred

Config-as-code today produces a static DAG: the full node set is known at compile time. Tenant-authored durable pipelines-as-code — user code that runs on the platform at execution time and grows the DAG dynamically (data-dependent fan-out) — remains deferred (see ADR-0284). The design reserves that path behind the same DAG seam so it can be added later without changing any downstream consumer, but if you need a runtime-dynamic DAG today, config-as-code is not it — the SDK is constrained to the static, declarative subset.

Last reviewed September 9, 2026

Suggest an improvement to this page Not for security reports — see disclosure