A moved v2.4.0 tag can make the same source archive URL deliver different code, while an editable GitHub release can replace the binary a customer downloaded yesterday. Those are two separate failure modes, and locking only one of them still leaves a release-shaped hole in your supply chain.

GitHub now offers immutable releases, but they are not a substitute for understanding Git tags or configuring tag rulesets. The useful question is not “should releases be immutable?” It is: which object must stop changing, at which point in the release process, and who still needs authority before that point?

1. Treat a version as four different things

Teams often say “we released v2.4.0” as though that describes one object. In a GitHub repository, it usually describes at least four: a Git ref named v2.4.0, a commit it resolves to, a GitHub Release record, and one or more downloadable assets.

Each control works at a different layer. That is why a protected default branch does not automatically protect a tag, and why a release page with a checksum does not prevent someone from changing the tag that produced its source archive.

Layer Example What it controls What it does not control alone
Git tag v2.4.0 A named pointer to a commit or Git object Release notes and uploaded binaries
GitHub Release “v2.4.0 — 18 June” Notes, release metadata, and downloadable assets associated with a tag Whether normal Git pushes can move unprotected tags
Tag ruleset Pattern v* Who may create, update, or delete matching tag refs The mutability of release notes and uploaded assets
Immutable release Published v2.4.0 release Protects the published release’s assets and tag from post-publication tampering Bad code, a bad version number, or a compromised build before publication

The practical consequence is simple: use tag rulesets to govern the path to publication, then use immutable releases to lock the published package.

2. Git tags are references, not release records

A Git tag is a named reference. Lightweight tags are simple names pointing at an object; annotated tags are Git objects that can contain a tagger identity, date, and message. For externally consumed versions, use annotated tags so the release marker is more than an anonymous pointer:

git tag -a v2.4.0 -m "Release v2.4.0"
git push origin v2.4.0

For teams that already manage signing keys, a signed annotated tag adds a useful verification step:

git tag -s v2.4.0 -m "Release v2.4.0"
git verify-tag v2.4.0

But an annotated or signed tag can still be deleted and recreated if repository policy permits it. The signature tells a verifier something about the tag object they received; it does not stop a permitted actor from replacing the ref with a different object.

This is the first tradeoff people skip: tags are ideal for tying version names to source history, but Git intentionally makes refs manageable. A release process needs an explicit rule for when normal ref-management flexibility ends.

3. GitHub Releases add the deliverable users actually download

GitHub describes releases as being based on Git tags, and the tag date can differ from the release date. That distinction matters when a tag is created during approval on Tuesday but the release is published after final validation on Thursday.

A release gives users a stable place to find release notes, source archives for the tag, and project-specific files such as mytool_2.4.0_linux_amd64.tar.gz. Many projects distribute binaries this way because users need a compiled artifact, not only a source commit.

Do not confuse a release page with proof that every asset came from the tagged source. Your CI workflow must establish that relationship. At a minimum, publish a checksum file alongside every artifact:

