From ab7f4973d69f80fd521446b1ca2e3c736c5bdf22 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 24 Apr 2024 14:27:32 +0000 Subject: [PATCH 1/6] chore(scripts): add release autoversion to bump releases in docs --- docs/install/kubernetes.md | 2 + scripts/release/main.go | 195 ++++++++++++++++++++++++++++++++--- scripts/release/main_test.go | 50 +++++++++ 3 files changed, 232 insertions(+), 15 deletions(-) diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index 780a525cfd09e..b372daaa81550 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -128,6 +128,7 @@ locally in order to log in and manage templates. For the **mainline** Coder release: + ```shell helm install coder coder-v2/coder \ --namespace coder \ @@ -137,6 +138,7 @@ locally in order to log in and manage templates. For the **stable** Coder release: + ```shell helm install coder coder-v2/coder \ --namespace coder \ diff --git a/scripts/release/main.go b/scripts/release/main.go index e97ce04ba0d97..74007ecdb37bc 100644 --- a/scripts/release/main.go +++ b/scripts/release/main.go @@ -4,13 +4,17 @@ import ( "context" "errors" "fmt" + "io/fs" "os" + "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" @@ -26,42 +30,75 @@ const ( ) func main() { - logger := slog.Make(sloghuman.Sink(os.Stderr)).Leveled(slog.LevelDebug) + r := &releaseCommand{ + fs: afero.NewOsFs(), + logger: slog.Make(sloghuman.Sink(os.Stderr)).Leveled(slog.LevelInfo), + } - var ghToken string - var dryRun bool + var channel string cmd := serpent.Command{ Use: "release ", Short: "Prepare, create and publish releases.", Options: serpent.OptionSet{ + { + Flag: "debug", + Description: "Enable debug logging.", + Value: serpent.BoolOf(&r.debug), + }, { Flag: "gh-token", Description: "GitHub personal access token.", Env: "GH_TOKEN", - Value: serpent.StringOf(&ghToken), + 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(&dryRun), + Value: serpent.BoolOf(&r.dryRun), }, }, Children: []*serpent.Command{ { - Use: "promote ", - Short: "Promote version to stable.", + Use: "promote ", + 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 !dryRun && ghToken == "" { + if !r.dryRun && r.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]) + err := r.promoteVersionToStable(ctx, inv, inv.Args[0]) + if err != nil { + return err + } + + return nil + }, + }, + { + Use: "autoversion ", + 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 } @@ -77,19 +114,36 @@ func main() { if errors.Is(err, cliui.Canceled) { os.Exit(1) } - logger.Error(context.Background(), "release command failed", "err", err) + r.logger.Error(context.Background(), "release command failed", "err", err) os.Exit(1) } } +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) + } + return next(inv) + } +} + //nolint:revive // Allow dryRun control flag. -func promoteVersionToStable(ctx context.Context, inv *serpent.Invocation, logger slog.Logger, ghToken string, dryRun bool, version string) error { +func (r *releaseCommand) promoteVersionToStable(ctx context.Context, inv *serpent.Invocation, version string) error { client := github.NewClient(nil) - if ghToken != "" { - client = client.WithAuthToken(ghToken) + if r.ghToken != "" { + client = client.WithAuthToken(r.ghToken) } - logger = logger.With(slog.F("dry_run", dryRun), slog.F("version", version)) + logger := r.logger.With(slog.F("dry_run", r.dryRun), slog.F("version", version)) logger.Info(ctx, "checking current stable release") @@ -161,7 +215,7 @@ func promoteVersionToStable(ctx context.Context, inv *serpent.Invocation, logger updatedNewStable.Body = github.String(updatedBody) updatedNewStable.Prerelease = github.Bool(false) updatedNewStable.Draft = github.Bool(false) - if !dryRun { + if !r.dryRun { _, _, err = client.Repositories.EditRelease(ctx, owner, repo, newStable.GetID(), newStable) if err != nil { return xerrors.Errorf("edit release failed: %w", err) @@ -221,3 +275,114 @@ func removeMainlineBlurb(body string) string { 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: +// +// +// +// 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(``) + +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[0-9]+\.[0-9]+\.[0-9]+)`, 1) + logger.Debug(ctx, "compiling match regexp", "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. + // Utilize the index where the match was found to replace the correct part. The only + // match group is the version. + if match := matchRe.FindStringSubmatchIndex(line); match != nil { + logger.Info(ctx, "updating version number", "line_number", i+1, "match", match) + lines[i] = line[:match[2]] + version + line[match[3]:] + 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", "uncommitted_changes", diff) + } + + return nil +} diff --git a/scripts/release/main_test.go b/scripts/release/main_test.go index f4c6a4e4d8d7c..f76bc355af223 100644 --- a/scripts/release/main_test.go +++ b/scripts/release/main_test.go @@ -1,10 +1,13 @@ package main import ( + "context" + "path/filepath" "testing" "time" "github.com/google/go-cmp/cmp" + "github.com/spf13/afero" ) func Test_removeMainlineBlurb(t *testing.T) { @@ -134,3 +137,50 @@ func Test_addStableSince(t *testing.T) { t.Errorf("addStableSince() mismatch (-want +got):\n%s", diff) } } + +func Test_release_autoversion(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dir := filepath.Join("testdata", "autoversion") + + fs := afero.NewCopyOnWriteFs(afero.NewOsFs(), afero.NewMemMapFs()) + r := releaseCommand{ + fs: afero.NewBasePathFs(fs, dir), + } + + err := r.autoversion(ctx, "mainline", "v2.11.1") + if err != nil { + t.Fatal(err) + } + + err = r.autoversion(ctx, "stable", "v2.9.4") + if err != nil { + t.Fatal(err) + } + + files, err := filepath.Glob(filepath.Join(dir, "docs", "*.md")) + if err != nil { + t.Fatal(err) + } + + for _, file := range files { + file := file + t.Run(file, func(t *testing.T) { + t.Parallel() + + got, err := afero.ReadFile(fs, file) + if err != nil { + t.Fatal(err) + } + want, err := afero.ReadFile(fs, file+".golden") + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(string(got), string(want)); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} From a3bd315805587a9111e726da9e5f96ffa6dcd722 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 24 Apr 2024 14:42:53 +0000 Subject: [PATCH 2/6] add testdata --- .../testdata/autoversion/docs/kubernetes.md | 23 +++++++++++++++++++ .../autoversion/docs/kubernetes.md.golden | 23 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 scripts/release/testdata/autoversion/docs/kubernetes.md create mode 100644 scripts/release/testdata/autoversion/docs/kubernetes.md.golden diff --git a/scripts/release/testdata/autoversion/docs/kubernetes.md b/scripts/release/testdata/autoversion/docs/kubernetes.md new file mode 100644 index 0000000000000..107ad30fe648e --- /dev/null +++ b/scripts/release/testdata/autoversion/docs/kubernetes.md @@ -0,0 +1,23 @@ +# Some documentation + +1. Run the following command to install the chart in your cluster. + + For the **mainline** Coder release: + + + ```shell + helm install coder coder-v2/coder \ + --namespace coder \ + --values values.yaml \ + --version 2.10.0 + ``` + + For the **stable** Coder release: + + + ```shell + helm install coder coder-v2/coder \ + --namespace coder \ + --values values.yaml \ + --version 2.9.1 + ``` diff --git a/scripts/release/testdata/autoversion/docs/kubernetes.md.golden b/scripts/release/testdata/autoversion/docs/kubernetes.md.golden new file mode 100644 index 0000000000000..58850965bd553 --- /dev/null +++ b/scripts/release/testdata/autoversion/docs/kubernetes.md.golden @@ -0,0 +1,23 @@ +# Some documentation + +1. Run the following command to install the chart in your cluster. + + For the **mainline** Coder release: + + + ```shell + helm install coder coder-v2/coder \ + --namespace coder \ + --values values.yaml \ + --version 2.11.1 + ``` + + For the **stable** Coder release: + + + ```shell + helm install coder coder-v2/coder \ + --namespace coder \ + --values values.yaml \ + --version 2.9.4 + ``` From d4df4e20e3254b3e5a568323dc409f7c78789ca4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 24 Apr 2024 16:43:21 +0000 Subject: [PATCH 3/6] amend some pr comments --- docs/install/kubernetes.md | 2 ++ scripts/release/main.go | 7 +++++-- scripts/release/testdata/autoversion/docs/kubernetes.md | 2 ++ .../release/testdata/autoversion/docs/kubernetes.md.golden | 2 ++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index b372daaa81550..796c59df2dc62 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -129,6 +129,7 @@ locally in order to log in and manage templates. For the **mainline** Coder release: + ```shell helm install coder coder-v2/coder \ --namespace coder \ @@ -139,6 +140,7 @@ locally in order to log in and manage templates. For the **stable** Coder release: + ```shell helm install coder coder-v2/coder \ --namespace coder \ diff --git a/scripts/release/main.go b/scripts/release/main.go index 74007ecdb37bc..113429143f0d0 100644 --- a/scripts/release/main.go +++ b/scripts/release/main.go @@ -132,6 +132,9 @@ func (r *releaseCommand) debugMiddleware(next serpent.HandlerFunc) serpent.Handl 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) } } @@ -143,7 +146,7 @@ func (r *releaseCommand) promoteVersionToStable(ctx context.Context, inv *serpen client = client.WithAuthToken(r.ghToken) } - logger := r.logger.With(slog.F("dry_run", r.dryRun), slog.F("version", version)) + logger := r.logger.With(slog.F("version", version)) logger.Info(ctx, "checking current stable release") @@ -308,7 +311,7 @@ func (r *releaseCommand) autoversion(ctx context.Context, channel, version strin // // Example: // -// +// // // 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. diff --git a/scripts/release/testdata/autoversion/docs/kubernetes.md b/scripts/release/testdata/autoversion/docs/kubernetes.md index 107ad30fe648e..5cfaf91ba7e18 100644 --- a/scripts/release/testdata/autoversion/docs/kubernetes.md +++ b/scripts/release/testdata/autoversion/docs/kubernetes.md @@ -5,6 +5,7 @@ For the **mainline** Coder release: + ```shell helm install coder coder-v2/coder \ --namespace coder \ @@ -15,6 +16,7 @@ For the **stable** Coder release: + ```shell helm install coder coder-v2/coder \ --namespace coder \ diff --git a/scripts/release/testdata/autoversion/docs/kubernetes.md.golden b/scripts/release/testdata/autoversion/docs/kubernetes.md.golden index 58850965bd553..26b3d5bd88564 100644 --- a/scripts/release/testdata/autoversion/docs/kubernetes.md.golden +++ b/scripts/release/testdata/autoversion/docs/kubernetes.md.golden @@ -5,6 +5,7 @@ For the **mainline** Coder release: + ```shell helm install coder coder-v2/coder \ --namespace coder \ @@ -15,6 +16,7 @@ For the **stable** Coder release: + ```shell helm install coder coder-v2/coder \ --namespace coder \ From db333c69de2d92ae89e82e40a027dc7292da4b29 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 24 Apr 2024 16:47:24 +0000 Subject: [PATCH 4/6] internal test --- scripts/release/{main_test.go => main_internal_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename scripts/release/{main_test.go => main_internal_test.go} (100%) diff --git a/scripts/release/main_test.go b/scripts/release/main_internal_test.go similarity index 100% rename from scripts/release/main_test.go rename to scripts/release/main_internal_test.go From b436540e8e7ec0c2887461efff8a9da293ba311d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 24 Apr 2024 16:51:29 +0000 Subject: [PATCH 5/6] require --- scripts/release/main_internal_test.go | 28 ++++++++++----------------- 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/scripts/release/main_internal_test.go b/scripts/release/main_internal_test.go index f76bc355af223..74a6d46d05c8a 100644 --- a/scripts/release/main_internal_test.go +++ b/scripts/release/main_internal_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/spf13/afero" + "github.com/stretchr/testify/require" ) func Test_removeMainlineBlurb(t *testing.T) { @@ -118,7 +119,7 @@ Enjoy. 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) + require.Fail(t, "removeMainlineBlurb() mismatch (-want +got):\n%s", diff) } }) } @@ -134,7 +135,7 @@ func Test_addStableSince(t *testing.T) { result := addStableSince(date, body) if diff := cmp.Diff(expected, result); diff != "" { - t.Errorf("addStableSince() mismatch (-want +got):\n%s", diff) + require.Fail(t, "addStableSince() mismatch (-want +got):\n%s", diff) } } @@ -150,19 +151,13 @@ func Test_release_autoversion(t *testing.T) { } err := r.autoversion(ctx, "mainline", "v2.11.1") - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) err = r.autoversion(ctx, "stable", "v2.9.4") - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) files, err := filepath.Glob(filepath.Join(dir, "docs", "*.md")) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) for _, file := range files { file := file @@ -170,16 +165,13 @@ func Test_release_autoversion(t *testing.T) { t.Parallel() got, err := afero.ReadFile(fs, file) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) + want, err := afero.ReadFile(fs, file+".golden") - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) if diff := cmp.Diff(string(got), string(want)); diff != "" { - t.Errorf("mismatch (-want +got):\n%s", diff) + require.Failf(t, "mismatch (-want +got):\n%s", diff) } }) } From 1fdff416fe8742e8dcd94e8214744f3e19d037e1 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 24 Apr 2024 17:27:54 +0000 Subject: [PATCH 6/6] ensure run in coder repo, use base path fs --- scripts/release/main.go | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/scripts/release/main.go b/scripts/release/main.go index 113429143f0d0..79580eee6127b 100644 --- a/scripts/release/main.go +++ b/scripts/release/main.go @@ -6,6 +6,7 @@ import ( "fmt" "io/fs" "os" + "os/exec" "path/filepath" "regexp" "slices" @@ -30,8 +31,22 @@ const ( ) 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.NewOsFs(), + fs: afero.NewBasePathFs(afero.NewOsFs(), toplevel), logger: slog.Make(sloghuman.Sink(os.Stderr)).Leveled(slog.LevelInfo), } @@ -109,7 +124,7 @@ func main() { }, } - err := cmd.Invoke().WithOS().Run() + err = cmd.Invoke().WithOS().Run() if err != nil { if errors.Is(err, cliui.Canceled) { os.Exit(1) @@ -119,6 +134,17 @@ func main() { } } +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 @@ -389,3 +415,12 @@ func (r *releaseCommand) autoversionFile(ctx context.Context, file, channel, ver 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 +}