Module 3: AI-Specific CI Checks

6. Artifact Management

Overview

When your GitHub Actions workflow runs tests, generates coverage reports, runs prompt evaluations, or estimates costs, all that information lives on the runner — a temporary virtual machine that gets destroyed when the job finishes. If you don't save those files, you lose them forever.

Artifacts in GitHub Actions solve this. An artifact is a file or directory that gets persisted from a workflow run and stays available to download — from GitHub's UI, from the CLI, or from another job in the same workflow:

  1. Persisting results: Test reports, coverage HTML, evaluation logs
  2. Sharing data between jobs: One job generates a file, another job consumes it
  3. Post-hoc debugging: You download the artifact and analyze what happened
  4. Auditing: Keeping evidence that the checks passed and with what results

In the context of AI applications, artifacts are especially valuable. A prompt evaluation generates results you want to compare with the baseline. A cost estimation produces a report you want to review before approving a PR. Without artifacts, all this information exists only while the runner lives.

This capsule teaches you to use actions/upload-artifact@v4 and actions/download-artifact@v4 to save, retrieve, and share files between jobs.


How artifacts work

The lifecycle

Workflow Run
├── Job 1 (runner A)
│   ├── Step: generates report.xml
│   ├── Step: generates evaluation-results.json
│   └── Step: upload-artifact → GitHub Storage
│
├── Job 2 (runner B)
│   ├── Step: download-artifact ← GitHub Storage
│   └── Step: uses evaluation-results.json
│
└── Available artifacts
    ├── test-results (report.xml)
    └── evaluation-results (evaluation-results.json)
    → Downloadable for 90 days
  1. A job generates files on the runner
  2. upload-artifact copies those files to GitHub Storage
  3. Other jobs can download the files with download-artifact
  4. The files stay available in GitHub's UI, associated with the workflow run
  5. After N days (configurable), GitHub deletes them automatically

Limits and retention

AspectLimit
Max size per artifact10 GB
Total size per workflow run10 GB
Default retention90 days
Configurable retention1-400 days
Storage on the Free plan500 MB
Storage on the Pro plan1 GB

For AI projects, the storage limit is the most relevant one. If you save evaluation logs with the LLM's complete responses on every run, the storage fills up fast. Configure a low retention-days for large artifacts and only save the essentials.


Uploading artifacts: actions/upload-artifact@v4

Basic usage

- name: Upload test results
  uses: actions/upload-artifact@v4
  with:
    name: test-results
    path: test-results/report.xml

Main parameters:

ParameterRequiredDescriptionDefault
nameThe artifact's name (unique per workflow run)
pathThe file or directory to upload
retention-daysHow many days it's kept90
if-no-files-foundWhat to do if there are no files: warn, error, ignorewarn
compression-levelCompression level (0-9)6
overwriteOverwrite an existing artifact with the same namefalse

Uploading a file

- name: Run tests
  run: pytest tests/ -v --junitxml=report.xml

- name: Upload test report
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: test-report
    path: report.xml
    retention-days: 30

Uploading a directory

- name: Run tests with coverage
  run: |
    pytest tests/ \
      --cov=src \
      --cov-report=html:coverage-html \
      --cov-report=term-missing

- name: Upload coverage HTML
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: coverage-report
    path: coverage-html/
    retention-days: 30

Uploading multiple paths

- name: Upload all reports
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: all-reports
    path: |
      test-results/
      coverage-html/
      evaluation-logs/
    retention-days: 14

The if-no-files-found parameter

- uses: actions/upload-artifact@v4
  with:
    name: results
    path: results/
    if-no-files-found: warn      # default: a warning but it doesn't fail
    # if-no-files-found: error   # fails the step if there are no files
    # if-no-files-found: ignore  # silent if there are no files

