Skip to main content
Back to Blog
Guide
2026-08-07

CI Artifact Retention Cost Optimization Without Losing Debug Evidence

Use CI artifact retention cost optimization to cut storage and transfer waste while preserving the test evidence engineers need to diagnose failures.

CI Artifact Retention Cost Optimization Without Losing Debug Evidence

CI artifact retention cost optimization is the discipline of keeping each build output for the shortest period that still serves a defined consumer. The practical answer is not one global expiration number. Classify artifacts by purpose, upload fewer artifacts on successful runs, give failures a diagnosis window, and preserve only release or compliance evidence for long periods. Measure stored bytes by artifact class and access age before changing policy.

For QA teams, screenshots, videos, traces, coverage reports, test logs, JUnit XML, packaged applications, and performance results have very different value curves. A Playwright trace from a failed pull request is valuable today and almost useless after the branch is merged. A signed release bundle may need durable, separately governed retention. Treating both alike either wastes money or destroys evidence.

This guide turns that idea into an auditable workflow for GitHub Actions and GitLab CI. It shows how to inventory storage, model cost drivers, change upload conditions safely, validate expiration behavior, and diagnose the common failure where a "small" test suite quietly produces gigabytes of duplicate media.

Start with consumers, not a blanket number of days

Every retained file should have an answer to three questions: who will retrieve it, what decision will it support, and by when? If a team cannot name a consumer, stop uploading it or keep it briefly while collecting access evidence. Do not begin by declaring that all artifacts expire after seven or thirty days. That hides important distinctions and invites exceptions that become permanent.

Build an inventory from workflow files and the CI provider's storage reports or API. Include artifacts created inside archives, because a 20 MB ZIP may unpack into thousands of duplicate logs, and include reports the CI platform ingests separately from downloadable artifacts.

Artifact classPrimary consumerHigh-value windowTypical disposition
Failed UI traceEngineer fixing the current commitHours to a few daysShort retention, failure only
Failed screenshotEngineer or product reviewerCurrent investigationShort retention, failure only
Full test videoRarely needed unless failure is visualShort diagnosis windowRecord on failure or retain briefly
JUnit XMLCI test-report parser and flaky-test analysisPipeline plus trend windowPublish report, retain compact source as needed
Coverage outputReviewer and coverage servicePull request lifetimeKeep summary, avoid redundant HTML archives
Release packageDeployment and rollback systemsRelease lifecycleGovern outside transient test evidence
Performance resultPerformance owner comparing baselinesDefined trend horizonStore compact metrics long-term, raw samples briefly
Security evidenceSecurity and compliance reviewerPolicy-defined periodExplicit controlled class, not default CI debris

Artifact is an overloaded term. A CI job artifact is not automatically a release artifact, backup, legal record, or analytics store. Moving long-lived outputs into a purpose-built registry or object store can improve lifecycle control, but it also changes access, integrity, and audit responsibilities. Make that migration an explicit architecture decision.

Measure the three cost multipliers

Stored bytes are only one driver. Upload and download transfer, runner time spent compressing, developer wait time, and API listing overhead can dominate. The useful measurement unit is artifact class per pipeline outcome, not only total account storage.

For each class, collect:

  • Compressed bytes uploaded per job.
  • Upload frequency and success versus failure ratio.
  • Retention duration configured and effective.
  • Download count and age at last download.
  • Compression and upload time in the critical path.
  • Duplication across shards, retries, and matrix jobs.
  • Whether the same data is already sent to a report service.

A simple monthly approximation helps prioritize work:

stored byte-days = compressed bytes per run
                 x runs per day
                 x average retained days

monthly transfer = uploaded bytes + downloaded bytes

runner overhead = compression seconds + upload seconds + download seconds

Do not present this as an invoice formula. Providers price storage and transfer differently, may include allowances, and can change billing rules. Use current provider billing data for financial forecasts. The byte-day model is a stable engineering comparison: if two policy options use the same provider, their relative storage pressure becomes visible.

Cost multiplierSignal to captureOptimization leverRegression risk
Artifact sizeCompressed bytes by nameExclude noise, reduce media, split classesMissing diagnostic file
Upload rateArtifacts per workflow outcomeUpload heavy evidence only on failureIntermittent pass loses useful baseline
RetentionByte-days by classShorten transient classesEvidence expires before triage
DuplicationHash or path overlap across jobsMerge once, avoid shard bundlesAggregation job becomes bottleneck
Download volumeCount and age of retrievalPublish summaries, selective bundlesEngineers cannot self-serve details
Runner timeStep duration and CPUAvoid compression of unused outputLarger uncompressed transfer

Record a baseline for at least one representative development cycle if possible. A release week, dependency update, or incident can distort failure rates. If urgency requires an immediate change, begin with obviously redundant success media and retain rollback data for the policy itself.

