Official GitHub Action for running Bruno CLI commands in CI.
Installs @usebruno/cli, runs an arbitrary bru command, parses the emitted JUnit XML, and exposes machine-readable counts (exit-code, passed, failed, total, duration-ms) for downstream steps. UI rendering, artifact upload, PR comments, and soft-fail semantics are delegated to the GitHub Actions ecosystem (EnricoMi/publish-unit-test-result-action, dorny/test-reporter, actions/upload-artifact, continue-on-error). See Pairing with downstream actions for canonical recipes.
Design pattern follows postmanlabs/postman-cli-action and kong/setup-inso: minimal-input pass-through, no flag mirroring.
# Minimal — installs the CLI, runs the collection, returns counts as outputs.
# Step fails (red) on assertion failure, succeeds (green) on success.
# No UI surfaces, no artifact upload by default.
# See "Pairing with downstream actions" for PR comments, annotations,
# Step Summary, artifact upload, soft-fail, and more.
- uses: usebruno/bruno-cli-action@v1
with:
working-directory: tests/payments
command: 'run --env prod'What you'll see: the workflow step turns red on assertion failure (green on success). No PR comments, no annotations, no Step Summary, no uploaded artifact. Outputs are populated for downstream conditional steps.
| Input | Required | Default | Description |
|---|---|---|---|
command |
yes | — | The bru subcommand and flags (e.g. run --env prod). The action prepends bru. Reporter flags are optional; --reporter-junit is auto-injected if absent. |
bru-version |
no | latest |
Version of @usebruno/cli to install. |
working-directory |
no | . |
Shell working directory. Typically the Bruno collection root. |
No typed inputs for --env, --env-var, --tags, --bail, --sandbox, --reporter-*, etc. They all go in command. Every CLI flag works the day it ships in the CLI, with zero coordination cost.
Available as ${{ steps.<id>.outputs.<name> }} in subsequent steps:
| Output | Description |
|---|---|
exit-code |
The bru process exit code. |
passed |
Number of passed requests. |
failed |
Number of failed requests (assertion failures or runtime errors). |
total |
Total requests run. |
duration-ms |
Total run duration in milliseconds. |
Report file paths are intentionally not outputs. Users who chain to downstream actions pass an explicit --reporter-junit <path> in command and reference that path directly in the next step.
If the user's command does not contain --reporter-junit, the action appends --reporter-junit "$RUNNER_TEMP/bruno-junit.xml" before invoking bru. JUnit is required for output extraction; auto-injection means a minimal command: 'run' produces count outputs without the user having to remember reporter flags. The file lives in the runner's temp dir and is auto-cleaned post-job.
If the user explicitly passes --reporter-junit some/path.xml, the action honors that path and parses from there.
The action propagates bru's exit code naturally. The workflow step succeeds on exit 0 and fails on non-zero. Users who want the workflow to continue past failures use GitHub Actions' built-in continue-on-error: true (see recipe #12).
This section covers every reporting and artifact use case. The action emits clean JUnit XML; downstream actions render it for the user-visible surface needed or upload it as a workflow artifact.
The most common ask. EnricoMi/publish-unit-test-result-action posts a single comment per PR with structured results, updated on re-runs. Adds a check run with rich annotations as a side benefit.
name: API Tests
on: [pull_request]
permissions:
pull-requests: write
checks: write
contents: read
jobs:
bruno:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: usebruno/bruno-cli-action@v1
with:
working-directory: tests/payments
command: 'run --env prod --reporter-junit results.xml'
- uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: tests/payments/results.xmlPrerequisites: pull-requests: write and checks: write permissions in the workflow file.
What you'll see: a single Bruno-themed comment in the PR Conversation tab that updates in place on every re-run, plus a check run with structured per-test results in the PR Checks tab.
Why if: always(): EnricoMi must run even when the Bruno step fails the build, otherwise users do not see the comment on failure.
For teams who do not want a green-passes-everywhere comment polluting the conversation:
- uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: tests/payments/results.xml
comment_mode: failures # post a comment only when something failedWhat you'll see: PR comment appears only when at least one assertion failed. Successful runs leave no comment behind.
Other comment_mode values: always (default), failures, errors, off.
EnricoMi writes a Step Summary by default. If you want the workflow-page digest but no PR comment noise:
- uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: tests/payments/results.xml
comment_mode: off # disable PR comment
job_summary: true # default true; shown for clarityWhat you'll see: a markdown summary on the workflow run page (visible by clicking into the run from the Actions tab). No PR Conversation comment, no Checks-tab check run annotations.
EnricoMi posts annotations via the Check Runs API. These appear in the PR Checks tab attached to EnricoMi's check run:
- uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: tests/payments/results.xml
report_individual_runs: true # one annotation per failed testWhat you'll see: the PR Checks tab shows the EnricoMi check run with one annotation per failed assertion, expandable for failure details.
Note: line-anchored annotations on .bru source lines would require the CLI to emit file= and line= in JUnit. Not currently planned.
If you have a polyglot test stack (Jest, Pytest, Bruno) and want all results in the same Checks tab UI, dorny is the better tool than EnricoMi:
- uses: usebruno/bruno-cli-action@v1
with:
working-directory: tests/payments
command: 'run --env prod --reporter-junit results.xml'
- uses: dorny/test-reporter@v1
if: always()
with:
name: Bruno API tests
path: tests/payments/results.xml
reporter: java-junitWhat you'll see: a separate check run in the PR Checks tab labeled "Bruno API tests" with structured per-test results and expandable failure details. Visually consistent with check runs from your other JUnit-emitting test suites.
When you want both: a PR Conversation comment from EnricoMi and a polished Checks tab UI from dorny:
- uses: usebruno/bruno-cli-action@v1
with:
working-directory: tests/payments
command: 'run --env prod --reporter-junit results.xml'
- uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: tests/payments/results.xml
comment_mode: always
- uses: dorny/test-reporter@v1
if: always()
with:
name: Bruno API tests
path: tests/payments/results.xml
reporter: java-junitWhat you'll see: PR Conversation gets the EnricoMi sticky comment; PR Checks tab shows two check runs (one from each downstream action) plus the EnricoMi annotations.
Bruno's CLI handles sensitive-header redaction; pass the flag in command. Chain actions/upload-artifact@v7 to persist the report:
- uses: usebruno/bruno-cli-action@v1
with:
working-directory: tests/payments
command: 'run --env prod --reporter-junit results.xml --reporter-skip-headers "Authorization Cookie X-Tenant-Token"'
- uses: actions/upload-artifact@v7
if: always()
with:
name: bruno-report-${{ github.run_id }}-${{ github.job }}
path: tests/payments/results.xmlWhat you'll see: an artifact named bruno-report-<run_id>-<job> on the workflow run page, downloadable for 90 days (GitHub default retention). Authorization, Cookie, and X-Tenant-Token headers are redacted in the JUnit XML before upload.
To customize retention or use compression options, see actions/upload-artifact@v7's own inputs (retention-days, compression-level, if-no-files-found).
Pass multiple reporter flags in command. Chain actions/upload-artifact@v7 with a path list:
- uses: usebruno/bruno-cli-action@v1
with:
working-directory: tests/payments
command: 'run --env prod --reporter-junit results.xml --reporter-html report.html --reporter-json report.json'
- uses: actions/upload-artifact@v7
if: always()
with:
name: bruno-reports-${{ github.run_id }}
path: |
tests/payments/results.xml
tests/payments/report.html
tests/payments/report.jsonWhat you'll see: an artifact containing all three report files. Download to a browser to view the rich HTML report; JSON is consumable by custom dashboards or aggregators.
Use the JUnit-derived failed output as a conditional. Use continue-on-error: true so the notification step still runs:
- id: bruno
uses: usebruno/bruno-cli-action@v1
continue-on-error: true
with:
working-directory: tests/payments
command: 'run --env prod'
- if: steps.bruno.outputs.failed != '0'
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Bruno tests failed: ${{ steps.bruno.outputs.failed }}/${{ steps.bruno.outputs.total }} requests failed on ${{ github.ref_name }}",
"blocks": [{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Bruno test failures on ${{ github.ref_name }}*\n${{ steps.bruno.outputs.failed }}/${{ steps.bruno.outputs.total }} requests failed in ${{ steps.bruno.outputs.duration-ms }}ms. <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>"
}
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Prerequisites: SLACK_WEBHOOK_URL secret configured in the repository.
What you'll see: the Bruno step shows red on failure (honest signal) but the workflow continues; a Slack message lands in the channel mapped to the webhook with counts, branch, duration, and a link to the workflow run.
For users who do not want EnricoMi's full setup and only need a quick "post a comment with the counts" pattern (no stickiness, each run adds a new comment):
- id: bruno
uses: usebruno/bruno-cli-action@v1
with:
working-directory: tests/payments
command: 'run --env prod'
- if: always() && github.event_name == 'pull_request'
run: |
if [ "${{ steps.bruno.outputs.failed }}" -gt 0 ]; then
ICON="❌"
STATUS="${{ steps.bruno.outputs.passed }}/${{ steps.bruno.outputs.total }} passed, ${{ steps.bruno.outputs.failed }} failed"
else
ICON="✅"
STATUS="${{ steps.bruno.outputs.total }}/${{ steps.bruno.outputs.total }} passed"
fi
gh pr comment ${{ github.event.pull_request.number }} \
--body "${ICON} **Bruno:** ${STATUS} in ${{ steps.bruno.outputs.duration-ms }}ms · [view run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Prerequisites: pull-requests: write permission and the workflow triggered on pull_request.
What you'll see: a new comment posted to the PR on every workflow run. Each re-run adds another comment (no in-place update). Use EnricoMi (recipe #1) if you want stickiness.
Use distinct JUnit paths and artifact names per matrix leg. Chain EnricoMi and actions/upload-artifact@v7 per leg:
on: [pull_request]
permissions:
pull-requests: write
checks: write
contents: read
jobs:
bruno:
runs-on: ubuntu-latest
strategy:
matrix:
env: [staging, prod]
steps:
- uses: actions/checkout@v6
- uses: usebruno/bruno-cli-action@v1
with:
working-directory: tests/payments
command: 'run --env ${{ matrix.env }} --reporter-junit results-${{ matrix.env }}.xml'
- uses: actions/upload-artifact@v7
if: always()
with:
name: bruno-report-${{ matrix.env }}
path: tests/payments/results-${{ matrix.env }}.xml
- uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
check_name: Bruno (${{ matrix.env }})
files: tests/payments/results-${{ matrix.env }}.xml
comment_mode: failuresWhat you'll see: two matrix legs running in parallel against staging and prod. Each leg produces its own artifact (bruno-report-staging, bruno-report-prod), its own check run (Bruno (staging) / Bruno (prod)) in the PR Checks tab, and a PR comment only when that leg fails.
Note: include the matrix key in both --reporter-junit and the artifact name to avoid actions/upload-artifact@v7's duplicate-name error across matrix legs.
Record results without failing the build, then notify selectively. Use GitHub Actions' built-in continue-on-error: true:
- id: bruno
uses: usebruno/bruno-cli-action@v1
continue-on-error: true
with:
working-directory: tests/payments
command: 'run --env prod'
- if: steps.bruno.outputs.failed != '0'
run: ./notify-slack.sh ${{ steps.bruno.outputs.failed }}What you'll see: the Bruno step appears red in the workflow run UI when assertions fail (honest signal that something went wrong), but downstream steps still run because continue-on-error lets the workflow proceed. Outputs are populated for the conditional notification step.
GitHub restricts GITHUB_TOKEN to read-only for workflows triggered by pull_request events from forked repositories, regardless of the permissions block in the workflow file. EnricoMi and any PR comment poster need write permission, which means workflows running on community contributions must use pull_request_target (with care: it runs with the base branch's permissions and secrets). See the GitHub docs and EnricoMi's fork PR docs for the trade-offs.
Marshall secrets via shell, pass paths to bru:
- run: |
mkdir -p /tmp/certs && chmod 700 /tmp/certs
echo "$CA_CERT" > /tmp/certs/ca.pem
echo "$CLIENT_CERT_CONFIG" > /tmp/certs/client.json
env:
CA_CERT: ${{ secrets.API_CA_CERT }}
CLIENT_CERT_CONFIG: ${{ secrets.API_CLIENT_CERT_CONFIG }}
- uses: usebruno/bruno-cli-action@v1
with:
working-directory: tests/payments
command: 'run --env prod --cacert /tmp/certs/ca.pem --client-cert-config /tmp/certs/client.json'Prerequisites: API_CA_CERT and API_CLIENT_CERT_CONFIG secrets configured.
What you'll see: Bruno runs against an internal mTLS-protected API. Secrets never appear in logs (GitHub auto-masks); cert files are written to a temp dir and cleaned up when the runner is destroyed.
Bruno's CLI works on Jenkins, Azure DevOps, GitLab CI, and Bitbucket Pipelines via direct CLI invocation. The Bruno CLI Docker image is the recommended primitive there. See the Bruno CLI docs for platform-specific examples.
| Tag | Behaviour |
|---|---|
@v1 |
Floating major. Receives every backwards-compatible release. |
@v1.2.3 |
Immutable. Pinned to a specific release. |
The v<major> tag is retagged automatically on every published release.
Sandbox migration (Bruno CLI v3+). v3 changed the default sandbox. If your tests rely on Node built-ins (require, Buffer, etc.), add --sandbox developer to command:
command: 'run --env ci --sandbox developer'exit-code is non-zero but failed is 0. The bru process crashed before writing JUnit, or wrote an empty report. Treat as a runtime error — check the step log for stderr. See the exit-code reference below.
Verifying which flags bru was invoked with. The action prints the full bru invocation inside a ::group:: block in the run log, including any flags it auto-injected (--reporter-junit "$RUNNER_TEMP/bruno-junit.xml"). Expand the group to confirm what ran.
bru exits with one of the following codes, available as steps.<id>.outputs.exit-code:
| Code | Meaning | Common cause |
|---|---|---|
| 0 | All requests, tests, and assertions passed | — |
| 1 | One or more requests, tests, or assertions failed | inspect the JUnit XML; fix the failing tests |
| 2 | Reporter output directory does not exist | create the dir or change --reporter-junit path |
| 3 | Request chain caused an infinite loop | break the loop in your collection |
| 4 | bru was invoked outside a collection root |
set working-directory to the collection dir |
| 5 | A file referenced by command was not found |
typo'd path in command |
| 6 | Environment file not found | check environments/<env>.bru exists |
| 7 | --env-var value not parsable as name=value |
fix the quoting in command |
| 8 | --env-var format incorrect |
same — see Bruno CLI docs |
| 9 | Invalid reporter format | only json / junit / html accepted |
| 10 | Failed to parse a .bru / env / config file |
syntax error in the file |
| 11 | Workspace not found (when --workspace-path used) |
check the path |
| 12 | --global-env used without --workspace-path |
add --workspace-path |
| 13 | Global environment file not found | check the global env name |
| 137 | OS killed the process (SIGKILL, usually OOM) |
bigger runner or split the collection |
| 130 | Job was cancelled (SIGINT) |
check timeout-minutes; nothing to fix in command |
| 255 | Unhandled CLI crash (ERROR_GENERIC) |
open an issue in usebruno/bruno with the stderr |
(Source: packages/bruno-cli/src/constants.js in the Bruno repo. Codes may shift across major CLI versions; the nightly workflow catches changes.)
If the run log shows a red Install Bruno CLI step (not Run bru), the failure is in npm install -g @usebruno/cli, not in your collection. outputs.exit-code will be empty because bru never ran. Expand that step and look for one of:
| stderr substring | Likely cause | Fix |
|---|---|---|
E404 / 404 Not Found |
bru-version doesn't exist on npm |
pick a real version (or latest) |
ENOTFOUND / ETIMEDOUT |
runner can't reach registry.npmjs.org |
check corp proxy / network policy |
EACCES |
self-hosted runner missing install permission | adjust npm prefix or run with sudo |
ENOSPC |
self-hosted runner disk full | clear space |
EINTEGRITY |
corrupted npm cache | npm cache clean --force then re-run |
All of these surface npm exit 1. The useful signal is the stderr text, not the exit number.
Network timeouts. Bruno honours HTTP_PROXY / HTTPS_PROXY / NO_PROXY. Set them via env: on the step.
MIT