Skip to main content

GitHub Actions

The minimum

flow export github-actions > .github/workflows/flowwright.yml
name: CI
on: [push, pull_request]
jobs:
flow:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
- run: npx @flowwright/cli run

That works, and for a pipeline with no dependencies it's all you need. The reporter is selected automatically — GITHUB_ACTIONS is always true on a runner.

A realistic workflow

Add dependency installation, cache persistence and a test report:

name: CI
on: [push, pull_request]
jobs:
flow:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm

- run: pnpm install --frozen-lockfile

# Persist FlowWright's own cache between runs.
- uses: actions/cache@v4
with:
path: .flowwright/cache
key: ${{ runner.os }}-flowwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-flowwright-

- run: pnpm exec flow run -j 4 --junit report.xml

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: junit
path: report.xml
if-no-files-found: ignore

if: always() on the upload matters — without it you lose the report on exactly the runs you wanted it for. Two caches are in play and they're unrelated: cache: pnpm on setup-node handles the package store, actions/cache handles what your pipeline stashed with cache.save.

What the log looks like

Each stage becomes a collapsible group:

FlowWright · acme-api
::group::Install
$ npm ci
[07:28:52] up to date, audited 1 package in 134ms
::endgroup::
::group::Lint
$ npm run lint
[07:28:52] lint: 0 problems
::endgroup::
success in 381ms

Under -j the stages run concurrently but each group is still emitted whole, when its stage finishes — GitHub can't nest log groups, so buffering is the only way to keep them readable.

Failures

A failed stage produces an annotation, which GitHub renders against the diff and in the run summary:

::group::Test
$ node -e process.exit(2)
::endgroup::
::error title=Stage test failed::node -e process.exit(2) (exit 2)
::notice title=Stage deploy skipped::upstream-failed
failed in 109ms
::error::FlowWright run failed

The annotation names the stage, the command that failed and its exit code. Skipped stages get a notice, so a stage that didn't run is still visible.

The job summary

The github reporter writes a markdown table to the run's summary page:

## FlowWright — acme-api

| Stage | Status | Time |
| ------ | ---------------------------- | ---- |
| lint | ✅ success | 86ms |
| test | ❌ failed | 22ms |
| deploy | ⏭️ skipped (upstream-failed) | 0ms |

_Total: 109ms_

It's appended to $GITHUB_STEP_SUMMARY, which the runner sets for you.

note

If a job sets GITHUB_STEP_SUMMARY: "" — a common trick to silence another tool's summary — FlowWright treats that as "summary off" and writes nothing. A genuine write failure warns on stderr rather than disappearing.

Test reports

Point any JUnit-consuming action at the --junit path. The XML carries the failing command and exit code:

<testcase name="test" classname="acme-api" time="0.022">
<failure message="stage failed">command: node -e process.exit(2)
exit code: 2</failure>
</testcase>
<testcase name="deploy" classname="acme-api" time="0.000">
<skipped message="upstream-failed"/>
</testcase>

Cancelling a run

Cancelling the workflow sends SIGTERM. FlowWright stops, writes the job summary and the JUnit file, and exits 130 — so a cancelled run still shows which stages had finished.

Matrix builds

Resist the urge. A FlowWright pipeline already fans out — see Matrix stages — and doing it in the pipeline means it also works locally. Use a GitHub matrix for things that genuinely need separate runners: different operating systems, or different runner labels.