Upload JUnit XML reports from any CI with curl
If your CI provider is not on the dedicated pages, the commands below work in any environment that runs a shell and has curl installed. Adapt the environment variable names to whatever your CI exposes.
The three curl commands
A single build is three calls: register the upload, send the JUnit file to the URL you get back, and finalize the run once every file is in.
# 1. Register the upload. The response includes a presigned URL to send the file to.
response=$(curl -sS -X POST https://app.testnod.com/integrations/test_runs/upload \
-H "Project-Token: $TESTNOD_PROJECT_TOKEN" \
-F "test_run[metadata][build_id]=$BUILD_ID" \
-F "test_run[metadata][branch]=$BRANCH" \
-F "test_run[metadata][commit_sha]=$COMMIT_SHA" \
-F "test_run[metadata][run_url]=$RUN_URL" \
-F "tags[][value]=ci")
# 2. Upload the JUnit file to the presigned URL from that response.
upload_url=$(echo "$response" | grep -o '"presigned_url":"[^"]*"' | cut -d'"' -f4)
curl -sS -H "Content-Type: application/xml" \
--upload-file path/to/junit.xml "$upload_url"
# 3. After the last file for this build is uploaded, finalize the run.
curl -sS -X POST https://app.testnod.com/integrations/test_runs/finalize \
-H "Project-Token: $TESTNOD_PROJECT_TOKEN" \
-d "build_id=$BUILD_ID"
The first call does not carry the report itself; it records the upload and hands back a presigned URL that the PUT in step 2 sends the XML to. That URL expires five minutes after it is issued, so run the two calls back to back rather than splitting them across jobs. Run steps 1 and 2 once per JUnit file you produce (one call pair if your suite runs in a single process, more if you shard across parallel containers), and run finalize exactly once per build. Until finalize lands, the run sits in pending.
The full request and response format for each endpoint is in the Submission API reference.
What you need to provide
| Variable | Where it comes from |
|---|---|
TESTNOD_PROJECT_TOKEN |
A CI secret you set, sourced from your project's settings page in TestNod. |
BUILD_ID |
A unique-per-build identifier from your CI tool. Required. Use the build number, pipeline ID, or whatever your CI exposes that uniquely names this build. The same value goes on every upload and the matching finalize call. |
BRANCH |
Your CI's branch variable, often $CI_BRANCH, $BRANCH_NAME, or $GIT_BRANCH. |
COMMIT_SHA |
Your CI's commit variable, or $(git rev-parse HEAD) as a fallback. |
RUN_URL |
The URL to the build in your CI tool. Skip if your CI does not expose one. |
BUILD_ID is required on the upload. Branch, commit, run URL, and tags are optional but worth setting.
Run on failure too
Whatever construct your CI tool uses for "run this step regardless of earlier failures", use it. Skipping the upload on red builds is the most common day-one mistake, and it hides the runs you most want to inspect.
| CI tool | Always-run directive |
|---|---|
| GitHub Actions | if: ${{ !cancelled() }} |
| GitLab CI | after_script: or when: always |
| CircleCI | when: always on the step |
| Jenkins | post { always { ... } } |
| Buildkite | allow_dependency_failure: true on the dependent step |
| Drone | when: status: [success, failure] |
| TeamCity | "Execute always" build step option |
Inspecting the response from a script
The register call in step 1 is the one most likely to fail outright, since a bad token or a missing build_id is rejected with a 4xx. To log the response and surface a friendlier error to the build log, capture both body and status:
response=$(curl -sS -w "\n%{http_code}" -X POST https://app.testnod.com/integrations/test_runs/upload \
-H "Project-Token: $TESTNOD_PROJECT_TOKEN" \
-F "test_run[metadata][build_id]=$BUILD_ID" \
-F "test_run[metadata][branch]=$BRANCH" \
-F "test_run[metadata][commit_sha]=$COMMIT_SHA")
status=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$status" -ne 201 ]; then
echo "TestNod upload failed (HTTP $status): $body" >&2
exit 1
fi
This pattern is helpful when you want the CI build to fail loudly if the upload itself broke, rather than silently swallowing the error. The body it captures is the JSON you then read presigned_url from for step 2.
Finalize from a separate job
If your CI splits work across multiple jobs (one job per shard, one job per matrix axis), every shard uploads with the same BUILD_ID and a follow-up job calls finalize once. The finalize job needs only the TESTNOD_PROJECT_TOKEN secret and the same BUILD_ID; it does not need the artifact or the source tree.