Tagging pre-releases and release candidates Jump to heading

Release candidates let people test a build before it becomes the release, but only if everyone β€” and every tool β€” can tell a candidate from a release. Tag names that sort wrongly (v2.4.0-rc10 before v2.4.0-rc2), candidates marked as the latest release on the forge, package managers installing a beta by default, and git describe reporting v2.4.0-rc.3 for development builds after the final release are all common. They come from tag naming and a few settings, and are easy to get right once. This page sets a naming scheme that follows Semantic Versioning, configures Git and the forge to sort and label pre-releases correctly, and promotes a candidate to the final release without rebuilding something different, within release tagging and versioning.

When to use this approach Jump to heading

  • You publish alphas, betas or release candidates for testing.
  • Tags sort in the wrong order, or the forge marks a candidate as the latest release.
  • Development builds after a release describe themselves as a release candidate.
  • You want the final release to be byte-for-byte the candidate that was tested.

Step 1 β€” Use a SemVer pre-release scheme Jump to heading

Semantic Versioning places pre-release identifiers after a hyphen and compares numeric parts numerically. Separate the number with a dot so rc.10 sorts after rc.2.

v2.4.0-alpha.1   v2.4.0-beta.1   v2.4.0-rc.1   v2.4.0-rc.2   v2.4.0
# Avoid: v2.4.0-rc1 … v2.4.0-rc10   (compared as text: rc10 < rc2)
Pre-release tags leading to a releaseAlphas come first for early testing, then betas once features are complete, then numbered release candidates that are expected to become the release unless a problem is found. The final tag has no suffix. SemVer orders every pre-release before the release with the same version.Early testingfeatures incompletealpha.1Feature completewider testingbeta.1Candidatecould shiprc.1Candidateafter one fixrc.2Release= rc.2's commitv2.4.0every pre-release sorts before v2.4.0 in SemVer order

Step 2 β€” Make Git sort tags by version Jump to heading

Git sorts tags alphabetically by default. Configure version sorting, and tell it that hyphenated suffixes are pre-releases so they sort before the release.

git config --global tag.sort version:refname
git config --global versionsort.suffix -alpha
git config --global --add versionsort.suffix -beta
git config --global --add versionsort.suffix -rc
git tag --list 'v2.4.*'
# v2.4.0-alpha.1  v2.4.0-beta.1  v2.4.0-rc.1  v2.4.0-rc.2  v2.4.0  v2.4.1

Step 3 β€” Keep pre-releases out of β€œlatest” Jump to heading

On the forge, mark candidate releases as pre-releases so they are not shown as the latest release, and publish packages to a pre-release channel so install does not pick them up by default.

gh release create v2.4.0-rc.1 --prerelease --title "2.4.0 RC 1" --notes-file notes.md
npm publish --tag next              # npm: `npm install pkg` still gets latest

Python package indexes treat PEP 440 pre-releases (2.4.0rc1) as opt-in automatically; convert tags to that form at build time, as in deriving versions from git describe.

Step 4 β€” Keep candidates out of describe on main Jump to heading

If candidates are tagged on main, development builds after the final release can still describe against a candidate when the release tag sits elsewhere. Exclude pre-release tags when computing development versions.

git describe --match 'v[0-9]*' --exclude '*-*'      # only final release tags
Where do pre-release tags go?A tool computing development versions should ignore pre-release tags. A forge release for a candidate should be marked as a pre-release. A package built from a candidate should go to a pre-release channel. Only the final tag should become latest everywhere.Which tool is reading the tag?git describeExclude *-*final tags onlyforge release--prereleasenot latestpackage registryPre-release channelnext / rcthe final tag is the only one that should be latest anywhere

Step 5 β€” Promote the tested candidate, don’t rebuild Jump to heading

The final release should be the commit β€” and ideally the artefact β€” that passed testing as the last candidate. Tag the candidate’s commit, and promote its artefacts rather than building again.

rc=v2.4.0-rc.2
commit=$(git rev-parse "$rc^{commit}")
git tag -s v2.4.0 "$commit" -m "Release 2.4.0 (promoted from $rc)"
git push origin v2.4.0
# Promote the tested image rather than rebuilding it
crane tag "registry.example.com/app:${rc#v}" "${rc%%-*}" 2>/dev/null || \
  echo "re-tag the rc image as the release with your registry's tooling"

If anything changed since the last candidate, cut another candidate first. A final release built from untested changes defeats the purpose of candidates.

Step 6 β€” Check the promotion in CI Jump to heading

Make the release job verify that the final tag points to the same commit as a candidate, so a release cannot skip testing.

v=${GITHUB_REF_NAME}                                      # v2.4.0
c=$(git rev-parse "$v^{commit}")
git tag --points-at "$c" --list "$v-rc.*" | grep -q . || { echo "::error::$v does not match any release candidate"; exit 1; }
From candidate to releaseA candidate tag is pushed and its build is published to the pre-release channel. Testers verify it. If a problem is found, a fix leads to the next candidate. If not, the final tag is created on the same commit and the tested artefact is promoted to latest.rc tagv2.4.0-rc.2Buildpre-release channelTestfix β†’ next rcFinal tagsame commitPromoteartefact β†’ latesta found problem loops back to a new rc tag, never to the final

Step 7 β€” Clean up candidate noise after the release Jump to heading

Candidate tags are part of the record and should stay, but their forge releases and pre-release packages can clutter listings once the final version is out. Keep the tags, and tidy the rest.

# Leave tags in place; delete the pre-release pages for a shipped version
gh release list --limit 100 --json tagName,isPrerelease   --jq '.[] | select(.isPrerelease and (.tagName | startswith("v2.4.0-"))) | .tagName' |
while read -r t; do gh release delete "$t" --yes; done        # tags are kept unless --cleanup-tag
# npm: move the next dist-tag forward to the next cycle's first candidate when it exists

Keeping the tags matters: a bug report that says β€œfound in rc.1” can still be checked out and reproduced months later, and git tag --contains on a fix commit shows which candidates had it.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Should release candidates be on a release branch or main? Jump to heading

Either. Teams with release branches tag candidates there, as in cutting release branches from trunk. Teams releasing from main tag candidates on main and exclude them from describe.

Do candidates need to be signed? Jump to heading

Sign them like any release tag. Testers deploying candidates benefit from the same verification, and it costs nothing extra in an automated pipeline.

What about date-based or build-number versions? Jump to heading

The same ideas apply β€” a suffix that marks the build as a candidate, version sorting, and promotion of the tested commit. Only the naming differs.