Skip to main content

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 -.

NameId
Testtest
Build & Packagebuild-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.

Duplicate ids are silently renamed

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

OptionTypeDefaultDocumented on
run(ctx) => void | Promise<void>requiredthis page
idstringslug of the namethis page
needsstring[][]Dependencies
whenWhenDescriptoralwaysConditions
containerstringnoneContainers
servicesIRService[]noneContainers
producesstring[][]Artifacts
credentialsStageCredential[][]Credentials
timeoutstring | nullnullthis page
retriesnumber0this page
continueOnErrorbooleanfalseDependencies

Timeouts

stage("Integration tests", {
timeout: "10m",
run: async () => {
await sh(["npm", "run", "test:integration"]);
},
});
UnitExample
milliseconds"500ms"
seconds"30s"
minutes"10m"
hours"2h"

Fractions work: "1.5h".

Always write the unit

A bare number is millisecondstimeout: "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" with retries: 2 can 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.

pipeline.ts
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"]);
},
},
});
  • success or failure runs first, then always.
  • They can't declare needs — they're not part of the graph.
  • On cancellation (Ctrl-C), only always runs, with a short grace period.
  • A failing post.always turns a green run red. A failing post.failure doesn'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