mirror of https://github.com/coder/coder.git
chore(scripts): add release autoversion to bump releases in docs (#13063)
This PR adds a command to bump versions in docs/markdown. This is still standalone and needs to be wired up. For now, I'm planning on putting this in `scripts/release.sh` (checkout main -> autoversion (this command) -> commit -> submit PR). It would be pretty neat to make it a GH actions that's triggered on release though, something for the future. Part of #12465
This commit is contained in:
parent
c933c75aa7
commit
46dced9cfe
|
@ -128,6 +128,8 @@ locally in order to log in and manage templates.
|
|||
|
||||
For the **mainline** Coder release:
|
||||
|
||||
<!-- autoversion(mainline): "--version [version]" -->
|
||||
|
||||
```shell
|
||||
helm install coder coder-v2/coder \
|
||||
--namespace coder \
|
||||
|
@ -137,6 +139,8 @@ locally in order to log in and manage templates.
|
|||
|
||||
For the **stable** Coder release:
|
||||
|
||||
<!-- autoversion(stable): "--version [version]" -->
|
||||
|
||||
```shell
|
||||
helm install coder coder-v2/coder \
|
||||
--namespace coder \
|
||||
|
|
|
@ -4,13 +4,18 @@ 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"
|
||||
|
||||
|
@ -26,42 +31,89 @@ const (
|
|||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.Make(sloghuman.Sink(os.Stderr)).Leveled(slog.LevelDebug)
|
||||
// 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)
|
||||
}
|
||||
|
||||
var ghToken string
|
||||
var dryRun bool
|
||||
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: "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 <version>",
|
||||
Short: "Promote version to stable.",
|
||||
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 !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 <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
|
||||
}
|
||||
|
@ -72,24 +124,55 @@ func main() {
|
|||
},
|
||||
}
|
||||
|
||||
err := cmd.Invoke().WithOS().Run()
|
||||
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)
|
||||
r.logger.Error(context.Background(), "release command failed", "err", 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 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("version", version))
|
||||
|
||||
logger.Info(ctx, "checking current stable release")
|
||||
|
||||
|
@ -161,7 +244,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 +304,123 @@ 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:
|
||||
//
|
||||
// <!-- 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", "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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_removeMainlineBlurb(t *testing.T) {
|
||||
|
@ -115,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -131,6 +135,44 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = r.autoversion(ctx, "stable", "v2.9.4")
|
||||
require.NoError(t, err)
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(dir, "docs", "*.md"))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, file := range files {
|
||||
file := file
|
||||
t.Run(file, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := afero.ReadFile(fs, file)
|
||||
require.NoError(t, err)
|
||||
|
||||
want, err := afero.ReadFile(fs, file+".golden")
|
||||
require.NoError(t, err)
|
||||
|
||||
if diff := cmp.Diff(string(got), string(want)); diff != "" {
|
||||
require.Failf(t, "mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
# Some documentation
|
||||
|
||||
1. Run the following command to install the chart in your cluster.
|
||||
|
||||
For the **mainline** Coder release:
|
||||
|
||||
<!-- autoversion(mainline): "--version [version]" -->
|
||||
|
||||
```shell
|
||||
helm install coder coder-v2/coder \
|
||||
--namespace coder \
|
||||
--values values.yaml \
|
||||
--version 2.10.0
|
||||
```
|
||||
|
||||
For the **stable** Coder release:
|
||||
|
||||
<!-- autoversion(stable): "--version [version]" -->
|
||||
|
||||
```shell
|
||||
helm install coder coder-v2/coder \
|
||||
--namespace coder \
|
||||
--values values.yaml \
|
||||
--version 2.9.1
|
||||
```
|
|
@ -0,0 +1,25 @@
|
|||
# Some documentation
|
||||
|
||||
1. Run the following command to install the chart in your cluster.
|
||||
|
||||
For the **mainline** Coder release:
|
||||
|
||||
<!-- autoversion(mainline): "--version [version]" -->
|
||||
|
||||
```shell
|
||||
helm install coder coder-v2/coder \
|
||||
--namespace coder \
|
||||
--values values.yaml \
|
||||
--version 2.11.1
|
||||
```
|
||||
|
||||
For the **stable** Coder release:
|
||||
|
||||
<!-- autoversion(stable): "--version [version]" -->
|
||||
|
||||
```shell
|
||||
helm install coder coder-v2/coder \
|
||||
--namespace coder \
|
||||
--values values.yaml \
|
||||
--version 2.9.4
|
||||
```
|
Loading…
Reference in New Issue