Moving a single release tag such as v2.7.4 can make two users download different source archives under the same version name. If that tag also triggered a GitHub Release with uploaded binaries, the mismatch becomes a supply-chain and support problem rather than a harmless Git mistake.

GitHub’s retirement of legacy tag protection rules matters because release tags are often the boundary between ordinary repository activity and software that customers deploy. GitHub announced that existing tag protection rules would be migrated to tag rulesets on August 30, 2024, while the old tag-protection APIs were deprecated. Treat that change as more than a UI migration: it is a chance to write down exactly which tags are releases, who may create them, and which automation is allowed to do so.

The practical goal is narrow: recreate the old protection scope, prove it works on a harmless tag, and prevent a ruleset from breaking the workflow that publishes your releases. This checklist uses a repository with stable tags like v1.8.0, prereleases like v1.9.0-rc.1, and a GitHub Actions release workflow as a concrete example.

1. Start with the release tags people actually consume

Do not begin by creating a broad rule for every tag beginning with v. First identify what your repository has published and what downstream users treat as immutable. A GitHub Release is tied to a Git tag, and releases commonly carry downloadable binaries as well as source archives. That means the tag pattern behind a release is usually more important than the release title visible in the web UI.

List tags locally, then separate stable releases from temporary tags created by CI, experiments, or deployments.

git fetch --tags origin
git tag --list --sort=-version:refname | head -50

git tag --list 'v*' --sort=-version:refname
git tag --list 'release-*' --sort=-version:refname
git tag --list '*-rc.*' --sort=-version:refname

For the example repository, the first inventory might reveal three categories:

Tag examples Consumer expectation Migration decision
v1.8.0, v2.0.1 Stable, deployable release Protect creation, update, and deletion
v1.9.0-rc.1 Candidate build used by testers Protect if candidates are externally consumed
build-8472, test/alice Disposable CI or developer marker Leave outside the release ruleset

The decision rule is simple: protect a pattern when changing a tag under that pattern would change what another person, deployment, package job, or audit record believes a named version means. A nightly tag that is deliberately moved should not be governed like v2.0.1.

2. Recover the intent of the retired protection rule

Legacy tag protection often hid important policy inside a short wildcard such as v*. Copying that wildcard mechanically can be correct, but only if you understand what it was intended to cover. Ask the release owner whether v* meant “all versions,” “only stable SemVer versions,” or merely “the tag format we used three years ago.”

Record the answer in a small migration note before changing rulesets. Include the old pattern, representative matching tags, representative nonmatching tags, and the people or systems that must be able to publish. This makes review possible without requiring every reviewer to reconstruct release history from the tag list.

  • Scope: for example, stable releases use v*; experimental tags under test/ are excluded.
  • Operations to block: decide separately whether creation, update, and deletion should be restricted.
  • Authorized actors: name a release-maintainers team, a repository administrator, or a release application rather than saying “CI.”
  • Workflow path: identify the exact workflow file and event that creates the tag or release.
  • Rollback owner: name the person who can pause publishing if a policy test blocks an urgent release.

The overlooked migration cost is exceptions. A rule that accurately protects v* but does not account for the release workflow can stop publishing at the final step, after tests and artifact builds have already completed. That failure wastes a release window and encourages someone to weaken protection under pressure.

3. Translate tag scope into fnmatch patterns, not regular expressions

Repository rulesets use fnmatch syntax to target branches or tags. That is a different mental model from regular expressions: do not paste a familiar expression such as ^v\d+\.\d+\.\d+$ into a ruleset and expect it to describe Semantic Versioning. Start with the smallest pattern that matches the release family you have documented.

For many repositories, v* is appropriate because stable releases and release candidates are both versioned tags. For others, the correct pattern is more specific. A repository that uses tags such as release/2026.09 needs a pattern designed for that naming convention, including the slash.

