A customer who downloads v1.8.3 on Tuesday and again on Friday can receive different source code if that tag is moved. Even a perfectly signed CI build cannot repair that trust failure when the published release tag or its downloadable assets remain mutable.

There are two different release-security problems

GitHub’s release controls solve problems at two different points in time. Tag rulesets control what can happen before a tag becomes a public production release. Immutable releases control what can happen after that release has been published.

Conflating the two leads to incomplete setups. A repository can have strict tag rules that only permit a release workflow to create v1.4.0, then still allow somebody to alter release assets later. It can also publish an immutable release while allowing several developers to create confusing tags such as v1.4.0-hotfix, v1.4.0-test, or a replacement v1.4.0 before the intended release is finalized.

The useful mental model is simple: tag rulesets are the gate at the factory entrance; immutable releases are the tamper seal on the package leaving the factory. A secure release process needs both controls because they defend different moments and different actors.

Question Tag ruleset Immutable release
When does it operate? Before or during tag creation, update, and deletion attempts After an immutable release is published
Main object protected Git tag names matching a pattern such as v1.* The published release, its assets, and its associated tag
Main purpose Prevent unauthorized or accidental publication actions Prevent later tampering with published software
Typical owner Repository administrators and release-policy owners The release publisher and the repository’s release process

Why a protected branch is not enough

Many teams protect main, require pull requests, and assume release integrity follows automatically. It does not. A Git tag is a separate Git reference that points to a specific commit. A release is based on that tag, and GitHub notes that the tag date and release date can differ.

Suppose main is protected and commit a1b2c3d has passed tests. A maintainer creates v1.8.3 at that commit and publishes binaries. If the tag can later be force-updated to commit e4f5g6h, the release name remains familiar while the source point behind it changes. Branch protection did its job; it just was not governing the tag.

The same distinction matters for deletion. Deleting v1.8.3 and recreating it is operationally different from moving it, but it produces the same consumer problem: a stable version label no longer identifies one stable thing.

For a production namespace, treat the tag name as an API contract. If customers, deployment systems, package manifests, or SBOMs refer to v1.8.3, that name should retain one meaning permanently.

Use tag rulesets to prevent the wrong tag action

A GitHub ruleset can target tags with an fnmatch pattern. For a conventional major-version namespace, use v1.* to cover tags such as v1.0.0, v1.8.3, and v1.12.0-rc.1. Keep prerelease naming in mind: if release candidates should remain editable, put them in a separate pattern such as v1-rc.* rather than mixing them into the production namespace.

For tags matched by v1.*, configure the ruleset to restrict the three actions that change a tag’s meaning:

  • Restrict tag creation.
  • Restrict tag updates.
  • Restrict tag deletion.
  • Require signed tags where your release process can reliably create and verify them.

The important operational detail is the bypass path. If creation is restricted, your release identity must be explicitly allowed to create the tag. Do not create a broad bypass for every repository administrator merely to make the first release easier. That converts a preventive control into a polite warning.

Use one named release automation identity or a small, audited release-maintainer group. Then test it with the identity that will actually publish releases. A workflow using GITHUB_TOKEN, a GitHub App token, and a maintainer’s personal access token are different identities with different policy behavior.

Immutable releases protect what users actually download

Tag protection alone does not make a GitHub Release page permanent. A release commonly contains more than the source point: installers, container-related metadata, checksums, migration archives, CLI binaries, and release notes. GitHub’s immutable releases protect published assets from being added, modified, or deleted, and protect tags from tampering after publication.

This matters because consumers usually download a file, not a Git object. Consider a release containing these three files:

  • acme-cli_1.8.3_linux_amd64.tar.gz
  • acme-cli_1.8.3_darwin_arm64.tar.gz
  • SHA256SUMS

If the release remains editable, replacing the Linux archive after publication changes what customers receive under the same version. Replacing SHA256SUMS at the same time can make a casual integrity check appear valid. An immutable published release changes the workflow: assemble and verify the complete release package first, then publish it as the sealed record.

That is why immutability is not just “stronger tag protection.” It extends the integrity boundary from a Git reference to the artifacts distributed to users.

For most teams, make v1.* the irreversible production namespace. Use draft releases, branches, workflow artifacts, or a separate prerelease tag namespace for anything that needs iteration. Production release identifiers should not be your staging area.

Area Recommended configuration Reason
Tag targeting Tag ruleset pattern v1.* Applies policy to all version 1 production tags
Tag creation Restrict to one release automation identity or a tightly limited release group Stops ad hoc production tags
Tag updates Restrict Prevents a released version from pointing to another commit
Tag deletion Restrict Prevents delete-and-recreate replacement
Tag signing Require signed tags if your publisher supports them Adds cryptographic author verification to the tag
Published release Publish as immutable Locks assets and the release-associated tag after publication
Prereleases Use a separate namespace such as v1-rc.* Preserves an editable testing path without weakening v1.*