Make test output intentional before tuning expiration

Retention cannot fix an artifact that is unnecessarily huge at creation. First reduce what the test runner emits. For browser tests, capture traces and videos according to the diagnosis policy supported by the runner. Avoid copying the entire working directory into every shard. Remove dependency caches, source checkouts, downloaded browsers, secrets, and temporary server data from artifact paths.

Create a manifest before upload so reviewers can see what is included. This Bash example inventories a known results directory without modifying it:

set -euo pipefail

results_dir="test-results"
if [ ! -d "$results_dir" ]; then
  echo "No test results directory"
  exit 0
fi

du -sh "$results_dir"
find "$results_dir" -type f -print0 \
  | xargs -0 du -k \
  | sort -n \
  | tail -n 30

The command is diagnostic, not a deletion step. Review the largest files and their consumers before adding exclusions.

Common sources of accidental growth include videos recorded for passing tests, one trace per retry and shard, network payload dumps, repeated screenshots in polling loops, HTML reports that embed images while also uploading the image folder, heap dumps, and server logs with unbounded request bodies. Fix the producer where possible. A cleanup script after the fact can hide excessive test behavior and makes local reproduction differ from CI.

Define a tiered retention policy with ownership

A tier is useful only when it has entry criteria and an owner. Name tiers after purpose rather than vague importance. For example: ephemeral diagnostics, active-branch evidence, trend data, release deliverables, and governed records.

Retention tierEntry criteriaFormat strategyOwner and review trigger
Ephemeral diagnosticsReproducible failure evidenceDetailed trace, log, screenshotQA platform, review when median triage time changes
Active-change evidenceNeeded through pull request reviewCompact report and selected diagnosticsRepository maintainers, review when merge cycle changes
Trend metricsUsed for longitudinal analysisStructured, aggregated recordsQuality analytics owner, review with dashboard needs
Release deliverablesDeployable or rollback outputImmutable package plus provenanceRelease engineering, review with support window
Governed recordsExplicit compliance or security requirementAccess-controlled, integrity protectedPolicy owner, review on policy change

Set a default tier for new artifacts, preferably short-lived. Require the workflow author to opt into longer retention with a reason in code review. Add a periodic report that identifies artifact names without a recognized class.

The key distinction is raw versus derived data. A load test may emit millions of samples, but the long-term consumer may need percentiles, thresholds, environment metadata, and a link to the commit. Keep the compact result for trends and raw samples only through the investigation window. Similarly, JUnit XML can feed test analytics while full console output expires sooner.

Configure GitHub Actions uploads around outcomes

GitHub's official actions/upload-artifact action supports a retention-days input, subject to repository or organization retention settings. Use the maintained action documentation at https://github.com/actions/upload-artifact for current behavior. Do not hardcode a number copied from another organization without confirming the allowed policy range.

Separate compact reports from heavy failure evidence so each can have its own condition and retention value.

name: browser-tests
on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run test:e2e

      - name: Upload compact test report
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: e2e-report-${{ github.run_attempt }}
          path: reports/junit.xml
          retention-days: 14

      - name: Upload failure diagnostics
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: e2e-failure-${{ github.run_attempt }}
          path: test-results/
          retention-days: 5

The example values illustrate tiering, not universal recommendations. Align the diagnosis window with actual time to first triage, weekend and holiday coverage, and whether pull requests commonly stay open longer.

if: always() needs thought. It makes the step run after preceding failures, which is useful for reports, but the expected file may not exist if installation or setup failed. The artifact action can be configured for missing-file behavior according to its documented inputs, or you can guard report production. Do not let an absent report turn the original infrastructure failure into a confusing upload failure.

Matrix and shard jobs need unique names. Include stable dimensions such as browser and shard index, not timestamps that defeat grouping. A later aggregation job can download and merge compact reports, but do not download gigabytes of traces merely to re-upload them as one archive.

Apply the same lifecycle reasoning in GitLab CI

GitLab CI job artifacts support an expire_in setting under artifacts, and report artifacts such as JUnit can be declared under artifacts:reports. Consult the current official documentation at https://docs.gitlab.com/ci/jobs/job_artifacts/ because instance policy and project settings affect effective behavior.

browser_tests:
  stage: test
  script:
    - npm ci
    - npm run test:e2e
  artifacts:
    when: on_failure
    expire_in: 5 days
    paths:
      - test-results/
    reports:
      junit: reports/junit.xml

unit_tests:
  stage: test
  script:
    - npm ci
    - npm run test:unit -- --reporter=junit --outputFile=reports/unit.xml
  artifacts:
    when: always
    expire_in: 14 days
    reports:
      junit: reports/unit.xml

Verify the exact test-runner arguments in the repository before using the illustrative unit command. The retention design does not depend on a particular runner flag.

