Stages
A stage is a named unit of work. It has a body, and optionally a set of options.
The two forms
// Just a body.
stage("Test", async () => {
await sh(["npm", "test"]);
});
// Options, with the body under `run`.
stage("Test", {
needs: ["install"],
retries: 2,
run: async () => {
await sh(["npm", "test"]);
},
});
The short form is the same as passing { run } and nothing else.
Names and ids
Every stage has a name (what you see) and an id (what you reference). The id
is derived from the name: lowercased, with every run of non-alphanumeric characters
collapsed to a single -.
| Name | Id |
|---|---|
Test | test |
Build & Package | build-package |
E2E (chrome) | e2e-chrome |
needs refers to ids, and ids are what appear in flow explain, logs and
reports. Set one explicitly when you don't want the derived one:
stage("Run the integration suite", { id: "itest", run: async () => {} });
Ids must match ^[a-z0-9][a-z0-9-]*$ — lowercase letters, digits and hyphens,
starting with a letter or digit.
If two stages end up with the same id, the second gets -2, the third -3, and so
on. This applies to explicit ids too — declaring { id: "build" } twice gives
you build and build-2 with no error. Anything that needs: ["build"] will point
at the first one only.
Run flow explain after adding stages; the id list is right there.
Options
| Option | Type | Default | Documented on |
|---|---|---|---|
run | (ctx) => void | Promise<void> | required | this page |
id | string | slug of the name | this page |
needs | string[] | [] | Dependencies |
when | WhenDescriptor | always | Conditions |
container | string | none | Containers |
services | IRService[] | none | Containers |
produces | string[] | [] | Artifacts |
credentials | StageCredential[] | [] | Credentials |
timeout | string | null | null | this page |
retries | number | 0 | this page |
continueOnError | boolean | false | Dependencies |
Timeouts
stage("Integration tests", {
timeout: "10m",
run: async () => {
await sh(["npm", "run", "test:integration"]);
},
});
| Unit | Example |
|---|---|
| milliseconds | "500ms" |
| seconds | "30s" |
| minutes | "10m" |
| hours | "2h" |
Fractions work: "1.5h".
A bare number is milliseconds — timeout: "10" means 10ms, not 10 seconds, and
your stage dies instantly. flow validate rejects unitless durations for exactly
this reason:
✖ invalid pipeline (1 error)
• stages[0].timeout: duration "10" has no unit — did you mean "10s"? (a bare number is milliseconds) [bad_duration]
Individual commands can carry their own timeout — see Commands.
What a timed-out stage looks like
A stage that exceeds its timeout is reported as failed with a null exit code.
There is no "stage timed out" line in the output — the stage simply fails. If the
timeout was on the command rather than the stage, you do get an explicit
step timed out after Nms on stderr, which is easier to diagnose. Prefer command
timeouts when you can pin the slow step.
Retries
stage("Flaky E2E", {
retries: 2,
run: async () => {
await sh(["npm", "run", "e2e"]);
},
});
retries: 2 means up to three attempts. Things worth knowing:
- The whole stage re-runs from its first step, not just the command that failed. Earlier steps' effects on the workspace are still there from the previous attempt.
- There is no backoff. Attempts are immediate and back to back — retries won't help with a rate limit or a service that needs a moment.
- The timeout applies per attempt.
timeout: "5m"withretries: 2can occupy 15 minutes. - Caches and stashes are only saved after the final, successful attempt.
The post lifecycle
post handlers run after the pipeline finishes, outside the dependency graph.
import { pipeline, stage, sh } from "@flowwright/core";
export default pipeline({
name: "acme-api",
stages: [
stage("Test", async () => {
await sh(["npm", "test"]);
}),
],
post: {
success: async () => {
await sh(["./notify.sh", "green"]);
},
failure: async () => {
await sh(["./notify.sh", "red"]);
},
always: async () => {
await sh(["./cleanup.sh"]);
},
},
});
successorfailureruns first, thenalways.- They can't declare
needs— they're not part of the graph. - On cancellation (Ctrl-C), only
alwaysruns, with a short grace period. - A failing
post.alwaysturns a green run red. A failingpost.failuredoesn't change anything.
A post hook is the right place for alerting your pipeline owns — it runs anywhere the
pipeline runs, including locally. A server sends its own
notifications for run outcomes, which is a different
layer: instance-configured, and only when a run happens on the server.
They show up in flow explain under their own heading:
post (runs after the pipeline)
always
$ ./cleanup.sh