3.02 Uploading and Downloading Artifacts
Overview
Storing a job's output takes one step; consuming it in a later job takes one more.
actions/upload-artifact saves files from the runner, actions/download-artifact fetches them back.
Abstract
upload-artifact needs two inputs under with: — a name identifying the artifact and a
path pointing at the files. Multiple paths go on separate lines after a |, and exclusions
are written with !. Once the run finishes, the artifact appears at the bottom of the run
summary as a downloadable ZIP. In a later job — which must have needs on the producing job —
download-artifact with the same name fetches and unpacks it into the workspace. The
unpacking is where people get caught: the uploaded folder itself is not recreated, so files
inside dist/ land directly in the working directory.
Why It Matters in Production
This pair is the handoff that lets a pipeline build once and deploy that exact output. The build job validates and produces; the deploy job consumes. Nothing is rebuilt, so nothing can differ.
The directory-flattening behavior matters practically. A deploy step written as
aws s3 sync dist/ s3://bucket works in the build job and fails in the deploy job, because after
download the files are at the root, not under dist/. Knowing that before writing the upload
command saves a debugging cycle on every new pipeline.
Key Concepts
| Input | Action | Purpose |
|---|---|---|
name |
both | Identifier for the artifact; must match between upload and download |
path |
upload-artifact |
File(s) or folder(s) to store — one per line after | |
path |
download-artifact |
Destination directory on the runner (defaults to the workspace) |
retention-days |
upload-artifact |
Days to keep it before deletion |
if-no-files-found |
upload-artifact |
warn (default), error, or ignore |
The path structure rule
The archive preserves paths relative to the least common ancestor of everything you uploaded. This single rule explains all the confusing cases:
Uploaded path |
Least common ancestor | Archive contains | After download |
|---|---|---|---|
dist |
dist/ |
index.html, assets/… |
files at the workspace root |
dist and package.json |
workspace root | dist/, package.json |
dist/ folder plus package.json |
dist/** |
dist/ |
contents of dist | files at the root |
build/out |
build/out/ |
contents of out | files at the root |
The uploaded folder is not recreated
Uploading path: dist on its own stores the folder's contents, so after download the files
sit directly in the job's working directory — there is no dist/ to navigate into. Add a
second path (or use download-artifact's path: input) if you need the folder back.
needs is mandatory, not stylistic
Without needs: build, the deploy job starts in parallel with the build job and the artifact
does not exist yet. The download step fails or finds nothing.
Common Use Cases
- Upload
dist/from a build job and deploy it from a separate job - Upload a binary or installer for manual download and signing
- Bundle several paths (build output plus a manifest) under one artifact name
- Exclude source maps or test fixtures from an otherwise useful artifact
- Download into an explicit directory so deploy commands can keep using familiar paths
Example Configuration or Commands
The full three-job pipeline — test, build with upload, deploy with download:
name: Deploy website
on:
push:
branches:
- main
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Lint code
run: npm run lint
- name: Test code
run: npm run test
build:
needs: test
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build website
run: npm run build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist-files
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Get build artifacts
uses: actions/download-artifact@v4
with:
name: dist-files
- name: Output contents
run: ls
- name: Deploy
run: echo "Deploying..."
The deploy job needs no checkout and no npm ci — it only consumes what the build job produced.
dist/ as a folder alongside
package.json.
Confirming what actually arrived
ls after the download step is the cheapest diagnostic in the whole pipeline. The run log also
reports how many files were downloaded, so a count that looks wrong is visible before any
deploy command runs:
Retrieving the same artifact outside the workflow:
# The run summary page lists artifacts at the bottom; the CLI equivalent:
gh run download <run-id> --name dist-files --dir ./downloaded
ls -R ./downloaded
# Artifact metadata via the REST API
gh api repos/OWNER/REPO/actions/runs/<run-id>/artifacts \
--jq '.artifacts[] | {name, size_in_bytes, expired}'
Version note
The lecture uses @v3. Prefer @v4 for new pipelines: it uploads faster and makes artifacts
immutable. Two consequences — a repeated name within a run now fails instead of merging, and
v3 and v4 cannot be mixed in the same workflow because their storage backends differ.
Best Practices
Best Practices
- Upload only what the next job needs. The redundant extra path is useful for learning, not for a real pipeline.
- Give the download step an explicit
path:matching the folder your deploy commands expect — it removes the flattening surprise entirely. - Always
ls(orls -R) once after adding a download step, before trusting any path in a later command. - Keep the artifact name in one place conceptually; a mismatch between upload and download is the most common failure and produces a confusing "not found".
- Skip checkout and dependency installs in a consuming job unless it genuinely needs the source.
- Exclude source maps and test files from deployment artifacts.
- Set
retention-dayson large or frequent uploads; the default is far longer than useful. - Use
if-no-files-found: errorso a build that produced nothing fails loudly instead of uploading an empty artifact.
Security Best Practices
Security
- Artifacts on a public repository are downloadable by anyone with the link. The upload step is effectively a publish step.
path: .or a bare workspace path sweeps up.git/,node_modules/, and any file a previous step wrote — including credentials materialized at runtime. Name specific paths.- Exclude source maps (
!dist/**/*.map) unless you intend to ship them; they reconstruct your source. - A deploy job that downloads an artifact executes or publishes files it did not build. Keep deploy jobs on trusted triggers, never on fork pull requests.
- Do not use artifacts to pass secrets between jobs. They are stored, downloadable, and retained. Use secrets or job outputs instead.
- Check what the build wrote before uploading. A build that bakes an environment variable into its output puts that value in a downloadable ZIP.
- Keep
retention-daysshort for anything containing internal detail — storage you no longer need is exposure you no longer need.
Do and Don't
| ✅ Do | ❌ Don't |
|---|---|
Keep needs between producer and consumer |
Let the deploy job race the build job |
Use | for multi-line path values |
Put several paths on one line |
Run ls after downloading |
Assume the folder structure survived |
Set path: on download when you want a folder |
Hardcode dist/ in a deploy command without checking |
| Name specific upload paths | Upload the whole workspace |
| Match upload and download names exactly | Rename one and forget the other |
Use @v4 consistently |
Mix @v3 and @v4 in one workflow |
Common Mistakes
Common Mistakes
- Expecting
dist/to exist after download. Uploadingpath: distalone stores its contents; the folder is not recreated. - Missing the pipe. Multiple paths require
path: |with one entry per indented line. - Name mismatch. A typo between upload and download produces "unable to find an artifact", not a helpful diff.
- Forgetting
needs. Parallel execution means the artifact isn't uploaded when the download runs. - Deploy commands with stale paths.
npm run deploy ./distfails after a flattened download. - Checking out code in the deploy job out of habit. Harmless but wasteful, and it can overwrite or mask downloaded files.
- Uploading an empty folder. The build step failed but the upload step only warns by default.
- Reusing an artifact name across matrix jobs. v4 rejects the duplicate.
- Expecting cross-run downloads. By default a download only sees artifacts from the current run.
Breaks in production
A deploy step that silently uploads the wrong directory — the workspace root instead of the
flattened build output — publishes package.json and node_modules/ to the web host and no
index.html. The pipeline is green; the site is broken.
Troubleshooting
# What did the build actually produce? Add before the upload step.
ls -R dist
# What arrived in the consuming job? Add after the download step.
ls -R
# Artifact name, size, and expiry for a run
gh api repos/OWNER/REPO/actions/runs/<run-id>/artifacts \
--jq '.artifacts[] | "\(.name) \(.size_in_bytes) expired=\(.expired)"'
# Pull it locally and inspect the real structure
gh run download <run-id> --name dist-files --dir /tmp/check && find /tmp/check
| Symptom | Likely cause |
|---|---|
| "No files were found with the provided path" | Build output is elsewhere, or the build step failed |
| "Unable to find any artifacts for the associated workflow run" | Name mismatch, missing needs, or wrong run |
Files at the root instead of in dist/ |
Expected flattening — set path: on download |
Nested dist/dist/ after download |
Uploaded multiple paths, then downloaded into dist as well |
| Downloaded file count looks wrong | Upload path too broad or too narrow — check the build job's ls |
| Conflict on upload | Duplicate artifact name within the run (v4) |
| Deploy publishes the wrong files | Deploy command assumes a folder the download flattened away |
Tip
The artifact section at the bottom of the run summary page shows the size. A dist-files
artifact measured in hundreds of megabytes almost always means node_modules/ got swept in by
an over-broad path.
Quick Recap
actions/upload-artifacttakesnameandpathunderwith:.- Multiple paths need
path: |with one entry per line;!excludes. - The finished run lists artifacts at the bottom of the summary page as downloadable ZIPs.
actions/download-artifactwith the matchingnamefetches and unpacks the artifact.- The consuming job must declare
needson the producing job. - Archive paths are relative to the least common ancestor of the uploaded paths.
- Uploading one folder stores its contents, so the folder is not recreated on download.
- Use
download-artifact'spath:input to place files where later commands expect them. lsafter downloading is the fastest way to confirm the structure.
Interview / Revision Notes
Quick revision
Q: Which two inputs does upload-artifact require?
name and path.
Q: How do you upload several paths?
path: | followed by one path per indented line.
Q: How do you exclude files?
Prefix a pattern with ! in the path list.
Q: Where do you find uploaded artifacts manually? At the bottom of the workflow run summary page, as a ZIP download (or via the REST API).
Q: What does download-artifact need?
The same name used on upload, and the job must needs the producing job.
Q: What happens to the folder structure on download?
Paths are relative to the least common ancestor, so uploading just dist puts its contents at
the destination root.
Q: How do you get the folder back?
Set path: dist on the download step, or upload a second path so the common ancestor moves up.
Q: Why does the deploy job not need checkout? It consumes build output, not source.
Q: Why does needs matter here?
Parallel jobs would try to download an artifact that hasn't been uploaded yet.
This topic can be expanded into sub-pages: artifact retention and storage billing, job outputs for passing values, caching dependencies, multi-platform matrix artifacts, and publishing release assets.