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:
- Persisting results: Test reports, coverage HTML, evaluation logs
- Sharing data between jobs: One job generates a file, another job consumes it
- Post-hoc debugging: You download the artifact and analyze what happened
- 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
- A job generates files on the runner
upload-artifactcopies those files to GitHub Storage- Other jobs can download the files with
download-artifact - The files stay available in GitHub's UI, associated with the workflow run
- After N days (configurable), GitHub deletes them automatically
Limits and retention
| Aspect | Limit |
|---|---|
| Max size per artifact | 10 GB |
| Total size per workflow run | 10 GB |
| Default retention | 90 days |
| Configurable retention | 1-400 days |
| Storage on the Free plan | 500 MB |
| Storage on the Pro plan | 1 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:
| Parameter | Required | Description | Default |
|---|---|---|---|
name | ✅ | The artifact's name (unique per workflow run) | — |
path | ✅ | The file or directory to upload | — |
retention-days | ❌ | How many days it's kept | 90 |
if-no-files-found | ❌ | What to do if there are no files: warn, error, ignore | warn |
compression-level | ❌ | Compression level (0-9) | 6 |
overwrite | ❌ | Overwrite an existing artifact with the same name | false |
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)
| Aspect | Artifacts | Cache |
|---|---|---|
| Purpose | Saving a run's results | Speeding up future runs |
| Durability | Kept for N days | Evicted 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 case | Test reports, evaluation logs | pip 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
| Artifact | Retention | Reason |
|---|---|---|
| Test results | 30 days | Debugging recent failures |
| Coverage HTML | 14 days | Immediate review on PRs, it goes stale fast |
| Evaluation logs | 30 days | Comparison across sprints |
| Cost reports | 60 days | Covers a billing cycle |
| Docker build logs | 7 days | Only for immediate debugging |
| Baselines | 90 days | Long-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@v4uploads files to GitHub's storage - ✅
actions/download-artifact@v4downloads artifacts in other jobs of the same workflow - ✅
if: always()is essential on uploads — you need the results most when something fails - ✅
retention-dayscontrols how long they're kept — configure it based on their usefulness - ✅ Unique names are mandatory in v4 — use suffixes like
-py3.12in matrix jobs - ✅ Sharing between jobs uses
uploadin one job +downloadwithneeds: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
- actions/upload-artifact@v4 — Official documentation of the action for uploading artifacts
- actions/download-artifact@v4 — Official documentation for downloading artifacts
- Storing workflow data as artifacts — GitHub's official guide on artifacts
- GitHub Actions storage limits — Storage limits per plan