Use error for mandatory results (the pipeline fails if they weren't generated) and ignore for results that are only generated on certain triggers (e.g. only on PRs).


Downloading artifacts: actions/download-artifact@v4

Downloading in another job of the same workflow

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pytest tests/ --junitxml=report.xml
      - uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: report.xml

  summary:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: test-report

      - name: Display results
        run: cat report.xml | head -20

needs: test guarantees that the summary job waits for test to finish. The file keeps its original name and structure.

Downloading to a specific directory

- uses: actions/download-artifact@v4
  with:
    name: evaluation-results
    path: downloaded-evaluations/

Downloading from the CLI

gh run list --limit 5
gh run view 12345 --json artifacts
gh run download 12345 -n test-report

Use cases for AI projects

1. Evaluation logs

When you run prompt regression testing, the detailed results are invaluable for debugging:

- name: Run prompt evaluation
  run: python scripts/evaluate_prompts.py --output evaluation-results/

- name: Upload evaluation logs
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: evaluation-logs
    path: evaluation-results/
    retention-days: 30

When a test fails, you download the artifact and see exactly what output the LLM generated and why it didn't meet the criteria — without having to reproduce the run.

2. Cost reports

- name: Run cost estimation
  run: python scripts/estimate_cost.py --output cost-report.json

- name: Upload cost report
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: cost-report
    path: cost-report.json
    retention-days: 14

3. Baseline files for comparison

- name: Save evaluation baseline
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  uses: actions/upload-artifact@v4
  with:
    name: evaluation-baseline
    path: evaluation-results/baseline.json
    retention-days: 90

The condition github.ref == 'refs/heads/main' guarantees that only main's results are saved as the baseline — PR results are comparisons, not baselines.


Sharing data between jobs

Pattern: Build → Test → Summary

name: CI with Artifacts

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  evaluate:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"
      - run: pip install -r requirements.txt

      - name: Run tests
        run: |
          mkdir -p results
          pytest tests/ -v \
            --junitxml=results/test-report.xml \
            --cov=src \
            --cov-report=term-missing

      - name: Run prompt evaluation
        run: python scripts/evaluate_prompts.py --output results/evaluation.json

      - name: Run cost estimation
        run: python scripts/estimate_cost.py --output results/cost-report.json

      - name: Upload all results
        # Failed evaluations produce the most valuable debugging data — always upload
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: evaluation-results
          path: results/
          retention-days: 30

  summary:
    runs-on: ubuntu-latest
    needs: evaluate
    # Job-level if: always() ensures summary runs even when evaluate fails
    if: always()
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: evaluation-results
          path: results/

      - name: Generate summary
        run: |
          # $GITHUB_STEP_SUMMARY renders Markdown directly in the workflow run UI — no extra tooling needed
          echo "## CI Results Summary" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY

          if [ -f results/evaluation.json ]; then
            PASS_RATE=$(python3 -c "
          import json
          with open('results/evaluation.json') as f:
              data = json.load(f)
          print(data['summary']['pass_rate'])
          ")
            echo "**Prompt Evaluation:** ${PASS_RATE} pass rate" >> $GITHUB_STEP_SUMMARY
          fi

          if [ -f results/cost-report.json ]; then
            COST=$(python3 -c "
          import json
          with open('results/cost-report.json') as f:
              data = json.load(f)
          print(data['total_daily_cost'])
          ")
            echo "**Estimated Daily Cost:** \$${COST}" >> $GITHUB_STEP_SUMMARY
          fi

if: always() on summary guarantees that the summary gets generated even if evaluate fails. $GITHUB_STEP_SUMMARY publishes the summary as Markdown in the workflow run's UI.

Pattern: Parallel jobs → Merge results

When you have several parallel jobs that generate results:

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest tests/unit/ --junitxml=report.xml
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: unit-test-results
          path: report.xml

  integration-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest tests/integration/ --junitxml=report.xml
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: integration-test-results
          path: report.xml

  merge-reports:
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests]
    if: always()
    steps:
      - uses: actions/download-artifact@v4
      - run: find . -name "*.xml" -type f

Without a name in download-artifact, every artifact gets downloaded, each into its own subdirectory.


Best practices for artifacts in AI projects

1. Name artifacts descriptively

# ❌ Generic
  name: results
# ✅ Specific
  name: prompt-evaluation-results

2. Configure retention-days based on importance

retention-days: 30   # Test results: recent debugging
retention-days: 90   # Evaluation baselines: long-term comparisons
retention-days: 7    # Docker build logs: immediate debugging

3. Always use if: always() for debugging uploads

Without if: always(), the upload gets skipped when the previous step fails — exactly when you need the results most.

4. Minimize artifact size

Save metrics and summaries, not the LLM's complete responses on every run. If you need the full responses for debugging, save them with retention-days: 3.

Complete workflow: Artifacts for an AI pipeline
# .github/workflows/ai-pipeline.yml
name: AI Pipeline with Artifacts

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality-checks:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"
      - run: pip install -r requirements.txt
      - run: mkdir -p results/tests results/evaluation results/cost
      - run: ruff check src/ tests/ 2>&1 | tee results/lint-output.txt

      - name: Run tests with coverage
        run: |
          pytest tests/ -v \
            --junitxml=results/tests/report.xml \
            --cov=src \
            --cov-report=term-missing \
            --cov-report=html:results/tests/coverage-html \
            --cov-report=xml:results/tests/coverage.xml

      - run: python scripts/evaluate_prompts.py --output results/evaluation/results.json --baseline baselines/prompt-baseline.json
      - run: python scripts/estimate_cost.py --output results/cost/report.json

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: results/tests/
          retention-days: 30

      - name: Upload evaluation results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: evaluation-results
          path: results/evaluation/
          retention-days: 30

      - name: Upload cost report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: cost-report
          path: results/cost/
          retention-days: 14

  report:
    runs-on: ubuntu-latest
    needs: quality-checks
    if: always()
    steps:
      - uses: actions/download-artifact@v4
        with:
          path: all-results/

      - name: Generate pipeline summary
        run: |
          echo "## AI Pipeline Results" >> $GITHUB_STEP_SUMMARY
          for dir in all-results/*/; do
            name=$(basename "$dir")
            file_count=$(find "$dir" -type f | wc -l)
            echo "| $name | $file_count files |" >> $GITHUB_STEP_SUMMARY
          done

Comparisons

Artifacts vs cache (actions/cache)

AspectArtifactsCache
PurposeSaving a run's resultsSpeeding up future runs
DurabilityKept for N daysEvicted by LRU
Access between jobs✅ With download-artifact✅ Automatic by key
Downloadable by humans✅ From the UI or CLI❌ Only for runners
CI/CD use caseTest reports, evaluation logspip cache, node_modules

Troubleshooting

1. "Artifact name already exists"

Cause: In v4, every artifact must have a unique name within the workflow run.

Solution:

# Unique names per job
  name: test-results-unit
  name: test-results-integration

# Or use overwrite: true
  overwrite: true

2. "No files were found with the provided path"

Cause: The directory doesn't exist or is empty because a previous step failed.

Solution:

- run: mkdir -p results/

- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: results
    path: results/
    if-no-files-found: warn

3. The artifact is too large

Cause: Unnecessary files (__pycache__, the LLM's complete responses).

Solution:

- name: Clean up before upload
  run: |
    find results/ -name "__pycache__" -type d -exec rm -rf {} +
    find results/ -name "*.pyc" -delete

- uses: actions/upload-artifact@v4
  with:
    name: results
    path: results/

Exercises

Exercise 1: Add artifact upload to a test workflow

Modify this workflow to upload the test results and the coverage report as artifacts:

name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: pytest tests/ -v --junitxml=report.xml --cov=src --cov-report=html:coverage-html
See solution
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"
      - run: pip install -r requirements.txt

      - name: Run tests with reports
        run: |
          mkdir -p test-results
          pytest tests/ -v \
            --junitxml=test-results/report.xml \
            --cov=src \
            --cov-report=term-missing \
            --cov-report=html:coverage-html

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: test-results/
          retention-days: 30

      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage-html/
          retention-days: 30

Key points: mkdir -p guarantees the directory exists, if: always() uploads the artifacts even when the tests fail, separate artifacts make it easy to download only what you need.

Exercise 2: Share artifacts between jobs

Write a workflow with two jobs: (1) evaluate runs a script that generates results/evaluation.json, and (2) report downloads the result and publishes a summary in $GITHUB_STEP_SUMMARY.

See solution
name: AI Evaluation Pipeline
on: [push, pull_request]

jobs:
  evaluate:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"
      - run: pip install -r requirements.txt
      - run: |
          mkdir -p results
          python scripts/evaluate_prompts.py --output results/evaluation.json

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: evaluation-results
          path: results/evaluation.json
          retention-days: 30

  report:
    runs-on: ubuntu-latest
    needs: evaluate
    if: always()
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: evaluation-results
          path: results/
        continue-on-error: true

      - name: Generate summary
        run: |
          echo "## Prompt Evaluation Summary" >> $GITHUB_STEP_SUMMARY
          if [ ! -f results/evaluation.json ]; then
            echo "⚠️ No evaluation results available" >> $GITHUB_STEP_SUMMARY
            exit 0
          fi
          python3 -c "
          import json
          with open('results/evaluation.json') as f:
              data = json.load(f)
          s = data.get('summary', {})
          rate = s.get('pass_rate', 0)
          status = '✅' if rate >= 0.8 else '❌'
          print(f'{status} **Pass Rate:** {rate:.0%} ({s.get(\"passed\",0)}/{s.get(\"total\",0)})')
          " >> $GITHUB_STEP_SUMMARY

Key points: needs: evaluate establishes the dependency, continue-on-error: true prevents failures if the artifact doesn't exist, the if [ ! -f ... ] check handles the case where it wasn't downloaded.

Exercise 3: Artifacts with dynamic names in a matrix

Write a workflow with a matrix job that runs tests on Python 3.10, 3.11, and 3.12. Each version must generate its own artifact with a unique name.

See solution
name: Matrix Testing with Artifacts
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12"]
      fail-fast: false
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: "pip"
      - run: pip install -r requirements.txt
      - run: |
          mkdir -p test-results
          pytest tests/ -v --junitxml=test-results/report.xml
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results-py${{ matrix.python-version }}
          path: test-results/
          retention-days: 30

  collect-results:
    runs-on: ubuntu-latest
    needs: test
    if: always()
    steps:
      - uses: actions/download-artifact@v4
        with:
          pattern: test-results-*
          path: all-results/

      - run: |
          echo "## Test Results by Python Version" >> $GITHUB_STEP_SUMMARY
          for dir in all-results/test-results-*/; do
            version=$(basename "$dir" | sed 's/test-results-//')
            echo "### Python $version" >> $GITHUB_STEP_SUMMARY
          done

Key points: name: test-results-py${{ matrix.python-version }} generates unique names, pattern: test-results-* only downloads the matching ones, fail-fast: false guarantees every version runs.

Exercise 4: A retention strategy per artifact type

Design a retention-days strategy for these artifacts of an AI project: test results, coverage HTML, prompt evaluation logs, cost reports, Docker build logs, and evaluation baselines. Justify each value.

See solution
ArtifactRetentionReason
Test results30 daysDebugging recent failures
Coverage HTML14 daysImmediate review on PRs, it goes stale fast
Evaluation logs30 daysComparison across sprints
Cost reports60 daysCovers a billing cycle
Docker build logs7 daysOnly for immediate debugging
Baselines90 daysLong-term reference for detecting gradual regressions

The general principle: retention-days is proportional to the window of time in which the artifact is useful for making decisions.


Summary

  • Artifacts persist files from a workflow run — test results, logs, reports
  • actions/upload-artifact@v4 uploads files to GitHub's storage
  • actions/download-artifact@v4 downloads artifacts in other jobs of the same workflow
  • if: always() is essential on uploads — you need the results most when something fails
  • retention-days controls how long they're kept — configure it based on their usefulness
  • Unique names are mandatory in v4 — use suffixes like -py3.12 in matrix jobs
  • Sharing between jobs uses upload in one job + download with needs: in another
  • Artifacts for AI: evaluation logs, cost reports, baselines
  • Minimize the size: save metrics and summaries, not the LLM's complete responses

Additional resources

  1. actions/upload-artifact@v4 — Official documentation of the action for uploading artifacts
  2. actions/download-artifact@v4 — Official documentation for downloading artifacts
  3. Storing workflow data as artifacts — GitHub's official guide on artifacts
  4. GitHub Actions storage limits — Storage limits per plan