Every CI provider treats test reports as disposable. If you need to host test reports beyond your CI’s retention window — to debug a regression, answer a compliance question, or figure out when a test started flaking — the data is gone. GitHub Actions deletes artifacts after 90 days. GitLab defaults to 30. Bitbucket gives you 14.
CI Artifact Retention Limits
Default retention limits across major CI platforms:
| CI Provider | Default Retention | Max Retention | Access Method |
|---|---|---|---|
| GitHub Actions | 90 days | 400 days (paid) | Download zip, extract locally |
| GitLab CI | 30 days | Configurable | Download zip or browse in UI |
| CircleCI | 30 days | 30 days (hard limit) | Download from artifacts tab |
| Bitbucket Pipelines | 14 days | 14 days (hard limit) | Download from pipeline page |
| Azure DevOps | 30 days | Configurable | Download from build summary |
| Jenkins | Until disk fills up | Manual cleanup | Browse build artifacts (if server is up) |
Even when artifacts exist, accessing them is friction-heavy. Most CI providers require downloading a zip file, extracting it locally, and opening it in a browser. There’s no shareable URL. There’s no way for someone without CI access — a QA lead, a product manager, a contractor — to view the results directly.
The underlying issue is straightforward: CI providers optimize for compute, not for report hosting. Test artifacts are a side effect of the build, not a first-class feature.
Why Teams Need Persistent Test Report Hosting
Hosting test reports isn’t about hoarding data. It solves specific problems that come up repeatedly in engineering teams:
Debugging regressions. A bug resurfaces that was supposedly fixed two months ago. The original test failure had a stack trace and screenshot that would tell you exactly what happened. With CI artifacts expired, you’re starting from scratch.
Flaky test analysis. Identifying flaky tests requires data across dozens or hundreds of runs. If your CI only retains 30 days of artifacts, you’re working with a narrow window — sometimes too narrow to see the pattern.
Cross-team visibility. QA, product, and management often need to see test results. Giving everyone CI access just to view reports is either impractical (permissions) or impossible (external stakeholders).
Audit trails. Regulated industries need evidence that tests passed before deployment. “The CI artifact expired” doesn’t satisfy an auditor.
DIY Approaches and Their Drawbacks
Most teams eventually try to solve this themselves. Each approach works up to a point, then falls apart.
S3 or GCS Bucket
The most common approach: add a CI step to upload reports to cloud storage.
- name: Upload test reports to S3 run: | aws s3 cp ./test-results s3://team-test-reports/${{ github.sha }}/ \ --recursiveThis gives you retention control, but creates new problems:
- No browsable UI. You need to know the exact commit SHA to find a report. There’s no way to browse by branch, date, or test status.
- Access control is manual. S3 bucket policies are notoriously difficult to get right. Too permissive and it’s a security risk. Too restrictive and people can’t access what they need.
- No cleanup automation. Lifecycle rules work for simple cases, but break down when you need different retention per project or per branch.
- HTML reports don’t just work. Playwright reports reference relative assets (CSS, JS, screenshots). Serving them from S3 requires CloudFront or static hosting configuration to get Content-Type headers right.
- Storage costs compound. Without active management, costs grow linearly every month. A team generating 50 GB/month of test artifacts is paying real money within a quarter.
GitHub Pages
Some teams push reports to a dedicated GitHub Pages repo.
- name: Deploy report to GitHub Pages run: | git clone https://[email protected]/org/test-reports.git cp -r ./playwright-report test-reports/$(date +%Y-%m-%d)-${{ github.sha }} cd test-reports && git add . && git commit -m "Add report" && git pushThis creates browsable HTML, but:
- The repo grows unboundedly — Git wasn’t designed for this
- No authentication (GitHub Pages is public by default for free plans)
- No search, no filtering, no analytics
- Cleanup requires rewriting Git history
Custom Server
A few teams build a lightweight Express or Flask app to receive and serve reports. This gives you full control, but now you’re maintaining infrastructure for a problem that isn’t your core product. You need to handle uploads, storage, authentication, cleanup, monitoring, and uptime — for a tool that your team uses but nobody’s job is to maintain.
The Common Thread
Every DIY approach solves the storage problem while creating operational problems. You trade “artifacts expire” for “who maintains this,” “how do I find the report from Tuesday,” and “why is the S3 bill so high.”
What a Purpose-Built Solution Looks Like
A proper test report hosting platform handles the full lifecycle: upload, store, browse, share, analyze, and clean up. It should be framework-agnostic (not just Playwright or just JUnit), CI-agnostic (not just GitHub Actions), and require minimal setup.
Specifically, it should:
- Accept any report format — HTML reports, JUnit XML, CTRF JSON, raw files
- Provide shareable URLs for every test run
- Parse structured data (JUnit XML, CTRF) for analytics — pass rates, flaky tests, duration trends
- Handle retention automatically with per-project controls
- Work with any CI provider through a standard upload mechanism
How Gaffer Handles Test Report Hosting
Gaffer is built specifically for this. Upload test reports from any CI provider, any framework, and access them through shareable URLs with configurable retention.
Upload Any Report Format
Gaffer accepts HTML reports (Playwright, pytest-html, Mochawesome), structured data (JUnit XML, CTRF JSON), and raw files (screenshots, logs, traces). Upload them together or separately.
HTML reports are served directly in the browser — your team sees the full interactive report without downloading anything.
Structured data (JUnit XML, CTRF JSON) is parsed for analytics: pass/fail counts, test durations, failure messages. This feeds into trend tracking, flaky test detection, and pass rate history.
Framework-Agnostic Setup
Gaffer works with whatever your tests already produce. Here are two common patterns:
JUnit XML (works with nearly every test framework):
# GitHub Actions- name: Run tests run: npx vitest --reporter=junit --outputFile=results.xml
- name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_UPLOAD_TOKEN }} report_path: ./results.xmlCTRF JSON (standardized format across frameworks):
- name: Run tests run: npx playwright test --reporter=ctrf-json-reporter
- name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_UPLOAD_TOKEN }} report_path: ./ctrf/ctrf-report.jsonHTML reports with structured data (both human-readable and machine-parseable):
- name: Run Playwright tests run: npx playwright test
- name: Upload HTML report to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_UPLOAD_TOKEN }} report_path: ./playwright-report
- name: Upload CTRF results to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_UPLOAD_TOKEN }} report_path: ./ctrf/ctrf-report.jsonNon-GitHub CI Providers
For GitLab CI, CircleCI, Jenkins, Bitbucket Pipelines, or Azure DevOps, use the CLI upload:
curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_UPLOAD_TOKEN" \ -F "files=@./results.xml" \ -F 'tags={"branch":"'"$CI_BRANCH"'","commitSha":"'"$CI_COMMIT_SHA"'"}'Three lines. Works anywhere you can run curl.
Share with Anyone
Every upload gets a URL in the Gaffer dashboard. Team members with access can browse reports by project, branch, or date.
For people outside your organization — contractors, stakeholders, clients — generate a share link with a configurable expiration. Recipients view the report directly in their browser without needing a Gaffer account.
Retention You Control
| Plan | Retention | Storage |
|---|---|---|
| Free | 7 days | 500 MB |
| Pro ($15/mo) | 30 days | 10 GB |
| Team ($49/mo) | 90 days | 50 GB |
All plans include unlimited users — no per-seat pricing. Configure retention per project: keep production test reports for 90 days while feature branch reports auto-delete after 7.
Analytics from Structured Data
When you upload JUnit XML or CTRF JSON alongside your HTML reports, Gaffer parses the structured data and gives you:
- Pass rate trends — track stability across builds
- Flaky test detection — identify tests that flip between pass and fail
- Duration tracking — catch tests that are getting slower
- Failure patterns — see which tests fail most frequently
These work across multiple test frameworks. A project with Playwright E2E tests and Vitest unit tests shows unified analytics in one dashboard.
Comparing Your Options
| CI Artifacts | S3 + CloudFront | GitHub Pages | Gaffer | |
|---|---|---|---|---|
| Retention | 14-90 days | Unlimited (manual cleanup) | Unlimited (repo grows) | 7-90 days (auto cleanup) |
| Shareable URLs | No (download zip) | Yes (with setup) | Yes (public only) | Yes |
| HTML report viewing | Download required | Yes (with config) | Yes | Yes |
| Structured data parsing | No | No | No | Yes |
| Analytics | No | No | No | Yes |
| Access control | CI permissions only | S3 policies | Public or private repo | Team-based + share links |
| Setup time | Built-in | Hours | Hours | Minutes |
| Maintenance | None | Ongoing | Ongoing | None |
Get Started
Gaffer’s free tier includes 500 MB of storage with 7-day retention — enough to evaluate the workflow.