Mert Özen Further With Every Line
Developer Tools API Tools — #05

A Git-friendly API Client: Keeping Collections in the Repo with Bruno, OpenCollection YAML and Running Them in CI with the CLI

Mert Özen Nov 14, 2026 13 min 12 views
A Git-friendly API Client: Keeping Collections in the Repo with Bruno, OpenCollection YAML and Running Them in CI with the CLI

Keeping a Bruno collection in the repo as OpenCollection YAML files and running it in GitHub Actions with the Bruno CLI turns request changes into readable diffs in code review. A collection with inherited auth for Bookstore, OpenAPI and Postman import (pm.variables.get mistranslated to bru.getVar), secrets via .env, assertions and test scripts, JUnit/HTML reports, the CLI exiting with 2 without a report folder, and a run that silently skips a .bru file in a YAML collection.

Version note: The code in this post was built and run with .NET 10; the collection was run with Bruno CLI 4.3 (@usebruno/cli), and the workflow was checked with actionlint 1.7. The Bruno desktop app's UI and pricing are taken from the official documentation and release notes. I did not run the GitHub Actions workflow on GitHub; I tried it by running its steps locally in the same order.

Guess First

Bookstore's Bruno collection is in the OpenCollection YAML format: each request is a .yml file. Someone on the team copies a request from an old project in the .bru format into the same folder:

meta {
  name: Me
  type: http
  seq: 2
}

get {
  url: {{baseUrl}}/api/auth/me
  body: none
  auth: inherit
}

assert {
  res.status: eq 200
}

bru run runs in CI. What happens: does the CLI stop with a format error, read both formats and run the request, or something else? Is the run green or red? The answer is in the "Common Pitfalls" section; a hint: the worst option is the quietest one.

Bruno's Model and OpenCollection YAML

Bruno is an API client that works without an account or a cloud and keeps collections directly on the file system. A collection is a folder, a request is a file, an environment is a file. The app reads and writes these files and leaves synchronisation to Git. Its core and CLI are MIT licensed. The app has Pro, Ultimate and Enterprise editions. According to the pricing page, some operations of the in-app Git UI and some integrations are in the paid editions; committing the files with your own git commands works in every edition.

On the format side there was an important change in 2026. For years Bruno used its own .bru language. Version 3.0 brought the YAML-based OpenCollection format, and according to Bruno's blog post of 17 February 2026, new and imported collections have been YAML by default since 3.1. The 4.0 changelog says the default format for imports was also made YAML; the CLI I use in this post is 4.3.0. .bru is still supported. The OpenCollection specification is published openly at spec.opencollection.com. Bookstore's collection lives in the repo in the bruno/ folder:

bruno/
├── opencollection.yml
├── .env.example
├── environments/
│   └── dev.yml
├── Identity/
│   ├── folder.yml
│   └── login.yml
├── Catalog/
│   ├── folder.yml
│   └── find-the-hobbit.yml
└── Ordering/
    ├── folder.yml
    ├── create-cart.yml
    ├── add-two-copies.yml
    ├── place-order.yml
    └── read-order.yml

The root file carries the collection's name and the default auth that every request inherits:

opencollection: 1.0.0

info:
  name: Bookstore API

request:
  auth:
    type: bearer
    token: "{{accessToken}}"

extensions:
  bruno:
    ignore:
      - node_modules
      - .git

A request file has four sections: info (name, order), http (method, URL, body, auth), runtime (assertions and scripts) and settings. "Place order":

info:
  name: Place order
  type: http
  seq: 3

http:
  method: POST
  url: "{{baseUrl}}/api/orders"
  body:
    type: json
    data: |-
      {
        "cartId": "{{cartId}}",
        "customerName": "Ada Lovelace",
        "customerEmail": "{{email}}"
      }
  auth: inherit

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "201"
  scripts:
    - type: after-response
      code: |-
        bru.setVar("orderUrl", res.headers["location"]);

settings:
  encodeUrl: true

auth: inherit takes the token from the root file. The login and catalog requests have no auth key at all; in my first attempt I wrote type: none there, and the CLI printed the warning "toBrunoAuth failed: Unsupported auth type" on every run. Looking at the source of Bruno's converter, I saw that for a request without auth you have to remove the key entirely.

