feat: add release candidate (RC) support to release tooling (#23600)

This adds full RC release support to the release scripts and GitHub
Actions workflow. Previously, the tooling only supported stable and
mainline releases with strict vMAJOR.MINOR.PATCH semver tags.

Changes:
- scripts/releaser/version.go: Add Pre field to version struct for
prerelease suffixes (e.g. "rc.0"), update regex, parsing, String(),
comparison methods, and add IsRC()/rcNumber() helpers.
- scripts/releaser/release.go: Detect RC branches (release/X.Y-rc.N),
suggest RC version numbers, auto-set "rc" channel (skipping
stable/mainline prompt), add RC advisory to release notes, skip docs
update for RC releases.
- .github/workflows/release.yaml: Add "rc" channel option, fix branch
derivation for RC tags (v2.32.0-rc.0 -> release/2.32-rc.0 instead of
broken release/2.32.0-rc), skip homebrew/winget/package publishing for
RC releases.
- scripts/release/publish.sh: Add --rc flag, pass --prerelease to gh
release create for RC releases.
- scripts/releaser/version_test.go: Add comprehensive unit tests for
version parsing, string formatting, IsRC, rcNumber, GreaterThan, and
Equal with RC versions.

<!--

If you have used AI to produce some or all of this PR, please ensure you
have read our [AI Contribution
guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING)
before submitting.

-->
This commit is contained in:
Garrett Delfosse
2026-04-01 16:00:49 -04:00
committed by GitHub
parent 83e2699914
commit be2e641162
5 changed files with 324 additions and 44 deletions
+16 -1
View File
@@ -34,11 +34,12 @@ if [[ "${CI:-}" == "" ]]; then
fi
stable=0
rc=0
version=""
release_notes_file=""
dry_run=0
args="$(getopt -o "" -l stable,version:,release-notes-file:,dry-run -- "$@")"
args="$(getopt -o "" -l stable,rc,version:,release-notes-file:,dry-run -- "$@")"
eval set -- "$args"
while true; do
case "$1" in
@@ -46,6 +47,10 @@ while true; do
stable=1
shift
;;
--rc)
rc=1
shift
;;
--version)
version="$2"
shift 2
@@ -68,6 +73,10 @@ while true; do
esac
done
if [[ "$stable" == 1 ]] && [[ "$rc" == 1 ]]; then
error "Cannot specify both --stable and --rc"
fi
# Check dependencies
dependencies gh
@@ -162,6 +171,11 @@ if [[ "$stable" == 1 ]]; then
latest=true
fi
prerelease_flag=()
if [[ "$rc" == 1 ]]; then
prerelease_flag=(--prerelease)
fi
target_commitish=main # This is the default.
# Skip during dry-runs
if [[ "$dry_run" == 0 ]]; then
@@ -176,6 +190,7 @@ fi
true |
maybedryrun "$dry_run" gh release create \
--latest="$latest" \
"${prerelease_flag[@]}" \
--title "$new_tag" \
--target "$target_commitish" \
--notes-file "$release_notes_file" \
+78 -34
View File
@@ -30,9 +30,11 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
}
var latestMainline *version
if len(allTags) > 0 {
v := allTags[0]
latestMainline = &v
for _, t := range allTags {
if t.Pre == "" {
latestMainline = &t
break
}
}
stableMinor := -1
@@ -41,7 +43,7 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
stableMinor = latestMainline.Minor - 1
// Find highest tag in the stable minor series.
for _, t := range allTags {
if t.Major == latestMainline.Major && t.Minor == stableMinor {
if t.Major == latestMainline.Major && t.Minor == stableMinor && t.Pre == "" {
latestStableStr = t.String()
break
}
@@ -66,15 +68,17 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
return xerrors.Errorf("detecting branch: %w", err)
}
branchRe := regexp.MustCompile(`^release/(\d+)\.(\d+)$`)
// Match standard release branches (release/2.32) and RC
// branches (release/2.32-rc.0).
branchRe := regexp.MustCompile(`^release/(\d+)\.(\d+)(?:-rc\.(\d+))?$`)
m := branchRe.FindStringSubmatch(currentBranch)
if m == nil {
warnf(w, "Current branch %q is not a release branch (release/X.Y).", currentBranch)
warnf(w, "Current branch %q is not a release branch (release/X.Y or release/X.Y-rc.N).", currentBranch)
branchInput, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Enter the release branch to use (e.g. release/2.21)",
Text: "Enter the release branch to use (e.g. release/2.21 or release/2.21-rc.0)",
Validate: func(s string) error {
if !branchRe.MatchString(s) {
return xerrors.New("must be in format release/X.Y (e.g. release/2.21)")
return xerrors.New("must be in format release/X.Y or release/X.Y-rc.N (e.g. release/2.21 or release/2.21-rc.0)")
}
return nil
},
@@ -87,6 +91,10 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
}
branchMajor, _ := strconv.Atoi(m[1])
branchMinor, _ := strconv.Atoi(m[2])
branchRC := -1 // -1 means not an RC branch.
if m[3] != "" {
branchRC, _ = strconv.Atoi(m[3])
}
successf(w, "Using release branch: %s", currentBranch)
// --- Fetch & sync check ---
@@ -134,9 +142,27 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
if prevVersion == nil {
infof(w, "No previous release tag found on this branch.")
suggested = version{Major: branchMajor, Minor: branchMinor, Patch: 0}
if branchRC >= 0 {
suggested.Pre = fmt.Sprintf("rc.%d", branchRC)
}
} else {
infof(w, "Previous release tag: %s", prevVersion.String())
suggested = version{Major: prevVersion.Major, Minor: prevVersion.Minor, Patch: prevVersion.Patch + 1}
if branchRC >= 0 {
// On an RC branch, suggest the next RC for
// the same base version.
nextRC := 0
if prevVersion.IsRC() {
nextRC = prevVersion.rcNumber() + 1
}
suggested = version{
Major: prevVersion.Major,
Minor: prevVersion.Minor,
Patch: prevVersion.Patch,
Pre: fmt.Sprintf("rc.%d", nextRC),
}
} else {
suggested = version{Major: prevVersion.Major, Minor: prevVersion.Minor, Patch: prevVersion.Patch + 1}
}
}
fmt.Fprintln(w)
@@ -147,7 +173,7 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
Default: suggested.String(),
Validate: func(s string) error {
if _, ok := parseVersion(s); !ok {
return xerrors.New("must be in format vMAJOR.MINOR.PATCH (e.g. v2.31.1)")
return xerrors.New("must be in format vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-rc.N (e.g. v2.31.1 or v2.31.0-rc.0)")
}
return nil
},
@@ -303,29 +329,36 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
// --- Channel selection ---
// This is done before release notes generation because the
// notes format differs between mainline and stable channels.
channelDefault := cliui.ConfirmNo
channelHint := ""
if newVersion.Minor == stableMinor {
channelDefault = cliui.ConfirmYes
channelHint = " (this looks like a stable release)"
}
// RC releases are always on the "rc" channel and skip the
// stable/mainline prompt.
channel := "mainline"
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("Mark this as the latest stable release on GitHub?%s", channelHint),
Default: channelDefault,
IsConfirm: true,
})
if err == nil {
channel = "stable"
} else if !errors.Is(err, cliui.ErrCanceled) {
return err
}
if channel == "stable" {
infof(w, "Channel: stable (will be marked as GitHub Latest).")
if newVersion.IsRC() {
channel = "rc"
infof(w, "Channel: rc (release candidate, will be marked as prerelease on GitHub).")
} else {
infof(w, "Channel: mainline (will be marked as prerelease).")
channelDefault := cliui.ConfirmNo
channelHint := ""
if newVersion.Minor == stableMinor {
channelDefault = cliui.ConfirmYes
channelHint = " (this looks like a stable release)"
}
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("Mark this as the latest stable release on GitHub?%s", channelHint),
Default: channelDefault,
IsConfirm: true,
})
if err == nil {
channel = "stable"
} else if !errors.Is(err, cliui.ErrCanceled) {
return err
}
if channel == "stable" {
infof(w, "Channel: stable (will be marked as GitHub Latest).")
} else {
infof(w, "Channel: mainline (will be marked as prerelease).")
}
}
fmt.Fprintln(w)
@@ -408,12 +441,17 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
// scripts/release/generate_release_notes.sh.
var notes strings.Builder
// Stable since header or mainline blurb.
// Stable since header, mainline blurb, or RC advisory.
if channel == "stable" {
fmt.Fprintf(&notes, "> ## Stable (since %s)\n\n", time.Now().Format("January 02, 2006"))
}
fmt.Fprintln(&notes, "## Changelog")
if channel == "mainline" {
switch channel {
case "rc":
fmt.Fprintln(&notes)
fmt.Fprintln(&notes, "> [!NOTE]")
fmt.Fprintln(&notes, "> This is a **release candidate** (RC) for testing purposes. It is not recommended for production use. Please report any issues you encounter. Learn more about our [Release Schedule](https://coder.com/docs/install/releases).")
case "mainline":
fmt.Fprintln(&notes)
fmt.Fprintln(&notes, "> [!NOTE]")
fmt.Fprintln(&notes, "> This is a mainline Coder release. We advise enterprise customers without a staging environment to install our [latest stable release](https://github.com/coder/coder/releases/latest) while we refine this version. Learn more about our [Release Schedule](https://coder.com/docs/install/releases).")
@@ -576,7 +614,13 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
successf(w, "Release workflow triggered!")
// --- Update release docs ---
promptAndUpdateDocs(inv, newVersion, channel, dryRun)
// RC releases skip docs updates (calendar, helm versions, etc.)
// since they are not production releases.
if newVersion.IsRC() {
infof(w, "Skipping docs update for release candidate.")
} else {
promptAndUpdateDocs(inv, newVersion, channel, dryRun)
}
fmt.Fprintln(w)
successf(w, "Done! 🎉")
+45 -5
View File
@@ -7,14 +7,16 @@ import (
"strings"
)
// version holds a parsed semver version.
// version holds a parsed semver version with optional prerelease
// suffix (e.g. "rc.0").
type version struct {
Major int
Minor int
Patch int
Pre string // e.g. "rc.0", "" for stable releases.
}
var semverRe = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)$`)
var semverRe = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)(-(.+))?$`)
func parseVersion(s string) (version, bool) {
m := semverRe.FindStringSubmatch(s)
@@ -24,13 +26,35 @@ func parseVersion(s string) (version, bool) {
maj, _ := strconv.Atoi(m[1])
mnr, _ := strconv.Atoi(m[2])
pat, _ := strconv.Atoi(m[3])
return version{Major: maj, Minor: mnr, Patch: pat}, true
return version{Major: maj, Minor: mnr, Patch: pat, Pre: m[5]}, true
}
func (v version) String() string {
if v.Pre != "" {
return fmt.Sprintf("v%d.%d.%d-%s", v.Major, v.Minor, v.Patch, v.Pre)
}
return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
}
// IsRC returns true when the version has a prerelease suffix starting
// with "rc." (e.g. "rc.0", "rc.1").
func (v version) IsRC() bool {
return strings.HasPrefix(v.Pre, "rc.")
}
// rcNumber returns the numeric RC identifier (e.g. 0 for "rc.0").
// It returns -1 when the version is not an RC.
func (v version) rcNumber() int {
if !v.IsRC() {
return -1
}
n, err := strconv.Atoi(strings.TrimPrefix(v.Pre, "rc."))
if err != nil {
return -1
}
return n
}
func (v version) GreaterThan(b version) bool {
if v.Major != b.Major {
return v.Major > b.Major
@@ -38,11 +62,27 @@ func (v version) GreaterThan(b version) bool {
if v.Minor != b.Minor {
return v.Minor > b.Minor
}
return v.Patch > b.Patch
if v.Patch != b.Patch {
return v.Patch > b.Patch
}
// A release without prerelease suffix is greater than one
// with a prerelease suffix (v2.32.0 > v2.32.0-rc.0).
if v.Pre == "" && b.Pre != "" {
return true
}
if v.Pre != "" && b.Pre == "" {
return false
}
// Both have prerelease: compare numerically for RC versions.
if v.IsRC() && b.IsRC() {
return v.rcNumber() > b.rcNumber()
}
// Fallback for non-RC prerelease strings.
return v.Pre > b.Pre
}
func (v version) Equal(b version) bool {
return v.Major == b.Major && v.Minor == b.Minor && v.Patch == b.Patch
return v.Major == b.Major && v.Minor == b.Minor && v.Patch == b.Patch && v.Pre == b.Pre
}
// allSemverTags returns all semver tags sorted descending.
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"testing"
)
func TestParseVersion(t *testing.T) {
t.Parallel()
tests := []struct {
input string
ok bool
want version
}{
{"v2.32.0", true, version{2, 32, 0, ""}},
{"v1.0.0", true, version{1, 0, 0, ""}},
{"v2.32.0-rc.0", true, version{2, 32, 0, "rc.0"}},
{"v2.32.0-rc.1", true, version{2, 32, 0, "rc.1"}},
{"v2.32.1-beta.3", true, version{2, 32, 1, "beta.3"}},
{"2.32.0", false, version{}},
{"v2.32", false, version{}},
{"vx.y.z", false, version{}},
{"", false, version{}},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
got, ok := parseVersion(tt.input)
if ok != tt.ok {
t.Fatalf("parseVersion(%q) ok = %v, want %v", tt.input, ok, tt.ok)
}
if ok && got != tt.want {
t.Fatalf("parseVersion(%q) = %+v, want %+v", tt.input, got, tt.want)
}
})
}
}
func TestVersionString(t *testing.T) {
t.Parallel()
tests := []struct {
v version
want string
}{
{version{2, 32, 0, ""}, "v2.32.0"},
{version{2, 32, 0, "rc.0"}, "v2.32.0-rc.0"},
{version{1, 0, 0, "beta.1"}, "v1.0.0-beta.1"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
t.Parallel()
if got := tt.v.String(); got != tt.want {
t.Fatalf("String() = %q, want %q", got, tt.want)
}
})
}
}
func TestVersionIsRC(t *testing.T) {
t.Parallel()
tests := []struct {
v version
want bool
}{
{version{2, 32, 0, "rc.0"}, true},
{version{2, 32, 0, "rc.1"}, true},
{version{2, 32, 0, ""}, false},
{version{2, 32, 0, "beta.1"}, false},
}
for _, tt := range tests {
t.Run(tt.v.String(), func(t *testing.T) {
t.Parallel()
if got := tt.v.IsRC(); got != tt.want {
t.Fatalf("IsRC() = %v, want %v", got, tt.want)
}
})
}
}
func TestVersionRCNumber(t *testing.T) {
t.Parallel()
tests := []struct {
v version
want int
}{
{version{2, 32, 0, "rc.0"}, 0},
{version{2, 32, 0, "rc.5"}, 5},
{version{2, 32, 0, ""}, -1},
{version{2, 32, 0, "beta.1"}, -1},
}
for _, tt := range tests {
t.Run(tt.v.String(), func(t *testing.T) {
t.Parallel()
if got := tt.v.rcNumber(); got != tt.want {
t.Fatalf("rcNumber() = %d, want %d", got, tt.want)
}
})
}
}
func TestVersionGreaterThan(t *testing.T) {
t.Parallel()
tests := []struct {
a, b version
want bool
}{
// Standard comparisons.
{version{2, 32, 1, ""}, version{2, 32, 0, ""}, true},
{version{2, 32, 0, ""}, version{2, 32, 1, ""}, false},
{version{2, 33, 0, ""}, version{2, 32, 0, ""}, true},
{version{3, 0, 0, ""}, version{2, 99, 99, ""}, true},
// Release > RC with same base version.
{version{2, 32, 0, ""}, version{2, 32, 0, "rc.0"}, true},
{version{2, 32, 0, "rc.0"}, version{2, 32, 0, ""}, false},
// RC ordering.
{version{2, 32, 0, "rc.1"}, version{2, 32, 0, "rc.0"}, true},
{version{2, 32, 0, "rc.0"}, version{2, 32, 0, "rc.1"}, false},
{version{2, 32, 0, "rc.10"}, version{2, 32, 0, "rc.9"}, true},
{version{2, 32, 0, "rc.9"}, version{2, 32, 0, "rc.10"}, false},
// Equal.
{version{2, 32, 0, ""}, version{2, 32, 0, ""}, false},
{version{2, 32, 0, "rc.0"}, version{2, 32, 0, "rc.0"}, false},
}
for _, tt := range tests {
t.Run(tt.a.String()+"_gt_"+tt.b.String(), func(t *testing.T) {
t.Parallel()
if got := tt.a.GreaterThan(tt.b); got != tt.want {
t.Fatalf("%s.GreaterThan(%s) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestVersionEqual(t *testing.T) {
t.Parallel()
tests := []struct {
a, b version
want bool
}{
{version{2, 32, 0, ""}, version{2, 32, 0, ""}, true},
{version{2, 32, 0, "rc.0"}, version{2, 32, 0, "rc.0"}, true},
{version{2, 32, 0, ""}, version{2, 32, 0, "rc.0"}, false},
{version{2, 32, 0, "rc.0"}, version{2, 32, 0, "rc.1"}, false},
}
for _, tt := range tests {
t.Run(tt.a.String()+"_eq_"+tt.b.String(), func(t *testing.T) {
t.Parallel()
if got := tt.a.Equal(tt.b); got != tt.want {
t.Fatalf("%s.Equal(%s) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}