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