A release can pass every CI check on Monday and still distribute a different binary on Tuesday if someone replaces its uploaded asset or moves the tag behind it. The expensive failure is not merely malware: it is a customer reporting that the SHA-256 you published no longer matches the file your own release page serves.
GitHub’s immutable releases close that post-publication gap: once published, the release assets and associated Git tag cannot be changed. That protection only works well when the files in the public release are deliberately promoted from a known CI build, rather than rebuilt ad hoc by a release script or confused with GitHub Actions artifacts.
1. Treat CI artifacts and release assets as different products
GitHub Actions artifacts are workflow-run outputs. They exist to move files between jobs, preserve test evidence, and make build results available to later automation. A GitHub release asset is a public, versioned download attached to a release page and associated with a Git tag.
Those two storage locations answer different questions. An artifact answers, “What did this workflow run produce?” A release asset answers, “What should every user receive for version v2.4.0?” Mixing them causes subtle release failures, especially when a release job downloads “the latest” artifact instead of the artifact from the exact validated run.
| Property | CI build artifact | Public release asset |
|---|---|---|
| Primary audience | CI jobs and maintainers | Users, package managers, and downstream automation |
| Typical purpose | Test reports, intermediate builds, candidate archives | Signed binaries, source bundles, checksums, installers |
| Selection rule | Artifact name or ID from a specific workflow run | Explicit version tag such as v2.4.0 |
| After an immutable release is published | Not the public integrity boundary | Cannot be changed along with its associated tag |
The practical rule is simple: upload artifacts during builds, download a specific artifact in the release job, and upload only the final deliverables to the public release. Do not tell users to download a workflow artifact, and do not use a release page as temporary CI storage.
2. Decide what must be immutable before creating the tag
Immutability is most useful when a release contains enough information for an independent user to identify and verify what they downloaded. For a command-line tool, that usually means platform archives plus a checksum file. For example, a v2.4.0 release may include acme_2.4.0_linux_amd64.tar.gz, acme_2.4.0_darwin_arm64.tar.gz, and SHA256SUMS.
Make the asset list a release contract before your workflow starts. If a release needs Windows, Linux, and macOS builds, define all three as required. A partially published immutable release is harder to recover from than an editable one because the missing asset cannot simply be attached later.
- Versioned archives or installers users actually install.
- A checksum manifest generated from those exact archives.
- Optional signature files if your project already signs release material.
- Release notes that identify support boundaries and upgrade risks.
- A Git tag naming convention, such as
v2.4.0, used consistently by code and documentation.
The tradeoff nobody enjoys is correction policy. With immutable releases enabled, a typo in a binary, a missing platform archive, or an incorrect generated file requires a new release version rather than a silent repair. That is the point: consumers can trust that v2.4.0 remains one thing forever.
3. Build once and preserve the candidate with Actions artifacts
Build in CI from the commit you intend to release, package each platform output, then upload those files as a named artifact. GitHub Actions artifact actions v4 are the current major version described by GitHub’s changelog, and GitHub notes substantial performance improvements along with workflow differences from earlier versions.
A single build job might package files into a directory named dist and upload it as release-candidate. The important detail is that the release job downloads this exact candidate rather than running the compiler a second time. A second build can produce a different result because of dependency resolution, timestamps, runner images, or unpinned tooling.
name: build
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build release archives
run: |
mkdir -p dist
./scripts/package-release.sh dist
- name: Create checksums
run: |
cd dist
sha256sum * > SHA256SUMS
- name: Upload release candidate
uses: actions/upload-artifact@v4
with:
name: release-candidate
path: dist/
For matrix builds, use one artifact per operating system and architecture, such as candidate-linux-amd64. A later assembly job can download the known set and fail if one is absent. That failure is preferable to publishing two of three promised downloads.
4. Make the release job a promotion gate, not another build
Your release job should be boring. It checks out the tag, downloads artifacts produced by the build jobs, verifies expected filenames and checksums, then creates and publishes the release. It should not run npm publish, compile Go, invoke a packaging tool, or fetch a new compiler just before uploading public assets.
Use a dependency such as needs: build so the promotion cannot begin until the build completed. Downloading by artifact name is acceptable inside the same workflow when the workflow has one build path; artifact IDs are a stronger choice when multiple uploads have similar names or a separate workflow performs promotion.
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- name: Download candidate
uses: actions/download-artifact@v4
with:
name: release-candidate
path: dist
- name: Verify candidate
run: |
cd dist
sha256sum --check SHA256SUMS
test -f acme_2.4.0_linux_amd64.tar.gz
Replace the literal filename with a version-aware check in a real workflow. The decision rule is: if the release workflow generates any bytes users download, move that step earlier into the build job and promote its artifact instead.
5. Create the tag from the commit you tested
An immutable release protects the associated tag from being moved after publication. That means the tag becomes part of the public integrity promise, not a convenient label that can be retargeted after a hotfix.
Build and test the exact commit that will receive the tag. If your workflow runs on tag pushes, the tag itself selects the commit; the release pipeline then packages and tests that checkout. If your team creates a release manually after CI finishes, record the tested commit SHA and verify that the tag resolves to it before publishing.
git fetch --tags --force
git rev-parse v2.4.0
git rev-parse 4f3c2a1
Those two commands should produce the same commit identifier when 4f3c2a1 is the approved commit. Do not use a mutable branch name such as main as the release build input. A branch can advance between approval and packaging; a tag identifies a specific release point.
Protected tags and immutable releases solve related but different problems. A protected tag can help control who creates or changes a version name. Immutable releases add the published-release boundary: once published, GitHub prevents changes to both the assets and the associated tag. Use tag protection as an authorization control and immutable releases as a post-publication integrity control.
6. Publish only after validating the complete release directory
Publishing is the irreversible step, so perform final checks before it. Verify checksums, inspect the directory contents, and ensure the tag is the version the archive claims to contain. A file called acme_2.4.0_linux_amd64.tar.gz containing build 2.4.1-dev is not rescued by a correct SHA-256 hash; the hash only proves the wrong file was transferred consistently.
GitHub’s command-line tool can create a release and upload files in one command. The exact mechanism matters less than the sequence: the release command runs only after verification, and every asset passed to it comes from the downloaded candidate directory.
gh release create "$GITHUB_REF_NAME" \
dist/acme_2.4.0_linux_amd64.tar.gz \
dist/acme_2.4.0_darwin_arm64.tar.gz \
dist/SHA256SUMS \
--title "$GITHUB_REF_NAME" \
--generate-notes
Keep release notes separate from binary identity. Generated notes are useful context, but consumers should verify a named asset against SHA256SUMS. A minimal shell verification command is sha256sum --check SHA256SUMS from the directory containing the downloads.
7. Enable immutable releases and understand the point of no return
Enable immutable releases in the repository’s release settings before your team begins publishing the new workflow. GitHub documents immutable releases as releases whose assets and associated Git tag cannot be changed after publication, specifically to reduce supply-chain attack opportunities such as injecting malware into an existing project release.
The timing distinction matters. A draft is where maintainers review filenames, notes, and candidate files. Publication is the trust transition. Once an immutable release is published, treat its assets, its tag, and its checksum manifest as permanent public records.
This changes incident response. If you discover a defect in v2.4.0, do not attempt to make v2.4.0 mean something new. Publish v2.4.1, explain the upgrade in its notes, and update documentation that points users to the recommended version. The old release remains a truthful record of what was available under its original version.
Immutability is not a substitute for testing. It is a guarantee that a tested release cannot later be silently replaced with an untested one.
8. Plan for the operational friction before it becomes an outage
The strongest objection to immutable releases is practical: release teams sometimes need to fix a broken archive within minutes. Immutability turns that “small patch” into a new version, new notes, and potentially another package publication. That additional work is intentional, but it needs a written operating rule.
| Problem found | Unsafe response | Immutable-release response |
|---|---|---|
| Checksum file is wrong | Replace the checksum file on the existing release | Publish a corrected new version with a new checksum manifest |
| One platform archive is missing | Attach the archive later without changing the version | Publish a complete subsequent version |
| Tag points at the wrong commit | Force-move the existing tag | Create a new tag for the corrected release commit |
| Critical vulnerability | Replace the existing binary | Publish a fixed version and communicate which versions users should avoid |
Write down who can approve a release, who can enable repository settings, and where the release checklist lives. The second-order benefit is auditability: when a user reports behavior from v2.4.0, maintainers can investigate the same commit and the same published files rather than reconstructing which replacement happened later.
9. Run this release checklist for your next version
Start with one non-critical version rather than trying to redesign every deployment path at once. The goal this week is to prove that a public release is promoted from CI output, verified before publication, and immutable afterward.
- List every file users should download for the next version, including
SHA256SUMS. - Change build jobs to upload those files with
actions/upload-artifact@v4. - Create a release job that uses
actions/download-artifact@v4and does not rebuild the project. - Require the release job to verify checksums and required filenames before calling
gh release create. - Confirm the version tag resolves to the tested commit before publishing.
- Enable immutable releases in the repository and brief maintainers that published releases cannot be patched in place.
- Practice the correction path by publishing a later test version instead of attempting to alter an earlier one.
After that first run, review the workflow with one question: “Can any actor make v2.4.0 download different bytes after users have seen it?” If the answer is no because the tag, release assets, and promotion path are all controlled, you have created a release process that is materially harder to tamper with—and much easier to explain to downstream users.