GitHub says roughly four million artifacts are created every day, and one changed directory level is enough to turn a deploy command such as ./deploy.sh staging dist/ into a release of an empty folder. The v4 upgrade is quick only when the job that consumes the artifact checks the files it received instead of assuming the old path still exists.
The obvious migration is changing actions/download-artifact@v3 to actions/download-artifact@v4. The safer migration treats artifacts as an interface between jobs: the build job publishes a named filesystem tree, the test job consumes a known tree, and the deploy job refuses to continue unless its expected entrypoint is present.
1. Start with the deploy command, not the action version
A build artifact is not useful merely because the download step exits with status 0. Your deploy command needs a precise directory contract. For a static site, that might be release/dist/index.html; for a Node service, it might be release/server.js plus release/package.json.
Write that contract down before editing YAML. This prevents a common failure mode: a workflow successfully downloads an artifact named dist, but a later command points at a directory created for the artifact name rather than the directory containing the artifact’s extracted files.
| Pipeline stage | Artifact contract | Example failure if the contract is vague |
|---|---|---|
| Build | Publishes the contents of dist/ as web-dist |
The upload path accidentally includes source files or omits the generated entrypoint. |
| Test | Downloads web-dist into candidate/ |
The test runs against files left on the runner rather than the artifact. |
| Deploy | Downloads web-dist into release/dist/ |
The deploy tool receives release/ while the files are actually elsewhere. |
The decision rule is simple: if a command deploys one artifact, download that artifact by name into the exact directory passed to the deploy command. Do not download every available artifact and hope the resulting tree matches a production command.
2. Inventory every artifact producer and consumer first
Search the repository before changing a single reference. You need both sides of each artifact handoff because a v4 download migration can expose an older upload pattern that v4 no longer permits.
git grep -n "actions/upload-artifact@"
git grep -n "actions/download-artifact@"
git grep -n "artifact-name"
git grep -n "artifacts/" .github
For each match, record four facts: the artifact name, the upload path, the download path, and the first file or command used after download. A small table in the pull request description is often enough. It gives reviewers something more valuable than “updated Actions versions”: proof that the producer and consumer still agree.
- Name: for example,
web-dist,test-report, orlinux-amd64. - Producer path: for example,
dist/orreports/junit.xml. - Consumer path: for example,
release/dist/. - Required result: for example,
release/dist/index.html. - Multiplicity: one artifact, all artifacts, or a matrix of artifacts.
This inventory also catches deploy jobs that download a build artifact without declaring needs: build. A job dependency is part of the artifact contract: it makes the production order explicit and stops a later cleanup from turning artifact availability into an accidental workflow behavior.
3. Map the common v3 download patterns to v4 deliberately
The simplest v3-to-v4 change is a version replacement. The risky cases are downloads involving multiple artifacts, patterns, or a path later consumed by packaging and deployment commands. In v4, make the selection and final layout intentional.
| Existing intent | v4 configuration | Layout decision to verify |
|---|---|---|
| Download one known artifact | name: web-dist and path: release/dist |
Confirm release/dist/index.html exists. |
| Download all artifacts | Omit name, artifact-ids, and pattern |
Expect artifact-name directories when multiple artifacts are downloaded; inspect them before using a fixed path. |
| Download selected matrix artifacts | Use pattern: binary-* |
Keep each artifact separate unless combining their contents is genuinely safe. |
| Combine filtered artifacts | Use pattern plus merge-multiple: true |
Check for duplicate filenames before merging. |
| Download an exact artifact identity | Use artifact-ids |
Validate the same required files as a named download. |
| Download from another run or repository | Provide github-token, repository, and run-id as needed |
Verify that the selected run is the intended build, not simply the latest successful one. |
The important distinction is between selection and placement. name, pattern, and artifact-ids select artifacts. path chooses where their files are written. merge-multiple changes whether multiple selected artifacts retain separate directories or are overlaid into one destination.
4. Use a single-artifact contract for ordinary deploys
Most deployment jobs should not need merge-multiple. A web build is usually one deployable unit, so name it once in the build job and retrieve it once in the deploy job. That avoids depending on the directory conventions used when several artifacts are downloaded together.
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- run: test -f dist/index.html
- uses: actions/upload-artifact@v4
with:
name: web-dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: web-dist
path: release/dist
- run: test -f release/dist/index.html
- run: ./deploy.sh staging release/dist
The test -f line is cheap and does more than the download action’s success status. It confirms that the uploaded tree contains the entrypoint and that v4 extracted it where the deploy command expects it.
For a backend deployment, replace the static-site check with the actual contract. Examples include test -f release/server.js, test -f release/package.json, or a short application-specific smoke command. Validate the file that makes deployment meaningful, not merely the parent directory.
5. Handle multi-artifact and matrix builds without accidental overlays
Matrix jobs commonly upload artifacts such as binary-linux, binary-macos, and binary-windows. A release job may need all three, but it should not flatten them by default. Each platform can contain an identically named executable, README, or checksum file.
- uses: actions/download-artifact@v4
with:
pattern: binary-*
path: release-artifacts
After that step, inspect the directory tree and package each platform independently. The separate artifact directories are useful isolation, not clutter. They preserve provenance: a packaging command can show whether a file came from the Linux or macOS build.
- name: Inspect downloaded artifacts
run: |
find release-artifacts -maxdepth 3 -type f | sort
- name: Package Linux output
run: tar -czf binary-linux.tar.gz -C release-artifacts/binary-linux .
Use merge-multiple: true only when each artifact contributes non-overlapping paths to one final tree. A realistic case is a build that produces locale-specific directories such as locales-en/en/ and locales-fr/fr/. If two artifacts can both contain manifest.json, merging creates a collision policy you probably did not document.
6. Upgrade upload-artifact too: v4 names are part of the API
The download action is only half of the migration. GitHub’s v4 artifact actions introduce important behavior differences, including immutable artifacts. The old pattern of having several jobs upload repeatedly to the same artifact name should be redesigned rather than copied forward.
For example, do not make three matrix jobs all upload build-output. Give each job a unique name and download them later with a pattern:
- uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.os }}
path: out/
# Later, in the packaging job:
- uses: actions/download-artifact@v4
with:
pattern: build-*
path: collected
This changes maintenance in a useful way. Each matrix job owns one immutable output, and the release job becomes the only place that decides whether artifacts stay separated or are merged. A failed or rerun matrix leg no longer has to share a mutable artifact namespace with its siblings.
Also make artifact names specific enough for humans reading a failed run. web-dist, junit-report, and build-ubuntu-latest are more diagnosable than three unrelated artifacts named output.
7. Add a layout gate between download and deployment
A deployment job should expose the tree it is about to publish and stop before credentials or deployment tooling are involved. This is especially useful during the migration pull request, when reviewers need evidence that the artifact is not simply downloading successfully but landing in the intended location.
- name: Show release candidate layout
run: |
echo "Files downloaded for deployment:"
find release -maxdepth 3 -type f | sort
- name: Validate release candidate
run: |
test -d release/dist
test -f release/dist/index.html
test ! -d release/dist/node_modules
- name: Deploy
run: ./deploy.sh staging release/dist
The last negative assertion is optional, but it demonstrates the right principle: validate both required and forbidden content. A static-site artifact containing node_modules may still deploy, yet it signals that the build package is broader than intended.
Keep the layout gate permanently for the one or two files that define a valid release. Remove only the verbose tree listing later if logs become noisy. The cost is a few shell commands; the benefit is that a path regression fails at the boundary where it is understandable.
8. Do not overlook permissions, archives, and GitHub Enterprise Server
Artifact download is a file transfer mechanism, not a portable Unix permission-preservation mechanism. The official action documentation notes that file permissions are not maintained through upload and download; downloaded files use standard permissions. If your deployment requires an executable script, package permission-sensitive content in a tar archive and unpack it in the consuming job.
# Build job
- run: tar -czf deploy-bundle.tgz deploy/
# Deploy job
- uses: actions/download-artifact@v4
with:
name: deploy-bundle
path: release
- run: tar -xzf release/deploy-bundle.tgz -C release
- run: test -x release/deploy/deploy.sh
Also check where the workflow runs. GitHub’s artifact action documentation distinguishes GitHub Enterprise Server support, and v4 availability is a deployment-platform concern rather than a YAML syntax concern. If your runners target GitHub Enterprise Server, confirm the supported artifact-action version for that installation before bulk-replacing v3 references.
Finally, do not use a cross-repository or cross-run download as a shortcut for “the newest artifact.” Supply the repository and run identity intentionally, plus the required token. Release promotion should consume the build selected by the pipeline, not whichever run happens to be newest at download time.
9. Make the migration a testable change this week
Use one non-production workflow run to establish the expected filesystem layout, then move through the remaining workflows by artifact contract. The goal is not a repository-wide version bump; it is a set of deploy jobs whose inputs are explicit and checked.
- Run the repository searches for v3 upload and download actions.
- Create a producer-consumer inventory for every artifact used by test, package, release, or deploy jobs.
- Replace single-artifact downloads with
actions/download-artifact@v4, an explicitname, and an explicit destination path. - Give each v4 upload a unique artifact name, especially in matrix jobs.
- Use
patternfor intended groups and enablemerge-multiple: trueonly after checking for filename collisions. - Add
findoutput and one or moretest -fassertions before deployment. - Verify permission-sensitive bundles and your GitHub Enterprise Server compatibility separately.
Commit the layout assertions with the v4 upgrade. Six months later, the assertions will still protect the pipeline when someone renames dist to build, changes an upload path, or adds a second artifact to a job that previously produced only one.