GitLab can ingest JUnit report artifacts for test reporting. A compact report may serve UI and flaky-test analysis even when a downloadable browser evidence bundle expires sooner. The relationship between GitLab reports and repeat failures is explored in GitLab CI JUnit reporting for flaky tests.

Be aware that platform features which keep artifacts from selected pipelines can extend effective retention beyond the job's nominal expire_in. Review project and instance settings, protected references, release links, and manual keep actions before forecasting savings. The test for a policy is observation of actual artifacts over time, not only YAML inspection.

Cancel superseded pipelines before they manufacture waste

Retention deals with stored output after a run. Cancellation prevents stale work from creating output at all. Pull requests often receive several commits while long browser suites are running. If every obsolete run completes, it consumes runner time and uploads evidence no one will inspect.

Use the CI provider's supported concurrency or interruptibility controls based on workflow semantics. Cancellation is safe only when the job has no required external side effect and cleanup remains reliable. Deployment, migration, and stateful environment jobs require separate design.

On GitHub Actions, a concurrency group can identify runs for the same workflow and pull request or reference:

concurrency:
  group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

Test the expression for every event that triggers the workflow. Pull request fields are not present on every event, which is why a reference fallback is shown. A complete cancellation strategy, including teardown and reporting, is covered in canceling stale E2E runs on a new commit.

After enabling cancellation, track canceled jobs separately from failures. Do not upload full failure diagnostics for an expected cancellation unless they are needed to debug cleanup. Confirm that partially written archives do not remain and temporary test environments are reclaimed.

Preserve diagnosis value with selective packaging

Smaller artifacts should still answer the first triage questions: what failed, with which inputs and environment, at what step, and where can the engineer reproduce it? Create a compact manifest that points to the relevant media and excludes unrelated passing cases.

{
  "schema": 1,
  "commit": "COMMIT_SHA_FROM_CI",
  "runAttempt": "RUN_ATTEMPT_FROM_CI",
  "suite": "checkout-chromium",
  "failedTests": [
    {
      "id": "apply-expired-coupon",
      "trace": "traces/apply-expired-coupon.zip",
      "screenshot": "screenshots/apply-expired-coupon.png"
    }
  ]
}

Populate CI metadata from trusted environment variables using a script, not by literally uploading placeholders. Avoid secrets, access tokens, cookies, authorization headers, and customer data. Browser traces can capture DOM content and network information, so short retention does not remove the need for sanitization and access control.

A deterministic packaging script can copy files referenced by failed test results into a staging directory. Fail closed on paths outside the known result root, and keep the original runner output until packaging is validated. Do not use broad recursive deletion in CI cleanup logic.

import path from 'node:path';

export function safeArtifactPath(root: string, candidate: string): string {
  const absoluteRoot = path.resolve(root);
  const absoluteCandidate = path.resolve(root, candidate);
  const prefix = absoluteRoot.endsWith(path.sep)
    ? absoluteRoot
    : absoluteRoot + path.sep;

  if (!absoluteCandidate.startsWith(prefix)) {
    throw new Error(`Artifact path escapes result root: ${candidate}`);
  }
  return absoluteCandidate;
}

This boundary check is about packaging integrity. Follow symbolic links and platform-specific paths only according to a reviewed policy. An even simpler design is for the test runner to write all eligible evidence under one dedicated directory with no untrusted filenames.

Validate retention as a behavior, not a configuration review

A YAML diff proves intent, not outcome. Validate the lifecycle in a non-critical repository or with clearly named canary artifacts. Upload one artifact per tier, record provider ID, creation time, configured expiration, branch or tag context, and whether any keep or release reference applies. Query or inspect them after the expected boundary.

Validation questionEvidenceFailure interpretation
Did the job create only expected classes?Artifact names and sizes per runUpload conditions or paths are broad
Is configured expiry visible?Provider metadata or UISetting rejected, capped, or overridden
Does failure-only evidence skip passes?Successful canary runConditional is wrong if heavy bundle exists
Are canceled runs clean?Canceled canary plus environment inventoryFinalization or teardown is incomplete
Can a developer diagnose before expiry?Timed retrieval exerciseRetention window or manifest is inadequate
Does deletion match policy?Observation after lifecycle boundaryKeep rule, release association, or sweeper lag

Allow for documented asynchronous cleanup. Expiration metadata and physical deletion may not occur at the exact same second. For cost reporting, use provider-reported billable storage rather than assuming a file disappears instantly.

Add policy tests that parse workflow configuration for recognized artifact names and retention inputs. These tests catch accidental omission, but keep them flexible enough for platform syntax. A repository linter can flag uploads of ./, dependency directories, or test results on all successful shards. It should report a reviewable warning before becoming a hard gate.

type ArtifactObservation = {
  name: string;
  bytes: number;
  outcome: 'success' | 'failure' | 'cancelled';
};

