A release missing its Windows ZIP is usually a five-minute repair; with immutable GitHub releases enabled, it becomes a new version number, a new tag, and a support problem that can outlive the incident. The same is true when checksums.txt contains the wrong hash: once the release is published, GitHub will not let you replace that asset or move the tag underneath it.
That constraint is not an argument against immutable releases. It is an argument against treating “create release” as the last casual command in a deployment job. A release pipeline needs a deliberate staging state: build from one commit, inventory every intended asset, upload them to a draft, verify what GitHub received, then publish exactly once.
1. Treat publication as a commit, not an upload destination
GitHub’s immutable release protection applies after a release is published. At that point, the assets and tag are locked: you cannot add, replace, or delete release assets, and you cannot move or delete the tag while the release exists. You may still edit the title and release notes, and change whether the release is a pre-release or latest release.
The important design consequence is that release publication is not merely a visibility toggle. It is the commit point for a software distribution transaction. Before it, your pipeline can fail safely. After it, the release contents must be considered permanent distribution evidence for that version.
That changes the question a release job must answer. Do not ask, “Did gh release create return exit code 0?” Ask, “Have we proved that GitHub holds every file a user should download for this exact source revision?”
| Pipeline phase | What may fail | Safe response |
|---|---|---|
| Build | Compilation, packaging, signing | Fix and rebuild before a release exists |
| Draft upload | Network interruption, omitted file, wrong filename | Delete or repair the draft before publication |
| Draft verification | Hash mismatch, wrong asset count, wrong tag target | Stop the pipeline; do not publish |
| Publish | Final state transition | Only run after all verification gates pass |
2. Build the release around one immutable input: a commit SHA
Version strings are useful labels, but a commit SHA is the pipeline’s real source identity. If your workflow is triggered by a tag such as v2.4.0, capture both the tag and the resolved commit SHA at the start. Every later stage should refer back to that SHA: build logs, provenance records, test reports, checksums, and the release tag validation.
A practical shell guard looks like this:
TAG="v2.4.0"
SHA="$(git rev-parse HEAD)"
test -n "$SHA"
git describe --exact-match --tags "$SHA"
test "$(git describe --exact-match --tags "$SHA")" = "$TAG"
In a tag-triggered GitHub Actions workflow, make sure the checkout is actually at the triggering revision rather than assuming a branch head is equivalent. A branch can advance while the workflow is queued; the release must describe the tagged commit, not whichever commit happens to be newest when packaging starts.
The often-missed tradeoff is that reproducible release builds require more than a stable tag. If your build downloads an unpinned toolchain or resolves a dependency differently on a retry, two builds from the same SHA may produce different binaries. Immutable releases will preserve whichever binary passed through your pipeline first. That makes the build environment part of release quality, not a separate infrastructure concern.
3. Define an asset manifest before compiling anything
The easiest way to omit an asset is to make the expected asset list implicit in a glob such as dist/*. That works until a packaging job silently fails, a new platform is introduced, or a temporary file is uploaded alongside the actual archive.
Create an explicit manifest for each release line. For a command-line tool named acme, the manifest might require six downloadable files plus one checksum file:
acme_2.4.0_darwin_amd64.tar.gzacme_2.4.0_darwin_arm64.tar.gzacme_2.4.0_linux_amd64.tar.gzacme_2.4.0_linux_arm64.tar.gzacme_2.4.0_windows_amd64.zipacme_2.4.0_windows_arm64.zipchecksums.txt
Generate the manifest from the version variable, then fail if the actual directory differs. Count is not enough: seven files can still mean the Linux ARM64 archive is missing and an accidental debug.log was uploaded instead. Compare exact names, reject duplicates, and reject unexpected files.
find dist -maxdepth 1 -type f -printf '%f\n' | sort > actual-assets.txt
sort expected-assets.txt > expected-assets.sorted.txt
diff -u expected-assets.sorted.txt actual-assets.txt
This list becomes a release contract. When you intentionally add a platform, changing the manifest is a visible code review event rather than an accidental side effect of a packaging glob.
4. Generate checksums from the final files, not from assumptions
Checksums are only useful if they describe the exact bytes uploaded to GitHub. Generate checksums.txt after archives, installers, and binaries have reached their final filenames and final compression format. Do not create hashes before a later packaging step renames or rebuilds the archive.
For a directory containing the six distribution artifacts, a typical command is:
cd dist
sha256sum \
acme_2.4.0_darwin_amd64.tar.gz \
acme_2.4.0_darwin_arm64.tar.gz \
acme_2.4.0_linux_amd64.tar.gz \
acme_2.4.0_linux_arm64.tar.gz \
acme_2.4.0_windows_amd64.zip \
acme_2.4.0_windows_arm64.zip \
> checksums.txt
sha256sum --check checksums.txt
Run the verification immediately, but do not stop there. The pipeline must verify the assets again after upload, because a successful local checksum check does not prove the uploaded files are the same files. An incorrect path, an upload loop that skips one archive, or a retry that uses a stale workspace can all produce a locally valid checksum file and an incomplete GitHub release.
If you publish signatures, software bills of materials, or provenance attestations, put them in the same manifest. They are not optional metadata once users depend on them to validate a download. Missing verification material is still a missing release asset.
5. Use a draft release as the staging area
The safest release workflow is draft first, publish last. Create a draft associated with the intended tag and commit, upload all assets, and leave the release unpublished while automated checks inspect it. A draft is where you discover that the macOS ARM build was not produced or that an archive has the wrong name.
With the GitHub CLI, a representative sequence is:
gh release create "$TAG" \
--draft \
--target "$SHA" \
--title "$TAG" \
--notes-file RELEASE_NOTES.md
gh release upload "$TAG" dist/*
Do not interpret the second command’s success as adequate verification. It only tells you that the upload command completed successfully. Your release may still contain an incomplete set, files with unexpected names, or a stale artifact produced by a previous job attempt.
There is also a concurrency rule: one workflow run should own one version tag. If two manually dispatched runs can both attempt v2.4.0, they can race over the draft release and asset set. Use your CI system’s concurrency controls, or make the release job reject an existing release unless it is explicitly a resumable draft created by the same controlled process.
6. Verify the release object after GitHub receives the files
Verification needs to query GitHub, not merely inspect dist/. Ask the GitHub CLI for the draft release’s assets, save the names, compare them to the expected manifest, and fail on any difference. This is the gate that turns asset upload from a best-effort operation into an assertion.
gh release view "$TAG" --json isDraft,tagName,targetCommitish,assets \
--jq '.assets[].name' | sort > uploaded-assets.txt
diff -u expected-assets.sorted.txt uploaded-assets.txt
Also inspect the release state and tag. The release must still be a draft, tagName must equal the intended version, and the tag must resolve to the SHA used for the build. The exact JSON fields you use matter less than the invariant: source, expected assets, uploaded assets, and checksum entries must agree before publication.
For higher-value releases, download the uploaded assets into a clean temporary directory and run checksum validation there. That catches a different class of defect: the checksum file itself may have been omitted, renamed, or produced from a different workspace. It costs an extra download step, but it is much cheaper than releasing v2.4.1 solely to repair a bad v2.4.0.
7. Walk through the failure that immutability exposes
Assume the release team publishes v2.4.0 with five archives and checksums.txt. Ten minutes later, a Windows user reports that the release has no acme_2.4.0_windows_arm64.zip. In a mutable-release workflow, someone uploads the missing ZIP, possibly updates checksums, and moves on.
With immutable releases enabled, that repair path is gone after publication. GitHub protects the published assets from being added, modified, or deleted. The release tag is protected too, so the team cannot quietly rebuild v2.4.0, point the tag at a corrected commit, and attach a replacement archive.
The technically honest remedy is a new release, such as v2.4.1, built from a new commit or the same source commit if the defect was purely packaging. The team must communicate which version consumers should use, update release notes, and decide what to say about the incomplete prior release. If package managers, installation scripts, or documentation already point to v2.4.0, users can continue encountering the broken asset list.
That is the second-order cost of an omitted file: not only a rebuild, but version-management noise and user confusion. The draft verification gate eliminates the specific category of failure before the irreversible step.
8. Decide what belongs in the immutable release contract
Not every repository needs seven platform archives, but every repository needs an explicit answer to what users are allowed to rely on. The decision rule is simple: if a user, installer, automation script, or security check is expected to download it from the GitHub release page, put it in the expected asset manifest and validate it before publishing.
| Artifact | Include in the manifest? | Reason |
|---|---|---|
| Platform archive or installer | Yes | It is the primary distributed executable |
checksums.txt |
Yes | Users need it to validate downloaded archives |
| Signature file | Yes, if published | It is part of the verification path |
| SBOM or provenance file | Yes, if users or policy consume it | Omitting it changes the release’s security evidence |
| CI log or temporary debug file | No | It is not a supported release interface |
Do not use title edits or release-note edits as a substitute for correcting artifacts. Those edits remain possible on immutable releases, but they do not alter the bytes users download. A note saying “Windows ARM64 is unavailable” may be necessary during an incident; it is not a release repair.
9. Implement the pipeline this week
You do not need to redesign every build job at once. Start with the release boundary: add a draft stage and refuse to publish unless an exact asset comparison succeeds. The first version can use a checked-in text manifest and two diff commands.
- List every file currently expected on your next GitHub release, including checksums, signatures, SBOMs, and provenance files.
- Add a versioned expected-assets generator or checked-in manifest to the repository.
- Make the release workflow build from one captured commit SHA and validate that the release tag names that revision.
- Create the GitHub release as a draft, then upload assets before any publish command runs.
- Query the draft release through
gh release viewand compare uploaded asset names against the manifest. - Validate checksum entries against the final archives; for critical releases, download the draft assets and validate them again in a clean directory.
- Make publication a separate final job or final command that depends on every prior check.
- Run one intentional failure test: omit an expected file and confirm the workflow leaves an unpublished draft rather than an incomplete release.
That last test is valuable because it proves the behavior you need under pressure. Immutable releases provide their security value precisely when a rushed operator cannot rewrite a published tag or swap an artifact. Build the pipeline so nobody needs that escape hatch.