A v1.8.0 tag is only a Git ref, so one retrying release job can make the same version string point at different source code. The dangerous failure is not a broken build; it is shipping a corrected artifact under an unchanged tag while downstream users still trust the first commit.

That is the policy problem behind GitHub’s move away from the older tag-protection workflow. The useful outcome is not simply having a new screen in repository settings. It is defining three separate decisions that teams often accidentally collapse: who can create a release tag, who can change an existing tag, and whether any automation identity can do either.

This guide uses a concrete policy for a repository that releases semantic versions such as v2.4.1. Maintainers prepare a release, a GitHub Actions workflow creates the tag, and nobody—including a repository administrator acting casually—should be able to move or delete a published release tag without a deliberate exception.

Start with the release failure you are preventing

Before configuring a ruleset, write down the tag operations your repository actually performs. Git has distinct operations for creating a new ref, updating a ref that already exists, and deleting a ref. Treating them as one permission is how a “protect releases” setting turns into either an unusable CI pipeline or a policy with a quiet loophole.

For a normal release repository, the desired behavior is usually asymmetric:

Operation Example Recommended policy for v* Why
Create Create v2.4.1 at an approved commit Allow only the release automation identity and named release maintainers A new version is an intentional publication event.
Update Move v2.4.1 to another commit Block Moving a version changes what users receive without changing the version.
Delete Delete v2.4.1 Block Deletion breaks scripts, package metadata, and source links.
Create a non-release tag Create test/ci-482 Keep outside the release policy, if needed at all Temporary tags should not widen permissions for production tags.

The decision rule is simple: use a protected namespace for identifiers that customers, package managers, deployment systems, or documentation treat as immutable. If a tag is merely a disposable CI marker, give it a different prefix rather than weakening the policy for v*.

Understand what changed from the old protection model

GitHub announced that legacy tag protection rules would be migrated to a new tag ruleset on August 30, 2024. Earlier milestones removed the ability for repositories without tag protection rules to add new ones through the GitHub.com UI, and GitHub scheduled API brownouts before the retirement. For a repository that had an older rule, the practical task is therefore often inspection and redesign, not a blind one-for-one recreation.

The older workflow centered on a tag-name pattern and who could create matching tags. A ruleset is a policy container for tag interactions: it can target tags and express restrictions around creation, updates, and deletion. Repository rulesets also make the important exception explicit through bypass configuration rather than leaving the team to rely on an undocumented convention such as “admins can fix it.”

That distinction matters in incident response. “Only maintainers can create v*” does not answer whether a maintainer can retarget v2.4.1 after discovering a packaging error. A release policy must answer it. The safest answer for public version tags is normally no: publish v2.4.2, explain the correction in release notes, and preserve the original reference for auditability.

Inventory tags and identities before touching the ruleset

Do not begin by adding restrictions. First identify the patterns that exist and the credentials that push them. A five-minute inventory prevents the common rollout failure where a nightly job uses v-prefixed tags and suddenly loses the ability to publish.

From a local clone, list version-like tags and inspect where they point:

git fetch --tags --force
git tag --list 'v*' --sort=-version:refname | head -20
git show-ref --tags | grep 'refs/tags/v'

Then inspect workflows and release scripts for push operations. Search for git tag, git push origin, refs/tags/, and release actions. Do not assume the workflow file tells the whole story: a reusable workflow, a deployment tool, or a maintainer’s local script may create the tag.

  • List the human maintainers who genuinely need an emergency release capability.
  • Identify the automation actor that authenticates the tag push.
  • Record whether the workflow creates lightweight tags or annotated tags.
  • Find tags that are not releases but happen to match the same prefix.
  • Check whether any documentation or package configuration refers to a mutable tag such as latest.

The last item deserves care. A channel tag such as latest is designed to move; a semantic release tag should not. Put them in separate patterns and give them separate policies.

Write the policy in plain language first

Here is a workable policy for a project with two release maintainers and one release workflow. It is intentionally narrow: it protects customer-facing versions without turning every tag in the repository into a change-control event.

Tags matching v* are release identifiers. The release workflow may create a previously unused matching tag after required checks pass. Alice and Ben may create a tag during a documented release incident. Existing matching tags may not be updated or deleted. Tags outside v* are not covered by this release policy.

Notice the wording “previously unused.” A policy that permits creation but blocks updates gives the team a clean release sequence. If v2.4.1 already exists, the release job must stop rather than trying to repair the release by force-pushing.

Also decide whether humans need direct creation rights at all. For many repositories, the stronger and lower-friction model is: maintainers merge an approved release-preparation pull request, automation tags the exact commit, and humans have no normal bypass. Keep a small break-glass route only if your operational reality requires it. Every permanent bypass is an additional credential path that needs review when people change roles.

Create a tag ruleset around the namespace, not the entire repository

In the repository’s rules settings, create or inspect the tag ruleset that targets the release namespace. Use the same pattern your release tooling actually produces. For the example policy, that is v*, which covers v2.4.1, v2.5.0-rc.1, and v3.0.0.

