GitHub receives roughly four million Actions artifacts every day, which is a useful warning: not every file produced by CI deserves to become a customer-facing download. If your release page contains coverage HTML, Playwright screenshots, and unsigned nightly ZIPs beside the installer, your pipeline has confused evidence with a product.
1. Treat build output as evidence first, a release second
A CI run can generate dozens of files: compiled binaries, JUnit XML, coverage reports, browser screenshots, crash dumps, SBOMs, benchmark logs, and deployment bundles. They do not have the same audience or lifetime. GitHub Actions artifacts are designed to persist files between jobs and after a workflow completes, which makes them a practical transport and diagnostic mechanism.
A GitHub Release solves a different problem. It creates a named public or repository-visible milestone, normally tied to a version tag, with release notes and downloadable assets intended for people outside the individual workflow run. A release asset says, “install this.” An artifact usually says, “inspect this run.”
The useful boundary is not “binary versus non-binary.” Both systems can store a ZIP file. The boundary is whether the file has passed the checks required for someone to rely on it. A Windows installer created 90 seconds into a pull-request run is a binary, but it is not yet a release candidate. Its test logs and the installer can both remain artifacts until the pipeline establishes provenance, quality, and approval.
2. The three questions that decide the destination
Before adding upload-artifact or a release action, ask three questions for every output. These questions work better than a blanket rule such as “packages go to Releases,” because a package may be an internal intermediate rather than something users should install.
| Decision question | Choose an Actions artifact when... | Choose a GitHub Release asset when... |
|---|---|---|
| How long must it exist? | It is useful for debugging one run, a pull request, or a short investigation window. | It must remain available for a supported version, rollback, audit, or user download. |
| Who downloads it? | CI jobs, maintainers investigating a failure, or reviewers of a specific run. | Customers, operators, package consumers, support staff, or automation selecting a versioned release. |
| What policy must it satisfy? | It may be untrusted, incomplete, experimental, or produced before final checks. | It has passed the required tests, scanning, provenance checks, and release approval gate. |
If any answer is “this is temporary evidence,” keep it as an artifact. If all three answers point to a durable, approved distribution, publish it to a Release. This prevents a common failure mode: a team begins using a convenient CI download as an unofficial distribution channel, then discovers months later that retention removed the only copy of a build a customer deployed.
3. A worked example: shipping a cross-platform command-line tool
Consider a repository named acme/widgetctl. On every pull request, it builds Linux, macOS, and Windows binaries; runs unit tests; runs integration tests; and produces coverage output. On a version tag such as v2.4.0, it should publish three vetted archives: widgetctl_2.4.0_linux_amd64.tar.gz, widgetctl_2.4.0_darwin_arm64.tar.gz, and widgetctl_2.4.0_windows_amd64.zip.
The pull-request workflow uploads these outputs as artifacts:
test-results: JUnit XML and failed-test logs.coverage-html: a browser-readable coverage report.integration-screenshots: only when browser tests fail.build-linux-amd64,build-darwin-arm64, andbuild-windows-amd64: intermediate binaries for later jobs.sbom-candidate: a generated inventory to inspect before release.
None belongs on a Release yet. The binaries may have been built from a pull-request commit, tests may still be running on another platform, and the SBOM may identify a dependency that requires review. The same outputs become release inputs only after the tag pipeline rebuilds from the tagged commit and completes its assurance gate.
4. Use artifacts as the handoff between isolated jobs
GitHub Actions jobs do not share a filesystem. A build job that creates dist/widgetctl cannot assume a later test, scan, or packaging job can see that path. Uploading and downloading artifacts makes the transfer explicit and leaves a downloadable record when a later job fails.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make build-linux
- uses: actions/upload-artifact@v4
with:
name: build-linux-amd64
path: dist/widgetctl-linux-amd64
integration-test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: build-linux-amd64
path: dist
- run: ./scripts/integration-test dist/widgetctl-linux-amd64
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: test-results/
The if: always() detail matters. A failed integration test is exactly when its XML, logs, or screenshots are valuable. Uploading only after a successful test creates the least useful artifact policy: evidence disappears whenever someone needs it.
Use distinctive names in a matrix build. A generic name such as build makes it hard to identify the Linux versus Windows output and encourages later jobs to download everything. Include dimensions such as operating system, architecture, runtime, or package format in the artifact name.
5. Retention is a product decision, not a storage checkbox
Artifact retention should follow the time needed to diagnose a run, not the time users may need a version. GitHub’s artifact upload action supports a retention-days setting, so a repository can give a failure bundle a shorter life than a compliance report. The exact retention policy should be chosen centrally for your organization rather than copied blindly into every workflow.
For the widgetctl pipeline, a sensible classification might be: keep logs and screenshots long enough for normal pull-request review; keep release-candidate test evidence for the team’s audit window; and keep official installers through the project’s supported-release lifecycle. The last category belongs in Releases because its purpose is availability, not just CI debugging.
The second-order cost is support friction. If an operator reports a regression in v2.3.1, they need a durable way to retrieve v2.3.1, compare checksums, and roll back. Telling them to locate a workflow run from months ago is not a release process. Conversely, retaining every coverage report indefinitely makes CI storage into a poorly indexed archive that few people will search.
6. Package only after the quality gate has passed
The release pipeline should build or retrieve a controlled candidate, test it, scan it, and only then publish the customer-facing archive. Do not create the Release at the start of the workflow merely because a tag was pushed. That produces a visible version before you know whether its files meet policy.
For v2.4.0, the sequence is:
- Verify the workflow is running for the expected version tag and commit.
- Build platform outputs and upload them as named artifacts.
- Download those artifacts in dedicated test and security jobs.
- Run unit, integration, and packaging checks; generate checksums and an SBOM.
- Upload failure evidence regardless of outcome.
- Require the release gate to succeed, then publish only the approved archives, checksums, and selected metadata.
This design also gives a clean response to a failed scan. Delete or allow the candidate artifact to expire according to policy, fix the issue, and run a new candidate. Do not leave a failed build masquerading as a downloadable release simply because the tag exists.
7. Put enforcement at the promotion boundary
A workflow convention is not enforcement. A developer can accidentally add a gh release create command to an early build job unless the repository’s release path is designed to make that hard. The promotion job is the correct choke point because it is where temporary files become a durable distribution.
Use a separate publish-release job with explicit dependencies. Configure it to run only for version tags, after test and scan jobs succeed. If your repository uses protected branches, protected tags, required reviews, or GitHub Environments, align those controls with the job that holds release credentials or has permission to create releases.
publish-release:
needs: [test, integration, security-scan, package]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: release-bundle
path: release
- run: sha256sum release/* > release/SHA256SUMS
- run: gh release create "${GITHUB_REF_NAME}" release/* \
--generate-notes
The important policy is not the particular command-line client. It is that only release-bundle reaches this job, and that bundle was produced after the checks your organization requires. Test screenshots, raw logs, and pull-request binaries never enter the release directory.
8. Avoid the two shortcuts that blur the boundary
The first shortcut is publishing every successful branch build as a Release. That creates release pages faster, but it overloads a user-facing catalog with ephemeral builds. If you need preview distributions, give them an explicit preview channel and label them as such; do not mix them with stable versions merely because GitHub Releases provides a download URL.
The second shortcut is using artifacts as a permanent distribution mechanism. Artifacts are excellent for downloading a file from another workflow run or for inspecting a failing job. Their names and URLs are organized around workflow execution, however, not a versioned product history. A customer should not need the Actions tab, a run ID, and maintainer permissions to install software you claim to ship.
| Output | Example | Recommended destination |
|---|---|---|
| Failure evidence | Playwright screenshot, core dump, test log | Artifact, uploaded even on failure |
| Intermediate build | Linux binary awaiting integration tests | Artifact |
| Review material | Coverage HTML or benchmark comparison | Artifact |
| Approved distributable | Signed installer or versioned tarball | GitHub Release asset |
| Release verification data | Checksum file and selected SBOM | GitHub Release asset when users or auditors need it |
9. Implement the boundary in one workflow this week
Start with the next repository that creates a ZIP, installer, container export, or command-line binary. List every generated output from one run, then assign it a downloader, a retention period, and a required gate. If you cannot name the external downloader and the checks it passed, it should not be a Release asset.
- Search your workflows for
actions/upload-artifactand list current artifact names. - Mark each artifact as handoff, debug evidence, review output, or release candidate.
- Add explicit
retention-daysvalues where your policy calls for shorter-lived diagnostic data. - Create a single
release-bundleartifact containing only files approved for publication. - Move release creation into one job that depends on tests and security checks.
- Attach versioned deliverables, checksums, and customer-relevant metadata to the GitHub Release.
The result is deliberately asymmetric: CI can produce many artifacts, but only a small, vetted subset can cross the promotion boundary. That asymmetry is what makes a Release trustworthy instead of just another place a workflow uploaded files.