A developer can push v1.8.0 while testing a release script, and Go users may immediately be able to request that version with go get example.com/acme/widget@v1.8.0. Deleting the tag five minutes later does not make the incident harmless: CI logs, proxy caches, release notifications, and downstream builds may already have observed it.
That is the concrete concern raised in GitHub’s protected-tags discussion by maintainers of Go-related organizations: a version-shaped Git tag is not merely a repository label. For a Go module, it can be release metadata consumed by dependency tooling. The practical answer is not “protect tags” in the abstract. It is to define exactly which tag names can become Go versions, then give only a small release identity permission to create, move, or delete them.
Why a harmless-looking tag becomes a Go release
GitHub Releases are based on Git tags, but a GitHub Release is not required for Go users to recognize a version. A repository can have no release notes, no uploaded binary, and no green deployment badge; the relevant part is that a version-like tag points at a commit that is reachable from the module’s source repository.
Consider a module whose go.mod begins with this line:
module github.com/acme/widget
During a branch cleanup, a maintainer runs the following command locally and pushes it:
git tag v1.8.0
git push origin v1.8.0
That tag communicates a much stronger promise than “I marked a commit.” It says, in effect, that the commit is the source for a public module version. A consumer can put it in a go.mod requirement, automated update tooling can discover it, and a later forceful correction creates uncertainty about which source downstream systems saw.
This is why the usual advice to “tag only stable commits that pass CI” is necessary but incomplete. It describes good human behavior. A tag ruleset makes one accidental local command insufficient to publish version-shaped metadata.
Protect the tags Go can interpret, not every tag by reflex
For a root Go module, the first high-value pattern is usually v*. It covers stable tags such as v0.14.2, v1.8.0, and v2.0.0, as well as prereleases such as v1.9.0-rc.1. The broad match is deliberate: a tag protection policy that covers only today’s major version leaves v2.0.0 exposed exactly when the release carries the most compatibility risk.
Do not assume that only v1.* deserves protection. Go’s major-version convention means versions at major version 2 and later are materially distinct. A module adopted at v1.8.0 can eventually move to v2.0.0; the policy should not need emergency editing on release day.
| Tag example | Typical meaning | Protection decision |
|---|---|---|
v0.14.2 |
Pre-1.0 module release | Protect |
v1.8.0 |
Stable module release | Protect most strictly |
v2.0.0 |
New major module release | Protect most strictly |
v1.9.0-rc.1 |
Go prerelease version | Protect, but permit a separate prerelease workflow |
build-2026-10-10 |
Internal build marker, not a normal Go release tag | Usually do not include in the Go-release ruleset |
A team may choose to protect all tags, but that creates needless friction if tags are also used for deployments, experiments, or temporary build markers. Protect the names that carry a public compatibility promise; use a separate policy for operational tags if necessary.
Write a two-lane policy for prereleases and stable versions
The useful split is not “developers versus maintainers.” It is prerelease authority versus stable-release authority. A release candidate such as v1.9.0-rc.1 is intentionally consumable by testers, but it should not carry the same approval threshold as v1.9.0.
A small team can implement the following policy without turning every release into a ticket queue:
| Tag lane | Examples | Who may create it | Who may update or delete it |
|---|---|---|---|
| Stable | v1.8.0, v2.0.0 |
Release automation only | Release automation only, with an incident procedure |
| Prerelease | v1.9.0-rc.1, v1.9.0-beta.2 |
Release automation and designated release maintainers | Same limited group |
| Non-release | build-4821, demo-october |
Team-defined | Team-defined |
The second-order benefit is accountability. When every stable version is created by one GitHub Actions workflow or a dedicated release bot, a version has a predictable audit trail: workflow run, commit SHA, checks, tag, then optional GitHub Release. When five humans can push v*, an unexpected release becomes a forensic exercise.
Use GitHub rulesets, not the retired tag-protection model
GitHub’s older tag protection rules were migrated to tag rulesets in 2024, and the old REST and GraphQL tag-protection endpoints were deprecated as part of that change. For a new policy, configure repository rulesets that target tags rather than looking for the former protected-tags interface or automating its deprecated API.
Create one or more tag-targeting rulesets and make the rule action match the risk. The important controls are restrictions on tag creation, updates, and deletion. A release tag should not be movable after publication merely because someone has ordinary write access; moving v1.8.0 changes what that version name denotes.
In each ruleset, explicitly name the bypass actors. “Repository administrators can bypass” may be convenient during setup, but it weakens the operational promise if every administrator can override the rule during a rushed incident. Prefer a GitHub App, automation identity, or narrowly defined release-maintainer group where your organization’s GitHub plan and administration model support it.
Rulesets are enforcement, not release automation. They stop an unauthorized push or UI-created tag, but they do not verify that a changelog was reviewed, that tests passed, or that the tagged commit is from the intended branch. Put those checks in the workflow identity that has permission to create the tag.
Make tag patterns narrow enough to express the policy
The difficult part is distinguishing stable tags from prereleases without opening a hole. If a stable ruleset broadly includes v*, it also includes v1.9.0-rc.1. Adding a more permissive prerelease rule does not reliably solve that problem: overlapping restrictions should be treated as cumulative, not as an allow-rule override.
Configure the stable lane to include Go version tags while excluding tags containing the prerelease separator. In a ruleset interface that supports include and exclude reference-name patterns, the policy can be expressed conceptually as:
Stable include: v*
Stable exclude: v*-*
Prerelease include: v*-*
Test the pattern behavior against real names before enforcing it. Your test set should include v0.1.0, v1.0.0, v10.2.3, v2.0.0-rc.1, and one non-release tag such as build-4821. The goal is not clever glob syntax; it is verifying that the people who can create a release candidate cannot also create a stable tag.
If your policy cannot cleanly express exclusions, use the safer fallback: protect all v* tags with the stable-level identity, and have the release workflow create prereleases too. That is more restrictive, but a little release friction is cheaper than a public version created from an arbitrary branch.
Give humans approval power, but give automation tag power
A robust release workflow separates the person who approves a release from the credential that pushes the tag. The release manager can choose version v1.8.0, approve the production environment, and review generated notes. The workflow then performs the Git operations using the one identity listed as a ruleset bypass actor.
- A maintainer opens or approves a release pull request containing the version and changelog changes.
- CI runs unit tests, static checks, and any project-specific compatibility tests against the release commit.
- A protected release workflow receives approval for the stable or prerelease lane.
- The workflow creates an annotated tag and pushes it.
- Only after the tag push succeeds does the workflow create a GitHub Release and publish artifacts, if the project ships them.
Annotated tags are a sensible release convention because they store tag metadata rather than only a reference to a commit. Go version selection is concerned with the version tag, not whether the tag was lightweight or annotated, so annotations are not a substitute for a ruleset. They are useful release records; the ruleset is the authorization boundary.
Do not hand every developer a long-lived credential that can bypass tag rules. A repository secret with that power is also a release credential. Scope it to the release workflow, rotate it according to your organization’s process, and avoid exposing it to pull-request workflows from untrusted code.
Release from an exact commit, then verify the tag
The tag command should run only after the workflow has identified the exact commit it intends to publish. Avoid a release job that tags whatever happens to be at the moving tip of a branch after a manual delay. A commit SHA is an unambiguous release input; a branch name is not.
git fetch --tags origin
git checkout --detach "$RELEASE_SHA"
git tag -a "v1.8.0" -m "Release v1.8.0"
git push origin "v1.8.0"
For a prerelease, change only the version and lane approval:
git tag -a "v1.9.0-rc.1" -m "Release candidate v1.9.0-rc.1"
git push origin "v1.9.0-rc.1"
After the push, verify both the ref and its target before publishing anything else:
git ls-remote --tags origin "v1.8.0"
This check catches mundane but expensive mistakes: the workflow used the wrong repository remote, a tag with the same name already existed, or the release job tagged a merge commit other than the tested SHA. It also makes an incident runbook more precise: operators can compare the remote tag target with the recorded release commit rather than relying on a web UI screenshot.
Account for Go major versions and monorepos before rollout
A root module can use tags such as v1.8.0 and v2.0.0, but Go modules at major version 2 and above conventionally use a module path ending in the major suffix, such as github.com/acme/widget/v2. The tag ruleset still matters: v2.0.0 is exactly the kind of high-impact tag that should never be an ordinary developer push.
Monorepos need an extra design pass. A repository can contain multiple modules, and a version tag intended for a module in a subdirectory may use that module’s directory as part of the tag name. Do not deploy a root-only v* policy and assume it protects every module in a repository.
- List every directory containing a
go.modfile. - Record the intended tag namespace for each independently versioned module.
- Assign each namespace to the workflow and maintainers responsible for that module.
- Test an attempted unauthorized tag push for each namespace.
This inventory prevents a subtle organizational failure: the root module is protected, while a submodule remains releaseable by anyone with write access. The more modules share a repository, the more useful separate rulesets become because ownership is usually different too.
Handle a mistaken version as an incident, not routine cleanup
Protection should make accidental tags rare, but teams still need a documented response. If v1.8.0 is created from the wrong commit, resist the temptation to silently move it. A stable version name should identify one immutable source state. Moving it makes two consumers using the same requested version potentially build different code at different times.
Use a short decision rule:
- If the tag never left the controlled release workflow and no release was published, stop the workflow and remove it using the incident-authorized identity.
- If the tag was pushed to the public repository, assume it may have been observed and publish a corrected new version such as
v1.8.1rather than retargetingv1.8.0. - If the problem is security-sensitive, communicate the affected version explicitly and follow your project’s security-response process.
This is also why delete protection belongs beside creation protection. Without it, a well-meaning maintainer can remove evidence of a bad version before the team has determined who consumed it. Controlled recovery is slower by design; it preserves a trustworthy version history.
Implement the policy this week
Start with one repository and one dry-run release. The objective is not to perfect every naming convention; it is to make the dangerous command git push origin v1.8.0 fail for ordinary contributors and succeed only from the intended release path.
- Find all current version-shaped tags with
git tag -l 'v*'and identify the module they belong to. - Write down stable, prerelease, and non-release tag namespaces in the repository’s release documentation.
- Create tag rulesets for the stable and prerelease lanes, restricting creation, update, and deletion.
- Add only the release automation identity and designated release maintainers as bypass actors where appropriate.
- Attempt to push a disposable version-shaped test tag from a normal contributor account; it should be rejected.
- Run the release workflow against a test prerelease and confirm that it can push only through the approved lane.
- Document the mistaken-release response: do not retarget a public stable version; issue a new version.
The final test is behavioral, not administrative: a developer with normal write access should still be able to collaborate normally, yet be unable to turn a typo into a Go module release. That is the boundary GitHub tag rulesets are meant to enforce.