Building the Collection: Importing from OpenAPI and Postman

Instead of starting from an empty collection, bru import can generate a skeleton from Bookstore.Api's OpenAPI document. The default format is OpenCollection, and requests are grouped into folders by OpenAPI tags:

$ npx @usebruno/cli import openapi -s openapi.json -o imported -n "Bookstore API"
Reading OpenAPI specification from .../openapi.json...
Converting OpenAPI specification to Bruno format...
Bruno collection created at .../imported
$ find imported -type f | sort | head -8
imported/Catalog/folder.yml
imported/Catalog/get -api-books--isbn-cover.yml
imported/Catalog/get -api-books--isbn.yml
imported/Catalog/get -api-books.yml
imported/Catalog/get -api-categories.yml
imported/Catalog/put -api-books--isbn-cover.yml
imported/Identity/folder.yml
imported/Identity/get -api-auth-me.yml

What we saw in the Postman conversion shows up here as file names: endpoints without a summary get names like get -api-books--isbn.yml. The Bearer security scheme paid off again; protected requests came with auth: type: bearer, and the bodies are skeletons with empty strings. For Bookstore I used the import only as a reference and wrote the requests by hand with short names.

Importing a Postman collection is done by the Bruno app; the CLI's import command only accepts OpenAPI and WSDL. The converter the app uses ships with the CLI as the @usebruno/converters package. I passed the Postman collection from the fourth post through its postmanToBruno function. The collection's pre-request script was translated like this (first lines):

