Skip to main content

Writing a pipeline

A pipeline is a TypeScript file with a default export:

pipeline.ts
import { pipeline, stage, sh } from "@flowwright/core";

export default pipeline({
name: "acme-api",
stages: [
stage("Install", async () => {
await sh(["npm", "ci"]);
}),
stage("Test", {
needs: ["install"],
run: async () => {
await sh(["npm", "test"]);
},
}),
],
});

That's the whole surface: pipeline() wraps a list of stage()s, and stage bodies call sh to describe commands. Everything else — dependencies, containers, caching, credentials — is options on those two functions.

Two times, not one

Almost every surprising thing about authoring follows from one fact:

Your stage bodies run once, before the build, to produce a plan. They do not run during the build.

flow run spawns a short-lived child process, imports your pipeline.ts, and calls every stage body once, in order. During that pass the SDK primitives don't do anything — they record. sh appends a step to a list. cache.restore notes that a cache should be restored. Nothing is executed, nothing touches the filesystem.

The result is an execution plan: a JSON description of every stage, every command, and every dependency. Look at it with flow explain, and find every field of it on the Execution plan reference.

Then execution begins — and it reads only that JSON. Your TypeScript is gone. The closures were never serialized.

What records, what executes

You writeRecorded asWhen it happens
sh(...)a step, in orderat run time
file.write(...)a step, in orderat run time
cache.restore / cache.savea stage-level cache opat run time, at the stage boundary
stash / unstasha stage-level stash opat run time, at the stage boundary
artifact.upload(...)an entry in producesdeclaration only
hashFiles(...)nothing — it reads the disk immediatelyat plan time
Any other TypeScriptnothingat plan time

hashFiles is the odd one out, and deliberately so: it runs for real during recording so its result can be baked into a cache key as a literal string.

What this means for your code

sh doesn't return output

// This does not work.
const result = await sh(["git", "rev-parse", "HEAD"]);
await sh(["docker", "build", "-t", `app:${result.stdout}`, "."]);

At record time sh returns { exitCode: 0, stdout: "", stderr: "" } — always, for every command. Those are placeholders; the command hasn't run. You'd tag your image app:.

Let the shell do it at run time instead:

await sh.raw('docker build -t "app:$(git rev-parse HEAD)" .');

The context is empty while recording

A stage body receives a ctx, but during recording its fields are placeholders: ctx.env is {}, ctx.git is {}, and ctx.run.id is "record".

// This does not work.
stage("Deploy", async (ctx) => {
if (ctx.git.branch === "main") {
await sh(["./deploy.sh"]);
}
});

ctx.git.branch is undefined while recording, so the body records no steps at all and the stage silently does nothing on every branch. Use when, which is evaluated at run time:

import { onBranch } from "@flowwright/core";

stage("Deploy", {
when: onBranch("main"),
run: async () => {
await sh(["./deploy.sh"]);
},
});

For environment variables, reference them inside the command so the shell expands them when it runs — sh.raw('echo "$DEPLOY_TARGET"') — rather than reading ctx.env at plan time.

Top-level code runs every time

Anything at the top level of pipeline.ts executes on every flow run, every flow explain, every flow validate, and every save under flow run --watch. Keep it to imports and pure computation. A top-level fs.rmSync() will fire when you only meant to inspect the plan.

Non-determinism freezes

// Both are evaluated once, at plan time.
const tag = `build-${Date.now()}`;

Every step in the run uses that one value. That's usually fine and occasionally exactly wrong — a timestamp meant to be "now, when this ran" is really "when the plan was built".

Generating stages is the payoff

The flip side is the reason plan-time execution is worth having: a pipeline is a program, so you can compute it.

pipeline.ts
import { pipeline, stage, sh } from "@flowwright/core";

const services = ["api", "web", "worker"];

export default pipeline({
name: "acme",
stages: services.map((svc) =>
stage(`Build ${svc}`, async () => {
await sh(["npm", "run", "build", "--workspace", svc]);
}),
),
});

No templating language, no YAML anchors. It's a .map().

A stage body that throws fails the build

If a stage body throws while recording, the whole load fails and names the stage:

error: Error: stage "Build" (build) threw while recording the plan:
TypeError: Cannot read properties of undefined (reading 'trim')
at Object.run (/app/pipeline.ts:8:49)

That's a real error in your pipeline.ts, not in your build — fix it there.

Where to go next

PageCovers
Stagesstage() and every option on it
Commandssh, escaping, and writing files
Dependenciesneeds, parallel, group, and how failure spreads
ConditionsRunning a stage only on some branches or tags
ContainersRunning stages in Docker, with sidecar services
Artifacts, cache and stashMoving files between stages and runs
CredentialsBinding secrets into a stage
MatrixFanning one stage out over a grid