diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3874bb8bf4..63e88db619 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,6 +9,7 @@ on: options: - mainline - stable + - rc release_notes: description: Release notes for the publishing the release. This is required to create a release. dry_run: @@ -119,9 +120,19 @@ jobs: exit 1 fi - # 2.10.2 -> release/2.10 + # Derive the release branch from the version tag. + # Standard: 2.10.2 -> release/2.10 + # RC: 2.32.0-rc.0 -> release/2.32-rc.0 version="$(./scripts/version.sh)" - release_branch=release/${version%.*} + if [[ "$version" == *-rc.* ]]; then + # Extract major.minor and rc suffix from e.g. 2.32.0-rc.0 + base_version="${version%%-rc.*}" # 2.32.0 + major_minor="${base_version%.*}" # 2.32 + rc_suffix="${version##*-rc.}" # 0 + release_branch="release/${major_minor}-rc.${rc_suffix}" + else + release_branch=release/${version%.*} + fi branch_contains_tag=$(git branch --remotes --contains "${GITHUB_REF}" --list "*/${release_branch}" --format='%(refname)') if [[ -z "${branch_contains_tag}" ]]; then echo "Ref tag must exist in a branch named ${release_branch} when creating a release, did you use scripts/release.sh?" @@ -531,6 +542,9 @@ jobs: if [[ $CODER_RELEASE_CHANNEL == "stable" ]]; then publish_args+=(--stable) fi + if [[ $CODER_RELEASE_CHANNEL == "rc" ]]; then + publish_args+=(--rc) + fi if [[ $CODER_DRY_RUN == *t* ]]; then publish_args+=(--dry-run) fi @@ -643,7 +657,7 @@ jobs: retention-days: 7 - name: Send repository-dispatch event - if: ${{ !inputs.dry_run }} + if: ${{ !inputs.dry_run && inputs.release_channel != 'rc' }} uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.CDRCI_GITHUB_TOKEN }} @@ -731,7 +745,7 @@ jobs: name: Publish to winget-pkgs runs-on: windows-latest needs: release - if: ${{ !inputs.dry_run }} + if: ${{ !inputs.dry_run && inputs.release_channel != 'rc' }} steps: - name: Harden Runner diff --git a/scripts/release/publish.sh b/scripts/release/publish.sh index 5ffd40aeb6..97ec1a0938 100755 --- a/scripts/release/publish.sh +++ b/scripts/release/publish.sh @@ -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" \ diff --git a/scripts/releaser/release.go b/scripts/releaser/release.go index 9b2b98a6a5..67f58c5d53 100644 --- a/scripts/releaser/release.go +++ b/scripts/releaser/release.go @@ -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(¬es, "> ## Stable (since %s)\n\n", time.Now().Format("January 02, 2006")) } fmt.Fprintln(¬es, "## Changelog") - if channel == "mainline" { + switch channel { + case "rc": + fmt.Fprintln(¬es) + fmt.Fprintln(¬es, "> [!NOTE]") + fmt.Fprintln(¬es, "> 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(¬es) fmt.Fprintln(¬es, "> [!NOTE]") fmt.Fprintln(¬es, "> 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! 🎉") diff --git a/scripts/releaser/version.go b/scripts/releaser/version.go index cb1a7f04aa..7f5b21df90 100644 --- a/scripts/releaser/version.go +++ b/scripts/releaser/version.go @@ -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. diff --git a/scripts/releaser/version_test.go b/scripts/releaser/version_test.go new file mode 100644 index 0000000000..0fc6c0e5ff --- /dev/null +++ b/scripts/releaser/version_test.go @@ -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) + } + }) + } +}