sha256sum dist/* > dist/SHA256SUMS
sha256sum --check SHA256SUMS

A user can then verify the file they downloaded:

sha256sum --check SHA256SUMS

This check catches an accidental damaged or substituted download only if the checksum file itself is trusted. Once the release is immutable, the checksum and the asset become a locked pair, which is substantially more useful than a checksum attached to an editable release.

4. Use tag rulesets before publication, not after an incident

Tag rulesets are GitHub’s policy layer for refs whose names match a pattern. Rulesets can target branches or tags, and GitHub supports fnmatch patterns for that targeting. A release repository can use a narrow pattern such as v[0-9]* or a broader convention such as release/*, depending on its versioning scheme.

For a SemVer-style public project, the useful policy is usually:

  • Apply a tag ruleset to public version tags, such as v*.
  • Allow tag creation only through a release automation identity or a small release-maintainer group.
  • Block casual updates and deletion of published-version tag names.
  • Keep temporary tags outside that namespace, for example test/2.4.0-rc1.

The detail that causes operational friction is the automation identity. If the person who can create v2.4.0 is different from the GitHub Actions workflow that publishes it, the workflow may fail precisely when it tries to create the tag. Decide whether the tag is created by a human before the workflow runs or by a tightly scoped automation path. Do not discover the answer in the final release job.

5. Immutable releases lock the published boundary

Immutable releases add a second control after publication: GitHub protects the release assets and tag from tampering once the release is published. This is specifically valuable because a release is where users make trust decisions: they read the notes, click an asset, or copy an installation command.

Think of immutability as a one-way publication gate. Before publication, maintainers may need to correct release notes, rebuild an artifact after a failed test, or abandon a draft version. After publication, the safer default is that v2.4.0 means exactly one source state and exactly one published asset set.

That changes how you handle mistakes. If the Linux ARM archive is wrong, do not plan on silently swapping it out. Publish a corrected version such as v2.4.1, explain the issue in its notes, and direct users to the new version. That costs an extra version number, but it avoids making audit logs, bug reports, and checksum records disagree about what “v2.4.0” contained.

Immutability is therefore best for releases that are consumed outside a small trusted team: CLI tools, libraries, desktop applications, deployment packages, and internal platform tooling used by multiple departments.

6. A concrete workflow for releasing v2.4.0

The following workflow keeps the editable work separate from the irreversible publication event. It assumes main is already protected through ordinary branch policy and that v* is the protected public-tag namespace.

  1. Merge the approved release commit into main. Record its full commit SHA in the release pull request or release ticket.
  2. Run tests and build from that exact SHA in GitHub Actions. Do not build locally and upload a different file from a maintainer laptop.
  3. Generate artifacts plus SHA256SUMS. Test installation from the built archives in a clean job or container.
  4. Create an annotated v2.4.0 tag pointing to the approved SHA, using the identity permitted by the tag ruleset.
  5. Create the GitHub Release from that tag, attach the tested artifacts and checksum file, and include the commit SHA in the notes.
  6. Review the draft release: version, target commit, asset filenames, checksums, and upgrade notes.
  7. Publish the release only after the review. Immutable-release protection now turns publication into the lock point.

The target commit check is worth making explicit. Before publishing, compare the tag target with the build input:

git rev-list -n 1 v2.4.0
git rev-parse HEAD

Those values should represent the same intended release commit. If they do not, stop rather than treating the difference as a cosmetic discrepancy.

7. Build once, then promote the same files

A common weak workflow builds an archive in CI, tests it, then rebuilds it during the release job. Even if both jobs use the same tag, a second build introduces another opportunity for dependency changes, timestamps, configuration drift, or a compromised runner to alter the output.

A safer pattern is to produce release candidates as workflow artifacts, test those exact files, and upload those exact files to the GitHub Release after approval. GitHub Actions supports uploading artifacts in one job and downloading them in a later job with actions/download-artifact@v4.

- name: Download tested release files
  uses: actions/download-artifact@v4
  with:
    name: release-files
    path: dist

- name: Verify checksums
  run: cd dist && sha256sum --check SHA256SUMS

The tradeoff is retention and process complexity. Workflow artifacts are an internal handoff; release assets are the public distribution record. Give the artifact a version-specific name, such as release-v2.4.0, and verify its checksums immediately before upload. Avoid calling an unversioned artifact simply build, especially if multiple release workflows can run at once.

8. Decide what happens to prereleases, hotfixes, and “latest”

Not every tag deserves the same lock. A release candidate named v2.4.0-rc.1 may need more operational flexibility than a public stable release. Create separate naming lanes and separate policy decisions rather than weakening the stable-tag ruleset for everyone.

Release type Suggested tag pattern Recommended control Reason
Stable public release v* Tag ruleset plus immutable GitHub Release Users and automation may depend on it for years
Release candidate rc/* or a documented prerelease convention Restricted tag creation; decide separately whether it is immutable Testers need clear provenance, but corrections are more likely
Nightly build nightly/* Clearly documented as replaceable Freshness matters more than permanence
Emergency fix v2.4.1 Normal stable-release lock A hotfix should not lower the integrity standard

Avoid using a movable latest Git tag as if it were a version. It is a channel pointer, not an immutable release identifier. If you offer a latest-download endpoint, document it as mutable and also provide versioned URLs for automation, reproducible deployments, and incident investigation.

9. Make the policy usable this week

You can improve release integrity without redesigning your entire CI system. Start with one repository that publishes a binary or package used by people outside the core maintainer group.

  • List the public version tag patterns currently in use: v*, release/*, or something else.
  • Create or review a tag ruleset for that namespace, including the account or automation path allowed to create tags.
  • Choose one release commit and require the build job to record its full SHA.
  • Add SHA256SUMS to the release asset set and verify it before publishing.
  • Enable and test immutable releases using a non-production version so maintainers understand the post-publication boundary.
  • Write a two-sentence correction policy: “Published stable releases are not replaced. Errors are corrected with a new version and an explanatory note.”

The final rule is easy to apply: use a tag ruleset when you need to control who may alter a version name; use an immutable release when you need to guarantee that a published version’s tag and downloadable assets no longer change. For a public stable release, use both. They protect different objects, at different times, and the overlap is exactly what makes the workflow safer.