diff --git a/go.mod b/go.mod index 8c6295d3d1..a6db7c7698 100644 --- a/go.mod +++ b/go.mod @@ -218,6 +218,7 @@ require ( github.com/benbjohnson/clock v1.3.5 github.com/coder/serpent v0.7.0 github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 + github.com/google/go-github/v61 v61.0.0 ) require ( diff --git a/go.sum b/go.sum index 1c84d915df..04995b80d0 100644 --- a/go.sum +++ b/go.sum @@ -469,6 +469,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v43 v43.0.1-0.20220414155304-00e42332e405 h1:DdHws/YnnPrSywrjNYu2lEHqYHWp/LnEx56w59esd54= github.com/google/go-github/v43 v43.0.1-0.20220414155304-00e42332e405/go.mod h1:4RgUDSnsxP19d65zJWqvqJ/poJxBCvmna50eXmIvoR8= +github.com/google/go-github/v61 v61.0.0 h1:VwQCBwhyE9JclCI+22/7mLB1PuU9eowCXKY5pNlu1go= +github.com/google/go-github/v61 v61.0.0/go.mod h1:0WR+KmsWX75G2EbpyGsGmradjo3IiciuI4BmdVCobQY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/scripts/release/main.go b/scripts/release/main.go new file mode 100644 index 0000000000..e97ce04ba0 --- /dev/null +++ b/scripts/release/main.go @@ -0,0 +1,223 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "slices" + "strings" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-github/v61/github" + "golang.org/x/mod/semver" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/serpent" +) + +const ( + owner = "coder" + repo = "coder" +) + +func main() { + logger := slog.Make(sloghuman.Sink(os.Stderr)).Leveled(slog.LevelDebug) + + var ghToken string + var dryRun bool + + cmd := serpent.Command{ + Use: "release ", + Short: "Prepare, create and publish releases.", + Options: serpent.OptionSet{ + { + Flag: "gh-token", + Description: "GitHub personal access token.", + Env: "GH_TOKEN", + Value: serpent.StringOf(&ghToken), + }, + { + Flag: "dry-run", + FlagShorthand: "n", + Description: "Do not make any changes, only print what would be done.", + Value: serpent.BoolOf(&dryRun), + }, + }, + Children: []*serpent.Command{ + { + Use: "promote ", + Short: "Promote version to stable.", + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + if len(inv.Args) == 0 { + return xerrors.New("version argument missing") + } + if !dryRun && ghToken == "" { + return xerrors.New("GitHub personal access token is required, use --gh-token or GH_TOKEN") + } + + err := promoteVersionToStable(ctx, inv, logger, ghToken, dryRun, inv.Args[0]) + if err != nil { + return err + } + + return nil + }, + }, + }, + } + + err := cmd.Invoke().WithOS().Run() + if err != nil { + if errors.Is(err, cliui.Canceled) { + os.Exit(1) + } + logger.Error(context.Background(), "release command failed", "err", err) + os.Exit(1) + } +} + +//nolint:revive // Allow dryRun control flag. +func promoteVersionToStable(ctx context.Context, inv *serpent.Invocation, logger slog.Logger, ghToken string, dryRun bool, version string) error { + client := github.NewClient(nil) + if ghToken != "" { + client = client.WithAuthToken(ghToken) + } + + logger = logger.With(slog.F("dry_run", dryRun), 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. + updatedNewStable := cloneRelease(newStable) + + updatedBody := removeMainlineBlurb(newStable.GetBody()) + updatedBody = addStableSince(time.Now().UTC(), updatedBody) + updatedNewStable.Body = github.String(updatedBody) + updatedNewStable.Prerelease = github.Bool(false) + updatedNewStable.Draft = github.Bool(false) + if !dryRun { + _, _, err = client.Repositories.EditRelease(ctx, owner, repo, newStable.GetID(), newStable) + if err != nil { + return xerrors.Errorf("edit release failed: %w", err) + } + logger.Info(ctx, "selected version promoted to stable", "url", newStable.GetHTMLURL()) + } else { + logger.Info(ctx, "dry-run: release not updated", "uncommitted_changes", cmp.Diff(newStable, updatedNewStable)) + } + + return nil +} + +func cloneRelease(r *github.RepositoryRelease) *github.RepositoryRelease { + rr := *r + return &rr +} + +// addStableSince adds a stable since note to the release body. +// +// Example: +// +// > ## Stable (since April 23, 2024) +func addStableSince(date time.Time, body string) string { + 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/v2/latest/install/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") +} diff --git a/scripts/release/main_test.go b/scripts/release/main_test.go new file mode 100644 index 0000000000..f4c6a4e4d8 --- /dev/null +++ b/scripts/release/main_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func Test_removeMainlineBlurb(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + want string + }{ + { + name: "NoMainlineBlurb", + body: `## Changelog + +### Chores + +- Add support for additional Azure Instance Identity RSA Certificates (#13028) (@kylecarbs) + +Compare: [` + "`" + `v2.10.1...v2.10.2` + "`" + `](https://github.com/coder/coder/compare/v2.10.1...v2.10.2) + +## Container image + +- ` + "`" + `docker pull ghcr.io/coder/coder:v2.10.2` + "`" + ` + +## Install/upgrade + +Refer to our docs to [install](https://coder.com/docs/v2/latest/install) or [upgrade](https://coder.com/docs/v2/latest/admin/upgrade) Coder, or use a release asset below. +`, + want: `## Changelog + +### Chores + +- Add support for additional Azure Instance Identity RSA Certificates (#13028) (@kylecarbs) + +Compare: [` + "`" + `v2.10.1...v2.10.2` + "`" + `](https://github.com/coder/coder/compare/v2.10.1...v2.10.2) + +## Container image + +- ` + "`" + `docker pull ghcr.io/coder/coder:v2.10.2` + "`" + ` + +## Install/upgrade + +Refer to our docs to [install](https://coder.com/docs/v2/latest/install) or [upgrade](https://coder.com/docs/v2/latest/admin/upgrade) Coder, or use a release asset below. +`, + }, + { + name: "WithMainlineBlurb", + body: `## Changelog + +> [!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/v2/latest/install/releases). + +### Chores + +- Add support for additional Azure Instance Identity RSA Certificates (#13028) (@kylecarbs) + +Compare: [` + "`" + `v2.10.1...v2.10.2` + "`" + `](https://github.com/coder/coder/compare/v2.10.1...v2.10.2) + +## Container image + +- ` + "`" + `docker pull ghcr.io/coder/coder:v2.10.2` + "`" + ` + +## Install/upgrade + +Refer to our docs to [install](https://coder.com/docs/v2/latest/install) or [upgrade](https://coder.com/docs/v2/latest/admin/upgrade) Coder, or use a release asset below. +`, + want: `## Changelog + +### Chores + +- Add support for additional Azure Instance Identity RSA Certificates (#13028) (@kylecarbs) + +Compare: [` + "`" + `v2.10.1...v2.10.2` + "`" + `](https://github.com/coder/coder/compare/v2.10.1...v2.10.2) + +## Container image + +- ` + "`" + `docker pull ghcr.io/coder/coder:v2.10.2` + "`" + ` + +## Install/upgrade + +Refer to our docs to [install](https://coder.com/docs/v2/latest/install) or [upgrade](https://coder.com/docs/v2/latest/admin/upgrade) Coder, or use a release asset below. +`, + }, + { + name: "EntireQuotedBlurbIsRemoved", + body: `## Changelog + +> [!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/v2/latest/install/releases). +> This is an extended note. +> This is another extended note. + +### Best release yet! + +Enjoy. +`, + want: `## Changelog + +### Best release yet! + +Enjoy. +`, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if diff := cmp.Diff(removeMainlineBlurb(tt.body), tt.want); diff != "" { + t.Errorf("removeMainlineBlurb() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func Test_addStableSince(t *testing.T) { + t.Parallel() + + date := time.Date(2024, time.April, 23, 0, 0, 0, 0, time.UTC) + body := "## Changelog" + + expected := "> ## Stable (since April 23, 2024)\n\n## Changelog" + result := addStableSince(date, body) + + if diff := cmp.Diff(expected, result); diff != "" { + t.Errorf("addStableSince() mismatch (-want +got):\n%s", diff) + } +} diff --git a/scripts/release_promote_stable.sh b/scripts/release_promote_stable.sh new file mode 100755 index 0000000000..33b55d3855 --- /dev/null +++ b/scripts/release_promote_stable.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -euo pipefail +# shellcheck source=scripts/lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# This script is a convenience wrapper around the release promote command. +# +# Sed hack to make help text look like this script. +exec go run "${SCRIPT_DIR}/release" promote "$@"