Skip to content

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:

import { pipeline, run, uses, emit, PipelineError } from "tovio-node-sdk/pipeline";

The Pipeline class is also exported (for instanceof checks or type imports):

import { pipeline, run, uses, emit, PipelineError, Pipeline } from "tovio-node-sdk/pipeline";

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:

tovio ci compile packages/tovio-node-sdk/examples/ci.pipeline.mjs

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.

const ci = pipeline("CI");

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:

on(): each trigger must be an event name or a { event } object (got <value>)

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:

job(): id must be a non-empty string

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:

  1. A bare string → a run step. "echo hi" becomes { run: "echo hi" }.
  2. run(command, opts?) → a run: shell step. command must be a non-empty string. opts.name labels it, opts.shell overrides the shell, and opts.env sets the step environment.
  3. uses(action, opts?) → a uses: action step. action must be a non-empty string. opts.name labels it, opts.with carries the action's inputs, and opts.env sets 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(): `with.path` must be a string (got object); join lists with newlines
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:

each step needs a `run` or `uses` (got <value>)

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: [] throws matrix must declare at least one axis.
  • Each axis has ≥1 value. An empty or non-array value list throws:
matrix axis `<name>` must be a non-empty array
  • Axis values MUST be strings. This is critical: a JS number like 3.10 is already 3.1 at the literal level (and String(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/exclude refinements are NOT supported. GitHub's include:/exclude: are not simple axes; the Rust model drops them, so config-as-code v1 rejects them rather than emit a bogus axis:
matrix `<include|exclude>:` refinements aren't supported in config-as-code — declare explicit axes

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.

emit(ci);   // writes the workflow JSON to stdout

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