3.01 Job Artifacts
Overview
A job that builds something produces files. Job artifacts are how those files leave the runner — either for a human to download, or for a later job to pick up.
Abstract
An artifact is the output of a job: website files, an app binary, an installer package, test
reports, coverage data, log files. Because every job runs on a fresh runner whose filesystem is
discarded when the job ends, anything worth keeping must be uploaded explicitly with
actions/upload-artifact. Stored artifacts can then be downloaded manually through the GitHub
UI or the REST API, or automatically inside another job with actions/download-artifact.
Why It Matters in Production
Artifacts are the seam between build and deploy. Building once and shipping that exact output is the difference between deploying what you tested and deploying something rebuilt from the same source and hoping it is identical.
They are also the only practical way to debug a failed run. Test logs, coverage reports, screenshots from a failed browser test, and crash dumps all vanish with the runner unless uploaded.
And they cost money. Artifact storage counts against the account's storage allowance, so retention and what you choose to upload are real budget decisions on an active repository.
Key Concepts
| Concept | Meaning |
|---|---|
| Job artifact | Files or folders produced by a job and stored by GitHub |
| Ephemeral runner | Each job's filesystem is destroyed when the job ends |
actions/upload-artifact |
Stores files from the runner under a named artifact |
actions/download-artifact |
Retrieves a stored artifact into a later job's workspace |
| Manual retrieval | Download from the run summary page, or via the REST API |
| Retention | How long GitHub keeps the artifact before deleting it |
Two ways to consume an artifact
| Route | How | Typical use |
|---|---|---|
| Manually | GitHub UI (run summary → Artifacts) or REST API | Inspect test logs, grab a binary to upload to an app store |
| In another job | actions/download-artifact |
Deploy job consumes the build job's output |
Artifacts vs caches vs outputs
An artifact is a deliverable you want to keep or hand to another job. A cache is a
speed optimization GitHub may evict at any time. A job output is a small string value
(a version number, an image tag) passed via needs, not a file. Don't use a cache where you
mean an artifact — a cache miss is silent.
Common Use Cases
- Build once in a
buildjob, deploy the uploaded output in adeployjob - Keep website
dist/files for upload to a hosting provider - Store a mobile app package or desktop executable for manual distribution
- Attach test reports, coverage output, or JUnit XML to the run for inspection
- Capture log files and screenshots from a failing test so they survive the run
- Produce a release binary a human downloads and signs before publishing
Example Configuration or Commands
Build in one job, deploy the stored output in another:
name: Deploy Project
on:
push:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4
- name: Install NodeJS
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Upload build output
uses: actions/upload-artifact@v4
with:
name: dist-files
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build output
uses: actions/download-artifact@v4
with:
name: dist-files
path: dist
- name: Output contents
run: ls -R dist
- name: Deploy
run: echo "Deploying..."
v4 behaves differently from v3
In upload-artifact@v4 each artifact is immutable and uploaded once: you cannot append to an
existing name, and a second upload under the same name fails rather than merging. Downloads are
also scoped to the current workflow run by default. Mixing v3 and v4 in one workflow does not
work — they use incompatible storage backends.
Working with artifacts outside a workflow:
# List artifacts for a run
gh run view <run-id> --json displayTitle
# Download all artifacts of a run to the current directory
gh run download <run-id>
# Download one named artifact
gh run download <run-id> --name dist-files --dir ./dist
# Via the REST API
gh api repos/OWNER/REPO/actions/runs/<run-id>/artifacts --jq '.artifacts[] | {name, size_in_bytes, expired}'
Best Practices
Best Practices
- Build once, upload, and deploy the uploaded artifact. Rebuilding in the deploy job wastes minutes and breaks the guarantee that you shipped what you tested.
- Upload only what the next step needs.
dist/is an artifact;node_modules/is not. - Set
retention-daysexplicitly — the repository default (up to 90 days) is usually far longer than anything is useful. - Name artifacts descriptively and uniquely, especially in matrix jobs where parallel uploads would otherwise collide.
- Attach diagnostics with
if: failure()so failed runs leave evidence behind. - Use
if-no-files-found: errorwhen an empty upload means the build silently produced nothing. - Set
compression-level: 0for already-compressed content (images, zips, videos) — it saves time without saving bytes. - Keep artifacts out of the repository itself. Committing build output back to Git is the anti-pattern artifacts exist to replace.
Security Best Practices
Security
- Artifacts of a public repository are downloadable by anyone. Treat every upload as
published: no
.envfiles, no credentials, no internal hostnames. - Source maps and debug symbols leak implementation detail. Exclude them explicitly
(
!dist/**/*.map) unless you intend to ship them. - Never upload
node_modules/,.git/, or a whole workspace directory. Broad paths sweep up configuration and tokens written during the run. - Secrets injected as environment variables can end up inside build output. Scan what you upload before making a repository public.
- Downloading an artifact produced by an untrusted job (a fork PR, for example) means executing or deploying files you did not build. Keep deploy jobs on trusted triggers only.
- Artifacts count against storage billing, so an attacker who can trigger workflows can also run up cost. Keep retention short and fork-PR approval on.
- Deleting an artifact removes it from storage, but anything already downloaded is out of your control — rotate anything exposed rather than relying on deletion.
Do and Don't
| ✅ Do | ❌ Don't |
|---|---|
Upload dist/ and pass it to the deploy job |
Rebuild the project in every job |
Set retention-days for short-lived output |
Leave everything at the 90-day default |
| Use unique artifact names in matrix jobs | Upload the same name from parallel jobs |
Attach logs with if: failure() |
Lose the evidence when a run fails |
| Exclude source maps and secrets | Upload the whole workspace |
| Use an artifact for deliverables | Use a cache for anything you must have |
| Keep v4 actions consistent across the workflow | Mix upload-artifact@v3 and @v4 |
Common Mistakes
Common Mistakes
- Expecting files to survive between jobs. Each job gets a clean runner; without an upload,
dist/does not exist downstream. - Forgetting
needs. Without it the deploy job starts in parallel and the artifact isn't uploaded yet. - Path confusion on download.
download-artifactextracts the artifact's contents intopath:, so uploadingdistand downloading intodistgives youdist/index.html, notdist/dist/index.html. Check withls -R. - Uploading the parent folder by accident.
path: diststores the folder's contents;path: ./stores the entire workspace. - Duplicate names in a matrix. v4 rejects the second upload instead of merging.
- Assuming artifacts cross workflow runs. By default a download sees only the current run's artifacts; other runs need the REST API or extra configuration.
- Relying on a cache as a handoff. Caches can be evicted; artifacts are guaranteed until they expire.
- Skipping the upload step on failure. Steps after a failed step don't run unless marked
if: failure()orif: always().
Breaks in production
A deploy job that rebuilds from source instead of downloading the tested artifact can ship different bytes than CI validated — a dependency resolved differently, a changed build-time environment variable — and the test results say nothing about what actually went live.
Troubleshooting
# What did the build job actually produce? Add this before uploading.
ls -R dist
# Artifact sizes and expiry for a run
gh api repos/OWNER/REPO/actions/runs/<run-id>/artifacts \
--jq '.artifacts[] | "\(.name)\t\(.size_in_bytes)\t expired=\(.expired)"'
# Pull an artifact down locally to inspect it
gh run download <run-id> --name dist-files --dir /tmp/check && ls -R /tmp/check
# Check the repository's artifact retention setting
gh api repos/OWNER/REPO/actions/permissions --jq '.'
| Symptom | Likely cause |
|---|---|
| "No files were found with the provided path" | Build output elsewhere, or the build step failed silently |
| Download step finds nothing | Missing needs, wrong artifact name, or a different workflow run |
| Nested directory after download | Mismatch between the uploaded path and the download path: |
| Upload fails with a conflict | v4 artifact name already used in this run |
| Artifact missing from an older run | Retention period elapsed |
| Deploy job can't see build output | No upload/download pair — runners don't share a filesystem |
| Storage quota warnings | Long retention plus large or frequent uploads |
| Mixed v3/v4 errors | The two versions use incompatible backends |
Tip
Artifacts appear in the run summary page under Artifacts once the run completes, with a
download link per artifact. That page is also where you confirm the size, which is the fastest
way to notice you uploaded node_modules/ by mistake.
Quick Recap
- A job artifact is the output a job produces: binaries, website files, packages, reports, logs.
- Runners are ephemeral, so anything worth keeping must be uploaded explicitly.
actions/upload-artifactstores files;actions/download-artifactretrieves them.- Artifacts can be downloaded manually via the GitHub UI or REST API, or automatically in another job.
- Combine with
needsso the consuming job runs after the producing one. - Set
retention-daysand upload only what is needed — storage is billed. - v4 artifacts are immutable and scoped to the current run; names must be unique.
- Artifacts on public repositories are publicly downloadable.
Interview / Revision Notes
Quick revision
Q: What is a job artifact? Files or folders produced by a job and stored by GitHub beyond the life of the runner.
Q: Why are they needed? Each job runs on a fresh runner whose filesystem is discarded when the job ends.
Q: Give examples. Website build output, app binaries, installer packages, test reports, coverage data, log files.
Q: Two ways to consume an artifact?
Download manually via the UI or REST API, or automatically in another job with
download-artifact.
Q: Which actions handle artifacts?
actions/upload-artifact and actions/download-artifact.
Q: Artifact vs cache? An artifact is a deliverable guaranteed to exist until it expires; a cache is an optimization that may be evicted.
Q: How do you keep logs from a failing job?
Upload them in a step marked if: failure().
Q: How do you stop matrix jobs from clashing on upload? Include matrix values in the artifact name.
Q: What changed in v4? Artifacts became immutable, same-name uploads fail instead of merging, and downloads are scoped to the current run.
This topic can be expanded into sub-pages:
upload-artifactanddownload-artifactinputs in depth, job outputs vs artifacts, caching dependencies, artifact retention and storage billing, and release asset publishing.