mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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"
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¬es, "> ## Stable (since %s)\n\n", time.Now().Format("January 02, 2006"))
|
||||
}
|
||||
fmt.Fprintln(¬es, "## Changelog")
|
||||
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":
|
||||
// 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(¬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).")
|
||||
}
|
||||
}
|
||||
|
||||
hasContent := false
|
||||
for _, s := range sections {
|
||||
if entries, ok := sectionCommits[s.Key]; ok && len(entries) > 0 {
|
||||
fmt.Fprintf(¬es, "\n### %s\n\n", s.Title)
|
||||
if s.Key == "experimental" {
|
||||
fmt.Fprintln(¬es, "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(¬es)
|
||||
}
|
||||
for _, e := range entries {
|
||||
fmt.Fprintln(¬es, e)
|
||||
}
|
||||
hasContent = true
|
||||
}
|
||||
}
|
||||
if !hasContent {
|
||||
prevStr := "the beginning of time"
|
||||
if prevVersion != nil {
|
||||
prevStr = prevVersion.String()
|
||||
}
|
||||
fmt.Fprintf(¬es, "\n_No changes since %s._\n", prevStr)
|
||||
}
|
||||
|
||||
// Compare link.
|
||||
compareBase := changelogBaseRef
|
||||
if prevVersion != nil {
|
||||
compareBase = prevVersion.String()
|
||||
}
|
||||
if compareBase != "" {
|
||||
fmt.Fprintf(¬es, "\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(¬es, "\n## Container image\n\n- `docker pull %s`\n", imageTag)
|
||||
|
||||
// Install/upgrade links.
|
||||
fmt.Fprintln(¬es, "\n## Install/upgrade")
|
||||
fmt.Fprintln(¬es, "\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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user