How to Host Test Reports Beyond CI Artifact Expiration

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 ProviderDefault RetentionMax RetentionAccess Method
GitHub Actions90 days400 days (paid)Download zip, extract locally
GitLab CI30 daysConfigurableDownload zip or browse in UI
CircleCI30 days30 days (hard limit)Download from artifacts tab
Bitbucket Pipelines14 days14 days (hard limit)Download from pipeline page
Azure DevOps30 daysConfigurableDownload from build summary
JenkinsUntil disk fills upManual cleanupBrowse 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 }}/ \
--recursive

This 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 push

This 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:

  1. Accept any report format — HTML reports, JUnit XML, CTRF JSON, raw files
  2. Provide shareable URLs for every test run
  3. Parse structured data (JUnit XML, CTRF) for analytics — pass rates, flaky tests, duration trends
  4. Handle retention automatically with per-project controls
  5. 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.xml

CTRF 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.json

HTML 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.json

Non-GitHub CI Providers

For GitLab CI, CircleCI, Jenkins, Bitbucket Pipelines, or Azure DevOps, use the CLI upload:

Terminal window
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

PlanRetentionStorage
Free7 days500 MB
Pro ($15/mo)30 days10 GB
Team ($49/mo)90 days50 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 ArtifactsS3 + CloudFrontGitHub PagesGaffer
Retention14-90 daysUnlimited (manual cleanup)Unlimited (repo grows)7-90 days (auto cleanup)
Shareable URLsNo (download zip)Yes (with setup)Yes (public only)Yes
HTML report viewingDownload requiredYes (with config)YesYes
Structured data parsingNoNoNoYes
AnalyticsNoNoNoYes
Access controlCI permissions onlyS3 policiesPublic or private repoTeam-based + share links
Setup timeBuilt-inHoursHoursMinutes
MaintenanceNoneOngoingOngoingNone

Get Started

Gaffer’s free tier includes 500 MB of storage with 7-day retention — enough to evaluate the workflow.

Start Free