The @tovio/pipeline SDK reference¶
@tovio/pipeline is a tiny, dependency-free ESM builder that lets you author CI pipelines as typed JavaScript instead of YAML. Its only job is to build the canonical Workflow model that tovio-ci-core parses .github/workflows/*.yml into. tovio ci compile then lowers your pipeline to the same DAG as the equivalent YAML would produce (REQ-CI-062, ADR-0287). The builder contains no native binding and no crypto — the entire compile boundary is a single JSON document it prints to stdout, which the Rust tovio ci compile validates and content-addresses. (See the CI compile command section for how the emitted JSON is consumed.)
Install and import¶
The builder ships as the ./pipeline subpath export of the tovio-node-sdk package. It is pure ESM ("type": "module") and has zero dependencies of its own — you import it directly:
The Pipeline class is also exported (for instanceof checks or type imports):
Every export:
| Export | Kind | Purpose |
|---|---|---|
pipeline(name) |
function | Start a new pipeline builder. |
run(command, opts?) |
function | Build a run: shell step. |
uses(action, opts?) |
function | Build a uses: action step. |
emit(p) |
function | Print the canonical workflow JSON to stdout. |
PipelineError |
class | The error thrown for a malformed pipeline. |
Pipeline |
class | The builder type returned by pipeline(). |
tovio-node-sdk is not published to a public registry yet, so that bare specifier only resolves where the package is already on disk — the repository workspace today. Outside it, import the builder by path (…/tovio-node-sdk/pipeline/index.mjs), exactly as the shipped example does. Nothing else about authoring changes: the builder is a self-contained .mjs file with no native binding behind it.
You author a *.pipeline.mjs file that builds a pipeline and calls emit(...) on it. Compile it with the CLI:
pipeline(name)¶
Starts a new builder. name becomes the workflow's name in the DAG; when you omit it, the CLI falls back to the file stem (mirroring .github/workflows/<name>.yml). Internally name is stringified, or null when not provided.
pipeline() returns a Pipeline instance. All of its methods except toWorkflow()/toJSON() return this, so calls chain fluently.
Pipeline methods¶
.on(...events)¶
Declares on: triggers. Each argument is either an event-name string or a trigger object with a required event field plus optional filters. Object arguments are shallow-copied into the trigger list, so any filter fields you set pass through unchanged.
pipeline("CI")
.on("pull_request") // proposals: opened + amended
.on({ event: "push", branches: ["main"] }); // an object with a filter
Recognized filter fields on a trigger object (from the Trigger type):
| Field | Type |
|---|---|
event |
string (required) |
branches |
string[] |
branches_ignore |
string[] |
paths |
string[] |
paths_ignore |
string[] |
tags |
string[] |
tags_ignore |
string[] |
types |
string[] |
cron |
string[] |
Anything that is neither a string nor an object with a string event is rejected:
Use GitHub event names, not TOVIO's. The trigger mapper only understands GitHub's on: vocabulary, so a dotted change.* name would parse into a workflow that maps to nothing and never runs. .on() refuses one at author time and names the GitHub event to use instead — change.proposed and change.amended both point at pull_request, change.landed at push:
on(): "change.landed" is not a GitHub Actions trigger — TOVIO maps GitHub events onto its change events. Use "push" (TOVIO runs it on change.landed).
.onSchedule(cron)¶
Adds a schedule trigger from one or more cron expressions. Accepts a single string or an array of strings; both are normalized to a string array under a trigger of the form { event: "schedule", cron: [...] }.
pipeline("nightly").onSchedule("0 3 * * *");
pipeline("nightly").onSchedule(["0 3 * * *", "0 15 * * *"]);
.job(id, spec)¶
Adds a job. id must be a non-empty string (the job's id in the DAG); an empty or non-string id throws:
spec is a JobSpec (see below). The job is normalized immediately when added, but cross-job validation (unknown/cyclic needs, duplicate ids) runs later, at toWorkflow() time.
pipeline("CI")
.job("lint", { steps: [run("cargo clippy")] })
.job("build", { needs: ["lint"], steps: [run("cargo build --release")] });
.toWorkflow() / .toJSON()¶
Runs validation, then returns the canonical Workflow object — the exact model tovio-ci-core parses YAML into. It has three top-level fields: name (string or null), triggers, and jobs. Triggers and jobs are deep-cloned (structuredClone) on every call, so repeated emits never alias each other's arrays. toJSON() is an alias for toWorkflow().
const wf = pipeline("CI").on("push").job("build", { steps: ["cargo build"] }).toWorkflow();
// wf.name === "CI"
// wf.triggers === [{ event: "push" }]
// wf.jobs[0] === { id: "build", runs_on: [], needs: [], steps: [{ run: "cargo build" }] }
JobSpec fields¶
The spec object passed to .job(id, spec):
| Field | Type | Maps to | Notes |
|---|---|---|---|
needs |
string \| string[] |
needs |
Job ids that must conclude successfully first. A single string is wrapped into a one-element array. Default []. |
runsOn |
string \| string[] |
runs_on |
Runner labels. Also accepts the snake alias runs_on. If omitted, runs_on is [] — the builder injects no default, matching the YAML front-end (parse_runs_on(None) → []) so the compiled model byte-matches the equivalent minimal YAML (REQ-CI-062). Set a runner explicitly with runsOn: "ubuntu-latest". (The JSDoc in index.d.ts says it "defaults to ubuntu-latest"; the implementation and its test assert [].) |
matrix |
Matrix |
matrix + has_matrix: true |
A build matrix (see below). When present, has_matrix is set to true, mirroring the YAML parser. When absent, both has_matrix and matrix are omitted from the job (default false/none). |
environment |
string |
environment |
A deployment environment name. Setting it marks the job a deploy job, so the land/deploy plane applies its manual gate. (See the deploy gates / approvals section.) |
ifCond |
string |
if_cond |
A conditional (if:) guard. |
if |
string |
if_cond |
Alias for ifCond (ifCond wins if both are set). |
steps |
StepInput[] |
steps |
The job's steps, in order. Default []. |
name |
string |
name |
Optional job display name (stringified; omitted when falsy). |
Steps¶
A step is one of three forms, all producing an object with a run or uses key:
- A bare string → a
runstep."echo hi"becomes{ run: "echo hi" }. run(command, opts?)→ arun:shell step.commandmust be a non-empty string.opts.namelabels it,opts.shelloverrides the shell, andopts.envsets the step environment.uses(action, opts?)→ auses:action step.actionmust be a non-empty string.opts.namelabels it,opts.withcarries the action's inputs, andopts.envsets the step environment.
with and env are maps of string values, sorted by key on the way into the model. A non-string value is refused rather than coerced — the Rust model drops non-scalars at parse, so a list would be silently lost; write a multi-line string instead (which is how upload-artifact's multi-path path: is expressed in YAML too):
uses("actions/upload-artifact@v4", { with: { name: "dist", path: "target/release/app\ntarget/release/app.pdb" } });
run("deploy", { env: { REGISTRY: "ghcr.io" } });
pipeline("s")
.on("push")
.job("j", {
steps: [
"echo hi", // bare string → { run: "echo hi" }
run("cargo test", { name: "Test", shell: "bash" }),
uses("actions/checkout@v4", { name: "Checkout" }),
],
});
// steps === [
// { run: "echo hi" },
// { name: "Test", run: "cargo test", shell: "bash" },
// { name: "Checkout", uses: "actions/checkout@v4" },
// ]
You can also pass a plain step object directly ({ run, uses, name?, shell?, with?, env? } — with/env go through the same string-map normalization, reporting as step: `with.<key>` …). Each step must carry at least a run or a uses; otherwise:
A step that is neither a string nor an object throws invalid step: <value>. Empty-string command/action passed to run()/uses() throw run(): command must be a non-empty string and uses(): action must be a non-empty string respectively.
Matrix¶
A matrix fans a job out into one instance per axis combination. Two forms are accepted:
Ergonomic — axis name to values:
pipeline("CI")
.job("test", { matrix: { node: ["18", "20", "22"], os: ["ubuntu"] }, steps: [run("cargo test")] });
Canonical — an explicit axes array:
pipeline("CI")
.job("test", {
matrix: { axes: [
{ name: "node", values: ["18", "20", "22"] },
{ name: "os", values: ["ubuntu"] },
] },
steps: [run("cargo test")],
});
Both normalize to the same canonical shape ({ axes: [{ name, values }, ...] }) and set has_matrix: true on the job.
Rules, each enforced with an exact message:
- At least one axis. An empty
axes: []throwsmatrix must declare at least one axis. - Each axis has ≥1 value. An empty or non-array value list throws:
- Axis values MUST be strings. This is critical: a JS number like
3.10is already3.1at the literal level (andString(1.0)is"1"), which would silently run the wrong version and break byte-identity with YAML (which preserves"3.10"verbatim). Quote every version. A non-string value throws:
matrix axis `<name>` values must be strings (e.g. "3.10", "18") — a number like 3.10 loses its trailing zero
include/excluderefinements are NOT supported. GitHub'sinclude:/exclude:are not simple axes; the Rust model drops them, so config-as-code v1 rejects them rather than emit a bogus axis:
Declare explicit axes instead.
emit(pipeline)¶
Serializes a pipeline to canonical JSON on stdout — the exact input tovio ci compile reads. It accepts either a Pipeline (it calls .toWorkflow(), which validates) or a plain workflow object (emitted as-is). The output is a single JSON.stringify(...) with no trailing newline.
On any authoring/validation error, emit writes the error message (plus a newline) to stderr and exits the process non-zero (process.exit(1)). This makes a malformed pipeline fail the compile loudly rather than emit garbage. Because validation happens inside toWorkflow(), calling emit(pipeline(...)) surfaces every rule below.
Validation rules¶
Client-side validation runs in toWorkflow() (and therefore in emit). These checks are a fast, friendly first line — the Rust tovio ci compile is the authoritative validator — but they mirror its rules exactly:
| Rule | Exact message |
|---|---|
| No jobs | a pipeline must declare at least one job |
| Empty job id | a job has an empty id |
| Duplicate job id | duplicate job id `<id>` |
Unknown needs target |
job `<id>` needs unknown job `<need>` |
| Dependency cycle | dependency cycle: a → b → a (the path, joined with →) |
Job-id emptiness is also caught earlier at .job() time (job(): id must be a non-empty string); the a job has an empty id check covers the model-level guard. Cycles are found with a standard three-color DFS over the needs edges.
The guarantee¶
The builder produces the same structural Workflow model that YAML parsing produces — same jobs, same needs edges, same steps, same matrix axes — so tovio ci compile lowers a config-as-code pipeline to the same DAG as the equivalent .yml (REQ-CI-062, ADR-0287). Notably, an omitted runsOn yields runs_on: [] (not an injected default) and a matrix-free job omits has_matrix/matrix precisely so the compiled model byte-matches minimal YAML. The Forge never evaluates your JavaScript: your code runs client-side, the output is inert JSON, and the compiler content-addresses and re-validates it.
Patterns¶
Config-as-code exists to give you the reuse YAML can't express. Because a pipeline is plain JavaScript, you can factor with ordinary language constructs.
Share a step across jobs — bind a step once and reuse the object:
const checkout = uses("actions/checkout@v4");
pipeline("CI")
.job("lint", { steps: [checkout, run("cargo clippy")] })
.job("build", { needs: ["lint"], steps: [checkout, run("cargo build --release")] });
Generate a matrix from an array — remember values must be strings:
const nodeVersions = ["18", "20", "22"];
pipeline("CI").on("push").job("test", {
matrix: { node: nodeVersions },
steps: [run("cargo test --workspace")],
});
Factor a job into a function — parameterize whole jobs:
const testJob = (name, cmd) => ({ needs: ["build"], runsOn: "ubuntu-latest", steps: [run(cmd)] });
pipeline("CI")
.job("build", { runsOn: "ubuntu-latest", steps: [run("cargo build --release")] })
.job("unit", testJob("unit", "cargo test --lib"))
.job("integration", testJob("integration", "cargo test --test '*'"));
Full worked example — the shipped examples/ci.pipeline.mjs, with two differences: it lives inside the package so it imports "../pipeline/index.mjs" rather than the package specifier, and it declares .on("pull_request", "push").
import { pipeline, run, uses, emit } from "tovio-node-sdk/pipeline";
// A shared checkout step, reused across jobs (the kind of DRY YAML can't express).
const checkout = uses("actions/checkout@v4");
const ci = pipeline("CI")
.on("pull_request")
.job("lint", {
runsOn: "ubuntu-latest",
steps: [checkout, run("cargo clippy --all-targets -- -D warnings"), run("cargo fmt --check")],
})
.job("build", {
needs: ["lint"],
runsOn: "ubuntu-latest",
steps: [checkout, run("cargo build --release")],
})
.job("test", {
needs: ["build"],
runsOn: "ubuntu-latest",
matrix: { node: ["18", "20", "22"] },
steps: [checkout, run("cargo test --workspace")],
});
emit(ci);
This builds the DAG lint → build → test (with test fanned out across three matrix values) — the same DAG a .github/workflows/ci.yml would produce — then prints its canonical JSON for tovio ci compile to read.
Source files: packages/tovio-node-sdk/pipeline/index.mjs, packages/tovio-node-sdk/pipeline/index.d.ts, packages/tovio-node-sdk/examples/ci.pipeline.mjs.
Last reviewed September 9, 2026
Suggest an improvement to this page Not for security reports — see disclosure