One mistaken git tag v1.2.3 && git push --tags can turn an unfinished commit into the version Go users download forever. If a package publishing workflow listens for v*, the tag can also publish an npm package, container image, or GitHub Release before anyone notices the branch was wrong.

The useful protection is not “developers cannot edit tags.” It is a release path where exactly one identity can create v1.2.3-style tags, and that identity can do so only after a successful build of an approved branch commit and an explicit production approval.

Why a tag mistake becomes a package-release incident

Semantic version tags are unusually consequential because downstream tooling treats them as publication signals. Go module users commonly resolve a module version directly from a repository tag such as v1.2.3; a corrected commit on main does not alter what v1.2.3 means. The practical recovery is a new release such as v1.2.4, with support and documentation work attached.

The same pattern appears in CI. A workflow triggered by push to v* may build and push an OCI image, publish to npm, upload a Python distribution, or create a GitHub Release. In that design, tag creation is the security boundary. Protecting the publishing job alone is insufficient if any repository writer can generate the event that starts it.

GitHub’s older tag protection rules were migrated to tag rulesets in 2024. Use rulesets rather than building new controls around the deprecated tag-protection API. A ruleset can match the version namespace, prevent ordinary users from creating or changing matching tags, and reserve that namespace for a controlled release identity.

Design the release boundary before writing YAML

This example uses one deliberately narrow policy: only commits that have passed the repository’s verification workflow on main may receive a stable version tag. A reviewer must approve the release environment, and a GitHub App named release-bot is the only bypass actor allowed to create tags matching v*.

The flow has four independent checks. Each check catches a different failure mode:

  1. A pull request must satisfy branch protection before code reaches main.
  2. The test workflow must complete successfully for the exact main commit.
  3. A protected environment requires a release approver before the tag job runs.
  4. A tag ruleset prevents people, local Git credentials, and ordinary automation tokens from creating v1.2.3.
Control Blocks Does not block by itself
Branch protection on main Unreviewed or failing pull requests A maintainer manually tagging a bad commit
Tag ruleset for v* Direct creation, update, or deletion by non-bypass actors The authorized bot tagging the wrong SHA
Environment approval An unattended CI run publishing immediately A workflow whose candidate SHA is not verified
Exact-SHA verification Tagging a branch, PR head, or later commit by accident A bad version policy in the repository itself

The important detail is the final row. Rulesets control who can write a ref. Your workflow must control which commit that approved writer is allowed to tag.

Create a ruleset that owns the v* namespace

In the repository’s Rules settings, create a tag ruleset named stable-semver-tags. Target tags matching v*. This includes stable tags such as v1.2.3, but also names such as v-next; that broader match is intentional if your goal is that humans never create release-looking tags.

Configure the ruleset to restrict tag updates and deletion, then add only the release GitHub App to its bypass list. Do not add a broad administrator role merely because an emergency might occur. A named bot identity leaves an audit trail and makes the exception narrow. If an emergency requires a manual tag, temporarily change the controlled process or have the app create the tag after an approved run.

