refactor: consolidate release tooling into a single releaser command (#27034)

## What

Consolidates the two separate release programs into a single command at
`scripts/releaser`:

- `scripts/releaser/v1/` — the former interactive releaser (package
`v1`).
- `scripts/releaser/v2/` — the former `scripts/release-action` CI tool
(package `v2`).
- `scripts/releaser/main.go` — new entrypoint. Runs the **v2** tooling
by
  default and the **v1** interactive wizard with `--legacy`.

## CLI shape

Three documented subcommands, each backed by v2 `prepare-release` with
the
release type baked in:

- `releaser rc` — tag a release candidate
- `releaser branch` — cut a new release branch and tag its first RC
- `releaser release` — tag a stable release or patch

The former release-action verbs (`calculate-version`, `prepare-release`,
`generate-notes`, `publish`) are retained as **hidden** top-level
commands with
identical flags and stdout, so `tag-and-release.yaml` migrates with a
path-only
change (`scripts/release-action` -> `scripts/releaser`). `--legacy` runs
the v1
wizard and is mutually exclusive with the subcommands.
`scripts/release.sh` now
launches `releaser --legacy`.

All file moves are rename-detected by git, so the per-file diff is just
the
package declaration.

## Testing

- `go build ./scripts/...`, `go vet ./scripts/releaser/...`, `go test
./scripts/releaser/...`
- `golangci-lint run ./scripts/releaser/...`, `make lint/emdash`,
`shellcheck`, `actionlint`
- Smoke: `releaser --help` shows only rc/branch/release; hidden verbs
still run;
`releaser rc --ref main --dry-run` emits the same JSON contract;
`--legacy rc`
  errors cleanly.

<details>
<summary>Implementation plan</summary>

# Plan: Consolidate release tooling into a single `scripts/releaser`
command

## Goal

Merge the two separate release programs into one binary at
`scripts/releaser`:

- `scripts/releaser/v1/` — the current interactive releaser (package
`v1`).
- `scripts/releaser/v2/` — the current CI `scripts/release-action`
(package `v2`).
- `scripts/releaser/main.go` — new entrypoint (package `main`).
  - Uses v2 by default, v1 with `--legacy`.
- Exposes 3 subcommands: `rc`, `branch` (cut release branch), `release`.

## Design decision (Option A, chosen)

The workflow needs `prepare-release`, `generate-notes`, and `publish`
invokable
separately (a build happens between prepare and publish). The latter two
are
version-driven and type-agnostic, so they do not map cleanly onto
`rc`/`branch`/`release`.

- Visible subcommands `rc`, `branch`, `release` run v2 `prepare-release`
with the
  type baked in and print the same JSON.
- Hidden verbs `calculate-version`, `prepare-release`, `generate-notes`,
  `publish` keep byte-identical flags/stdout, so the workflow change is
  path-only. Lowest risk; honors "3 subcommands" from a UX perspective.

## `--legacy` semantics

- `releaser --legacy` runs the v1 interactive wizard (preserves today's
  behavior; the wizard auto-detects RC vs release from the branch).
- `--legacy` is mutually exclusive with the subcommands (clear error if
  combined), because v1 auto-detects type and cannot cut a branch.

## Work items

1. Create `v1` and `v2` packages via `git mv`, renaming `package main`.
Move the `owner`/`repo` consts into each package. Add `v1.Run(inv,
dryRun)`
   (old wizard `main()` body) and v2 command builders (`CICommands`,
   `TypeCommand`) so internals stay unexported.
2. New `scripts/releaser/main.go`: top-level `releaser` with `--legacy`,
the 3
subcommands, and the hidden compat verbs; delegates to `v1.Run` for
legacy.
3. Update references: `tag-and-release.yaml` (3 command paths + header
comment)
   and `scripts/release.sh` (`--legacy`).
4. Verify: build, vet, test, `go run` smoke tests, fmt, lint.
5. Open a single PR from a feature branch.

## Risks / notes

- stdout contract for rc/branch/release and the hidden verbs must stay
identical
  (workflow parses stdout); logs go to stderr.
- Patch releases from pre-existing `release/X.Y` branches run those
branches'
own (old) workflow + `scripts/release-action`, so they stay
self-consistent.
New releases cut from branches containing this change get the new
workflow +
`scripts/releaser`. No forwarding stub needed since code and workflow
ship
  together.

</details>

---

This PR was created by Coder Agents on behalf of @f0ssel.
This commit is contained in:
Garrett Delfosse
2026-07-07 11:13:50 -04:00
committed by GitHub
parent 90861ffa75
commit bfbacd64f4
29 changed files with 420 additions and 287 deletions
+228
View File
@@ -0,0 +1,228 @@
package v1
import (
"regexp"
"sort"
"strconv"
"strings"
)
// commitEntry represents a single non-merge commit.
type commitEntry struct {
SHA string
FullSHA string
Title string
PRCount int // 0 if no PR number found
Timestamp int64
}
var prNumRe = regexp.MustCompile(`\(#(\d+)\)`)
// cherryPickPRRe matches cherry-pick bot titles like
// "chore: foo bar (cherry-pick #42) (#43)".
var cherryPickPRRe = regexp.MustCompile(`\(cherry-pick #(\d+)\)\s*\(#\d+\)$`)
// commitLog returns non-merge commits in the given range, filtering
// out left-side commits (already in the base) and deduplicating
// cherry-picks using git's --cherry-mark.
func commitLog(commitRange string) ([]commitEntry, error) {
// Use --left-right --cherry-mark to identify equivalent
// (cherry-picked) commits and left-side-only commits.
out, err := gitOutput("log", "--no-merges", "--left-right", "--cherry-mark",
"--pretty=format:%m %ct %h %H %s", commitRange)
if err != nil {
return nil, err
}
if out == "" {
return nil, nil
}
// Collect cherry-pick equivalent commits (marked with '=') so
// we can skip duplicates. We keep only the right-side version.
seen := make(map[string]bool)
var entries []commitEntry
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Format: %m %ct %h %H %s
// mark timestamp shortSHA fullSHA title...
parts := strings.SplitN(line, " ", 5)
if len(parts) < 5 {
continue
}
mark := parts[0]
ts, _ := strconv.ParseInt(parts[1], 10, 64)
shortSHA := parts[2]
fullSHA := parts[3]
title := parts[4]
// Skip left-side commits (already in the old version).
if mark == "<" {
continue
}
// Skip cherry-pick equivalents that we've already seen
// (marked '=' by --cherry-mark).
if mark == "=" {
if seen[title] {
continue
}
seen[title] = true
}
// Normalize cherry-pick bot titles:
// "chore: foo (cherry-pick #42) (#43)" → "chore: foo (#42)"
if m := cherryPickPRRe.FindStringSubmatch(title); m != nil {
title = title[:cherryPickPRRe.FindStringIndex(title)[0]] + "(#" + m[1] + ")"
}
e := commitEntry{
SHA: shortSHA,
FullSHA: fullSHA,
Title: title,
Timestamp: ts,
}
if m := prNumRe.FindStringSubmatch(e.Title); m != nil {
e.PRCount, _ = strconv.Atoi(m[1])
}
entries = append(entries, e)
}
// Sort by conventional commit prefix, then by timestamp
// (matching the bash script's sort -k3,3 -k1,1n).
sort.SliceStable(entries, func(i, j int) bool {
pi := commitSortPrefix(entries[i].Title)
pj := commitSortPrefix(entries[j].Title)
if pi != pj {
return pi < pj
}
return entries[i].Timestamp < entries[j].Timestamp
})
return entries, nil
}
// commitSortPrefix extracts the first word of a title for sorting.
func commitSortPrefix(title string) string {
idx := strings.IndexAny(title, " (:")
if idx < 0 {
return title
}
return title[:idx]
}
// humanizedAreas maps conventional commit scopes to human-readable area
// names. Order matters: more specific prefixes must come first so that
// the first partial match wins.
var humanizedAreas = []struct {
Prefix string
Area string
}{
{"agent/agentssh", "Agent SSH"},
{"coderd/database", "Database"},
{"enterprise/audit", "Auditing"},
{"enterprise/cli", "CLI"},
{"enterprise/coderd", "Server"},
{"enterprise/dbcrypt", "Database"},
{"enterprise/derpmesh", "Networking"},
{"enterprise/provisionerd", "Provisioner"},
{"enterprise/tailnet", "Networking"},
{"enterprise/wsproxy", "Workspace Proxy"},
{"agent", "Agent"},
{"cli", "CLI"},
{"coderd", "Server"},
{"codersdk", "SDK"},
{"docs", "Documentation"},
{"enterprise", "Enterprise"},
{"examples", "Examples"},
{"helm", "Helm"},
{"install.sh", "Installer"},
{"provisionersdk", "SDK"},
{"provisionerd", "Provisioner"},
{"provisioner", "Provisioner"},
{"pty", "CLI"},
{"scaletest", "Scale Testing"},
{"site", "Dashboard"},
{"support", "Support"},
{"tailnet", "Networking"},
}
// conventionalPrefixRe extracts prefix, scope, and rest from a
// conventional commit title. Does NOT match breaking "!" suffix —
// those titles are left as-is (matching bash behavior).
var conventionalPrefixRe = regexp.MustCompile(`^([a-z]+)(\((.+)\))?:\s*(.*)$`)
// humanizeTitle converts a conventional commit title to a
// human-readable form, e.g. "feat(site): add bar" → "Dashboard: Add bar".
func humanizeTitle(title string) string {
m := conventionalPrefixRe.FindStringSubmatch(title)
if m == nil {
return title
}
scope := m[3] // may be empty
rest := m[4]
if rest == "" {
return title
}
// Capitalize the first letter of the rest.
rest = strings.ToUpper(rest[:1]) + rest[1:]
if scope == "" {
return rest
}
// Look up scope in humanizedAreas (first partial match wins).
for _, ha := range humanizedAreas {
if strings.HasPrefix(scope, ha.Prefix) {
return ha.Area + ": " + rest
}
}
// Scope not found in map — return as-is.
return title
}
// breakingCommitRe matches conventional commit "!:" breaking changes.
var breakingCommitRe = regexp.MustCompile(`^[a-zA-Z]+(\(.+\))?!:`)
// categorizeCommit determines the release note section for a commit.
// The priority order matches the bash script: breaking title first,
// then labels (breaking, security, experimental), then prefix.
func categorizeCommit(title string, labels []string) string {
// Check breaking title first (matches bash behavior).
if breakingCommitRe.MatchString(title) {
return "breaking"
}
// Label-based categorization.
for _, l := range labels {
if l == "release/breaking" {
return "breaking"
}
if l == "security" {
return "security"
}
if l == "release/experimental" {
return "experimental"
}
}
// Extract the conventional commit prefix (e.g. "feat", "fix(scope)").
prefixRe := regexp.MustCompile(`^([a-z]+)(\(.+\))?[!]?:`)
m := prefixRe.FindStringSubmatch(title)
if m == nil {
return "other"
}
validPrefixes := []string{
"feat", "fix", "docs", "refactor", "perf",
"test", "build", "ci", "chore", "revert",
}
for _, p := range validPrefixes {
if m[1] == p {
return p
}
}
return "other"
}
+519
View File
@@ -0,0 +1,519 @@
package v1
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
const (
calendarStartMarker = "<!-- RELEASE_CALENDAR_START -->"
calendarEndMarker = "<!-- RELEASE_CALENDAR_END -->"
releasesFile = "docs/install/releases/index.md"
kubernetesFile = "docs/install/kubernetes.md"
rancherFile = "docs/install/rancher.md"
changelogURLFmt = "https://coder.com/changelog/coder-%d-%d"
releaseTagURLFmt = "https://github.com/coder/coder/releases/tag/%s"
)
// calendarRow represents one row in the release calendar table.
type calendarRow struct {
// ReleaseName is the display name, e.g. "2.30" or
// "[2.30](https://...)".
ReleaseName string
// Major and Minor parsed from the release name.
Major int
Minor int
// ReleaseDate as displayed, e.g. "February 03, 2026".
ReleaseDate string
// Status like "Mainline", "Stable", "Not Supported", etc.
Status string
// LatestRelease as displayed, e.g.
// "[v2.30.0](https://...)".
LatestRelease string
}
var autoversionPragmaRe = regexp.MustCompile(
`<!-- ?autoversion\(([^)]+)\): ?"([^"]+)" ?-->`,
)
// parseCalendarTable extracts calendar rows from the markdown
// between the start and end markers. Returns the rows and the
// column widths for re-rendering.
func parseCalendarTable(content string) ([]calendarRow, error) {
startIdx := strings.Index(content, calendarStartMarker)
endIdx := strings.Index(content, calendarEndMarker)
if startIdx == -1 || endIdx == -1 {
return nil, xerrors.New("calendar markers not found")
}
tableContent := content[startIdx+len(calendarStartMarker) : endIdx]
lines := strings.Split(strings.TrimSpace(tableContent), "\n")
var rows []calendarRow
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Skip header and separator lines.
if strings.HasPrefix(line, "| Release") ||
strings.HasPrefix(line, "|---") ||
strings.HasPrefix(line, "|-") {
continue
}
if !strings.HasPrefix(line, "|") {
continue
}
cols := strings.Split(line, "|")
// Split on "|" gives empty first and last elements.
if len(cols) < 5 {
continue
}
name := strings.TrimSpace(cols[1])
date := strings.TrimSpace(cols[2])
status := strings.TrimSpace(cols[3])
latest := strings.TrimSpace(cols[4])
major, minor := parseReleaseName(name)
rows = append(rows, calendarRow{
ReleaseName: name,
Major: major,
Minor: minor,
ReleaseDate: date,
Status: status,
LatestRelease: latest,
})
}
if len(rows) == 0 {
return nil, xerrors.New("no calendar rows found")
}
return rows, nil
}
// parseReleaseName extracts major.minor from a release name
// like "2.30" or "[2.30](https://...)".
func parseReleaseName(name string) (major, minor int) {
// Strip markdown link if present.
re := regexp.MustCompile(`\[(\d+\.\d+)\]`)
if m := re.FindStringSubmatch(name); len(m) > 1 {
name = m[1]
}
_, _ = fmt.Sscanf(name, "%d.%d", &major, &minor)
return major, minor
}
// renderCalendarTable renders the calendar rows as a markdown
// table.
func renderCalendarTable(rows []calendarRow) string {
// Compute column widths.
nameW, dateW, statusW, latestW := 12, 12, 6, 14
for _, r := range rows {
if len(r.ReleaseName) > nameW {
nameW = len(r.ReleaseName)
}
if len(r.ReleaseDate) > dateW {
dateW = len(r.ReleaseDate)
}
if len(r.Status) > statusW {
statusW = len(r.Status)
}
if len(r.LatestRelease) > latestW {
latestW = len(r.LatestRelease)
}
}
var b strings.Builder
// Header.
_, _ = fmt.Fprintf(&b, "| %-*s | %-*s | %-*s | %-*s |\n",
nameW, "Release name",
dateW, "Release Date",
statusW, "Status",
latestW, "Latest Release")
// Separator.
_, _ = fmt.Fprintf(&b, "|%s|%s|%s|%s|\n",
strings.Repeat("-", nameW+1),
strings.Repeat("-", dateW+2),
strings.Repeat("-", statusW+2),
strings.Repeat("-", latestW+2))
// Data rows.
for _, r := range rows {
_, _ = fmt.Fprintf(&b, "| %-*s | %-*s | %-*s | %-*s |\n",
nameW, r.ReleaseName,
dateW, r.ReleaseDate,
statusW, r.Status,
latestW, r.LatestRelease)
}
return b.String()
}
// updateCalendar modifies the calendar rows based on the new
// release version and channel.
func updateCalendar(
rows []calendarRow,
newVer version,
channel string,
) []calendarRow {
// For any release, update the "Latest Release" for the
// matching major.minor row.
for i, r := range rows {
if r.Major == newVer.Major && r.Minor == newVer.Minor {
rows[i].LatestRelease = fmt.Sprintf(
"[v%s](%s)",
newVer.String(),
fmt.Sprintf(releaseTagURLFmt, newVer.String()),
)
// If this row was "Not Released", update it.
if r.Status == "Not Released" {
rows[i].Status = "Mainline"
rows[i].ReleaseDate = time.Now().Format("January 02, 2006")
rows[i].ReleaseName = fmt.Sprintf(
"[%d.%d](%s)",
newVer.Major, newVer.Minor,
fmt.Sprintf(changelogURLFmt, newVer.Major, newVer.Minor),
)
}
}
}
// For patch releases, we only update Latest Release — done
// above.
if newVer.Patch > 0 {
return rows
}
// For new mainline releases (patch == 0), apply status
// transitions.
if channel == "mainline" {
for i, r := range rows {
switch {
case r.Major == newVer.Major && r.Minor == newVer.Minor:
// Already handled above.
continue
case r.Status == "Mainline":
rows[i].Status = "Stable"
case strings.Contains(r.Status, "Stable"):
// "Stable", "Stable + ESR" → Security Support.
rows[i].Status = "Security Support"
case r.Status == "Security Support":
rows[i].Status = "Not Supported"
}
}
// Add "Not Released" row for the next minor.
nextMinor := newVer.Minor + 1
hasNext := false
for _, r := range rows {
if r.Major == newVer.Major && r.Minor == nextMinor {
hasNext = true
break
}
}
if !hasNext {
rows = append(rows, calendarRow{
ReleaseName: fmt.Sprintf("%d.%d", newVer.Major, nextMinor),
Major: newVer.Major,
Minor: nextMinor,
ReleaseDate: "",
Status: "Not Released",
LatestRelease: "N/A",
})
}
// Trim oldest "Not Supported" rows to keep roughly
// the same number of rows. We allow up to the
// current count + 1 (for the new "Not Released"
// row), then trim.
rows = trimOldestNotSupported(rows)
}
return rows
}
// trimOldestNotSupported removes "Not Supported" rows from the
// start until we have at most 8 rows total, keeping at least
// one "Not Supported" row if any exist.
func trimOldestNotSupported(rows []calendarRow) []calendarRow {
const maxRows = 8
for len(rows) > maxRows {
// Find the first "Not Supported" row.
found := -1
for i, r := range rows {
if r.Status == "Not Supported" {
found = i
break
}
}
if found == -1 {
break
}
// Count how many "Not Supported" rows we have.
nsCount := 0
for _, r := range rows {
if r.Status == "Not Supported" {
nsCount++
}
}
// Keep at least one.
if nsCount <= 1 {
break
}
rows = append(rows[:found], rows[found+1:]...)
}
return rows
}
// updateCalendarFile reads the releases index.md, updates the
// calendar table, and writes it back.
func updateCalendarFile(
repoRoot string,
newVer version,
channel string,
) error {
path := filepath.Join(repoRoot, releasesFile)
content, err := os.ReadFile(path)
if err != nil {
return xerrors.Errorf("reading %s: %w", releasesFile, err)
}
rows, err := parseCalendarTable(string(content))
if err != nil {
return xerrors.Errorf("parsing calendar: %w", err)
}
rows = updateCalendar(rows, newVer, channel)
newTable := renderCalendarTable(rows)
// Replace the content between markers.
s := string(content)
startIdx := strings.Index(s, calendarStartMarker)
endIdx := strings.Index(s, calendarEndMarker)
updated := s[:startIdx+len(calendarStartMarker)] +
"\n" + newTable +
s[endIdx:]
//nolint:gosec // File permissions match the original.
return os.WriteFile(path, []byte(updated), 0o644)
}
// updateAutoversionFile reads a markdown file and replaces
// version strings in lines following autoversion pragmas for
// the given channel.
func updateAutoversionFile(path, channel, newVer string) error {
content, err := os.ReadFile(path)
if err != nil {
return xerrors.Errorf("reading %s: %w", path, err)
}
lines := strings.Split(string(content), "\n")
changed := false
for i, line := range lines {
m := autoversionPragmaRe.FindStringSubmatch(line)
if len(m) < 3 {
continue
}
pragmaChannel := m[1]
pattern := m[2]
if pragmaChannel != channel {
continue
}
// Build regex from the pattern by replacing
// [version] with a capture group.
escaped := regexp.QuoteMeta(pattern)
reStr := strings.ReplaceAll(
escaped,
regexp.QuoteMeta("[version]"),
`(\d+\.\d+\.\d+)`,
)
re, err := regexp.Compile(reStr)
if err != nil {
continue
}
// Search the next few lines for a match.
for j := i + 1; j < len(lines) && j <= i+5; j++ {
if loc := re.FindStringSubmatchIndex(lines[j]); loc != nil {
// loc[2]:loc[3] is the version capture
// group.
lines[j] = lines[j][:loc[2]] + newVer + lines[j][loc[3]:]
changed = true
break
}
}
}
if !changed {
return nil
}
//nolint:gosec // File permissions match the original.
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
}
// updateRancherFile updates the version strings in rancher.md.
func updateRancherFile(path, channel, newVer string) error {
content, err := os.ReadFile(path)
if err != nil {
return xerrors.Errorf("reading %s: %w", path, err)
}
s := string(content)
switch channel {
case "mainline":
// Match: - **Mainline**: `X.Y.Z`
re := regexp.MustCompile(
`(\*\*Mainline\*\*: ` + "`)" + `\d+\.\d+\.\d+` + "(`)",
)
s = re.ReplaceAllString(s, "${1}"+newVer+"${2}")
case "stable":
re := regexp.MustCompile(
`(\*\*Stable\*\*: ` + "`)" + `\d+\.\d+\.\d+` + "(`)",
)
s = re.ReplaceAllString(s, "${1}"+newVer+"${2}")
default:
return nil
}
//nolint:gosec // File permissions match the original.
return os.WriteFile(path, []byte(s), 0o644)
}
// updateReleaseDocs updates all release-related docs files and
// creates a PR with the changes.
//
//nolint:revive // dryRun flag is needed to control PR creation behavior.
func updateReleaseDocs(
inv *serpent.Invocation,
newVer version,
channel string,
dryRun bool,
) error {
w := inv.Stderr
// Find the repo root (where .git is).
repoRoot, err := gitOutput("rev-parse", "--show-toplevel")
if err != nil {
return xerrors.Errorf("finding repo root: %w", err)
}
verStr := fmt.Sprintf("%d.%d.%d", newVer.Major, newVer.Minor, newVer.Patch)
vTag := "v" + verStr
branchName := fmt.Sprintf("docs/update-release-%s", vTag)
infof(w, "Updating release docs for %s (channel: %s)...", vTag, channel)
fmt.Fprintln(w)
if dryRun {
_, _ = fmt.Fprintf(w, "[DRYRUN] would update %s\n", releasesFile)
_, _ = fmt.Fprintf(w, "[DRYRUN] would update %s\n", kubernetesFile)
_, _ = fmt.Fprintf(w, "[DRYRUN] would update %s\n", rancherFile)
_, _ = fmt.Fprintf(w, "[DRYRUN] would create branch %s\n", branchName)
_, _ = fmt.Fprintf(w, "[DRYRUN] would create PR: chore(docs): update release docs for %s\n", vTag)
return nil
}
// Create a new branch from main.
if err := gitRun("checkout", "-b", branchName, "origin/main"); err != nil {
return xerrors.Errorf("creating branch: %w", err)
}
// Update the files.
if err := updateCalendarFile(repoRoot, newVer, channel); err != nil {
return xerrors.Errorf("updating calendar: %w", err)
}
successf(w, "Updated %s", releasesFile)
k8sPath := filepath.Join(repoRoot, kubernetesFile)
if err := updateAutoversionFile(k8sPath, channel, verStr); err != nil {
return xerrors.Errorf("updating kubernetes.md: %w", err)
}
successf(w, "Updated %s", kubernetesFile)
rancherPath := filepath.Join(repoRoot, rancherFile)
if err := updateRancherFile(rancherPath, channel, verStr); err != nil {
return xerrors.Errorf("updating rancher.md: %w", err)
}
successf(w, "Updated %s", rancherFile)
// Stage and commit.
if err := gitRun("add",
filepath.Join(repoRoot, releasesFile),
k8sPath,
rancherPath,
); err != nil {
return xerrors.Errorf("staging files: %w", err)
}
commitMsg := fmt.Sprintf("chore(docs): update release docs for %s", vTag)
if err := gitRun("commit", "-m", commitMsg); err != nil {
return xerrors.Errorf("committing: %w", err)
}
// Push and create PR.
if err := gitRun("push", "origin", branchName); err != nil {
return xerrors.Errorf("pushing branch: %w", err)
}
prTitle := commitMsg
prBody := fmt.Sprintf("Automated docs update for %s release.\n\nCreated by `releasetui`.", vTag)
out, err := ghOutput("pr", "create",
"--repo", owner+"/"+repo,
"--title", prTitle,
"--body", prBody,
"--base", "main",
"--head", branchName,
)
if err != nil {
return xerrors.Errorf("creating PR: %w", err)
}
prURL := strings.TrimSpace(out)
successf(w, "Created PR: %s", prURL)
fmt.Fprintln(w)
infof(w, "Review and merge the PR to complete the docs update.")
return nil
}
// promptAndUpdateDocs asks the user if they want to create a
// docs update PR and does so if confirmed.
func promptAndUpdateDocs(
inv *serpent.Invocation,
newVer version,
channel string,
dryRun bool,
) {
w := inv.Stderr
_, _ = fmt.Fprintln(w)
_, _ = fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(),
"Next step: create a PR updating release docs "+
"(calendar, helm versions, rancher)."))
_, _ = fmt.Fprintln(w)
if err := confirmWithDefault(inv, "Create docs update PR?", cliui.ConfirmYes); err != nil {
infof(w, "Skipped docs update. You can update them manually.")
return
}
if err := updateReleaseDocs(inv, newVer, channel, dryRun); err != nil {
warnf(w, "Failed to create docs PR: %v", err)
warnf(w, "You'll need to update release docs manually.")
}
}
+91
View File
@@ -0,0 +1,91 @@
package v1
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strings"
"golang.org/x/xerrors"
)
// ReleaseExecutor handles dangerous write/mutating operations
// that should be skipped in dry-run mode. Only actions that
// modify the git repo or trigger external side effects belong
// here. Safe operations (file writes, fetches, editor) are
// called directly.
type ReleaseExecutor interface {
// CreateTag creates an annotated (optionally signed) git tag.
CreateTag(ctx context.Context, tag, ref, message string, sign bool) error
// PushTag pushes a tag to the origin remote.
PushTag(ctx context.Context, tag string) error
// TriggerWorkflow dispatches the release.yaml GitHub Actions
// workflow with the given inputs.
TriggerWorkflow(ctx context.Context, ref, channel, releaseNotes string) error
}
// liveExecutor performs real operations.
type liveExecutor struct{}
//nolint:revive // sign flag is part of the ReleaseExecutor interface contract.
func (e *liveExecutor) CreateTag(_ context.Context, tag, ref, message string, sign bool) error {
args := []string{"tag", "-a"}
if sign {
args = append(args, "-s")
}
args = append(args, tag, "-m", message, ref)
return gitRun(args...)
}
func (*liveExecutor) PushTag(_ context.Context, tag string) error {
return gitRun("push", "origin", tag)
}
func (*liveExecutor) TriggerWorkflow(_ context.Context, ref, channel, releaseNotes string) error {
payload := map[string]string{
"dry_run": "false",
"release_channel": channel,
"release_notes": releaseNotes,
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return xerrors.Errorf("marshaling workflow payload: %w", err)
}
cmd := exec.Command("gh", "workflow", "run", "release.yaml",
"--repo", owner+"/"+repo,
"--ref", ref,
"--json",
)
cmd.Stdin = strings.NewReader(string(payloadJSON))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// dryRunExecutor prints what would happen without doing it.
type dryRunExecutor struct {
w io.Writer
}
//nolint:revive // sign flag is part of the ReleaseExecutor interface contract.
func (e *dryRunExecutor) CreateTag(_ context.Context, tag, ref, message string, sign bool) error {
signFlag := ""
if sign {
signFlag = "-s "
}
_, _ = fmt.Fprintf(e.w, "[DRYRUN] would run: git tag %s-a %s -m %q %s\n", signFlag, tag, message, ref)
return nil
}
func (e *dryRunExecutor) PushTag(_ context.Context, tag string) error {
_, _ = fmt.Fprintf(e.w, "[DRYRUN] would run: git push origin %s\n", tag)
return nil
}
func (e *dryRunExecutor) TriggerWorkflow(_ context.Context, ref, channel, _ string) error {
_, _ = fmt.Fprintf(e.w, "[DRYRUN] would trigger release.yaml workflow (ref=%s, channel=%s)\n", ref, channel)
return nil
}
+30
View File
@@ -0,0 +1,30 @@
package v1
import (
"errors"
"os/exec"
"strings"
)
// gitOutput runs a read-only git command and returns trimmed stdout.
func gitOutput(args ...string) (string, error) {
cmd := exec.Command("git", args...)
out, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return "", exitErr
}
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// gitRun runs a git command with stdout/stderr connected to the
// terminal.
func gitRun(args ...string) error {
cmd := exec.Command("git", args...)
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run()
}
+195
View File
@@ -0,0 +1,195 @@
package v1
import (
"errors"
"os/exec"
"slices"
"strconv"
"strings"
"time"
)
// ghOutput runs a gh CLI command and returns trimmed stdout.
func ghOutput(args ...string) (string, error) {
cmd := exec.Command("gh", args...)
out, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return "", exitErr
}
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// checkGHAuth verifies that the gh CLI is installed and
// authenticated. Returns true if gh is available.
func checkGHAuth() bool {
cmd := exec.Command("gh", "auth", "status")
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run() == nil
}
// ghPR is a minimal pull request representation parsed from gh CLI
// JSON output.
type ghPR struct {
Number int `json:"number"`
Title string `json:"title"`
Author string `json:"author"`
Labels []string
}
// ghListOpenPRs returns open PRs targeting the given branch via
// the gh CLI.
func ghListOpenPRs(branch string) ([]ghPR, error) {
out, err := ghOutput("pr", "list",
"--repo", owner+"/"+repo,
"--base", branch,
"--state", "open",
"--json", "number,title,author",
"--jq", `.[] | "\(.number)\t\(.title)\t\(.author.login)"`,
)
if err != nil {
return nil, err
}
if out == "" {
return nil, nil
}
var prs []ghPR
for _, line := range strings.Split(out, "\n") {
parts := strings.SplitN(line, "\t", 3)
if len(parts) < 3 {
continue
}
num, _ := strconv.Atoi(parts[0])
prs = append(prs, ghPR{
Number: num,
Title: parts[1],
Author: parts[2],
})
}
return prs, nil
}
// ghListPRsWithLabel returns merged PRs targeting the given branch
// that have a specific label.
func ghListPRsWithLabel(branch, label string) ([]ghPR, error) {
out, err := ghOutput("pr", "list",
"--repo", owner+"/"+repo,
"--base", branch,
"--state", "merged",
"--label", label,
"--json", "number,title",
"--jq", `.[] | "\(.number)\t\(.title)"`,
)
if err != nil {
return nil, err
}
if out == "" {
return nil, nil
}
var prs []ghPR
for _, line := range strings.Split(out, "\n") {
parts := strings.SplitN(line, "\t", 2)
if len(parts) < 2 {
continue
}
num, _ := strconv.Atoi(parts[0])
prs = append(prs, ghPR{Number: num, Title: parts[1]})
}
return prs, nil
}
// prMetadata holds labels and author for a merged PR.
type prMetadata struct {
Labels []string
Author string
}
// prMetadataMaps holds PR metadata indexed by both merge-commit SHA
// and PR number. On release branches, commits are cherry-picked so
// their SHA differs from the original merge commit on main. The PR
// number (preserved in the commit title) provides a fallback lookup.
type prMetadataMaps struct {
bySHA map[string]prMetadata
byNumber map[int]prMetadata
}
// lookupCommit returns PR metadata for a commit, trying the full SHA
// first and falling back to PR number for cherry-picked commits.
func (m *prMetadataMaps) lookupCommit(fullSHA string, prNumber int) prMetadata {
if meta, ok := m.bySHA[fullSHA]; ok {
return meta
}
if prNumber > 0 {
return m.byNumber[prNumber]
}
return prMetadata{}
}
// ghBuildPRMetadataMap returns PR metadata indexed by both
// merge-commit SHA and PR number for merged PRs targeting main.
// This matches the bash script's approach of querying --base main
// with a date filter based on the oldest commit in the range.
func ghBuildPRMetadataMap(commits []commitEntry) (*prMetadataMaps, error) {
empty := &prMetadataMaps{
bySHA: make(map[string]prMetadata),
byNumber: make(map[int]prMetadata),
}
if len(commits) == 0 {
return empty, nil
}
// Find the earliest commit timestamp to scope the PR query.
earliest := commits[0].Timestamp
for _, c := range commits[1:] {
if c.Timestamp < earliest {
earliest = c.Timestamp
}
}
lookbackDate := time.Unix(earliest, 0).Format("2006-01-02")
out, err := ghOutput("pr", "list",
"--repo", owner+"/"+repo,
"--base", "main",
"--state", "merged",
"--limit", "10000",
"--search", "merged:>="+lookbackDate,
"--json", "number,mergeCommit,labels,author",
"--jq", `.[] | "\(.number)\t\(.mergeCommit.oid)\t\(.author.login)\t\([.labels[].name] | join(","))"`,
)
if err != nil {
return nil, err
}
if out == "" {
return empty, nil
}
result := &prMetadataMaps{
bySHA: make(map[string]prMetadata),
byNumber: make(map[int]prMetadata),
}
for _, line := range strings.Split(out, "\n") {
parts := strings.SplitN(line, "\t", 4)
if len(parts) < 4 {
continue
}
num, _ := strconv.Atoi(parts[0])
sha := parts[1]
author := parts[2]
var labels []string
if parts[3] != "" {
labels = strings.Split(parts[3], ",")
slices.Sort(labels)
}
meta := prMetadata{
Labels: labels,
Author: author,
}
result.bySHA[sha] = meta
if num > 0 {
result.byNumber[num] = meta
}
}
return result, nil
}
+839
View File
@@ -0,0 +1,839 @@
package v1
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
//nolint:revive // Long function is fine for a sequential release flow.
func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseExecutor, ghAvailable, gpgConfigured, dryRun bool) error {
w := inv.Stderr
// --- Release landscape ---
infof(w, "Checking current releases...")
allTags, err := allSemverTags()
if err != nil {
return xerrors.Errorf("listing tags: %w", err)
}
var latestMainline *version
for _, t := range allTags {
if t.Pre == "" {
latestMainline = &t
break
}
}
stableMinor := -1
latestStableStr := "(unknown)"
if latestMainline != nil {
stableMinor = latestMainline.Minor - 1
// Find highest tag in the stable minor series.
for _, t := range allTags {
if t.Major == latestMainline.Major && t.Minor == stableMinor && t.Pre == "" {
latestStableStr = t.String()
break
}
}
if latestStableStr == "(unknown)" {
latestStableStr = fmt.Sprintf("(none found for v%d.%d.x)", latestMainline.Major, stableMinor)
}
}
fmt.Fprintln(w)
mainlineStr := "(none)"
if latestMainline != nil {
mainlineStr = latestMainline.String()
}
fmt.Fprintf(w, " Latest mainline release: %s\n", pretty.Sprint(cliui.BoldFmt(), mainlineStr))
fmt.Fprintf(w, " Latest stable release: %s\n", pretty.Sprint(cliui.BoldFmt(), latestStableStr))
fmt.Fprintln(w)
// --- Branch detection ---
currentBranch, err := gitOutput("branch", "--show-current")
if err != nil {
return xerrors.Errorf("detecting branch: %w", err)
}
// Two modes:
// 1. On "main" — for tagging release candidates (RCs).
// 2. On "release/X.Y" — for releases and patches.
// RCs are tagged directly on main to avoid the toil of
// cherry-picking hundreds of commits onto a release branch.
// The release/X.Y branch is only cut when the release is
// ready.
//
// Detached HEAD is common: the release manager checks out a
// specific commit on main before running the tool. We detect
// this by checking whether HEAD is an ancestor of origin/main.
branchRe := regexp.MustCompile(`^release/(\d+)\.(\d+)$`)
onMain := currentBranch == "main"
var branchMajor, branchMinor int
// Detached HEAD: currentBranch is empty. Check if HEAD is
// reachable from origin/main.
if currentBranch == "" {
if err := gitRun("merge-base", "--is-ancestor", "HEAD", "origin/main"); err == nil {
onMain = true
currentBranch = "main"
successf(w, "Detached HEAD is an ancestor of main — RC tagging mode.")
}
}
switch {
case onMain:
successf(w, "On main branch — RC tagging mode.")
case branchRe.MatchString(currentBranch):
m := branchRe.FindStringSubmatch(currentBranch)
branchMajor, _ = strconv.Atoi(m[1])
branchMinor, _ = strconv.Atoi(m[2])
successf(w, "Using release branch: %s", currentBranch)
default:
if currentBranch == "" {
warnf(w, "Detached HEAD is not reachable from origin/main.")
} else {
warnf(w, "Current branch %q is not 'main' or a release branch (release/X.Y).", currentBranch)
}
branchInput, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Enter the branch to use (e.g. main, release/2.21)",
Validate: func(s string) error {
if s == "main" || branchRe.MatchString(s) {
return nil
}
return xerrors.New("must be 'main' or release/X.Y (e.g. release/2.21)")
},
})
if err != nil {
return err
}
currentBranch = branchInput
if currentBranch == "main" {
onMain = true
successf(w, "On main branch — RC tagging mode.")
} else {
m := branchRe.FindStringSubmatch(currentBranch)
branchMajor, _ = strconv.Atoi(m[1])
branchMinor, _ = strconv.Atoi(m[2])
successf(w, "Using release branch: %s", currentBranch)
}
}
// --- Commit selection (RC mode) ---
// RCs are always tagged at a specific commit. Show the current
// HEAD and let the user confirm or provide a different SHA.
// We always checkout the commit so the rest of the flow
// operates in detached HEAD at the exact commit being tagged.
if onMain {
headSHA, err := gitOutput("rev-parse", "HEAD")
if err != nil {
return xerrors.Errorf("resolving HEAD: %w", err)
}
headShort := headSHA[:12]
headTitle, _ := gitOutput("log", "-1", "--format=%s", "HEAD")
fmt.Fprintf(w, " Current commit: %s %s\n", headShort, headTitle)
fmt.Fprintln(w)
commitInput, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Commit SHA to tag (press Enter to use current)",
Default: headShort,
})
if err != nil {
return err
}
commitInput = strings.TrimSpace(commitInput)
// Resolve the input to a full SHA.
targetSHA, err := gitOutput("rev-parse", commitInput)
if err != nil {
return xerrors.Errorf("resolving %q: %w", commitInput, err)
}
// Always checkout so we're in detached HEAD at the
// target commit for the rest of the flow.
if err := gitRun("checkout", "--quiet", targetSHA); err != nil {
return xerrors.Errorf("checking out %s: %w", commitInput, err)
}
if targetSHA != headSHA {
newTitle, _ := gitOutput("log", "-1", "--format=%s", "HEAD")
successf(w, "Checked out %s %s", targetSHA[:12], newTitle)
}
fmt.Fprintln(w)
}
// --- Fetch & sync check ---
infof(w, "Fetching latest from origin...")
if err := gitRun("fetch", "--quiet", "--tags", "origin", currentBranch); err != nil {
return xerrors.Errorf("fetching: %w", err)
}
// Skip the local-vs-remote sync check in RC mode because
// we always checkout a specific commit (detached HEAD).
if !onMain {
localHead, err := gitOutput("rev-parse", "HEAD")
if err != nil {
return xerrors.Errorf("resolving HEAD: %w", err)
}
remoteHead, _ := gitOutput("rev-parse", "origin/"+currentBranch)
if remoteHead != "" && localHead != remoteHead {
warnf(w, "Your local branch is not up to date with origin/%s.", currentBranch)
fmt.Fprintf(w, " Local: %s\n", localHead[:12])
fmt.Fprintf(w, " Remote: %s\n", remoteHead[:12])
if err := confirmWithDefault(inv, "Continue anyway?", cliui.ConfirmNo); err != nil {
return err
}
fmt.Fprintln(w)
}
}
// --- Find previous version & suggest next ---
mergedTags, err := mergedSemverTags()
if err != nil {
return xerrors.Errorf("listing merged tags: %w", err)
}
var prevVersion *version
var suggested version
var changelogBaseRef string
if onMain { //nolint:nestif // Sequential release flow with two distinct modes is inherently nested.
// On main, suggest the next RC. Find the latest RC tag
// across all tags, then suggest the next one. If no RC
// tags exist, suggest rc.0 for the next minor after the
// latest mainline release.
var latestRC *version
for _, t := range allTags {
if t.IsRC() {
v := t
latestRC = &v
break
}
}
switch {
case latestRC != nil:
prevVersion = latestRC
infof(w, "Latest RC tag: %s", latestRC.String())
// Check if a final release already exists for this
// RC's minor series. If so, the series is complete
// and we should start the next minor's RC cycle.
seriesComplete := false
for _, t := range allTags {
if t.Major == latestRC.Major && t.Minor == latestRC.Minor && t.Pre == "" {
infof(w, "Final release %s already exists for this series, moving to next minor.", t.String())
seriesComplete = true
break
}
}
if seriesComplete {
suggested = version{
Major: latestRC.Major,
Minor: latestRC.Minor + 1,
Patch: 0,
Pre: "rc.0",
}
} else {
suggested = version{
Major: latestRC.Major,
Minor: latestRC.Minor,
Patch: latestRC.Patch,
Pre: fmt.Sprintf("rc.%d", latestRC.rcNumber()+1),
}
}
case latestMainline != nil:
infof(w, "No RC tags found. Latest mainline: %s", latestMainline.String())
suggested = version{
Major: latestMainline.Major,
Minor: latestMainline.Minor + 1,
Patch: 0,
Pre: "rc.0",
}
default:
infof(w, "No previous tags found.")
suggested = version{Major: 2, Minor: 0, Patch: 0, Pre: "rc.0"}
}
} else {
// On a release branch, find the latest tag matching this
// branch's major.minor. Without this filter, tags from
// newer branches reachable via merge history would be
// picked up incorrectly.
for _, t := range mergedTags {
if t.Major == branchMajor && t.Minor == branchMinor {
v := t
prevVersion = &v
break
}
}
// changelogBaseRef is the git ref used as the starting
// point for release notes. When a tag exists in this
// minor series we use it directly. For the first release
// on a new minor no matching tag exists, so we compute
// the merge-base with the previous minor's release branch
// instead. This works even when that branch has no tags
// yet. As a last resort we fall back to the latest
// reachable tag from a previous minor.
if prevVersion == nil {
prevReleaseBranch := fmt.Sprintf("release/%d.%d", branchMajor, branchMinor-1)
if err := gitRun("fetch", "--quiet", "origin", prevReleaseBranch); err != nil {
warnf(w, "Could not fetch %s: %v", prevReleaseBranch, err)
}
if mb, mbErr := gitOutput("merge-base", "HEAD", "origin/"+prevReleaseBranch); mbErr == nil && mb != "" {
changelogBaseRef = mb
infof(w, "Using merge-base with %s as changelog base: %s", prevReleaseBranch, mb[:12])
} else {
// No previous release branch; fall back to the
// latest reachable tag from a previous minor.
for _, t := range mergedTags {
if t.Major == branchMajor && t.Minor < branchMinor {
changelogBaseRef = t.String()
break
}
}
}
}
if prevVersion == nil {
infof(w, "No previous release tag found on this branch.")
suggested = version{Major: branchMajor, Minor: branchMinor, Patch: 0}
} else {
infof(w, "Previous release tag: %s", prevVersion.String())
if prevVersion.IsRC() {
// Branch has only RC tags; suggest the
// release (same base, no pre-release suffix).
suggested = version{
Major: prevVersion.Major,
Minor: prevVersion.Minor,
Patch: prevVersion.Patch,
}
} else {
suggested = version{
Major: prevVersion.Major,
Minor: prevVersion.Minor,
Patch: prevVersion.Patch + 1,
}
}
}
}
fmt.Fprintln(w)
// --- Version prompt ---
versionInput, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Version to release",
Default: suggested.String(),
Validate: func(s string) error {
if _, ok := parseVersion(s); !ok {
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
},
})
if err != nil {
return err
}
newVersion, _ := parseVersion(versionInput)
// Validate version against branch context.
switch {
case onMain && !newVersion.IsRC():
return xerrors.Errorf("cannot tag a non-RC version (%s) on main; switch to a release/X.Y branch", newVersion)
case !onMain && newVersion.IsRC():
return xerrors.Errorf("cannot tag an RC (%s) on a release branch; switch to main", newVersion)
case !onMain && (newVersion.Major != branchMajor || newVersion.Minor != branchMinor):
warnf(w, "Version %s does not match branch %s (expected v%d.%d.X).",
newVersion, currentBranch, branchMajor, branchMinor)
if err := confirmWithDefault(inv, "Continue anyway?", cliui.ConfirmNo); err != nil {
return err
}
fmt.Fprintln(w)
}
fmt.Fprintln(w)
infof(w, "=== Coder Release: %s ===", newVersion)
fmt.Fprintln(w)
// --- Check if tag already exists ---
tagExists := false
existingTag, _ := gitOutput("tag", "-l", newVersion.String())
if existingTag != "" {
tagExists = true
warnf(w, "Tag '%s' already exists!", newVersion)
if err := confirmWithDefault(inv, "This will skip tagging. Continue?", cliui.ConfirmNo); err != nil {
return err
}
fmt.Fprintln(w)
}
// --- Check open PRs ---
// This runs before breaking changes so any last-minute merges
// are caught by the subsequent checks. Skipped on main since
// there are always open PRs targeting main.
if !onMain {
infof(w, "Checking for open PRs against %s...", currentBranch)
var openPRs []ghPR
if ghAvailable {
openPRs, err = ghListOpenPRs(currentBranch)
if err != nil {
warnf(w, "Failed to check open PRs: %v", err)
}
} else {
infof(w, "Skipping (no gh CLI).")
}
if len(openPRs) > 0 {
fmt.Fprintln(w)
warnf(w, "There are open PRs targeting %s that may need merging first:", currentBranch)
fmt.Fprintln(w)
for _, pr := range openPRs {
fmt.Fprintf(w, " #%d %s (@%s)\n", pr.Number, pr.Title, pr.Author)
}
fmt.Fprintln(w)
if err := confirmWithDefault(inv, "Continue without merging these?", cliui.ConfirmNo); err != nil {
return err
}
fmt.Fprintln(w)
} else {
successf(w, "No open PRs against %s.", currentBranch)
}
fmt.Fprintln(w)
}
// --- Semver sanity checks ---
if prevVersion != nil { //nolint:nestif // Sequential release checks are inherently nested.
// Downgrade check.
if prevVersion.GreaterThan(newVersion) {
warnf(w, "Version DOWNGRADE detected: %s → %s.", prevVersion, newVersion)
if err := confirmWithDefault(inv, "Continue?", cliui.ConfirmNo); err != nil {
return err
}
fmt.Fprintln(w)
}
// Duplicate check.
if prevVersion.Equal(newVersion) {
warnf(w, "Version %s is the SAME as the previous tag %s.", newVersion, prevVersion)
if err := confirmWithDefault(inv, "Continue?", cliui.ConfirmNo); err != nil {
return err
}
fmt.Fprintln(w)
}
// Skipped patch check.
if newVersion.Major == prevVersion.Major && newVersion.Minor == prevVersion.Minor {
expectedPatch := prevVersion.Patch + 1
if newVersion.Patch > expectedPatch {
warnf(w, "Skipping patch version(s): expected v%d.%d.%d, got %s.",
newVersion.Major, newVersion.Minor, expectedPatch, newVersion)
if err := confirmWithDefault(inv, "Continue?", cliui.ConfirmNo); err != nil {
return err
}
fmt.Fprintln(w)
}
}
// Breaking changes in patch release check.
if newVersion.Major == prevVersion.Major && newVersion.Minor == prevVersion.Minor && newVersion.Patch > prevVersion.Patch {
infof(w, "Checking for breaking changes in patch release...")
commitRange := prevVersion.String() + "..HEAD"
commits, err := commitLog(commitRange)
if err != nil {
return xerrors.Errorf("reading commit log: %w", err)
}
var breakingCommits []commitEntry
for _, c := range commits {
if breakingCommitRe.MatchString(c.Title) {
breakingCommits = append(breakingCommits, c)
}
}
// Check PR labels for release/breaking.
var breakingPRLabeled []ghPR
if ghAvailable {
breakingPRLabeled, err = ghListPRsWithLabel(currentBranch, "release/breaking")
if err != nil {
warnf(w, "Failed to check PR labels: %v", err)
}
}
if len(breakingCommits) > 0 || len(breakingPRLabeled) > 0 {
fmt.Fprintln(w)
warnf(w, "BREAKING CHANGES detected in a PATCH release — this violates semver!")
fmt.Fprintln(w)
if len(breakingCommits) > 0 {
fmt.Fprintln(w, " Breaking commits (by conventional commit prefix):")
for _, c := range breakingCommits {
fmt.Fprintf(w, " - %s %s\n", c.SHA, c.Title)
}
}
if len(breakingPRLabeled) > 0 {
fmt.Fprintln(w, " PRs labeled release/breaking:")
for _, pr := range breakingPRLabeled {
fmt.Fprintf(w, " - #%d %s\n", pr.Number, pr.Title)
}
}
fmt.Fprintln(w)
if err := confirmWithDefault(inv, "Continue with patch release despite breaking changes?", cliui.ConfirmNo); err != nil {
return err
}
fmt.Fprintln(w)
} else {
successf(w, "No breaking changes detected.")
}
}
}
// --- Channel selection ---
// This is done before release notes generation because the
// notes format differs between mainline and stable channels.
// RC releases are always on the "rc" channel and skip the
// stable/mainline prompt.
channel := "mainline"
if newVersion.IsRC() {
channel = "rc"
infof(w, "Channel: rc (release candidate, will be marked as prerelease on GitHub).")
} else {
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)
// --- Adjust changelog base for initial releases ---
// When the new version is a .0 release (e.g. v2.33.0) and
// prevVersion is an RC (e.g. v2.33.0-rc.3), the release
// notes should show all changes since the last stable
// release in the previous minor series (e.g. v2.32.X),
// not just the delta from the last RC.
if !onMain && newVersion.Patch == 0 && !newVersion.IsRC() && prevVersion != nil && prevVersion.IsRC() {
var lastStable *version
for _, t := range allTags {
if t.Pre == "" && t.Major == newVersion.Major && t.Minor < newVersion.Minor {
lastStable = &t
break
}
}
if lastStable != nil {
infof(w, "Changelog base: %s (last stable release before %s series).", lastStable, newVersion)
prevVersion = lastStable
} else {
warnf(w, "No previous stable release found; changelog will diff from RC %s.", prevVersion)
}
}
// --- Generate release notes ---
infof(w, "Generating release notes...")
var commitRange string
switch {
case prevVersion != nil:
commitRange = prevVersion.String() + "..HEAD"
case changelogBaseRef != "":
commitRange = changelogBaseRef + "..HEAD"
default:
commitRange = "HEAD"
}
commits, err := commitLog(commitRange)
if err != nil {
return xerrors.Errorf("reading commit log: %w", err)
}
// Build PR metadata maps (by SHA and PR number) via gh CLI.
var prMeta *prMetadataMaps
if ghAvailable {
prMeta, err = ghBuildPRMetadataMap(commits)
if err != nil {
warnf(w, "Failed to fetch PR metadata: %v", err)
}
}
if prMeta == nil {
prMeta = &prMetadataMaps{
bySHA: make(map[string]prMetadata),
byNumber: make(map[int]prMetadata),
}
}
type section struct {
Key string
Title string
}
sections := []section{
{"breaking", "BREAKING CHANGES"},
{"security", "SECURITY"},
{"feat", "Features"},
{"fix", "Bug fixes"},
{"docs", "Documentation"},
{"refactor", "Code refactoring"},
{"perf", "Performance improvements"},
{"test", "Tests"},
{"build", "Builds"},
{"ci", "Continuous integration"},
{"chore", "Chores"},
{"revert", "Reverts"},
{"other", "Other changes"},
{"experimental", "Experimental changes"},
}
sectionCommits := make(map[string][]string)
for _, c := range commits {
meta := prMeta.lookupCommit(c.FullSHA, c.PRCount)
// Skip dependabot commits.
if meta.Author == "dependabot" || meta.Author == "app/dependabot" {
continue
}
cat := categorizeCommit(c.Title, meta.Labels)
humanTitle := humanizeTitle(c.Title)
// Strip trailing PR ref from humanized title if present,
// so we can rebuild it with the SHA appended.
humanTitle = prNumRe.ReplaceAllString(humanTitle, "")
humanTitle = strings.TrimSpace(humanTitle)
// Build entry: - Title (#PR, SHA) (@author)
var entry string
if c.PRCount > 0 {
entry = fmt.Sprintf("- %s (#%d, %s)", humanTitle, c.PRCount, c.SHA)
} else {
entry = fmt.Sprintf("- %s (%s)", humanTitle, c.SHA)
}
if meta.Author != "" {
entry += fmt.Sprintf(" (@%s)", meta.Author)
}
sectionCommits[cat] = append(sectionCommits[cat], entry)
}
// Build release notes markdown matching the format from
// scripts/release/generate_release_notes.sh.
var notes strings.Builder
// 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")
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":
// Only show the mainline blurb when the version is
// actually the current mainline series. Patches on
// older branches (e.g. ESR) are neither mainline nor
// stable, so we omit the note entirely.
if latestMainline != nil && newVersion.Minor == latestMainline.Minor {
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).")
}
}
hasContent := false
for _, s := range sections {
if entries, ok := sectionCommits[s.Key]; ok && len(entries) > 0 {
fmt.Fprintf(&notes, "\n### %s\n\n", s.Title)
if s.Key == "experimental" {
fmt.Fprintln(&notes, "These changes are feature-flagged and can be enabled with the `--experiments` server flag. They may change or be removed in future releases.")
fmt.Fprintln(&notes)
}
for _, e := range entries {
fmt.Fprintln(&notes, e)
}
hasContent = true
}
}
if !hasContent {
prevStr := "the beginning of time"
if prevVersion != nil {
prevStr = prevVersion.String()
}
fmt.Fprintf(&notes, "\n_No changes since %s._\n", prevStr)
}
// Compare link.
compareBase := changelogBaseRef
if prevVersion != nil {
compareBase = prevVersion.String()
}
if compareBase != "" {
fmt.Fprintf(&notes, "\nCompare: [`%s...%s`](https://github.com/%s/%s/compare/%s...%s)\n",
compareBase, newVersion, owner, repo, compareBase, newVersion)
}
// Container image.
imageTag := fmt.Sprintf("ghcr.io/coder/coder:%s", strings.TrimPrefix(newVersion.String(), "v"))
fmt.Fprintf(&notes, "\n## Container image\n\n- `docker pull %s`\n", imageTag)
// Install/upgrade links.
fmt.Fprintln(&notes, "\n## Install/upgrade")
fmt.Fprintln(&notes, "\nRefer to our docs to [install](https://coder.com/docs/install) or [upgrade](https://coder.com/docs/install/upgrade) Coder, or use a release asset below.")
releaseNotes := notes.String()
// Write to file.
releaseNotesFile := fmt.Sprintf("build/RELEASE-%s.md", newVersion)
if err := os.MkdirAll("build", 0o755); err != nil {
return xerrors.Errorf("creating build directory: %w", err)
}
if err := os.WriteFile(releaseNotesFile, []byte(releaseNotes), 0o600); err != nil {
return xerrors.Errorf("writing release notes: %w", err)
}
// --- Preview ---
fmt.Fprintln(w)
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), "--- Release Notes Preview ---"))
fmt.Fprintln(w)
fmt.Fprint(w, releaseNotes)
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), "--- End Preview ---"))
fmt.Fprintln(w)
infof(w, "Release notes written to %s", releaseNotesFile)
fmt.Fprintln(w)
// --- Offer to edit ---
editor := os.Getenv("EDITOR")
if editor == "" {
editor = os.Getenv("GIT_EDITOR")
}
if editor != "" {
if err := confirmWithDefault(inv, fmt.Sprintf("Edit release notes in %s?", editor), cliui.ConfirmNo); err == nil {
cmd := exec.Command(editor, releaseNotesFile)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return xerrors.Errorf("editor: %w", err)
}
updated, err := os.ReadFile(releaseNotesFile)
if err != nil {
return xerrors.Errorf("reading edited release notes: %w", err)
}
// The file will be re-read from disk before the
// workflow trigger step.
_ = string(updated)
infof(w, "Release notes updated.")
}
fmt.Fprintln(w)
}
// --- Tag ---
ref, err := gitOutput("rev-parse", "HEAD")
if err != nil {
return xerrors.Errorf("resolving HEAD: %w", err)
}
shortRef := ref[:12]
if !tagExists {
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), "Next step: create an annotated tag."))
fmt.Fprintf(w, " Tag: %s\n", newVersion)
fmt.Fprintf(w, " Commit: %s\n", shortRef)
fmt.Fprintf(w, " Branch: %s\n", currentBranch)
fmt.Fprintln(w)
if err := confirm(inv, "Create tag?"); err != nil {
return xerrors.New("cannot proceed without a tag")
}
if err := executor.CreateTag(ctx, newVersion.String(), ref, "Release "+newVersion.String(), gpgConfigured); err != nil {
return xerrors.Errorf("creating tag: %w", err)
}
successf(w, "Tag %s created.", newVersion)
fmt.Fprintln(w)
} else {
infof(w, "Tag %s already exists, skipping creation.", newVersion)
fmt.Fprintln(w)
}
// --- Push tag ---
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), fmt.Sprintf("Next step: push tag '%s' to origin.", newVersion)))
fmt.Fprintf(w, " This will run: git push origin %s\n", newVersion)
fmt.Fprintln(w)
if err := confirm(inv, "Push tag?"); err != nil {
return xerrors.New("cannot trigger release without pushing the tag")
}
if err := executor.PushTag(ctx, newVersion.String()); err != nil {
return xerrors.Errorf("pushing tag: %w", err)
}
successf(w, "Tag pushed.")
fmt.Fprintln(w)
// --- Trigger release workflow ---
// Re-read release notes from disk in case the user edited the
// file externally between the editor step and now.
freshNotes, err := os.ReadFile(releaseNotesFile)
if err != nil {
return xerrors.Errorf("re-reading release notes: %w", err)
}
releaseNotes = string(freshNotes)
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), "Next step: trigger the 'release.yaml' GitHub Actions workflow."))
fmt.Fprintf(w, " Workflow: release.yaml\n")
fmt.Fprintf(w, " Repo: %s/%s\n", owner, repo)
fmt.Fprintf(w, " Ref: %s\n", newVersion)
fmt.Fprintln(w)
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), " Payload fields:"))
fmt.Fprintf(w, " release_channel: %s\n", channel)
fmt.Fprintf(w, " dry_run: false\n")
fmt.Fprintln(w)
fmt.Fprintln(w, pretty.Sprint(cliui.BoldFmt(), " release_notes:"))
for _, line := range strings.Split(releaseNotes, "\n") {
fmt.Fprintf(w, " %s\n", line)
}
fmt.Fprintln(w)
if err := confirm(inv, "Trigger release workflow?"); err != nil {
infof(w, "Skipped workflow trigger. You can trigger it manually from GitHub Actions.")
fmt.Fprintln(w)
successf(w, "Done! 🎉")
return nil
}
if err := executor.TriggerWorkflow(ctx, newVersion.String(), channel, releaseNotes); err != nil {
return xerrors.Errorf("triggering workflow: %w", err)
}
successf(w, "Release workflow triggered!")
// --- Update release docs ---
// 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! 🎉")
return nil
}
+67
View File
@@ -0,0 +1,67 @@
package v1
import (
"fmt"
"os/exec"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/serpent"
)
const (
owner = "coder"
repo = "coder"
)
// Run executes the legacy interactive release wizard.
//
// It mirrors the behavior of the original standalone releaser tool: it
// verifies dependencies, warns when GPG signing or the gh CLI are not
// configured, wires up a live or dry-run executor, and then walks the
// operator through tagging, pushing, and triggering the release
// workflow.
//
//nolint:revive // dryRun selects the dry-run executor for the wizard.
func Run(inv *serpent.Invocation, dryRun bool) error {
ctx := inv.Context()
w := inv.Stderr
// --- Check dependencies ---
if _, err := exec.LookPath("git"); err != nil {
return xerrors.New("git is required but not found in PATH")
}
// --- Check GPG signing ---
signingKey, _ := gitOutput("config", "--get", "user.signingkey")
gpgFormat, _ := gitOutput("config", "--get", "gpg.format")
gpgConfigured := signingKey != "" || gpgFormat != ""
if !gpgConfigured {
warnf(w, "GPG signing is not configured. Tags will be unsigned, so there will be no way to verify who pushed the tag.")
_, _ = fmt.Fprintf(w, " To fix: set git config user.signingkey or gpg.format\n")
if err := confirmWithDefault(inv, "Continue without signing?", cliui.ConfirmNo); err != nil {
return err
}
_, _ = fmt.Fprintln(w)
}
// --- Check gh CLI auth ---
ghAvailable := checkGHAuth()
if !ghAvailable {
warnf(w, "gh CLI is not available or not authenticated.")
infof(w, "Continuing without GitHub features (PR checks, label lookups, workflow trigger).")
_, _ = fmt.Fprintln(w)
}
// --- Wire up executor ---
var executor ReleaseExecutor
if dryRun {
outputPrefix = "[DRYRUN] "
executor = &dryRunExecutor{w: w}
} else {
executor = &liveExecutor{}
}
return runRelease(ctx, inv, executor, ghAvailable, gpgConfigured, dryRun)
}
+49
View File
@@ -0,0 +1,49 @@
package v1
import (
"io"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
// outputPrefix is prepended to every message line. Set to
// "[DRYRUN] " when running in dry-run mode.
var outputPrefix string
// warnf prints a yellow warning to stderr.
func warnf(w io.Writer, format string, args ...any) {
pretty.Fprintf(w, cliui.DefaultStyles.Warn, outputPrefix+format+"\n", args...)
}
// infof prints a cyan info message to stderr.
func infof(w io.Writer, format string, args ...any) {
pretty.Fprintf(w, cliui.DefaultStyles.Keyword, outputPrefix+format+"\n", args...)
}
// successf prints a green success message to stderr.
func successf(w io.Writer, format string, args ...any) {
pretty.Fprintf(w, cliui.DefaultStyles.DateTimeStamp, outputPrefix+format+"\n", args...)
}
// confirm asks a yes/no question. Returns nil if the user confirms,
// or a cancellation error otherwise.
func confirm(inv *serpent.Invocation, msg string) error {
_, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: msg,
IsConfirm: true,
})
return err
}
// confirmWithDefault asks a yes/no question with the specified
// default ("yes" or "no").
func confirmWithDefault(inv *serpent.Invocation, msg, def string) error {
_, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: msg,
IsConfirm: true,
Default: def,
})
return err
}
+137
View File
@@ -0,0 +1,137 @@
package v1
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// 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+)(-(.+))?$`)
func parseVersion(s string) (version, bool) {
m := semverRe.FindStringSubmatch(s)
if m == nil {
return version{}, false
}
maj, _ := strconv.Atoi(m[1])
mnr, _ := strconv.Atoi(m[2])
pat, _ := strconv.Atoi(m[3])
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
}
if v.Minor != b.Minor {
return v.Minor > b.Minor
}
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 && v.Pre == b.Pre
}
// sortVersionsDesc sorts a slice of versions in descending order
// using semver-correct comparison. This is necessary because git's
// --sort=-v:refname treats pre-release suffixes (e.g. -rc.0) as
// greater than the release version, which is the opposite of semver
// where v2.32.0 > v2.32.0-rc.0.
func sortVersionsDesc(tags []version) {
sort.Slice(tags, func(i, j int) bool {
return tags[i].GreaterThan(tags[j])
})
}
// allSemverTags returns all semver tags sorted descending.
func allSemverTags() ([]version, error) {
out, err := gitOutput("tag", "--sort=-v:refname")
if err != nil {
return nil, err
}
if out == "" {
return nil, nil
}
var tags []version
for _, line := range strings.Split(out, "\n") {
if v, ok := parseVersion(strings.TrimSpace(line)); ok {
tags = append(tags, v)
}
}
sortVersionsDesc(tags)
return tags, nil
}
// mergedSemverTags returns semver tags reachable from HEAD, sorted
// descending.
func mergedSemverTags() ([]version, error) {
out, err := gitOutput("tag", "--merged", "HEAD", "--sort=-v:refname")
if err != nil {
return nil, err
}
if out == "" {
return nil, nil
}
var tags []version
for _, line := range strings.Split(out, "\n") {
if v, ok := parseVersion(strings.TrimSpace(line)); ok {
tags = append(tags, v)
}
}
sortVersionsDesc(tags)
return tags, nil
}
+240
View File
@@ -0,0 +1,240 @@
package v1 //nolint:testpackage // Tests unexported release helpers.
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)
}
})
}
}
func TestSortVersionsDesc(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input []version
want []version
}{
{
// This is the exact scenario that triggered the bug:
// git's --sort=-v:refname places v2.32.0-rc.0 before
// v2.32.0, but semver says v2.32.0 > v2.32.0-rc.0.
name: "release_sorts_before_rc",
input: []version{
{2, 32, 0, "rc.0"},
{2, 32, 0, ""},
{2, 31, 2, ""},
},
want: []version{
{2, 32, 0, ""},
{2, 32, 0, "rc.0"},
{2, 31, 2, ""},
},
},
{
name: "multiple_rcs_and_releases",
input: []version{
{2, 33, 0, "rc.1"},
{2, 33, 0, "rc.0"},
{2, 32, 0, "rc.0"},
{2, 32, 0, ""},
{2, 32, 1, ""},
{2, 31, 0, ""},
},
want: []version{
{2, 33, 0, "rc.1"},
{2, 33, 0, "rc.0"},
{2, 32, 1, ""},
{2, 32, 0, ""},
{2, 32, 0, "rc.0"},
{2, 31, 0, ""},
},
},
{
name: "already_sorted",
input: []version{{3, 0, 0, ""}, {2, 0, 0, ""}, {1, 0, 0, ""}},
want: []version{{3, 0, 0, ""}, {2, 0, 0, ""}, {1, 0, 0, ""}},
},
{
name: "empty",
input: []version{},
want: []version{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := make([]version, len(tt.input))
copy(got, tt.input)
sortVersionsDesc(got)
if len(got) != len(tt.want) {
t.Fatalf("sortVersionsDesc() returned %d elements, want %d", len(got), len(tt.want))
}
for i := range got {
if !got[i].Equal(tt.want[i]) {
t.Fatalf("sortVersionsDesc()[%d] = %s, want %s\n full result: %v", i, got[i], tt.want[i], got)
}
}
})
}
}