export function summarize(observations: ArtifactObservation[]) {
  return observations.reduce<Record<string, { count: number; bytes: number }>>(
    (totals, artifact) => {
      const key = `${artifact.outcome}:${artifact.name.replace(/[0-9]+/g, '#')}`;
      totals[key] ??= { count: 0, bytes: 0 };
      totals[key].count += 1;
      totals[key].bytes += artifact.bytes;
      return totals;
    },
    {},
  );
}

Normalize names carefully. The illustrative expression groups numeric suffixes, but it can collapse meaningful browser versions or shard dimensions. Prefer explicit class labels in artifact metadata when the platform and collection process allow them.

Diagnose the duplicate-media storage spike

A realistic incident starts with a sudden storage alert even though test count barely changed. The workflow has eight shards. Each shard uploads its own test-results folder on every outcome. A report-generation step also embeds all screenshots and then uploads the HTML directory. Retries produce a second trace while the first remains. One failure therefore creates several copies of the same media, and passing runs retain video too.

Diagnose methodically:

  1. Compare artifact bytes by workflow, job, shard, outcome, and run attempt.
  2. List the largest archive members without extracting into an uncontrolled path.
  3. Hash candidate files in a safe working directory to estimate exact duplication.
  4. Compare runner configuration with workflow upload paths.
  5. Check whether a report embeds media, references it, or copies it.
  6. Inspect retry and cancellation behavior.
  7. Confirm effective retention in provider metadata, including keep rules.

The fix may combine failure-only recording, narrower paths, one compact per-shard report, and a single aggregation job. Change one lever at a time or maintain a before-and-after table, so the team knows which optimization produced savings and whether diagnosis time worsened.

What people get wrong is optimizing the visible ZIP instead of the evidence pipeline. Stronger compression can reduce storage but increase runner time, delay test feedback, and still retain redundant content. Deleting all traces after one day can reduce byte-days but make Monday triage impossible for Friday failures. Optimize cost per useful diagnosis, not bytes in isolation.

Review results with engineering and governance signals

Track both efficiency and safety after rollout. Useful metrics include bytes uploaded per pipeline, artifact byte-days by class, upload duration, failure artifact download rate, median age at first download, percentage of downloads after the proposed expiry, and incidents where evidence had already expired.

Compare by outcome. A rising failure rate legitimately increases failure-only storage and signals a test-quality or product problem, not necessarily retention regression. Likewise, a lower download rate might mean better inline reporting, or it might mean artifacts are hard to find. Pair telemetry with short developer interviews.

Review long-lived exceptions quarterly or at a cadence that fits the organization. Every exception should name the artifact, owner, purpose, access boundary, expiration or review date, and replacement plan. Release and compliance records deserve explicit governance, not an arbitrary test-artifact setting.

The mature result is simple to explain: successful pipelines retain compact machine-readable summaries, current failures retain enough evidence for the team's actual triage window, canceled stale runs produce little or nothing, trend systems store aggregates, and release outputs follow their own lifecycle. That structure reduces cost while making the remaining artifacts easier to trust.

Frequently Asked Questions

What retention period should a QA team use for failed test artifacts?

Derive it from diagnosis behavior rather than copying a universal number. Measure time from artifact creation to first useful download, include weekends and team response patterns, and add a small operational margin. Keep failure evidence long enough for the majority of investigations, then provide an approved extension path for incidents. If artifacts are rarely downloaded, first confirm that inline reports are sufficient and that discoverability is not the real problem.

Should JUnit XML and browser traces share the same policy?

Usually not. JUnit XML is compact and can feed CI test reports or flaky-test trends, while browser traces are larger, potentially sensitive, and most valuable during immediate diagnosis. Publish or retain the compact report for the analysis horizon and keep detailed traces for a shorter failure window. Confirm whether the CI platform stores ingested report data separately from downloadable artifacts so your forecast does not double-count or accidentally remove a required source.

Does compressing artifacts always reduce CI cost?

Compression lowers transferred and stored bytes, but it consumes runner CPU and extends the job's critical path. Media such as video and many image formats may already be compressed, so another archive layer can save little. Measure compression time, resulting size, upload duration, and retrieval experience for each artifact class. Removing duplicate or unnecessary files usually produces a clearer gain than choosing an aggressive compression level for an oversized directory.

How can we prove that a retention change is safe?

Run canary pipelines for each tier, inspect the files, ask an engineer to diagnose a seeded failure, and observe provider metadata through the expiration boundary. Monitor late download attempts and expired-evidence incidents after rollout. Keep release and governed records outside the experiment unless their owners approve it. Configuration review is necessary, but proof comes from artifact creation, retrieval, effective expiry, cancellation cleanup, and preserved diagnosis outcomes.