A pipeline can compile a perfect binary in one job and still ship the wrong file if the next job downloads an artifact by a reused name. GitHub reports roughly four million artifacts created each day, and v4 makes the naming and handoff rules strict enough that accidental ambiguity now fails earlier instead of hiding until release day.
The useful mental model is not “an artifact is a file upload.” In a multi-job workflow, an artifact is the handoff contract between isolated runners. The build job creates a specific package, the test job proves that package works, the packaging job records exactly what was tested, and the release job publishes that same byte sequence to users.
1. Treat the build output as a contract, not a convenient folder
Each GitHub Actions job normally starts on a fresh runner. A file written to dist/ in build does not exist in test, even when both jobs run in the same workflow. Checking out the repository again only restores tracked Git files; it does not restore generated binaries, compiled frontend bundles, coverage reports, or test databases.
For a release pipeline, pass one portable build package rather than recreating the build in every job. In the example below, the build job creates out/widget.tgz. Every later job downloads that archive and works from it. That creates a useful invariant: the artifact tested is the artifact packaged, and the artifact packaged is the one attached to the release.
- Build: install dependencies, compile, and archive the output.
- Test: download the archive and run a smoke test against its extracted contents.
- Package: download the same archive and create a SHA-256 checksum.
- Release: download the packaged files and upload them as public release assets only for version tags.
This is more reliable than letting the release job run npm run build again. A second build can differ because dependencies, environment variables, generated timestamps, or build tooling changed between jobs.
2. Map the four jobs before writing YAML
The pipeline has two different kinds of dependency. The test and package jobs need the build output. The release job needs the package output and must wait for the test result. GitHub Actions expresses execution ordering with needs, while artifacts move bytes between the resulting runners.
| Job | Needs | Downloads | Uploads | Failure prevents |
|---|---|---|---|---|
build |
None | Nothing | Compiled widget.tgz |
Testing or packaging a missing build |
test |
build |
Build archive | Nothing | Publishing an untested archive |
package |
build, test |
Build archive | Archive plus checksum | Releasing without integrity metadata |
release |
package |
Release package | GitHub Release assets | Publishing from a failed pipeline |
Notice that package waits for test, even though it downloads its bytes directly from build. This avoids a common mistake: using needs only for file access and forgetting that it also defines whether a downstream job is allowed to publish.
3. Use this complete v4 multi-job workflow
This example assumes a Node project that emits deployable files into bundle/. Replace npm run build and npm run smoke with your project’s commands, but preserve the artifact boundaries. The smoke command receives the extracted directory as its first argument.
name: build-test-release
on:
push:
branches: [main]
tags: ["v*"]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
outputs:
build_artifact_id: ${{ steps.upload.outputs.artifact-id }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- run: |
mkdir -p out
tar -C bundle -czf out/widget.tgz .
- id: upload
uses: actions/upload-artifact@v4
with:
name: build-package
path: out/widget.tgz
if-no-files-found: error
retention-days: 7
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- uses: actions/download-artifact@v4
with:
artifact-ids: ${{ needs.build.outputs.build_artifact_id }}
path: incoming
- run: |
mkdir candidate
tar -xzf incoming/widget.tgz -C candidate
npm run smoke -- candidate
package:
needs: [build, test]
runs-on: ubuntu-latest
outputs:
release_artifact_id: ${{ steps.upload.outputs.artifact-id }}
steps:
- uses: actions/download-artifact@v4
with:
artifact-ids: ${{ needs.build.outputs.build_artifact_id }}
path: incoming
- run: |
mkdir release
cp incoming/widget.tgz release/widget.tgz
sha256sum release/widget.tgz > release/widget.tgz.sha256
- id: upload
uses: actions/upload-artifact@v4
with:
name: release-package
path: release/
if-no-files-found: error
retention-days: 30
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: package
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
artifact-ids: ${{ needs.package.outputs.release_artifact_id }}
path: release
- uses: softprops/action-gh-release@v2
with:
files: |
release/widget.tgz
release/widget.tgz.sha256
The critical details are the two job outputs: build_artifact_id and release_artifact_id. They turn an upload result into an explicit downstream input instead of making later jobs guess which artifact name to fetch.
4. Why v4 artifact IDs are safer than artifact names
In v4, uploaded artifacts are immutable. A job cannot quietly append files to an existing artifact with the same name in the same workflow run. That is a deliberate reliability change: two parallel matrix jobs attempting to upload build-package should not silently merge unrelated output.
A fixed name still works when exactly one job uploads it. For example, the workflow above has one build job and one upload named build-package. But an artifact ID is a stronger handoff when names may be reused, generated, or selected from a matrix.
The ID comes from the upload step’s artifact-id output. Export it as a job output, then use artifact-ids in actions/download-artifact@v4. The downstream job asks for the exact upload produced by its upstream dependency, not “whatever is named build-package.”
Use names for humans browsing the workflow summary; use IDs for machine-to-machine handoffs. That distinction matters most when a repository has multiple OS builds, architecture-specific binaries, or a fan-out/fan-in matrix.
5. Give matrix artifacts unique names and preserve a predictable layout
Suppose a workflow builds Linux, macOS, and Windows archives. The following is unsafe because all three jobs try to create an artifact named build-package. With v4 immutability, that collision becomes a visible upload failure rather than an accidental combined archive.
name: build-package-${{ matrix.os }}-${{ matrix.arch }}
A practical matrix artifact name includes the dimensions needed by the consumer: operating system, CPU architecture, runtime version, or package flavor. A release aggregation job can then download artifacts by a known pattern, but only when it intentionally expects several artifacts.
Also decide where files should land after download. In the earlier workflow, path: incoming means the downloaded archive is expected at incoming/widget.tgz. The explicit directory prevents a downloaded package from being mixed into the repository checkout or mistaken for a source-controlled file.
Verify the result before consuming it when the artifact contains critical deliverables:
ls -lah incoming
sha256sum -c release/widget.tgz.sha256
That final checksum command belongs in a deployment consumer or release verification process, after both the archive and checksum have been downloaded. Checksums do not replace tests, but they catch a wrong-file handoff immediately.
6. Workflow artifacts and release files solve different problems
A persisted workflow artifact is operational evidence from a workflow run. GitHub Actions artifacts can hold build outputs, test results, coverage reports, logs, and core dumps, and remain available after the run completes according to their retention settings. They are excellent for CI handoffs and debugging a particular run.
A GitHub Release is a project publication tied to a Git tag. GitHub’s release model is built around tags marking a specific point in repository history; release assets are the files you expect users, deployment systems, or package consumers to download. A release also has release notes and a stable project-facing place in the repository’s Releases view.
| Question | Workflow artifact | Release asset |
|---|---|---|
| Primary audience | CI jobs and maintainers investigating a run | Users and deployment consumers |
| Identity | Created by a workflow run | Published for a Git tag |
| Typical content | Coverage, logs, intermediate builds, test reports | Installable binaries, archives, checksums, release notes |
| Lifecycle decision | Retention period for CI evidence | Versioning and release support policy |
Do not tell customers to retrieve production software from a workflow artifact simply because it already exists. The release job should download the tested artifact and attach it to the tagged release, as the example does.
7. Avoid the v4 migration traps that break otherwise healthy pipelines
The first trap is mixing artifact action generations. A v4 download step cannot retrieve an artifact uploaded with the older v3 action generation. Update both sides of a handoff together: actions/upload-artifact@v4 in the producer and actions/download-artifact@v4 in every consumer.
The second trap is parallel upload collision. A test shard matrix may have worked when every shard wrote to a shared artifact name, but v4 requires a different design. Give each shard a unique artifact name such as junit-shard-${{ matrix.shard }}, then use a later aggregation job to download all intended reports.
The third trap is assuming an artifact upload proves the right files were selected. Set if-no-files-found: error for release inputs. Without it, a typo such as dist/ instead of bundle/ can turn into an empty or missing deliverable problem later in the workflow.
Finally, keep secrets out of artifacts. A generated .env, signing credential, or deployment configuration may be unintentionally included by a broad path such as path: .. Archive only the files required by the next job.
8. Choose retention and publication rules separately
The build archive in the example has retention-days: 7, while the release staging artifact has retention-days: 30. Those numbers are workflow choices, not release policy. Seven days is enough to investigate most failed builds; 30 days gives maintainers more time to inspect the exact package that fed a release job.
For a project with 40 daily builds, retaining a 250 MB build archive for 30 days creates 300 GB of retained build output before counting logs, test reports, and duplicate platform builds. The exact storage outcome depends on your repository settings and artifact sizes, but the multiplication is why “keep every artifact forever” is a poor default.
Release assets deserve a different rule. If v2.4.0 is supported for 18 months, its downloadable binary and checksum should remain available for that support period. The transient CI artifact used to create it does not need the same lifespan, because the release asset is now the public, versioned copy.
9. Put this into your repository this week
Start with one existing pipeline that builds and deploys in separate jobs. Do not convert every report or cache first; move the release-critical file path. Create one archive, upload it once with upload-artifact@v4, pass its artifact ID through job outputs, and make the next job download that exact ID.
- Find the generated directory currently rebuilt in a later job, such as
dist/,target/, orbin/. - Archive it into one named file and fail if that file is absent.
- Make the test job download and exercise the archive rather than rebuilding it.
- Add a checksum in a package job that runs only after tests pass.
- Publish the resulting files as release assets only when the ref is a version tag.
- Set short retention for intermediate artifacts and a separate availability policy for tagged releases.
After that first conversion, inspect the workflow graph. Every production file should have one answer to two questions: which job created these bytes, and which exact upload did the next job download? With v4 artifact IDs and immutable uploads, those answers can be encoded directly in the workflow instead of relying on convention.