@@ -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
+ }