Use a separate pattern for non-production tags if your team needs them. For example, allow preview/* tags without granting access to v*. Avoid a release workflow that treats every tag as publishable.

stable-semver-tags
Target: tags matching v*
Bypass actor: GitHub App "release-bot"
Rules: restrict updates, restrict deletions

Test the ruleset with a maintainer account before connecting package credentials. A direct attempt such as git push origin v0.0.1 should fail for a normal repository writer. That failed push is the proof that an accidental local command cannot start your production release pipeline.

Use a GitHub App instead of a maintainer token

A personal access token makes the release path depend on one employee’s account, organization membership, and token rotation habits. More importantly, it makes the bypass permission hard to distinguish from a human’s normal repository access. A GitHub App gives the release writer a specific name and narrowly scoped repository permission.

Create and install a GitHub App such as release-bot with repository contents write access for the release repository. Store its app ID and private key as GitHub Actions secrets. The workflow exchanges those credentials for a short-lived installation token, then uses that token only for the tag API call.

Do not solve the ruleset problem by exempting all GitHub Actions runs. A repository may have dozens of workflows, including jobs launched from pull requests, scheduled maintenance, or experiments. The bypass should belong to the release app, not to every workflow able to obtain a default GITHUB_TOKEN.

This division also clarifies incident response. If a release is suspect, disable or uninstall one GitHub App. You do not need to revoke every maintainer’s Git credentials or disable all CI automation.

Make successful verification the release trigger

A manual workflow_dispatch release is tempting, but its ref picker is easy to misuse. A maintainer can select a feature branch, or a branch can contain a modified workflow file that removes the safety checks. Instead, start the tag workflow after the repository’s named verification workflow completes successfully.

The following workflow listens for a successful workflow named Verify. It rejects runs that are not from main, checks out the completed run’s immutable commit SHA, and places the tag operation behind the production-release environment.

name: Tag stable release

on:
  workflow_run:
    workflows: ["Verify"]
    types: [completed]

permissions:
  contents: read

jobs:
  tag:
    if: >
      github.event.workflow_run.conclusion == 'success' &&
      github.event.workflow_run.head_branch == 'main'
    runs-on: ubuntu-latest
    environment: production-release

    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.workflow_run.head_sha }}
          fetch-depth: 0

      - name: Read version
        id: version
        run: |
          VERSION="$(tr -d '[:space:]' < VERSION)"
          case "$VERSION" in
            v[0-9]*.[0-9]*.[0-9]*) ;;
            *) echo "VERSION must look like v1.2.3"; exit 1 ;;
          esac
          echo "value=$VERSION" >> "$GITHUB_OUTPUT"

      - name: Confirm the tested SHA is on main
        run: |
          git fetch origin main
          git merge-base --is-ancestor \
            "${{ github.event.workflow_run.head_sha }}" origin/main

The workflow_run.head_sha value is the candidate. Do not substitute github.sha here: that identifies the release workflow’s own context, not necessarily the commit that passed Verify.

Validate the version before creating the ref

A tag guard should be idempotent and conservative. If v1.2.3 already exists, fail rather than move it. Reusing a semantic version is confusing even when a Git host permits a privileged actor to retarget the ref; downstream caches, release notes, and package registries may already have recorded the original version.

Add the GitHub App token step and create a lightweight Git ref through the GitHub API. Lightweight tags are sufficient for Go module version discovery and for tag-push release triggers. If your organization requires signed or annotated tags, use a signing-aware process, but keep the same candidate-SHA and ruleset controls.

      - name: Create installation token
        id: app-token
        uses: actions/create-github-app-token@v1
        with:
          app-id: ${{ secrets.RELEASE_APP_ID }}
          private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

      - name: Refuse an existing version
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          VERSION: ${{ steps.version.outputs.value }}
        run: |
          if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$VERSION" \
            --silent 2>/dev/null; then
            echo "$VERSION already exists; refusing to retag it"
            exit 1
          fi

      - name: Create the protected tag
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          VERSION: ${{ steps.version.outputs.value }}
          SHA: ${{ github.event.workflow_run.head_sha }}
        run: |
          gh api --method POST \
            "repos/$GITHUB_REPOSITORY/git/refs" \
            -f "ref=refs/tags/$VERSION" \
            -f "sha=$SHA"

The API call must succeed as release-bot, the bypass actor configured in the ruleset. A normal GITHUB_TOKEN should not be able to replace it. That intentional separation turns a permissions mistake into a failed job rather than an accidental package release.

Publish packages only after the protected tag exists

Now let a second workflow publish artifacts only when the protected tag is pushed. The release job creates the event; developers cannot. This is the point at which tag protection changes from repository hygiene into package-release prevention.

name: Publish packages

on:
  push:
    tags:
      - 'v*'

permissions:
  contents: read
  id-token: write

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: package-publish
    steps:
      - uses: actions/checkout@v4
      - run: git describe --exact-match --tags HEAD
      - run: go test ./...
      - run: go list -m

For a Go module, go list -m is a simple sanity check that the checked-out module is readable before downstream consumers encounter it. Your actual package upload command belongs after that check, using the registry’s recommended authentication method. Keep package credentials scoped to this publishing job, not to the workflow that merely runs tests.

The additional package-publish environment is optional but useful for high-impact registries. It creates a second approval point after the immutable tag exists. The tradeoff is operational friction: a release can be tagged but not yet published. Teams that need one-click releases can omit this second gate while retaining the stronger tag creation controls.

Do not let GitHub Releases become a second release path

GitHub Releases are presentation objects: release notes, attached binaries, and a “latest” label. They are not a substitute for controlling Git refs. A user drafting a release can choose an existing tag or create a tag through the release interface, so the tag ruleset must remain the authority over the v* namespace.

After package publication succeeds, create the GitHub Release from the already-existing tag. Do not configure a separate workflow that creates a tag while drafting a release. The safe order is:

  1. Merge the version change through protected main.
  2. Run Verify successfully on that exact commit.
  3. Approve production-release.
  4. Let release-bot create v1.2.3.
  5. Publish artifacts from the protected tag.
  6. Create release notes against the existing tag.

This ordering prevents an attractive but dangerous shortcut: publishing based on the GitHub Release event. Release metadata can be edited, marked as a prerelease, or set as latest; none of those actions should decide which source commit becomes a Go module version.

Handle prereleases and hotfixes as explicit policy choices

Decide whether v1.2.3-rc.1 belongs in the same protected namespace as stable versions. For most repositories, it should. A prerelease can still trigger container builds, package uploads, and automated deployment rules, so treating it as harmless often recreates the problem under a different pattern.

Hotfixes need a documented branch rule. If your policy permits releases from both main and release/1.x, update the candidate check to allow those exact branch names and nothing else. Do not use a loose condition such as “any branch beginning with release” unless your branch governance also controls who can create those branches.

Repository policy Allowed tag candidates Workflow condition
Trunk-only releases Successful main verification runs head_branch == 'main'
Supported maintenance lines main plus protected release/1.x Exact allowlist of branch names
Preview builds Tags outside v*, such as preview/* Separate non-production workflow

The decision rule is simple: if a ref can lead to a registry upload or a production deployment, treat that ref namespace as protected production infrastructure.

Test the failure cases before trusting the release path

Run four drills in a non-critical repository or with a harmless version such as v0.0.1. Record the expected outcome in the repository’s release documentation so maintainers know that failures are protections, not CI defects.

  • Push v0.0.1 from a maintainer workstation. It should be rejected by the tag ruleset.
  • Run verification on a feature branch. The tag workflow should not start because the completed run is not from main.
  • Merge a change with an invalid VERSION value such as 1.2.3. The tag job should fail before calling the API.
  • Run the process twice for the same version. The second run should refuse to retag the existing version.

Also inspect the Actions run log after a real release. It should show one tested SHA, one parsed version, an approved environment deployment, and a tag created by the GitHub App. Those four facts are enough to answer the uncomfortable question after an incident: “Who released this version, from exactly which commit, and why was it allowed?”

Set this up this week

Start with the tag ruleset, because it closes the biggest hole immediately: direct creation of v* tags by people and generic automation. Next, create the dedicated GitHub App and verify that it, not a maintainer token, can bypass the rule.

Then connect the tag workflow to a successful Verify run on your protected release branch. Add the exact-SHA check, the existing-tag refusal, and a required production-release environment reviewer. Finally, make package publishing listen only for protected tag pushes, not for manual release drafts or arbitrary branch pushes.

The result is intentionally less convenient than git tag from a laptop. That inconvenience is the control: a version number becomes a record of a tested commit, an approved release decision, and a narrowly authorized write to a protected namespace—not an irreversible side effect of one mistyped command.