Files
coder/scripts/release/main.go
T
Denis AfonsoandClaude 200cffcfd8 fix(scripts/release): promote stable on immutable releases (#30745)
## Problem

`./scripts/release_promote_stable.sh v2.37.4` failed today with:

```
edit release failed: PATCH https://api.github.com/repos/coder/coder/releases/403906567: 404 Not Found
```

Releases on this repo are immutable (`gh release view v2.37.4 --json
isImmutable` → `true`). `promoteVersionToStable` cloned the entire
`RepositoryRelease` into `EditRelease`, so the PATCH carried `tag_name`,
`target_commitish` and the assets. GitHub rejects that on an immutable
release even when the values are unchanged, and answers 404 rather than
422. The same edit with only `body`, `prerelease` and `make_latest`
succeeds (that's how v2.37.4 was promoted by hand).

## Change

- Build a minimal `RepositoryRelease{Body, Prerelease, MakeLatest}` for
the edit.
- Drop the `Draft: false` assignment (published releases are never
drafts, and `draft` is not an editable field on immutable releases).
- Dry-run now logs the body diff instead of a diff of the whole object.
- Remove the now-unused `cloneRelease` helper.

Verified with `go test ./scripts/release/` and a `promote --dry-run`
against v2.36.7.

Every release published since immutable releases were enabled is
affected, so without this the command cannot promote anything. Related:
the release-process review notes that this step is also missing from the
runbook and should become a workflow job on every `.0` mainline release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-14 16:42:56 +01:00

440 lines
13 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-github/v61/github"
"github.com/spf13/afero"
"golang.org/x/mod/semver"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/serpent"
)
const (
owner = "coder"
repo = "coder"
)
func main() {
// Pre-flight checks.
toplevel, err := run("git", "rev-parse", "--show-toplevel")
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
_, _ = fmt.Fprintf(os.Stderr, "NOTE: This command must be run in the coder/coder repository.\n")
os.Exit(1)
}
if err = checkCoderRepo(toplevel); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
_, _ = fmt.Fprintf(os.Stderr, "NOTE: This command must be run in the coder/coder repository.\n")
os.Exit(1)
}
r := &releaseCommand{
fs: afero.NewBasePathFs(afero.NewOsFs(), toplevel),
logger: slog.Make(sloghuman.Sink(os.Stderr)).Leveled(slog.LevelInfo),
}
var channel string
cmd := serpent.Command{
Use: "release <subcommand>",
Short: "Prepare, create and publish releases.",
Options: serpent.OptionSet{
{
Flag: "debug",
Description: "Enable debug logging.",
Value: serpent.BoolOf(&r.debug),
},
{
Flag: "github-token",
Description: "GitHub personal access token.",
Env: "GITHUB_TOKEN",
Value: serpent.StringOf(&r.ghToken),
},
{
Flag: "dry-run",
FlagShorthand: "n",
Description: "Do not make any changes, only print what would be done.",
Value: serpent.BoolOf(&r.dryRun),
},
},
Children: []*serpent.Command{
{
Use: "promote <version>",
Short: "Promote version to stable.",
Middleware: r.debugMiddleware, // Serpent doesn't support this on parent.
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
if len(inv.Args) == 0 {
return xerrors.New("version argument missing")
}
if !r.dryRun && r.ghToken == "" {
return xerrors.New("GitHub personal access token is required, use --gh-token or GH_TOKEN")
}
err := r.promoteVersionToStable(ctx, inv, inv.Args[0])
if err != nil {
return err
}
return nil
},
},
{
Use: "autoversion <version>",
Short: "Automatically update the provided channel to version in markdown files.",
Options: serpent.OptionSet{
{
Flag: "channel",
Description: "Channel to update.",
Value: serpent.EnumOf(&channel, "mainline", "stable"),
},
},
Middleware: r.debugMiddleware, // Serpent doesn't support this on parent.
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
if len(inv.Args) == 0 {
return xerrors.New("version argument missing")
}
err := r.autoversion(ctx, channel, inv.Args[0])
if err != nil {
return err
}
return nil
},
},
},
}
err = cmd.Invoke().WithOS().Run()
if err != nil {
if errors.Is(err, cliui.ErrCanceled) {
os.Exit(1)
}
r.logger.Error(context.Background(), "release command failed", slog.Error(err))
os.Exit(1)
}
}
func checkCoderRepo(path string) error {
remote, err := run("git", "-C", path, "remote", "get-url", "origin")
if err != nil {
return xerrors.Errorf("get remote failed: %w", err)
}
if !strings.Contains(remote, "github.com") || !strings.Contains(remote, "coder/coder") {
return xerrors.Errorf("origin is not set to the coder/coder repository on github.com")
}
return nil
}
type releaseCommand struct {
fs afero.Fs
logger slog.Logger
debug bool
ghToken string
dryRun bool
}
func (r *releaseCommand) debugMiddleware(next serpent.HandlerFunc) serpent.HandlerFunc {
return func(inv *serpent.Invocation) error {
if r.debug {
r.logger = r.logger.Leveled(slog.LevelDebug)
}
if r.dryRun {
r.logger = r.logger.With(slog.F("dry_run", true))
}
return next(inv)
}
}
//nolint:revive // Allow dryRun control flag.
func (r *releaseCommand) promoteVersionToStable(ctx context.Context, inv *serpent.Invocation, version string) error {
client := github.NewClient(nil)
if r.ghToken != "" {
client = client.WithAuthToken(r.ghToken)
}
logger := r.logger.With(slog.F("version", version))
logger.Info(ctx, "checking current stable release")
// Check if the version is already the latest stable release.
currentStable, _, err := client.Repositories.GetLatestRelease(ctx, "coder", "coder")
if err != nil {
return xerrors.Errorf("get latest release failed: %w", err)
}
logger = logger.With(slog.F("stable_version", currentStable.GetTagName()))
logger.Info(ctx, "found current stable release")
if currentStable.GetTagName() == version {
return xerrors.Errorf("version %q is already the latest stable release", version)
}
// Ensure the version is a valid release.
perPage := 20
latestReleases, _, err := client.Repositories.ListReleases(ctx, owner, repo, &github.ListOptions{
Page: 0,
PerPage: perPage,
})
if err != nil {
return xerrors.Errorf("list releases failed: %w", err)
}
var releaseVersions []string
var newStable *github.RepositoryRelease
for _, r := range latestReleases {
releaseVersions = append(releaseVersions, r.GetTagName())
if r.GetTagName() == version {
newStable = r
}
}
semver.Sort(releaseVersions)
slices.Reverse(releaseVersions)
switch {
case len(releaseVersions) == 0:
return xerrors.Errorf("no releases found")
case newStable == nil:
return xerrors.Errorf("version %q is not found in the last %d releases", version, perPage)
}
logger = logger.With(slog.F("mainline_version", releaseVersions[0]))
if version != releaseVersions[0] {
logger.Warn(ctx, "selected version is not the latest mainline release")
}
if reply, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Are you sure you want to promote this version to stable?",
Default: "no",
IsConfirm: true,
}); err != nil {
if reply == cliui.ConfirmNo {
return nil
}
return err
}
logger.Info(ctx, "promoting selected version to stable")
// Update the release to latest. Only send the fields we change:
// releases on this repo are immutable, and GitHub rejects an edit
// that carries the tag, target commitish or assets of an immutable
// release (it answers 404 Not Found), even when those values are
// unchanged. Body, prerelease and make_latest remain editable.
updatedBody := removeMainlineBlurb(newStable.GetBody())
updatedBody = addStableSince(time.Now().UTC(), updatedBody)
edit := &github.RepositoryRelease{
Body: github.String(updatedBody),
MakeLatest: github.String("true"),
Prerelease: github.Bool(false),
}
if !r.dryRun {
_, _, err = client.Repositories.EditRelease(ctx, owner, repo, newStable.GetID(), edit)
if err != nil {
return xerrors.Errorf("edit release failed: %w", err)
}
logger.Info(ctx, "selected version promoted to stable", slog.F("url", newStable.GetHTMLURL()))
} else {
logger.Info(ctx, "dry-run: release not updated", slog.F("body_diff", cmp.Diff(newStable.GetBody(), updatedBody)))
}
return nil
}
// addStableSince adds a stable since note to the release body.
//
// Example:
//
// > ## Stable (since April 23, 2024)
func addStableSince(date time.Time, body string) string {
// Protect against adding twice.
if strings.Contains(body, "> ## Stable (since") {
return body
}
return fmt.Sprintf("> ## Stable (since %s)\n\n", date.Format("January 02, 2006")) + body
}
// removeMainlineBlurb removes the mainline blurb from the release body.
//
// Example:
//
// > [!NOTE]
// > 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/reference/releases).
func removeMainlineBlurb(body string) string {
lines := strings.Split(body, "\n")
var newBody, clip []string
var found bool
for _, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "> [!NOTE]") {
clip = append(clip, line)
found = true
continue
}
if found {
clip = append(clip, line)
found = strings.HasPrefix(strings.TrimSpace(line), ">")
continue
}
if !found && len(clip) > 0 {
if !strings.Contains(strings.ToLower(strings.Join(clip, "\n")), "this is a mainline coder release") {
newBody = append(newBody, clip...) // This is some other note, restore it.
}
clip = nil
}
newBody = append(newBody, line)
}
return strings.Join(newBody, "\n")
}
// autoversion automatically updates the provided channel to version in markdown
// files.
func (r *releaseCommand) autoversion(ctx context.Context, channel, version string) error {
var files []string
// For now, scope this to docs, perhaps we include README.md in the future.
if err := afero.Walk(r.fs, "docs", func(path string, _ fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.EqualFold(filepath.Ext(path), ".md") {
files = append(files, path)
}
return nil
}); err != nil {
return xerrors.Errorf("walk failed: %w", err)
}
for _, file := range files {
err := r.autoversionFile(ctx, file, channel, version)
if err != nil {
return xerrors.Errorf("autoversion file failed: %w", err)
}
}
return nil
}
// autoversionMarkdownPragmaRe matches the autoversion pragma in markdown files.
//
// Example:
//
// <!-- autoversion(stable): "--version [version]" -->
//
// The channel is the first capture group and the match string is the second
// capture group. The string "[version]" is replaced with the new version.
var autoversionMarkdownPragmaRe = regexp.MustCompile(`<!-- ?autoversion\(([^)]+)\): ?"([^"]+)" ?-->`)
func (r *releaseCommand) autoversionFile(ctx context.Context, file, channel, version string) error {
version = strings.TrimPrefix(version, "v")
logger := r.logger.With(slog.F("file", file), slog.F("channel", channel), slog.F("version", version))
logger.Debug(ctx, "checking file for autoversion pragma")
contents, err := afero.ReadFile(r.fs, file)
if err != nil {
return xerrors.Errorf("read file failed: %w", err)
}
lines := strings.Split(string(contents), "\n")
var matchRe *regexp.Regexp
for i, line := range lines {
if autoversionMarkdownPragmaRe.MatchString(line) {
matches := autoversionMarkdownPragmaRe.FindStringSubmatch(line)
matchChannel := matches[1]
match := matches[2]
logger := logger.With(slog.F("line_number", i+1),
slog.F("match_channel", matchChannel), slog.F("match", match))
logger.Debug(ctx, "autoversion pragma detected")
if matchChannel != channel {
logger.Debug(ctx, "channel mismatch, skipping")
continue
}
logger.Info(ctx, "autoversion pragma found with channel match")
match = strings.Replace(match, "[version]", `(?P<version>[0-9]+\.[0-9]+\.[0-9]+)`, 1)
logger.Debug(ctx, "compiling match regexp", slog.F("match", match))
matchRe, err = regexp.Compile(match)
if err != nil {
return xerrors.Errorf("regexp compile failed: %w", err)
}
}
if matchRe != nil {
// Apply matchRe and find the group named "version", then replace it
// with the new version.
if match := matchRe.FindStringSubmatchIndex(line); match != nil {
vg := matchRe.SubexpIndex("version")
if vg == -1 {
logger.Error(ctx, "version group not found in match",
slog.F("num_subexp", matchRe.NumSubexp()),
slog.F("subexp_names", matchRe.SubexpNames()),
slog.F("match", match))
return xerrors.Errorf("bug: version group not found in match")
}
start := match[vg*2]
end := match[vg*2+1]
logger.Info(ctx, "updating version number", slog.F("line_number", i+1), slog.F("match_start", start), slog.F("match_end", end), slog.F("old_version", line[start:end]))
lines[i] = line[:start] + version + line[end:]
matchRe = nil
break
}
}
}
if matchRe != nil {
return xerrors.Errorf("match not found in file")
}
updated := strings.Join(lines, "\n")
// Only update the file if there are changes.
diff := cmp.Diff(string(contents), updated)
if diff == "" {
return nil
}
if !r.dryRun {
if err := afero.WriteFile(r.fs, file, []byte(updated), 0o644); err != nil {
return xerrors.Errorf("write file failed: %w", err)
}
logger.Info(ctx, "file autoversioned")
} else {
logger.Info(ctx, "dry-run: file not updated", slog.F("uncommitted_changes", diff))
}
return nil
}
func run(command string, args ...string) (string, error) {
cmd := exec.Command(command, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", xerrors.Errorf("command failed: %q: %w\n%s", fmt.Sprintf("%s %s", command, strings.Join(args, " ")), err, out)
}
return strings.TrimSpace(string(out)), nil
}