Documentation

TestNod documentation

Learn how to set up TestNod and configure it to help you spot flaky tests, catch regressions, and see how performance changes over time.

GitHub Actions integration for JUnit XML uploads

The TestNod uploader action sends your JUnit XML reports to TestNod from a GitHub Actions workflow. It keeps the change to your CI down to a single step, and it handles the upload and finalize flow for you, so there are no API calls to script yourself.

If you haven't created a project yet, the Quickstart walks through that and adding your first upload step. This page picks up from there, covering the action's inputs along with the patterns for matrix builds, parallel runners, and pull requests from forks.

Add the project token as a GitHub secret

In your repository, open Settings → Secrets and variables → Actions and add a repository secret named TESTNOD_PROJECT_TOKEN, using the project token from your project's settings page in TestNod as the value. The workflow reads it through ${{ secrets.TESTNOD_PROJECT_TOKEN }}, so the token never appears in your workflow YAML or in workflow logs.

GitHub Actions secrets page showing the new TESTNOD_PROJECT_TOKEN repository secret

For more information on GitHub secrets, see "Using secrets in GitHub Actions" in GitHub's documentation.

Add the upload step

Add the upload step after your test step. The if: ${{ !cancelled() }} directive runs the upload on both passing and failing test runs, since the failing ones are usually what you most want to inspect, while still letting a cancelled workflow stop cleanly.

The following workflow snippet shows an example of a test job that runs RSpec and uploads the JUnit XML report to TestNod:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run tests
        run: bundle exec rspec --format progress \
          --format RspecJunitFormatter --out tmp/rspec.xml

      - name: Upload test results to TestNod
        if: ${{ !cancelled() }}
        uses: testnod/testnod-uploader-action@v1
        with:
          file: tmp/rspec.xml
          token: ${{ secrets.TESTNOD_PROJECT_TOKEN }}
          tags: ci,rspec
          ignore-failures: true

That single step covers the whole upload. By default the action uploads the file and finalizes the run in one pass, so a workflow that produces one JUnit XML file needs nothing else.

Action inputs

Input Required Default What it does
token Yes The project token. Read it from the secret you created above.
file Conditional "" Path to the JUnit XML report. Required unless finalize is set to only.
tags No "" Comma-separated tags applied to the run. Tags group runs in the dashboard and scope TestNod's analysis to comparable runs.
ignore-failures No false When true, an upload error logs a warning instead of failing the step.
build-id No ${{ github.run_id }} The identifier that groups uploads into one TestNod run. Override it to split runs apart or merge them.
finalize No true true uploads the file and finalizes the run, false uploads without closing the run, and only finalizes without uploading.
uploader-version No latest Pins the underlying uploader to a specific version instead of tracking the latest release.

Metadata attached automatically

The action reads the branch name, commit SHA, and build ID from the workflow environment, and it builds a link back to the GitHub Actions run. All of it travels with the upload, so the TestNod run page points straight back at the CI run that produced it. None of it needs to be passed by hand.

Why if: ${{ !cancelled() }}

The directive does two things worth calling out:

  • It runs the upload step even when bundle exec rspec exits non-zero, so a failing run still reaches TestNod.
  • It's preferred over if: always() because GitHub's own docs warn that always() can keep a workflow from being cancelled in a timely manner, since cancellation signals get swallowed alongside failure signals.

Matrix and parallel builds

Matrix jobs and parallel runners share the same github.run_id, which the action uses as the default build-id. Every shard that uploads with that build ID joins the same TestNod run, which ends up made of one upload per shard.

Because the run stays open while shards are still reporting, each shard uploads with finalize: false, and a follow-up job finalizes once with finalize: only:

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        ruby: ["3.2", "3.3"]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run tests
        run: bundle exec rspec --format RspecJunitFormatter --out tmp/rspec.xml

      - name: Upload test results to TestNod
        if: ${{ !cancelled() }}
        uses: testnod/testnod-uploader-action@v1
        with:
          file: tmp/rspec.xml
          token: ${{ secrets.TESTNOD_PROJECT_TOKEN }}
          tags: ci,rspec
          ignore-failures: true
          finalize: false

  finalize-testnod:
    needs: test
    if: ${{ !cancelled() }}
    runs-on: ubuntu-latest
    steps:
      - name: Finalize TestNod run
        uses: testnod/testnod-uploader-action@v1
        with:
          token: ${{ secrets.TESTNOD_PROJECT_TOKEN }}
          finalize: only

needs: test together with if: ${{ !cancelled() }} on the finalize job means it runs after the matrix on both success and failure, while still stopping cleanly if the workflow is cancelled. Setting fail-fast: false keeps the matrix from cancelling its siblings on the first red shard, which would otherwise skip those uploads.

If you would rather track each matrix combination as its own TestNod run, with separate history and alerts per combination, give each shard a distinct build-id and let each finalize on its own with the default finalize: true. A value like build-id: ${{ github.run_id }}-ruby-${{ matrix.ruby }} is enough to keep the runs apart, and the separate finalize job is no longer needed.

Pull requests from forks

GitHub Actions does not expose secrets to workflows triggered by pull requests from forks. On those runs the token input is empty, and the TestNod API rejects the upload with a 404. With ignore-failures: true the step won't fail your workflow, but the upload still won't land. There are two ways to handle it:

  • Gate the upload step so it only runs for pushes and internal pull requests by adding github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository to the step's if condition, alongside the !cancelled() check.
  • Run external-fork tests in a separate workflow that does not attempt an upload.

Reusable workflow

If you maintain many repositories, factor the upload step into a reusable workflow and call it from each project. The action's contract is small enough that a short reusable workflow covers the common cases, and every repository then picks up changes from one place.

What's next

The Test run page tour walks through the insights TestNod surfaces once your runs start arriving. If a run doesn't show up or its status looks wrong, Troubleshooting uploads covers the most common causes.

Be first to try TestNod

We're opening early access soon. Drop your email and we'll get you in, and we're happy to help you set up too.

No spam. We'll only email you about TestNod.