Jobs share nothing. Two jobs in the same workflow run on separate machines with separate filesystems,
so the build job’s dist/ directory simply does not exist for the deploy job. Artifacts are how you
move files across that boundary — and how you get them out of the run entirely.
Artifact or cache?
Section titled “Artifact or cache?”| Artifact | Cache | |
|---|---|---|
| Purpose | Output you want to keep or pass on | Speed optimisation |
| Downloadable by people | Yes, from the run page | No |
| Retention | Explicit, up to 90 days (configurable) | LRU eviction against a quota |
| Missing one | Breaks the dependent job | Only makes the job slower |
| Scoped by branch | No — visible with repository read access | Yes |
The clarifying question: if this disappears, does the pipeline break or just get slower? Breaks means artifact. Slower means cache.
Uploading
Section titled “Uploading”- uses: actions/upload-artifact@v7 with: name: dist path: ./dist retention-days: 7 if-no-files-found: errorif-no-files-found defaults to warn, which is a poor default for a build output. A build that
silently produced nothing then uploads an empty artifact, and the deploy job downloads nothing and
deploys nothing — with green ticks all the way. Set error.
Multiple paths and exclusions:
path: | dist/** !dist/**/*.map reports/junit.xmlPassing output between jobs
Section titled “Passing output between jobs”jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: npm ci && npm run build - uses: actions/upload-artifact@v7 with: name: dist path: ./dist if-no-files-found: error
deploy: needs: build runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v8 with: name: dist path: ./dist - run: ./deploy.sh ./distWhat it doesMoves a directory from one job to another through artifact storage.
Why we run itJobs run on separate runners with separate filesystems. Rebuilding in the deploy job would deploy something the test job never saw.
Expected resultThe deploy job works with the exact bytes the build job produced.
This is the “build once, deploy that” principle, and it is the reason to bother. Rebuilding in the deploy job means deploying an artifact nothing tested — a different dependency resolution, a different timestamp, potentially different code.
Omitting name: on download fetches all artifacts, each into a subdirectory named after it.
Useful for a job that collects matrix results.
Artifacts are immutable
Section titled “Artifacts are immutable”An artifact name can be uploaded once per run. A second upload under the same name fails.
This bites hardest on a matrix, where every leg runs the same upload step:
- uses: actions/upload-artifact@v7 with: name: coverage-${{ matrix.os }}-${{ matrix.node }} path: coverage/Include enough of the matrix in the name to make it unique. Then merge downstream if you need one
combined artifact — download-artifact with a pattern and merge-multiple: true collects them.
if: always() on diagnostic uploads
Section titled “if: always() on diagnostic uploads”- name: Upload test results if: always() uses: actions/upload-artifact@v7 with: name: test-results-${{ matrix.os }} path: reports/Every step carries an implicit if: success(). Without always(), test reports, logs and
screenshots are uploaded only when the tests passed — precisely the case where nobody needs them. This
is the most valuable one-line change on this page.
For a failing browser test, uploading the screenshot and the trace turns “it failed in CI and I can’t reproduce it” into an actual diagnosis.
Retention and cost
Section titled “Retention and cost”retention-days on the step overrides the repository default; the repository default can be up to 90
days, and organisation policy can cap it lower.
Artifact storage is billed against the account, and it accumulates quietly — a pipeline uploading 200 MB per run, ten runs a day, at 90-day retention holds a lot. Set retention to what the artifact is actually for:
| Artifact | Reasonable retention |
|---|---|
| Test reports and logs | 7–14 days |
| Coverage | 14–30 days |
| Pull request build output | 1–7 days |
| Release binaries | Long, or move them to a release |
Release binaries are the case where artifacts are the wrong home: attach them to a GitHub Release instead, which is permanent, versioned and publicly linkable.
What must never be uploaded
Section titled “What must never be uploaded”An artifact is downloadable by anyone with read access to the repository, and on a public repository that is everyone, for the whole retention period.
- Credentials of any kind, including files a build wrote them into —
.npmrc,.docker/config.json,kubeconfig,.aws/credentials. - Terraform plan files. They embed resource attributes including generated passwords. See Terraform CI.
.envfiles, whether or not you think they are populated in CI.- Whole workspace directories.
path: .uploads the checkout, the caches, and anything any step wrote — which is how credentials end up in artifacts without anyone deciding to put them there.
Downloading outside the run
Section titled “Downloading outside the run”gh run download <run-id> --name distgh run download <run-id> # everythinggh api repos/OWNER/REPO/actions/artifactsDownloading an artifact from another repository, or from a different workflow run, needs a token with
actions: read and the artifact must not have expired.
The workflow_run pattern uses this deliberately: an untrusted pull_request workflow uploads a
sanitised report as an artifact, and a separate trusted workflow — running from the default branch,
with write permissions — downloads and publishes it. That separation is how you get pull request
comments from fork contributions without handing the fork a writable token. See
workflow security.
Exercise
Section titled “Exercise”-
Build in one job, upload
dist, download it in a second job withneeds:. Confirm the second job has files it never built. -
Remove
if-no-files-found: errorand make the build produce nothing. Confirm the pipeline stays green and deploys an empty directory. -
Add the flag back and confirm the same scenario now fails at upload.
-
Add a matrix and upload with a fixed artifact name. Read the collision error, then fix it with a name containing the matrix values.
-
Add
if: always()to a report upload, make a test fail, and confirm the report is still there. -
Check your repository’s artifact storage in the billing page. Set retention deliberately.