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
needsDAG whose checks gate the land — but until you connect a customer-operated (BYO) runner, nothing executes a job: every scheduled CI check resolves tofailureon 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_checksuntil 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
ManualApprovalgate and the Forge serves approve/reject routes for a parked gate, but the scheduler that turns a proposal into a run admits every nodeAutomatictoday — a job with anenvironment: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 arun: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'senv:orwith:, which is the shape most real workflows use.if:conditions are captured but never evaluated, the$GITHUB_OUTPUT/$GITHUB_ENV/$GITHUB_PATH/$GITHUB_STEP_SUMMARYprotocol is absent, onlypull_requesttriggers fire, andcheckoutand thesetup-*actions are recognized but do nothing in the step itself. See YAML & compatibility.
Use the pages in this section to get started:
- Quickstart — your first pipeline, both ways.
- The @tovio/pipeline SDK — the full config-as-code API reference.
- CLI: compile & check —
tovio ci compileandtovio ci check. - YAML & compatibility — GitHub Actions support and the compat ladder.
- The web UI and operating pipelines — runs, gates, approvals.
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/pipelineSDK 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