At 09:12, a customer installs v1.2.0; at 14:47, the same command can fetch different source code if somebody force-moves that tag. The immediate mistake may take one Git command, but the cleanup can include rebuilt containers, revoked provenance claims, confused incident responders, and users who cannot reproduce the binary they downloaded.
1. Treat a release tag as a public build input
The release-engineering problem is not that Git permits tags to move. Git deliberately treats refs as names pointing to objects, and a tag name can be deleted and recreated or updated with a force push. The problem begins when a name such as v1.2.0 is consumed outside the repository.
Consider a package manifest, deployment script, container build, or customer runbook that says “use v1.2.0.” Those consumers have recorded a human-friendly name, not necessarily the 40-character commit ID that the name resolved to on release day. If v1.2.0 later moves from commit A to commit B, the same declared version can yield two different inputs.
That creates several distinct failures:
- A hotfix intended for
v1.2.1is accidentally attached tov1.2.0. - A maintainer deletes a tag locally, recreates it after correcting a release mistake, then pushes the replacement.
- A compromised CI credential rewrites a trusted version to point at malicious code.
- A release workflow publishes artifacts for one commit while users later download source code resolved from a moved tag.
Immutable tags are therefore an integrity control. They make a version name a one-way commitment: once a protected release tag exists, it keeps identifying the same Git object.
2. The threat model is broader than a malicious force push
Security discussions often start with an attacker who has acquired write access. That is a valid scenario, but it is not the only one worth designing for. The more common event is a well-intentioned correction made under release pressure: a maintainer notices a missing file, assumes the tag is merely a label, and retags the commit.
That “small” correction breaks a useful forensic question: what exactly did version 1.2.0 mean when it was first announced? If the answer depends on when someone cloned the repository, the version is no longer a stable evidence record.
| Event | What changes | Why immutability helps |
|---|---|---|
| Force-update a tag | v1.2.0 points to a different commit |
Consumers continue to resolve one version name to one Git target. |
| Delete and recreate a tag | The old reference disappears, then a replacement is published | Deletion protection prevents the two-step rewrite path. |
| Publish a Release with the wrong notes | Release title, description, or attached files are wrong | Tag immutability preserves the code identity while metadata is corrected separately. |
| Create a typo tag | A new name such as v1.20.0 appears |
This is a naming and review problem; it is not solved merely by blocking updates. |
The important distinction is that a tag policy protects Git references. It does not validate binaries, write release notes, or decide whether a release should have happened. Pair it with those controls, but do not confuse their jobs.
3. A GitHub Release is not the tag it displays
GitHub Releases are based on Git tags, but they are a separate layer of repository data. A Release can have a title, notes, publication state, and downloadable assets; the tag identifies a point in Git history. GitHub’s documentation explicitly notes that a tag date and a release date can differ because the two can be created at different times.
This distinction matters during remediation. Suppose your v1.2.0 release notes omit an upgrade instruction. The safe correction is to edit the Release description, not to move the tag. Suppose the uploaded ZIP contains the wrong binary. The safe response is usually to withdraw or clearly mark the bad release and publish a new version such as v1.2.1; moving v1.2.0 makes the historical version ambiguous.
Think of the two layers this way:
- Git tag: the immutable identifier for source history, ideally a specific commit or annotated tag object.
- GitHub Release: the presentation and distribution record associated with that tag, including release notes and uploaded artifacts.
Uploaded release assets can have a different lifecycle from generated source archives. That is exactly why a moved tag is so dangerous: a page can still look like one release while the source reference behind its tag has changed. Keep code identity stable first; then manage human-facing metadata and artifact mistakes explicitly.
4. Protect a release namespace, not every tag by reflex
A useful policy starts with names your users are expected to trust. For many projects, that is a SemVer-style namespace such as v1.2.0, v1.2.1, and perhaps prereleases such as v1.3.0-rc.1. It is usually not every tag in the repository.
GitHub rulesets can target branches or tags, and GitHub supports fnmatch patterns for selecting names. The practical aim is to write the narrowest pattern that covers your public release contract. A broad v* pattern may be appropriate for a project that reserves every tag beginning with v for releases. It is risky if engineers also make disposable tags such as verify-ci or v-next-test.
| Tag convention | Reasonable target | Operational consequence |
|---|---|---|
Only published versions use a v prefix |
v* |
Simple policy; all version-like tags become permanent. |
| Stable releases and release candidates are public | A pattern that covers both version and prerelease names | Fix an RC by creating a new RC number, not by moving it. |
| Internal builds need tags | A dedicated prefix such as build-* remains outside the release rule |
CI can clean up temporary tags without weakening public version tags. |
| Multiple products share one repository | Product-specific prefixes such as api-v* and cli-v* |
Each product gets an independent release contract. |
The tradeoff nobody mentions is naming discipline. Once a tag matches the immutable namespace, a typo is permanent by design. That is cheaper than rewriting history, but it means tag creation deserves the same review mindset as publishing a package.
5. Configure the tag ruleset around update and deletion paths
GitHub’s older tag protection rules were migrated to tag rulesets in 2024. If your team’s runbook still says “add tag protection,” update it: use a repository ruleset targeting tags, then configure restrictions that stop matching tag references from being updated or deleted.
In the repository settings, create a ruleset aimed at the release-tag pattern your team chose. The exact interface can evolve, but the policy intent should remain unambiguous: once a matching tag exists, ordinary writers must not be able to change its target or remove it.
- Open the repository’s ruleset settings and create a new ruleset for tags.
- Target the release namespace, for example
v*if that prefix is reserved exclusively for releases. - Enable protections that restrict updates and deletions for matching tags.
- Review who, if anyone, can bypass the ruleset. Keep this list smaller than the set of people who can merge code.
- Make the ruleset active only after testing it against a disposable tag in the intended namespace.
Do not automatically restrict tag creation unless your process needs that additional gate. Preventing updates and deletions delivers immutability after publication. Restricting creation answers a different question: who is allowed to mint a new public version in the first place? Mature projects often need both, but they should document them as separate controls.
6. Walk through the release mistake before it happens
Assume a release workflow tags commit 8f3c... as v1.2.0, builds a binary, and creates a GitHub Release. Ten minutes later, a maintainer discovers that the binary was built with the wrong configuration. Without an immutable-tag policy, the tempting sequence is delete v1.2.0, fix the workflow, recreate v1.2.0, and republish.
That sequence appears tidy in the repository but creates two facts in the world: some users may have downloaded the first artifact or cloned the first target, while later users receive the replacement. A changelog saying “v1.2.0” no longer identifies one thing.
With tag immutability, the failed command is a feature. The response becomes a deliberate release decision:
- Preserve
v1.2.0at its original target. - Assess whether the release should be marked as problematic in its GitHub Release metadata and release notes.
- Fix the build configuration in a new commit.
- Create
v1.2.1pointing to that new commit. - Explain the replacement in the new release notes so users have an auditable upgrade path.
This costs one extra version number. In exchange, it preserves reproducibility and prevents the incident response team from having to determine which “v1.2.0” a report refers to.
7. Make CI publish tags with a narrow identity
Rulesets are strongest when the release workflow is also constrained. A GitHub Actions workflow that can write repository contents may be able to create tags as part of a release process, depending on its configured permissions and repository policy. That capability should belong to the release job, not every test workflow.
Separate build and publish stages. A build job can compile, test, and upload an artifact for later use. A publish job, triggered only after the release decision, can download that artifact and create the tag and Release. This reduces the number of workflow runs that hold tag-writing authority.
permissions:
contents: write
The snippet above illustrates a permission commonly needed by a job that writes repository content, but it should not be copied into every workflow. Give it only to the release job that needs it. For pull-request validation and ordinary tests, prefer read-only access where possible.
Also decide what happens when the workflow attempts to reuse a version. A good release script checks whether v1.2.0 already exists before building and publishing. If it exists, fail with a message such as “choose the next version; protected tags are not retargeted.” That turns an opaque permission failure into a predictable release procedure.
8. Bypass access is your exception process, not a convenience button
An immutable rule with a broad bypass list is only a polite request. If every repository administrator can bypass it during a hectic release, the organization has recreated the original risk with an extra click.
There are legitimate exceptional cases: legal removal requests, a credential exposure, or a tag created in violation of the project’s naming policy. But an exception should have an owner, a record, and a replacement plan. Before granting bypass, ask whether the problem can be resolved without changing the Git reference.
| Problem found after publication | Preferred response | Move the tag? |
|---|---|---|
| Typo in release notes | Edit the GitHub Release metadata. | No |
| Broken artifact | Publish a corrected patch version and document the bad version. | No |
| Incorrect source commit | Publish a new version from the correct commit. | No |
| Tag was created under the wrong namespace | Use a documented, reviewed exception and publish the correct tag. | Only if removal is necessary and authorized |
For releases used by external customers, package managers, deployment systems, or security attestations, the decision rule is simple: if anyone outside the release team could already have resolved the tag, do not retarget it. Supersede it with a new version.
9. Verify the rule with the two Git operations that matter
Do not declare tags immutable because the settings page looks correct. Test the behavior using a disposable tag that matches the ruleset, preferably in a non-production repository first. You want to test both ways a release name can be changed: updating the reference and deleting it.
git tag v0.0.0-test
git push origin v0.0.0-test
git tag -f v0.0.0-test HEAD
git push --force origin v0.0.0-test
git push origin :refs/tags/v0.0.0-test
The initial creation should follow the policy you selected. After that, the force update and deletion should be rejected for an identity that is not allowed to bypass the ruleset. If your pattern intentionally excludes test tags, use a temporary name that matches the protected release namespace, then clean it up through your documented exception process or perform the test in a dedicated sandbox repository.
This week, make three concrete changes:
- Write down which tag prefixes are public release contracts and which are disposable CI labels.
- Create or review a GitHub tag ruleset that blocks updates and deletions for the public namespace.
- Run one force-update test and one deletion test using a non-bypass account or token.
The output you want is not merely a blocked Git command. It is a release process where v1.2.0 has one meaning next month, next year, and during the next production incident.