mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: add new interactive release package (#22624)
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
package main
|
||||
|
||||
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.")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user