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) 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 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; } 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.
Related Jump to heading
- Release Tagging & Versioning β the parent topic.
- Annotated vs Lightweight Tags β candidates should be annotated too.
- Signing Release Tags from a Pipeline β automating signed candidate tags.
- Promoting a Release from Staging to Production β promotion without rebuilding.