mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
520 lines
14 KiB
Go
520 lines
14 KiB
Go
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.")
|
|
}
|
|
}
|