Skip to main content

Matrix stages

matrix() generates one stage per combination of the values you give it.

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

export default pipeline({
name: "acme-lib",
concurrency: 4,
stages: [
stage("Install", async () => {
await sh(["npm", "ci"]);
}),
...matrix(
"Test",
{ node: ["20", "22", "24"] },
{
needs: ["install"],
run: async ({ node }) => {
await sh(["npm", "test"], { env: { NODE_VERSION: node } });
},
},
),
],
});

Three stages, one per Node version. The combination is the first argument to run, and it's typed from the dimensions — node is "20" | "22" | "24", so a typo is a compile error.

Names and ids

The combination is appended to the name:

CombinationNameId
{ node: "20" }Test (node=20)test-node-20
{ node: "20", os: "linux" }Test (node=20, os=linux)test-node-20-os-linux

Those ids are what needs elsewhere refers to. Dimensions are combined in declaration order, with the first key varying slowest.

For a Node version matrix specifically, plugin-node's nodeVersions builds the same thing with the containers already wired.

More than one dimension

...matrix(
"E2E",
{ browser: ["chrome", "firefox"], shard: ["1", "2"] },
{
needs: ["build"],
container: "mcr.microsoft.com/playwright:v1.49.0",
run: async ({ browser, shard }) => {
await sh(["npx", "playwright", "test", "--project", browser, "--shard", `${shard}/2`]);
},
},
)

Four stages. They all share the same needs and never depend on each other, so with concurrency above 1 they fan out in parallel.

An empty dimension array produces zero stages — worth knowing if the list is computed.

What options a matrix accepts

matrix() takes a subset of the normal stage options:

OptionAccepted
needsYes
containerYes
whenYes
timeoutYes
continueOnErrorYes
retriesYes
servicesNo
credentialsNo
producesNo
idNo

The four missing ones aren't silently dropped — they're not on the type, so TypeScript rejects them.

When you need them, build the stages with a plain .map() instead. matrix() is a convenience over exactly that:

...["20", "22", "24"].map((node) =>
stage(`Test (node=${node})`, {
needs: ["install"],
container: `node:${node}`,
credentials: ["npm-token"],
produces: [`reports/${node}.xml`],
run: async () => {
await sh(["npm", "test"]);
},
}),
)

You lose the generated naming and the typed combination, and get the full option set back.

Checking what you generated

flow explain lists every generated stage — the fastest way to confirm the grid is what you meant:

test-node-20 ← install
$ npm test
test-node-22 ← install
$ npm test
test-node-24 ← install
$ npm test