Desired scope Example tags Candidate fnmatch pattern Important check
All tags beginning with v v1.2.3, v2.0.0-rc.1 v* Includes prerelease tags too
Release namespace release/2026.09 release/* Test a nested name if your team uses one
Candidate namespace candidate/2.1.0 candidate/* Keep it separate from stable releases if policy differs

Use real tags as test cases, not imagined names. Write down at least five expected matches and three expected nonmatches. If your release convention cannot be safely expressed with one understandable pattern, create separate rulesets for stable releases and prereleases rather than using an opaque catch-all.

4. Create a tag ruleset with deliberate operations

In the repository’s ruleset settings, create a ruleset that targets tags and enter the fnmatch pattern from your inventory. Give it an operational name such as Protect published v* release tags, not Tag policy. Six months later, the explicit name tells an on-call maintainer what will be affected before they edit it.

For immutable published releases, the usual policy is to restrict all three lifecycle actions: creating protected tags, updating matching tags, and deleting matching tags. The first restriction controls who can mint a release name; the update restriction prevents a force-push from silently moving a release; deletion restriction prevents an accidental cleanup command from removing a published version.

Do not assume every tag needs all three restrictions. A team may intentionally use a movable latest tag, but that is a different contract from versioned release tags. Put it in its own pattern and document that it is mutable. Mixing latest into v* through an overly broad pattern creates either an unusable release process or a loophole in immutable releases.

Before saving, compare the rule against the old policy’s effective scope. The migration is successful only when the new ruleset protects the intended names and permits the intended publisher. A rule that is stricter by accident is not automatically safer if it forces maintainers into manual exceptions.

5. Test with a nonproduction tag before release day

The safest policy test is a tag that matches the new pattern but cannot be mistaken for a production release. If your protected namespace is release/*, use something like release/policy-test-2026-09. If the production pattern is broad v*, consider temporarily testing with a narrower nonproduction pattern first, such as migration-test/*, before applying the broader production scope.

Run the test with two identities: an ordinary contributor who should be denied and the intended publisher who should be allowed. The point is not only to see a failure; it is to verify that failure happens for the correct actor and operation.

  1. Create a disposable commit or select an existing harmless commit.
  2. Try to create and push the matching test tag with an account that has normal write access.
  3. Confirm GitHub rejects the prohibited operation and retain the error text in the migration record.
  4. Run the actual release automation or its safe equivalent against the same test pattern.
  5. Attempt an update and deletion of the test tag with the identities that should be denied.
  6. Delete local test references and clean up the remote test tag only through an authorized path.

This test catches the policy gap that configuration review misses: Git credentials and GitHub Actions credentials may not behave like a maintainer’s interactive account. Test the exact mechanism your release workflow uses to push tags.

6. Treat release automation as a named exception, not a mystery token

“CI needs access” is not enough documentation for a tag ruleset bypass. Identify whether a maintainer creates the tag manually, a GitHub Actions workflow pushes it, or an external release system does so. Then grant the smallest available bypass or authorization that allows that exact publisher to perform the required operation.

For example, document the workflow as .github/workflows/release.yml, its trigger, the tags it may create, and the reason it needs permission. If the workflow creates v2.3.0 and then creates a GitHub Release with artifacts, a blocked tag push prevents the release from having the source point it expects. That relationship is why a tag rule must be tested alongside the release workflow rather than in isolation.

Questions to answer for every automation exception

  • Which workflow, GitHub App, or service account performs the Git push?
  • Does it create a new version tag, update an existing tag, delete a tag, or only create a GitHub Release?
  • Which patterns may it touch: v*, candidate/*, or a narrower prefix?
  • Who reviews changes to its workflow file and credentials?
  • What is the manual fallback if the publisher is unavailable during a security release?

The tradeoff nobody mentions is that a broad automation bypass can become a release bypass for anyone who can alter the workflow. Keep the exception tied to a reviewed release path, and revisit it when you rename workflows or replace a release bot.

7. Verify releases, artifacts, and rollback behavior

A successful tag push is not the end of validation. Create a nonproduction release, or exercise the equivalent safe release path, and inspect the tag name used by the release. GitHub Releases are dependent on Git tags, while binary artifacts attached to a release are commonly what users download. Your verification should therefore cover both the Git reference and the publishing result.

For a test release, check these facts:

  • The release points to the expected commit SHA.
  • The release name and tag_name match the tag your automation created.
  • Any uploaded artifact is associated with that test release, not a similarly named older tag.
  • An unauthorized user cannot retag the same version to a different commit.
  • The authorized release path still works without an administrator disabling the ruleset.

Also rehearse the failure path. If a release workflow builds artifacts successfully but cannot create a protected tag, decide whether the team retries after correcting authorization, creates a new version tag, or stops the release. Do not solve this by moving an already published tag unless your written policy explicitly permits that exceptional action and explains how consumers are notified.

8. Keep a compact ruleset record in the repository

Rulesets are configuration, but release-tag decisions deserve versioned documentation beside the workflow that depends on them. A file such as docs/release-tag-policy.md is enough. It should not duplicate every GitHub settings screen; it should explain the policy a future maintainer cannot infer from a wildcard.

Include a record like this in prose or a table:

Field Example value
Protected tag pattern v*
Protected operations Create, update, and delete
Allowed publisher Release maintainers and the documented release workflow
Excluded tags build-* and test/*
Exception rationale Workflow publishes a version tag and its corresponding GitHub Release
Validation date Date of last nonproduction policy test

This document reduces the most common maintenance friction: a new maintainer sees a failed tag push, assumes GitHub is broken, and grants broad repository permissions. A two-minute explanation of the intended publisher is more useful than an emergency permission change.

9. Run this migration checklist this week

Schedule the work before the next planned release, not during it. A small repository can complete the inventory and test in one maintenance session; the value comes from involving the person who owns releases and the person who owns automation. Those are often different people, and ruleset migrations fail when only one of them reviews the change.

  1. Export or record every existing protected tag pattern and list 10 recent matching tags.
  2. Classify each pattern as immutable release, mutable deployment marker, prerelease, or disposable CI tag.
  3. Create a tag ruleset using an explicit fnmatch pattern for each policy class.
  4. Configure restrictions for creation, update, and deletion according to the class.
  5. Document the exact human or automation publisher that needs an exception.
  6. Push a matching nonproduction tag as both an unauthorized user and the intended publisher.
  7. Exercise the release workflow far enough to verify its tag and release behavior.
  8. Add the final pattern, exception rationale, and test date to docs/release-tag-policy.md.

The durable outcome is not merely replacing a retired GitHub feature. It is making a release tag an explicit promise: the version name points to one known revision, only named publishers can establish that promise, and the automation that packages the software can do its job without bypassing the policy by accident.