GitLab CI
GitLab CI/CD runs tests on every push, but its own reporting resets at the merge request boundary. Gaffer adds run history, flaky detection, and a link you can hand to anyone, whether or not they have GitLab access.
Prerequisites
Section titled “Prerequisites”- A Gaffer account with a project
- Your project token
- A GitLab repository with a
.gitlab-ci.ymlfile
How do I add my Gaffer project token to a GitLab pipeline?
Section titled “How do I add my Gaffer project token to a GitLab pipeline?”Store it as a masked CI/CD variable, not inline in the YAML.
- Go to your GitLab project
- Navigate to Settings → CI/CD → Variables
- Click Add variable
- Key:
GAFFER_PROJECT_TOKEN - Value: your Gaffer project token
- Check Mask variable to hide it in job logs
How do I upload a report after the test job runs?
Section titled “How do I upload a report after the test job runs?”Add the curl call to after_script. Unlike script, after_script runs even when the job’s script section fails, which is when you most need the report.
test: stage: test script: - npm ci - npm test after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'"}' artifacts: when: always reports: junit: test-results/junit.xmlKeeping artifacts:reports:junit alongside the Gaffer upload is intentional: GitLab’s widget gives reviewers an inline pass/fail count on the merge request itself, and Gaffer gives you the history that widget doesn’t keep once the artifact retention window passes.
Which GitLab CI variables carry commit and branch info?
Section titled “Which GitLab CI variables carry commit and branch info?”| Variable | Description | Example |
|---|---|---|
$CI_COMMIT_SHA | Full commit SHA | abc123def456... |
$CI_COMMIT_REF_NAME | Branch or tag name | main, feature/login |
$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME | Source branch in MR pipelines | feature/login |
$CI_NODE_INDEX | 1-based index of the current job in a parallel: job. Not set by parallel:matrix: | 1, 2, 3 |
$CI_PROJECT_NAME | Project name | my-app |
Framework examples
Section titled “Framework examples”Playwright
Section titled “Playwright”playwright: stage: test image: mcr.microsoft.com/playwright:v1.40.0-jammy script: - npm ci - npx playwright install --with-deps - npx playwright test after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@playwright-report/index.html" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'","test_framework":"playwright","test_suite":"e2e"}' artifacts: when: always paths: - playwright-report/Jest with JUnit reporter
Section titled “Jest with JUnit reporter”jest: stage: test script: - npm ci - npm test -- --reporters=default --reporters=jest-junit after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@junit.xml" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'","test_framework":"jest"}' artifacts: when: always reports: junit: junit.xmlpytest
Section titled “pytest”pytest: stage: test image: python:3.11 script: - pip install pytest pytest-html - pytest --html=report.html --self-contained-html after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@report.html" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'","test_framework":"pytest"}' artifacts: when: always paths: - report.htmlUsing CTRF format
Section titled “Using CTRF format”For a standardized format across all your test frameworks, use CTRF:
test: script: - npm ci # Install the CTRF reporter for your framework: # npm install --save-dev jest-ctrf-json-reporter # npm install --save-dev playwright-ctrf-json-reporter # npm install --save-dev vitest-ctrf-json-reporter - npm test -- --reporter=jest-ctrf-json-reporter after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@ctrf-report.json" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'"}'Does Gaffer replace GitLab’s JUnit test report widget?
Section titled “Does Gaffer replace GitLab’s JUnit test report widget?”No, run both. GitLab’s artifacts:reports:junit widget shows pass/fail counts inline on a single merge request and disappears once that job’s artifact retention expires. Gaffer keeps every run’s full history, flags tests that flip pass/fail across branches, and gives you a link that works for people without GitLab access, like a customer asking why a release slipped. Use the widget for the at-a-glance MR check and Gaffer for anything that needs to survive past that one pipeline.
How do I upload from merge request pipelines specifically?
Section titled “How do I upload from merge request pipelines specifically?”Use rules: to detect an MR pipeline, and tag the branch with $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME. That variable is set only in merge request pipelines and names the source branch explicitly. $CI_COMMIT_REF_NAME resolves to the same branch there, so keep it as the fallback for ordinary branch pipelines. The synthetic ref refs/merge-requests/42/head lives in $CI_MERGE_REQUEST_REF_PATH, which is not what you want in a branch tag:
test: rules: - if: $CI_MERGE_REQUEST_IID script: - npm test after_script: - | BRANCH="${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME:-$CI_COMMIT_REF_NAME}" curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$BRANCH"'"}'How do I structure a test-then-upload pipeline with needs:?
Section titled “How do I structure a test-then-upload pipeline with needs:?”Split the upload into its own job that needs: the test job’s artifacts, so it runs as soon as the test job finishes instead of waiting for the whole stage to complete:
stages: - test - report
test: stage: test script: - npm ci - npm test || true # let the pipeline continue to the report job artifacts: when: always paths: - test-results/
upload-to-gaffer: stage: report needs: - job: test artifacts: true script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'"}'needs: creates a direct dependency edge rather than a stage-order one, so upload-to-gaffer starts the moment test finishes instead of waiting on everything else in the test stage. This only works because artifacts: true passes the report file across the job boundary. A GitLab job’s workspace doesn’t persist between jobs on its own: if you split test and upload into separate jobs and skip artifacts:, the upload job won’t find the report file at all, since it’s running on a clean checkout with none of the previous job’s output.
Can I upload to Gaffer from a job that only runs on failure?
Section titled “Can I upload to Gaffer from a job that only runs on failure?”Yes, and after_script already does it. after_script runs even when the preceding script section fails, which is the GitLab equivalent of running a step regardless of exit code. If you would rather have the upload as its own job than as a script hook, gate that job with rules: - when: on_failure, which GitLab evaluates only once a job in an earlier stage has failed.
How do I upload from a GitLab parallel matrix job?
Section titled “How do I upload from a GitLab parallel matrix job?”Tag each upload with one of your matrix variables, such as the $SHARD value below, so you can tell the runs apart later. $CI_NODE_INDEX is not set in parallel:matrix: jobs, only in parallel: jobs that take an integer. Gaffer records each curl call as a separate test run; without a distinguishing tag, four parallel shards produce four runs on the same commit and branch with no way to tell which shard is which.
test: parallel: matrix: - SHARD: [1, 2, 3, 4] script: - npm test -- --shard=$SHARD/4 after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'","shard":"'"$SHARD"'"}'What goes wrong and how do I fix it?
Section titled “What goes wrong and how do I fix it?”| Symptom | Cause | Fix |
|---|---|---|
| 401 API key required, and the variable looks correctly set | The variable is marked Protected but the pipeline is running on an unprotected branch or a fork MR | Unprotect the variable, or restrict the upload job to protected refs with rules: - if: $CI_COMMIT_REF_PROTECTED |
| Upload never runs | The curl call is in script instead of after_script, and the test step failed first | Move the upload to after_script, which runs regardless of the preceding step’s exit code |
| curl can’t find the report file in a later job | Job workspaces don’t persist between jobs without artifacts: | Add artifacts: paths: to the test job and needs: - job: test artifacts: true to the upload job |
Branch shows as a merge-request ref like refs/merge-requests/42/head | Tagging with $CI_MERGE_REQUEST_REF_PATH, which holds the synthetic MR ref rather than a branch name | Use $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME instead |
| 401 Unauthorized, variable is set correctly | Token missing the gfr_ prefix, or extra whitespace from copy-paste | Re-copy the token from project settings, check for trailing newlines in the variable value |
Next Steps
Section titled “Next Steps”- CTRF Guide - Use the universal test format
- Upload API Reference - Full API documentation
- Slack Integration - Get test results in Slack
Other CI Providers: GitHub Actions · CircleCI · Jenkins · Bitbucket · Azure DevOps