@@ -4,13 +4,18 @@ import (
4
4
"context"
5
5
"errors"
6
6
"fmt"
7
+ "io/fs"
7
8
"os"
9
+ "os/exec"
10
+ "path/filepath"
11
+ "regexp"
8
12
"slices"
9
13
"strings"
10
14
"time"
11
15
12
16
"github.com/google/go-cmp/cmp"
13
17
"github.com/google/go-github/v61/github"
18
+ "github.com/spf13/afero"
14
19
"golang.org/x/mod/semver"
15
20
"golang.org/x/xerrors"
16
21
@@ -26,42 +31,89 @@ const (
26
31
)
27
32
28
33
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
+ }
30
47
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
33
54
34
55
cmd := serpent.Command {
35
56
Use : "release <subcommand>" ,
36
57
Short : "Prepare, create and publish releases." ,
37
58
Options : serpent.OptionSet {
59
+ {
60
+ Flag : "debug" ,
61
+ Description : "Enable debug logging." ,
62
+ Value : serpent .BoolOf (& r .debug ),
63
+ },
38
64
{
39
65
Flag : "gh-token" ,
40
66
Description : "GitHub personal access token." ,
41
67
Env : "GH_TOKEN" ,
42
- Value : serpent .StringOf (& ghToken ),
68
+ Value : serpent .StringOf (& r . ghToken ),
43
69
},
44
70
{
45
71
Flag : "dry-run" ,
46
72
FlagShorthand : "n" ,
47
73
Description : "Do not make any changes, only print what would be done." ,
48
- Value : serpent .BoolOf (& dryRun ),
74
+ Value : serpent .BoolOf (& r . dryRun ),
49
75
},
50
76
},
51
77
Children : []* serpent.Command {
52
78
{
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.
55
82
Handler : func (inv * serpent.Invocation ) error {
56
83
ctx := inv .Context ()
57
84
if len (inv .Args ) == 0 {
58
85
return xerrors .New ("version argument missing" )
59
86
}
60
- if ! dryRun && ghToken == "" {
87
+ if ! r . dryRun && r . ghToken == "" {
61
88
return xerrors .New ("GitHub personal access token is required, use --gh-token or GH_TOKEN" )
62
89
}
63
90
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 ])
65
117
if err != nil {
66
118
return err
67
119
}
@@ -72,24 +124,55 @@ func main() {
72
124
},
73
125
}
74
126
75
- err : = cmd .Invoke ().WithOS ().Run ()
127
+ err = cmd .Invoke ().WithOS ().Run ()
76
128
if err != nil {
77
129
if errors .Is (err , cliui .Canceled ) {
78
130
os .Exit (1 )
79
131
}
80
- logger .Error (context .Background (), "release command failed" , "err" , err )
132
+ r . logger .Error (context .Background (), "release command failed" , "err" , err )
81
133
os .Exit (1 )
82
134
}
83
135
}
84
136
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
+
85
168
//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 {
87
170
client := github .NewClient (nil )
88
- if ghToken != "" {
89
- client = client .WithAuthToken (ghToken )
171
+ if r . ghToken != "" {
172
+ client = client .WithAuthToken (r . ghToken )
90
173
}
91
174
92
- logger = logger .With (slog . F ( "dry_run" , dryRun ), slog .F ("version" , version ))
175
+ logger := r . logger .With (slog .F ("version" , version ))
93
176
94
177
logger .Info (ctx , "checking current stable release" )
95
178
@@ -161,7 +244,7 @@ func promoteVersionToStable(ctx context.Context, inv *serpent.Invocation, logger
161
244
updatedNewStable .Body = github .String (updatedBody )
162
245
updatedNewStable .Prerelease = github .Bool (false )
163
246
updatedNewStable .Draft = github .Bool (false )
164
- if ! dryRun {
247
+ if ! r . dryRun {
165
248
_ , _ , err = client .Repositories .EditRelease (ctx , owner , repo , newStable .GetID (), newStable )
166
249
if err != nil {
167
250
return xerrors .Errorf ("edit release failed: %w" , err )
@@ -221,3 +304,123 @@ func removeMainlineBlurb(body string) string {
221
304
222
305
return strings .Join (newBody , "\n " )
223
306
}
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