Configure the ruleset to restrict the three operations independently:

  1. Restrict creation of tags matching v*.
  2. Restrict updates to matching tags.
  3. Restrict deletion of matching tags.
  4. Add only the maintainers and automation actor that must bypass the creation restriction.
  5. Do not grant a broad bypass merely because someone is an administrator; add an explicit break-glass actor only when there is a real operational need.

The tradeoff nobody mentions is that a broad bypass turns your ruleset into a notification rather than a guardrail. If every repository administrator can bypass it, a compromised administrator account can still retarget a release. Conversely, a zero-bypass configuration can delay a genuine recovery if the release workflow is unavailable. Pick based on your recovery plan: can you publish a corrected v2.4.2, or do you have a contractual reason to repair v2.4.1?

Make the CI identity part of the design, not an afterthought

A ruleset evaluates the actor that performs the push, not the friendly name of the workflow. That means a successful run of “Release” proves nothing until you know which credential reaches GitHub when git push runs.

For GitHub Actions, inspect the checkout and authentication setup in the job that creates the tag. A workflow often needs repository content write permission to push a tag, but write permission alone does not override a matching ruleset. The actor must also be permitted by the policy you configured.

name: Release tag

on:
  workflow_dispatch:

permissions:
  contents: write

jobs:
  tag:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Create annotated tag
        env:
          TAG: v2.4.1
        run: |
          git config user.name "release automation"
          git config user.email "release@example.invalid"
          git tag -a "$TAG" -m "Release $TAG"
          git push origin "$TAG"

This example deliberately does not use --force. If the tag exists, the job should fail. That failure is valuable evidence that either the release was already published or the process is attempting to reuse a version.

Add a preflight check so failures are understandable

Rulesets give you enforcement, but they do not make a failing release log self-explanatory. Add a preflight check before building artifacts or creating a release. It turns a late push rejection into an early, actionable message and prevents wasting a long build on a version that cannot be published.

TAG="v2.4.1"

if ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([.-].*)?$'; then
  echo "Refusing non-release tag: $TAG"
  exit 1
fi

if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
  echo "Tag already exists: $TAG"
  exit 1
fi

This check does not replace the ruleset. Two jobs can pass the check at nearly the same time, and only the server-side rule is authoritative. Its value is process clarity: a maintainer sees “tag already exists” before publishing assets, rather than interpreting a generic push failure after several jobs complete.

Put the preflight immediately before the tagging operation, after you have selected the release version and commit. If artifacts are built in earlier jobs, use the exact verified commit as the release input; do not silently tag whatever happens to be at the tip of the default branch when the final job starts.

Test the policy with four controlled attempts

Configuration review is not enough. Test from a disposable tag name that matches the real pattern, such as v0.0.0-policy-test, and remove the test only if your deletion policy and approved procedure allow it. If release tags must never be deleted, perform this exercise in a non-production repository first.

Test Expected result What a failure reveals
Unauthorized identity creates a matching tag Rejected The matching pattern or creation restriction is too broad.
Authorized CI identity creates a new matching tag Accepted If rejected, CI is not the permitted actor or lacks push capability.
Authorized CI identity attempts to move that tag Rejected If accepted, update protection or bypass scope is too permissive.
Maintainer attempts deletion Rejected under the normal policy If accepted, a human bypass is broader than intended.

Capture the actor, repository, tag, commit SHA, and result in the release runbook. This is more useful than a screenshot of settings because it documents the behavior that matters when a token or workflow implementation changes later.

Handle exceptions without rewriting release history

The pressure to move a tag usually arrives after a mistake: an artifact was assembled from the wrong commit, a release note contains a typo, or a package upload failed. These are different incidents and should not share one response.

  • For a bad build or bad source commit, publish a new version such as v2.4.2; do not move v2.4.1.
  • For a release-note typo, correct the text where your release process permits, while leaving the tag unchanged.
  • For a failed package upload, retry the distribution step using the existing immutable tag and its exact commit.
  • For a suspected credential compromise, pause releases, rotate the relevant credential, and review which tag operations succeeded before changing policy.

GitHub’s separate work on immutable releases points in the same direction: the useful supply-chain property is that a previously published identifier remains tied to what users originally received. A tag ruleset protects the Git reference; it does not automatically make every external package registry, release asset, or deployment environment immutable. Document those boundaries rather than claiming the tag policy solves them.

Review the policy this week and after every release-tool change

Use this week to turn an inherited configuration into an owned one. Open the repository’s rules settings, find the tag ruleset affecting your production version pattern, and compare it line by line with a written policy. The most important question is not “is there a ruleset?” but “can the account that runs our release workflow create a new tag while nobody can move yesterday’s tag?”

  1. Run git tag --list 'v*' and identify the real release namespace.
  2. Locate every workflow or script that pushes a matching tag.
  3. Write the create, update, and delete decisions in one paragraph.
  4. Set or revise the tag ruleset to match those decisions.
  5. Add a tag-exists preflight to the release workflow.
  6. Run the four controlled tests in a safe repository.
  7. Record the approved emergency path and the people allowed to use it.

A release-tag policy is successful when it makes the correct release path routine and the dangerous path visibly difficult. That is a better operational target than preserving the behavior of the retired tag-protection screen.