if (req.getName() === "Login") {
    return;
}
const token = bru.getCollectionVar("accessToken");
const expiresAt = Number(bru.getCollectionVar("accessTokenExpiresAt") || 0);
if (token && Date.now() < expiresAt - 30000) {
    return;
}
await bru.sendRequest({
    url: bru.interpolate("{{baseUrl}}/api/auth/login"),
    method: "POST",
    headers: { "Content-Type": "application/json" },
    data: JSON.stringify({ email: bru.getVar("email"), password: bru.getVar("password") })
}, async (error, response) => {

The translation looks right and the converter reported no "issues", but there is a difference in meaning. In Postman, pm.variables.get looks at every scope. In Bruno, bru.getVar only reads runtime variables; for the environment there is bru.getEnvVar. I tried it with a small script in the Bruno CLI:

getVar(email) = null
getEnvVar(email) = ada@example.com
interpolate = ada@example.com

So the translated script would send a null e-mail and password to login. Review the scripts of every imported collection line by line; variable reads and pm.sendRequest callbacks drift the most.

Environments and Secrets

Environments live under environments/, one YAML file each. The password is not in the file; {{process.env.BOOKSTORE_PASSWORD}} is read from the .env file in the collection root or from the process's environment variables:

name: dev
variables:
  - name: baseUrl
    value: http://localhost:5080
  - name: email
    value: ada@example.com
  - name: password
    value: "{{process.env.BOOKSTORE_PASSWORD}}"

Only .env.example goes into the repo. The .gitignore line we saw in the third post provides the protection here as well:

$ git check-ignore -v bruno/.env bruno/reports/bruno.html
.gitignore:7:.env	bruno/.env
.gitignore:494:reports/	bruno/reports/bruno.html

Bruno 4.0 changed something on the secrets side. According to the release notes, the configuration for external secret managers such as AWS, Azure and Vault moved into environment files, and the CLI can read them too. The old {{$secrets.*}} syntax is deprecated and will be removed after three months. Also, values written from a script with bru.setEnvVar() are now saved to disk. The release notes recommend bru.setVar() for sensitive values; that is why I keep the token in a runtime variable.

Assertions, Scripts and bru run

Bruno has two ways to check things. runtime.assertions is a list of expression, operator and value without writing code; a script of type tests is free JavaScript with Chai's expect. The assertions on login and the script that stores the token:

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "200"
    - expression: res.body.tokenType
      operator: eq
      value: Bearer
  scripts:
    - type: after-response
      code: |-
        bru.setVar("accessToken", res.body.accessToken);

"Add two copies" needs a calculation, so I used a test script:

runtime:
  scripts:
    - type: tests
      code: |-
        test("cart total is two copies", function () {
          expect(res.status).to.equal(200);
          expect(res.body.items).to.have.lengthOf(1);
          expect(res.body.total).to.equal(bru.getVar("unitPrice") * 2);
        });

The Bruno CLI runs from the collection root; the order of folders and requests comes from the seq fields. Since 3.0 scripts run in the "safe mode" sandbox by default; if you need external npm packages or the file system, you have to pass --sandbox=developer. Bookstore's scripts use neither:

$ cd bruno
$ mkdir -p reports
$ npx @usebruno/cli run --env dev --reporter-junit reports/bruno-junit.xml --reporter-html reports/bruno.html
Identity/login (200 OK) - 10 ms
Assertions
   ✓ res.status: eq 200
   ✓ res.body.tokenType: eq Bearer
Catalog/find-the-hobbit (200 OK) - 6 ms
Assertions
   ✓ res.status: eq 200
   ✓ res.body.total: eq 1
Ordering/create-cart (201 Created) - 7 ms
Assertions
   ✓ res.status: eq 201
Ordering/add-two-copies (200 OK) - 13 ms
Tests
   ✓ cart total is two copies
Ordering/place-order (201 Created) - 15 ms
Assertions
   ✓ res.status: eq 201
Ordering/read-order (200 OK) - 4 ms
Assertions
   ✓ res.status: eq 200
   ✓ res.body.status: eq Placed

📊 Execution Summary
┌───────────────┬──────────────┐
│ Metric        │    Result    │
├───────────────┼──────────────┤
│ Status        │    ✓ PASS    │
├───────────────┼──────────────┤
│ Requests      │ 6 (6 Passed) │
├───────────────┼──────────────┤
│ Tests         │     1/1      │
├───────────────┼──────────────┤
│ Assertions    │     8/8      │
├───────────────┼──────────────┤
│ Duration (ms) │     323      │
└───────────────┴──────────────┘
Wrote html results to reports/bruno.html
Wrote junit results to reports/bruno-junit.xml
$ grep -c '<testcase' reports/bruno-junit.xml
9

The mkdir -p reports line is not decoration. In my first attempt the folder did not exist; every request passed and the summary said everything succeeded, but the CLI wrote "Output directory ../reports does not exist" and exited with code 2. In CI this means a job that is red because it could not write the report even though the tests passed. It is easy to mistake that for a test failure on first reading.

A run with a wrong password and --bail stops at the first failure:

$ npx @usebruno/cli run --env dev --env-var password=wrong --bail
Identity/login (401 Unauthorized) - 14 ms
Assertions
   ✕ res.status: eq 200
      expected 401 to equal 200
   ✕ res.body.tokenType: eq Bearer
      expected undefined to equal 'Bearer'
Bail: Stopping run, assertion failure in "Login". Remaining 5 request(s) skipped.
...
│ Requests      │ 6 (1 Failed, 5 Skipped (Bail)) │
$ echo $?
1

In a chained flow --bail makes sense: if the token could not be obtained, the 401s of the next five requests carry no new information and only clutter the log.

Running in GitHub Actions

The flow in CI: start SQL Server as a service container, run the API, wait for it to come up, run the collection, keep the reports as artifacts. .github/workflows/api-tests.yml:

name: API tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  bruno:
    runs-on: ubuntu-24.04
    services:
      sql:
        image: mcr.microsoft.com/mssql/server:2025-latest
        env:
          ACCEPT_EULA: "Y"
          MSSQL_SA_PASSWORD: ${{ secrets.CI_SQL_PASSWORD }}
        ports:
          - 1433:1433
        options: >-
          --health-cmd "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P \"$MSSQL_SA_PASSWORD\" -Q 'SELECT 1'"
          --health-interval 10s
          --health-retries 12
    env:
      ConnectionStrings__BookstoreDb: Server=localhost,1433;Database=BookstoreDb;User Id=sa;Password=${{ secrets.CI_SQL_PASSWORD }};TrustServerCertificate=True
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      - uses: actions/setup-node@v7
        with:
          node-version: 24

      - name: Start Bookstore.Api
        run: |
          dotnet run --project src/Bookstore.Api > api.log 2>&1 &
          curl --silent --show-error --fail --retry 30 --retry-connrefused --retry-delay 2 \
            http://localhost:5080/api/categories > /dev/null

      - name: Run the Bruno collection
        working-directory: bruno
        env:
          BOOKSTORE_PASSWORD: ${{ secrets.BOOKSTORE_PASSWORD }}
        run: |
          mkdir -p reports
          npx --yes @usebruno/cli@4.3.0 run --env dev --env-var baseUrl=http://localhost:5080 \
            --reporter-junit reports/bruno-junit.xml --reporter-html reports/bruno.html

      - name: Upload reports
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: bruno-reports
          path: |
            bruno/reports/
            api.log

The decisions: the health check on the service container makes GitHub wait until SQL Server is ready before the steps run; this is required because the API applies migrations at startup. The API runs in the Development environment through its launch profile; the demo users and the random signing key come from there. The wait step is the --retry-connrefused line from the second post. The CLI version is pinned with @4.3.0; you do not want a new major version to change behaviour one morning, and indeed the JUnit report's classname field changed in 4.0. The address is passed from outside with --env-var baseUrl=...; the environment file keeps carrying the local development address. The secrets are repository secrets: SQL Server's sa password and the demo password. The demo password is already visible in appsettings.Development.json, but since the same slot will be a secret in a real environment, I set up the pattern correctly from the start.

I could not run the workflow on GitHub, so I did two things. First I checked its syntax with actionlint; it came out clean. Second I ran the steps locally in the same order: I reset the database and started the API, ran the wait command, removed the .env file and passed the password only as an environment variable:

$ curl --silent --show-error --fail --retry 30 --retry-connrefused --retry-delay 2 http://localhost:5080/api/categories > /dev/null
curl: (7) Failed to connect to localhost port 5080 after 0 ms: Couldn't connect to server
curl: (7) Failed to connect to localhost port 5080 after 0 ms: Couldn't connect to server
$ cd bruno && mkdir -p reports
$ BOOKSTORE_PASSWORD=*** npx --yes @usebruno/cli@4.3.0 run --env dev --env-var baseUrl=http://localhost:5080 \
    --reporter-junit reports/bruno-junit.xml --reporter-html reports/bruno.html
...
│ Status        │    ✓ PASS    │
│ Requests      │ 6 (6 Passed) │
│ Assertions    │     8/8      │
Wrote html results to reports/bruno.html
Wrote junit results to reports/bruno-junit.xml

While the API was starting, curl got two connection refusals and succeeded on the third attempt. process.env worked without .env as well. Bringing the API up as a container with a compose file is another option; that setup is covered in the Docker Compose series.

Diffs in Code Review and a Comparison with .http

The real gain of being file-based shows up in review. I changed the "Add two copies" request to three copies and made the same change in the Postman collection. Bruno's diff:

@@ -10,7 +10,7 @@ http:
     type: json
     data: |-
       {
-        "quantity": 2
+        "quantity": 3
       }
   auth: inherit

@@ -18,10 +18,10 @@ runtime:
   scripts:
     - type: tests
       code: |-
-        test("cart total is two copies", function () {
+        test("cart total is three copies", function () {
           expect(res.status).to.equal(200);
           expect(res.body.items).to.have.lengthOf(1);
-          expect(res.body.total).to.equal(bru.getVar("unitPrice") * 2);
+          expect(res.body.total).to.equal(bru.getVar("unitPrice") * 3);
         });

The Postman JSON diff has the same number of lines but reads differently:

@@ -277,7 +277,7 @@
             },
             "body": {
               "mode": "raw",
-              "raw": "{\n  \"quantity\": 2\n}",
+              "raw": "{\n  \"quantity\": 3\n}",
               "options": {
                 "raw": {
                   "language": "json"
@@ -294,8 +294,8 @@
                   "const cart = pm.response.json();",
                   "pm.test(\"cart has one line with two copies\", () => {",
                   "    pm.expect(cart.items).to.have.lengthOf(1);",
-                  "    pm.expect(cart.items[0].quantity).to.eql(2);",
-                  "    pm.expect(cart.total).to.eql(cart.items[0].unitPrice * 2);",
+                  "    pm.expect(cart.items[0].quantity).to.eql(3);",
+                  "    pm.expect(cart.total).to.eql(cart.items[0].unitPrice * 3);",
                   "});"

The body is inside a string with escaped line breaks, and the script is an array of quoted lines. For a two-line change that is no problem; resolving a ten-line script or a merge conflict in this shape is tedious. Postman v12's YAML format in Native Git aims to close this gap.

Compared with .http files: both live in the repo and their diffs are readable. An .http file sits inside the API project, runs in the IDE itself and needs no extra app; it is enough for a backend developer's daily manual checks. Bruno is independent of the IDE, gives the frontend developer and QA the same interface, and brings assertions, scripts, the CLI and reports together in one dialect. If the team is only backend developers and no flow needs to run in CI, .http is enough. If the team is mixed or the collection will run as a test in CI, Bruno causes fewer problems. In Bookstore the two sit side by side: .http files during development, the Bruno collection for sharing and CI.

In Production

The collection in CI writes orders to a real database. In Bookstore that is a service container built from scratch in every job; if you run against a shared staging database, every run leaves test orders behind. Either use a separate test customer and add a step that cleans it up, or do not run the collection's write requests on staging (for example, give bru run only the Catalog folder).

The HTML report can contain request and response bodies; stored as an artifact, anyone with access to the repo can download it. If you do not want headers with tokens and passwords in the report, the CLI has the --reporter-skip-all-headers and --reporter-skip-body options. The JUnit report also writes the machine's hostname; in CI that is the runner's name, locally it is your computer's name.

Watch Bruno's version upgrades. The 4.0 release notes say collections saved with 4.x may not open for teammates on older versions. Upgrade the app and the CLI as a team at the same time, and update the pinned version in CI in the same commit.

Common Pitfalls

Mixing YAML and .bru in the same collection. I put the file from the guessing question into a copy of Bookstore's collection where I kept only the Identity folder, and ran it with CLI 4.3.0:

$ npx @usebruno/cli run --env dev
Identity/login (200 OK) - 11 ms
Assertions
   ✓ res.status: eq 200
   ✓ res.body.tokenType: eq Bearer
...
│ Status        │    ✓ PASS    │
│ Requests      │ 1 (1 Passed) │

The CLI skipped the .bru file without an error and the run was green. Nobody notices that something did not run. Whatever the collection's format is, every file should be in that format; Bruno's blog post also says a collection should use a single format. During a migration you can check in a CI step that no .bru file is left.

Hard-coding the address in CI. Replacing http://localhost:5080 in the environment file with the staging address and committing it sends everyone's local requests to staging. Pass the address in CI with --env-var or a separate environment file.

Not creating the report folder. Even when the tests pass, the CLI exits with code 2; seeing the job red, you look at the tests first.

Trusting imported scripts. pm.variables.get from Postman is translated to bru.getVar and does not see environment variables; login quietly sends null.

Storing the token with bru.setEnvVar. Since 4.0 that value is written to the environment file, that is, to disk; it can get into the repo with the next commit. Use bru.setVar.

A Small Experiment

★☆☆ In the bruno/ folder, run only the catalog requests with npx @usebruno/cli run Catalog --env dev. Then change the total assertion in find-the-hobbit.yml to 2 and read the error message.

★★☆ Add Identity/me.yml to the collection (as YAML): confirm with a test script that the token owner's e-mail matches email in the environment. See the difference between bru.getEnvVar and bru.getVar for yourself.

★★★ Add a step to the workflow that stops the job if there is a file with the .bru extension under bruno/. Then run the workflow in your own GitHub fork, download the JUnit report from the artifact and see what the classname field that changed in 4.0 looks like.

That brings us to the end of the series: we ran the same Bookstore flow with curl, with .http files in two dialects, with Postman and with Bruno, and saw where each one lives, where it keeps secrets and how it behaves in CI. These collections tell you the flow is up. Testing the API's behaviour in the codebase is the topic of the upcoming Integration and E2E Test series, and measuring the same flow under load belongs to the Performance and Load Testing series.