Skip to content

Commit 46dced9

Browse files
authored
chore(scripts): add release autoversion to bump releases in docs (coder#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 coder#12465
1 parent c933c75 commit 46dced9

File tree

5 files changed

+317
-18
lines changed

5 files changed

+317
-18
lines changed

docs/install/kubernetes.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ locally in order to log in and manage templates.
128128

129129
For the **mainline** Coder release:
130130

131+
<!-- autoversion(mainline): "--version [version]" -->
132+
131133
```shell
132134
helm install coder coder-v2/coder \
133135
--namespace coder \
@@ -137,6 +139,8 @@ locally in order to log in and manage templates.
137139

138140
For the **stable** Coder release:
139141

142+
<!-- autoversion(stable): "--version [version]" -->
143+
140144
```shell
141145
helm install coder coder-v2/coder \
142146
--namespace coder \

scripts/release/main.go

Lines changed: 219 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io/fs"
78
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"regexp"
812
"slices"
913
"strings"
1014
"time"
1115

1216
"github.com/google/go-cmp/cmp"
1317
"github.com/google/go-github/v61/github"
18+
"github.com/spf13/afero"
1419
"golang.org/x/mod/semver"
1520
"golang.org/x/xerrors"
1621

@@ -26,42 +31,89 @@ const (
2631
)
2732

2833
func main() {
29-
logger := slog.Make(sloghuman.Sink(os.Stderr)).Leveled(slog.LevelDebug)
34+
// Pre-flight checks.
35+
toplevel, err := run("git", "rev-parse", "--show-toplevel")
36+
if err != nil {
37+
_, _ = fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
38+
_, _ = fmt.Fprintf(os.Stderr, "NOTE: This command must be run in the coder/coder repository.\n")
39+
os.Exit(1)
40+
}
41+
42+
if err = checkCoderRepo(toplevel); err != nil {
43+
_, _ = fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
44+
_, _ = fmt.Fprintf(os.Stderr, "NOTE: This command must be run in the coder/coder repository.\n")
45+
os.Exit(1)
46+
}
3047

31-
var ghToken string
32-
var dryRun bool
48+
r := &releaseCommand{
49+
fs: afero.NewBasePathFs(afero.NewOsFs(), toplevel),
50+
logger: slog.Make(sloghuman.Sink(os.Stderr)).Leveled(slog.LevelInfo),
51+
}
52+
53+
var channel string
3354

3455
cmd := serpent.Command{
3556
Use: "release <subcommand>",
3657
Short: "Prepare, create and publish releases.",
3758
Options: serpent.OptionSet{
59+
{
60+
Flag: "debug",
61+
Description: "Enable debug logging.",
62+
Value: serpent.BoolOf(&r.debug),
63+
},
3864
{
3965
Flag: "gh-token",
4066
Description: "GitHub personal access token.",
4167
Env: "GH_TOKEN",
42-
Value: serpent.StringOf(&ghToken),
68+
Value: serpent.StringOf(&r.ghToken),
4369
},
4470
{
4571
Flag: "dry-run",
4672
FlagShorthand: "n",
4773
Description: "Do not make any changes, only print what would be done.",
48-
Value: serpent.BoolOf(&dryRun),
74+
Value: serpent.BoolOf(&r.dryRun),
4975
},
5076
},
5177
Children: []*serpent.Command{
5278
{
53-
Use: "promote <version>",
54-
Short: "Promote version to stable.",
79+
Use: "promote <version>",
80+
Short: "Promote version to stable.",
81+
Middleware: r.debugMiddleware, // Serpent doesn't support this on parent.
5582
Handler: func(inv *serpent.Invocation) error {
5683
ctx := inv.Context()
5784
if len(inv.Args) == 0 {
5885
return xerrors.New("version argument missing")
5986
}
60-
if !dryRun && ghToken == "" {
87+
if !r.dryRun && r.ghToken == "" {
6188
return xerrors.New("GitHub personal access token is required, use --gh-token or GH_TOKEN")
6289
}
6390

64-
err := promoteVersionToStable(ctx, inv, logger, ghToken, dryRun, inv.Args[0])
91+
err := r.promoteVersionToStable(ctx, inv, inv.Args[0])
92+
if err != nil {
93+
return err
94+
}
95+
96+
return nil
97+
},
98+
},
99+
{
100+
Use: "autoversion <version>",
101+
Short: "Automatically update the provided channel to version in markdown files.",
102+
Options: serpent.OptionSet{
103+
{
104+
Flag: "channel",
105+
Description: "Channel to update.",
106+
Value: serpent.EnumOf(&channel, "mainline", "stable"),
107+
},
108+
},
109+
Middleware: r.debugMiddleware, // Serpent doesn't support this on parent.
110+
Handler: func(inv *serpent.Invocation) error {
111+
ctx := inv.Context()
112+
if len(inv.Args) == 0 {
113+
return xerrors.New("version argument missing")
114+
}
115+
116+
err := r.autoversion(ctx, channel, inv.Args[0])
65117
if err != nil {
66118
return err
67119
}
@@ -72,24 +124,55 @@ func main() {
72124
},
73125
}
74126

75-
err := cmd.Invoke().WithOS().Run()
127+
err = cmd.Invoke().WithOS().Run()
76128
if err != nil {
77129
if errors.Is(err, cliui.Canceled) {
78130
os.Exit(1)
79131
}
80-
logger.Error(context.Background(), "release command failed", "err", err)
132+
r.logger.Error(context.Background(), "release command failed", "err", err)
81133
os.Exit(1)
82134
}
83135
}
84136

137+
func checkCoderRepo(path string) error {
138+
remote, err := run("git", "-C", path, "remote", "get-url", "origin")
139+
if err != nil {
140+
return xerrors.Errorf("get remote failed: %w", err)
141+
}
142+
if !strings.Contains(remote, "github.com") || !strings.Contains(remote, "coder/coder") {
143+
return xerrors.Errorf("origin is not set to the coder/coder repository on github.com")
144+
}
145+
return nil
146+
}
147+
148+
type releaseCommand struct {
149+
fs afero.Fs
150+
logger slog.Logger
151+
debug bool
152+
ghToken string
153+
dryRun bool
154+
}
155+
156+
func (r *releaseCommand) debugMiddleware(next serpent.HandlerFunc) serpent.HandlerFunc {
157+
return func(inv *serpent.Invocation) error {
158+
if r.debug {
159+
r.logger = r.logger.Leveled(slog.LevelDebug)
160+
}
161+
if r.dryRun {
162+
r.logger = r.logger.With(slog.F("dry_run", true))
163+
}
164+
return next(inv)
165+
}
166+
}
167+
85168
//nolint:revive // Allow dryRun control flag.
86-
func promoteVersionToStable(ctx context.Context, inv *serpent.Invocation, logger slog.Logger, ghToken string, dryRun bool, version string) error {
169+
func (r *releaseCommand) promoteVersionToStable(ctx context.Context, inv *serpent.Invocation, version string) error {
87170
client := github.NewClient(nil)
88-
if ghToken != "" {
89-
client = client.WithAuthToken(ghToken)
171+
if r.ghToken != "" {
172+
client = client.WithAuthToken(r.ghToken)
90173
}
91174

92-
logger = logger.With(slog.F("dry_run", dryRun), slog.F("version", version))
175+
logger := r.logger.With(slog.F("version", version))
93176

94177
logger.Info(ctx, "checking current stable release")
95178

@@ -161,7 +244,7 @@ func promoteVersionToStable(ctx context.Context, inv *serpent.Invocation, logger
161244
updatedNewStable.Body = github.String(updatedBody)
162245
updatedNewStable.Prerelease = github.Bool(false)
163246
updatedNewStable.Draft = github.Bool(false)
164-
if !dryRun {
247+
if !r.dryRun {
165248
_, _, err = client.Repositories.EditRelease(ctx, owner, repo, newStable.GetID(), newStable)
166249
if err != nil {
167250
return xerrors.Errorf("edit release failed: %w", err)
@@ -221,3 +304,123 @@ func removeMainlineBlurb(body string) string {
221304

222305
return strings.Join(newBody, "\n")
223306
}
307+
308+
// autoversion automatically updates the provided channel to version in markdown
309+
// files.
310+
func (r *releaseCommand) autoversion(ctx context.Context, channel, version string) error {
311+
var files []string
312+
313+
// For now, scope this to docs, perhaps we include README.md in the future.
314+
if err := afero.Walk(r.fs, "docs", func(path string, _ fs.FileInfo, err error) error {
315+
if err != nil {
316+
return err
317+
}
318+
if strings.EqualFold(filepath.Ext(path), ".md") {
319+
files = append(files, path)
320+
}
321+
return nil
322+
}); err != nil {
323+
return xerrors.Errorf("walk failed: %w", err)
324+
}
325+
326+
for _, file := range files {
327+
err := r.autoversionFile(ctx, file, channel, version)
328+
if err != nil {
329+
return xerrors.Errorf("autoversion file failed: %w", err)
330+
}
331+
}
332+
333+
return nil
334+
}
335+
336+
// autoversionMarkdownPragmaRe matches the autoversion pragma in markdown files.
337+
//
338+
// Example:
339+
//
340+
// <!-- autoversion(stable): "--version [version]" -->
341+
//
342+
// The channel is the first capture group and the match string is the second
343+
// capture group. The string "[version]" is replaced with the new version.
344+
var autoversionMarkdownPragmaRe = regexp.MustCompile(`<!-- ?autoversion\(([^)]+)\): ?"([^"]+)" ?-->`)
345+
346+
func (r *releaseCommand) autoversionFile(ctx context.Context, file, channel, version string) error {
347+
version = strings.TrimPrefix(version, "v")
348+
logger := r.logger.With(slog.F("file", file), slog.F("channel", channel), slog.F("version", version))
349+
350+
logger.Debug(ctx, "checking file for autoversion pragma")
351+
352+
contents, err := afero.ReadFile(r.fs, file)
353+
if err != nil {
354+
return xerrors.Errorf("read file failed: %w", err)
355+
}
356+
357+
lines := strings.Split(string(contents), "\n")
358+
var matchRe *regexp.Regexp
359+
for i, line := range lines {
360+
if autoversionMarkdownPragmaRe.MatchString(line) {
361+
matches := autoversionMarkdownPragmaRe.FindStringSubmatch(line)
362+
matchChannel := matches[1]
363+
match := matches[2]
364+
365+
logger := logger.With(slog.F("line_number", i+1), slog.F("match_channel", matchChannel), slog.F("match", match))
366+
367+
logger.Debug(ctx, "autoversion pragma detected")
368+
369+
if matchChannel != channel {
370+
logger.Debug(ctx, "channel mismatch, skipping")
371+
continue
372+
}
373+
374+
logger.Info(ctx, "autoversion pragma found with channel match")
375+
376+
match = strings.Replace(match, "[version]", `(?P<version>[0-9]+\.[0-9]+\.[0-9]+)`, 1)
377+
logger.Debug(ctx, "compiling match regexp", "match", match)
378+
matchRe, err = regexp.Compile(match)
379+
if err != nil {
380+
return xerrors.Errorf("regexp compile failed: %w", err)
381+
}
382+
}
383+
if matchRe != nil {
384+
// Apply matchRe and find the group named "version", then replace it with the new version.
385+
// Utilize the index where the match was found to replace the correct part. The only
386+
// match group is the version.
387+
if match := matchRe.FindStringSubmatchIndex(line); match != nil {
388+
logger.Info(ctx, "updating version number", "line_number", i+1, "match", match)
389+
lines[i] = line[:match[2]] + version + line[match[3]:]
390+
matchRe = nil
391+
break
392+
}
393+
}
394+
}
395+
if matchRe != nil {
396+
return xerrors.Errorf("match not found in file")
397+
}
398+
399+
updated := strings.Join(lines, "\n")
400+
401+
// Only update the file if there are changes.
402+
diff := cmp.Diff(string(contents), updated)
403+
if diff == "" {
404+
return nil
405+
}
406+
407+
if !r.dryRun {
408+
if err := afero.WriteFile(r.fs, file, []byte(updated), 0o644); err != nil {
409+
return xerrors.Errorf("write file failed: %w", err)
410+
}
411+
logger.Info(ctx, "file autoversioned")
412+
} else {
413+
logger.Info(ctx, "dry-run: file not updated", "uncommitted_changes", diff)
414+
}
415+
416+
return nil
417+
}
418+
419+
func run(command string, args ...string) (string, error) {
420+
cmd := exec.Command(command, args...)
421+
out, err := cmd.CombinedOutput()
422+
if err != nil {
423+
return "", xerrors.Errorf("command failed: %q: %w\n%s", fmt.Sprintf("%s %s", command, strings.Join(args, " ")), err, out)
424+
}
425+
return strings.TrimSpace(string(out)), nil
426+
}

0 commit comments

Comments
 (0)