Writing a pipeline
A pipeline is a TypeScript file with a default export:
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 write | Recorded as | When it happens |
|---|---|---|
sh(...) | a step, in order | at run time |
file.write(...) | a step, in order | at run time |
cache.restore / cache.save | a stage-level cache op | at run time, at the stage boundary |
stash / unstash | a stage-level stash op | at run time, at the stage boundary |
artifact.upload(...) | an entry in produces | declaration only |
hashFiles(...) | nothing — it reads the disk immediately | at plan time |
| Any other TypeScript | nothing | at 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.
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
| Page | Covers |
|---|---|
| Stages | stage() and every option on it |
| Commands | sh, escaping, and writing files |
| Dependencies | needs, parallel, group, and how failure spreads |
| Conditions | Running a stage only on some branches or tags |
| Containers | Running stages in Docker, with sidecar services |
| Artifacts, cache and stash | Moving files between stages and runs |
| Credentials | Binding secrets into a stage |
| Matrix | Fanning one stage out over a grid |