The tradeoff people skip is support workload. Once v1.8.3 is immutable, you cannot “quietly fix” a broken archive under that version. The correct response is v1.8.4, a documented withdrawal notice, or both. That can feel slower on release day, but it prevents silent replacement from becoming normal practice.

Build the release before creating the permanent tag

The safest release pipeline separates build evidence from publication. GitHub Actions artifacts are useful for moving build outputs between jobs; use current artifact actions such as actions/upload-artifact@v4 and actions/download-artifact@v4 rather than treating a workflow artifact as the final public distribution channel.

A practical sequence for v1.8.3 looks like this:

  1. Build binaries from a reviewed commit on the protected default branch.
  2. Run unit, integration, packaging, and platform checks.
  3. Collect the exact files intended for public distribution.
  4. Generate checksums for those files and review the release notes.
  5. Create the signed v1.8.3 tag with the authorized release identity.
  6. Create the release, attach every final asset, and verify names, sizes, and checksums.
  7. Publish the release as immutable.

For a local signing workflow, the essential Git operations are recognizable:

git tag -s v1.8.3 -m "Release v1.8.3"
git push origin v1.8.3
git verify-tag v1.8.3

The commands are not the policy. The policy is that only the authorized publisher should be able to complete the push for a tag matching v1.*. A developer running the same commands from a laptop should receive a ruleset rejection unless they are deliberately in the approved release path.

Do not use immutability as a substitute for review

Immutable releases make mistakes durable. If a workflow accidentally packages a debug binary, embeds a development endpoint, or uploads the wrong architecture, immutability prevents a silent correction. That is a feature for consumers, but it forces teams to move quality gates earlier.

Before publication, verify the release as a candidate rather than trusting the job that produced it. Download the candidate archive in a separate job or environment, inspect its version output, and compare the computed hash against the intended checksum file. For a CLI, that can be as concrete as running ./acme-cli --version on each built platform artifact.

Draft releases can be useful for this assembly and review phase because the final immutable boundary has not been crossed. The release should become public and immutable only after all assets are present. Adding an omitted Windows archive later is exactly the kind of seemingly harmless exception that weakens a permanent release policy.

If a release must be corrected, publish a new version. Record why v1.8.3 should not be used in the next release notes or repository security communication, rather than attempting to make v1.8.3 mean something new.

Verify both the Git tag and the downloaded artifact

Release producers should test enforcement; consumers should verify the two layers independently. A tag signature answers whether an expected signing key signed the tag. A checksum answers whether the downloaded bytes match the published digest. Neither check replaces the other.

A minimal consumer-side check might look like this:

git fetch --tags origin
git verify-tag v1.8.3
sha256sum -c SHA256SUMS

For this to be meaningful, consumers need the trusted public key for the tag signer and must obtain the checksum file from the immutable release they are evaluating. Teams that distribute packages through several channels should decide which artifact is authoritative. If GitHub Releases is the source of record, the release page should contain the final binaries and checksum information, not merely a link to a mutable location.

This produces a useful audit trail: the tag identifies the source revision, the release identifies the published package set, and the checksum identifies exact bytes. Tag rulesets stop unauthorized changes on the way in; immutable releases preserve those objects once users can rely on them.

Test the policy with an intentional failure

Do not declare this setup complete because the repository settings page looks correct. Use a test tag such as v1.0.0-policy-test or perform the checks in a non-production repository with the same policy.

  1. Attempt to create a matching tag with an unapproved account.
  2. Attempt to force-update the tag after it exists.
  3. Attempt to delete the tag.
  4. Publish a test release with a small asset and mark it immutable.
  5. Attempt to add, replace, or remove that asset after publication.
  6. Confirm that the authorized release identity can perform only the actions it genuinely needs.

This exercise exposes the common configuration failure: the ruleset is technically enabled, but the release workflow cannot publish, so someone grants an overly broad bypass under deadline pressure. Fix the identity and permissions deliberately instead.

What to implement this week

Start with one major-version namespace rather than redesigning every historical release. If your current production line is version 1, apply the policy to v1.* and leave older tags unchanged. That limits the blast radius while establishing a clear date from which production version names are permanent.

  • Create a tag ruleset targeting v1.*.
  • Restrict creation, updates, and deletion for matching tags.
  • Choose the one automation identity or release group allowed to publish them.
  • Require signed tags if that publisher can produce them consistently.
  • Move release assembly into a draft or pre-publication review step.
  • Publish completed production releases as immutable, with binaries and checksums already attached.
  • Run one intentional unauthorized-tag test and one post-publication asset-change test.

The decision rule is straightforward: if a tag name is safe for customers, deployment systems, or downstream projects to pin, it belongs in the protected and immutable path. If it still needs to be edited, renamed, repointed, or repackaged, it is not ready to be called v1.*.