A release can look successful in the GitHub UI while shipping a binary that was rebuilt differently from the one your tests approved. The fix is not a better upload checklist: it is making the tested build artifact the only thing a release job is allowed to publish.
This walkthrough uses a small command-line application named widget, but the pattern applies equally to Node bundles, Go binaries, Python wheels, installers, and documentation archives. A tag such as v1.4.0 starts one workflow: the build job creates a tarball and checksum, the release job downloads that workflow artifact and attaches it to GitHub Releases, and a verification step downloads the release asset through the Releases API.
1. Treat the build artifact as the release handoff
GitHub Actions artifacts and GitHub Release assets solve different problems. An artifact moves files between jobs in a workflow; a release asset is the file your users and deployment scripts retrieve later. The dangerous pipeline downloads source again in the release job, runs another build, and uploads its output. That creates two production candidates from one tag.
Instead, create exactly one distributable bundle in the build job. Tests should run before that bundle is uploaded, and the release job should not contain a compiler command, package installation step, or source checkout. Its input is the artifact named release-bundle; its output is a GitHub Release containing the same bytes.
| Stage | Input | Output | What it proves |
|---|---|---|---|
| Build | Tagged source revision | Archive and SHA-256 file | The project can produce a distributable package |
| Test | Build output or source | Pass/fail result | The candidate met the selected checks |
| Release | Workflow artifact | GitHub Release assets | The approved bytes were published |
| Verify | Published Release asset | Checksum match | A consumer can retrieve the published bytes |
The second-order benefit is operational: when a user reports that widget-linux-amd64.tar.gz fails, the team has one named bundle to inspect rather than two builds produced on potentially different runners.
2. Define an asset contract before writing the workflow
Pick filenames that a person and a script can predict. For this example, every stable release publishes these two files:
widget-linux-amd64.tar.gzwidget-linux-amd64.tar.gz.sha256
The checksum file contains the SHA-256 digest generated from the archive after packaging. Do not checksum the unpacked directory and call it a release checksum; consumers download the archive, so the archive is what they must verify. Keep the checksum next to its archive as a separate release asset so a shell script can fetch both without parsing a release description.
Use the tag as the version boundary. This workflow triggers only on tags matching v*. That is deliberately stricter than releasing every push to main: a tag communicates an intentional public version, while ordinary commits remain build and test candidates.
If you publish multiple operating systems, extend the same contract rather than inventing names per platform. For example, add widget-darwin-arm64.tar.gz and its matching .sha256 file. A consumer script can then select one exact filename from an explicit platform mapping.
3. Build and package once on a tag
The workflow below assumes make build writes an executable at dist/widget. Replace that command with your project’s actual build command, but retain the separation between compiling, packaging, checksumming, and artifact upload.
name: release
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Build widget
run: make build
- name: Package release files
run: |
mkdir -p release
tar -czf release/widget-linux-amd64.tar.gz -C dist widget
sha256sum release/widget-linux-amd64.tar.gz \
> release/widget-linux-amd64.tar.gz.sha256
- name: Upload immutable release handoff
uses: actions/upload-artifact@v4
with:
name: release-bundle
path: release/
if-no-files-found: error
if-no-files-found: error matters. Without it, a typo such as writing the archive to releases/ can leave the pipeline with an apparently completed upload step and no package to publish. Failing where the file is created is cheaper than discovering an empty release after the tag has been pushed.
Notice that the archive does not include the entire repository. Shipping only widget makes the package intent clear and avoids accidentally distributing test fixtures, local configuration examples, or build metadata.
4. Use the workflow artifact, not a second checkout
A later job runs on a fresh runner. Files in release/ do not automatically cross the job boundary, which is why the artifact exists. The release job downloads the artifact into its workspace and immediately lists its contents before publishing.
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download build handoff
uses: actions/download-artifact@v4
with:
name: release-bundle
path: release
- name: Inspect release handoff
run: |
find release -maxdepth 1 -type f -printf "%f %s bytes\n"
sha256sum -c release/widget-linux-amd64.tar.gz.sha256
The local checksum verification catches a corrupted or incorrectly paired archive before a public release is created. It also forces a useful discipline: if the release job needs a file, that file must have been deliberately included in the artifact.
A common example uses an action that creates releases and accepts an artifact glob. That can work, but the GitHub CLI makes the boundary easier to audit: one command creates the release and names every uploaded file. More importantly, neither approach should rebuild the application in the release job. The choice of release tool is secondary; the one-build rule is not.
5. Create the GitHub Release and attach exact files
GitHub-hosted Ubuntu runners include the GitHub CLI, so the release job can use gh release create. Set GH_TOKEN from the workflow token and grant only the repository content permission needed to write the release.
- name: Create release and upload assets
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
gh release create "$TAG" \
release/widget-linux-amd64.tar.gz \
release/widget-linux-amd64.tar.gz.sha256 \
--generate-notes \
--title "Widget $TAG"
github.ref_name is the pushed tag, such as v1.4.0. Passing it to the command avoids hard-coding a version or trying to infer one from a package manifest. The two asset paths are explicit, which prevents unrelated files in the working directory from becoming public downloads.
For release candidates, use a separate tag convention such as v1.4.0-rc.1 and add --prerelease. Do not let a “latest release” consumer silently select a release candidate. A stable installer should request a stable tag or use GitHub’s latest-release endpoint only when your release policy guarantees that it means stable.
6. Verify the published asset, not merely the upload step
An upload command returning success proves that the command completed; it is not the same as exercising the retrieval path used by customers. Add a post-publication verification step that asks GitHub Releases for the tagged asset, downloads it into a clean directory, and checks the downloaded checksum.
- name: Verify published release download
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
rm -rf downloaded
mkdir downloaded
gh release download "$TAG" \
--pattern "widget-linux-amd64.tar.gz" \
--pattern "widget-linux-amd64.tar.gz.sha256" \
--dir downloaded
cd downloaded
sha256sum -c widget-linux-amd64.tar.gz.sha256
This is intentionally a second download. Checking the original file in release/ only validates the build handoff. Checking downloaded/ validates that the asset exists under the release tag and that its public release representation matches the digest created in the build job.
For a private repository, this workflow-token download is authenticated. Add a separate test using the same credentials and network constraints as your real private-repository consumer if that is part of your distribution model.
7. Give users a tag-based Bash downloader
Consumers should download a specific tag when reproducibility matters. “Latest” is convenient for a development installer, but it makes a deployment performed on Tuesday potentially different from the same command run on Friday. The script below resolves one release by tag through the GitHub API, finds two named assets with jq, and verifies the archive.
#!/usr/bin/env bash
set -euo pipefail
repo="acme/widget"
tag="${1:?usage: download-widget.sh v1.4.0}"
asset="widget-linux-amd64.tar.gz"
checksum="${asset}.sha256"
api="https://api.github.com/repos/${repo}/releases/tags/${tag}"
release_json="$(curl --fail --silent --show-error \
-H "Accept: application/vnd.github+json" \
"$api")"
asset_url="$(jq -r --arg name "$asset" \
'.assets[] | select(.name == $name) | .browser_download_url' \
<<< "$release_json")"
checksum_url="$(jq -r --arg name "$checksum" \
'.assets[] | select(.name == $name) | .browser_download_url' \
<<< "$release_json")"
test -n "$asset_url" && test "$asset_url" != "null"
test -n "$checksum_url" && test "$checksum_url" != "null"
curl --fail --location --remote-name "$asset_url"
curl --fail --location --remote-name "$checksum_url"
sha256sum -c "$checksum"
The API lookup avoids guessing an asset URL from the tag. It also fails clearly when the tag exists but a required asset is absent. For private releases, add an authorization mechanism appropriate to your environment rather than embedding a token in the script or repository.
8. Handle retries, duplicate tags, and release policy deliberately
The most awkward failure occurs after GitHub creates the release but before every asset finishes uploading. A workflow rerun may then fail because the tag already has a release. Decide in advance whether a rerun may modify an existing release.
| Policy | Best for | Release-job behavior |
|---|---|---|
| Immutable release assets | Packages used in production deployments | Fail on an existing release and investigate the partial publish |
| Repairable draft releases | Teams that review assets before publication | Create a draft, upload and verify assets, then publish it |
| Overwrite during development | Internal experimental tags only | Delete or replace assets explicitly, never accidentally |
For production tags, immutable is the safer default. Replacing widget-linux-amd64.tar.gz under the same version means two machines can claim to run v1.4.0 while executing different bytes. The checksum catches this only for consumers that actually verify it; version immutability prevents the ambiguity in the first place.
Also protect the tag namespace in repository settings and restrict who can create release tags. The release workflow has contents: write, so a tag is not merely a label: it is the trigger that authorizes publication.
9. Put this release pipeline into use this week
Start with one real artifact, not every platform at once. A single Linux archive plus checksum is enough to prove the handoff and retrieval design. Once that path is reliable, add matrix builds only if each platform produces the same two-file contract with a distinct filename.
- Create a test tag such as
v0.1.0in a noncritical repository. - Make the build job generate an archive and
sha256sumfile underrelease/. - Upload those files as one artifact named
release-bundle. - Make the release job download that artifact and verify its checksum before calling
gh release create. - Download the published assets into a fresh directory and run
sha256sum -cagain. - Run the Bash downloader from a machine outside the workflow and confirm it retrieves the tagged asset.
The practical test is simple: temporarily change the archive name in the build job. A correctly designed pipeline should fail at artifact upload, release upload, or consumer download; it should never create a green release with an unverified, manually supplied file. Once that failure mode is impossible, GitHub Releases becomes a dependable distribution endpoint rather than a screen someone has to remember to use.