@@ -4,13 +4,17 @@ import (
4
4
"context"
5
5
"errors"
6
6
"fmt"
7
+ "io/fs"
7
8
"os"
9
+ "path/filepath"
10
+ "regexp"
8
11
"slices"
9
12
"strings"
10
13
"time"
11
14
12
15
"github.com/google/go-cmp/cmp"
13
16
"github.com/google/go-github/v61/github"
17
+ "github.com/spf13/afero"
14
18
"golang.org/x/mod/semver"
15
19
"golang.org/x/xerrors"
16
20
@@ -26,42 +30,75 @@ const (
26
30
)
27
31
28
32
func main () {
29
- logger := slog .Make (sloghuman .Sink (os .Stderr )).Leveled (slog .LevelDebug )
33
+ r := & releaseCommand {
34
+ fs : afero .NewOsFs (),
35
+ logger : slog .Make (sloghuman .Sink (os .Stderr )).Leveled (slog .LevelInfo ),
36
+ }
30
37
31
- var ghToken string
32
- var dryRun bool
38
+ var channel string
33
39
34
40
cmd := serpent.Command {
35
41
Use : "release <subcommand>" ,
36
42
Short : "Prepare, create and publish releases." ,
37
43
Options : serpent.OptionSet {
44
+ {
45
+ Flag : "debug" ,
46
+ Description : "Enable debug logging." ,
47
+ Value : serpent .BoolOf (& r .debug ),
48
+ },
38
49
{
39
50
Flag : "gh-token" ,
40
51
Description : "GitHub personal access token." ,
41
52
Env : "GH_TOKEN" ,
42
- Value : serpent .StringOf (& ghToken ),
53
+ Value : serpent .StringOf (& r . ghToken ),
43
54
},
44
55
{
45
56
Flag : "dry-run" ,
46
57
FlagShorthand : "n" ,
47
58
Description : "Do not make any changes, only print what would be done." ,
48
- Value : serpent .BoolOf (& dryRun ),
59
+ Value : serpent .BoolOf (& r . dryRun ),
49
60
},
50
61
},
51
62
Children : []* serpent.Command {
52
63
{
53
- Use : "promote <version>" ,
54
- Short : "Promote version to stable." ,
64
+ Use : "promote <version>" ,
65
+ Short : "Promote version to stable." ,
66
+ Middleware : r .debugMiddleware , // Serpent doesn't support this on parent.
55
67
Handler : func (inv * serpent.Invocation ) error {
56
68
ctx := inv .Context ()
57
69
if len (inv .Args ) == 0 {
58
70
return xerrors .New ("version argument missing" )
59
71
}
60
- if ! dryRun && ghToken == "" {
72
+ if ! r . dryRun && r . ghToken == "" {
61
73
return xerrors .New ("GitHub personal access token is required, use --gh-token or GH_TOKEN" )
62
74
}
63
75
64
- err := promoteVersionToStable (ctx , inv , logger , ghToken , dryRun , inv .Args [0 ])
76
+ err := r .promoteVersionToStable (ctx , inv , inv .Args [0 ])
77
+ if err != nil {
78
+ return err
79
+ }
80
+
81
+ return nil
82
+ },
83
+ },
84
+ {
85
+ Use : "autoversion <version>" ,
86
+ Short : "Automatically update the provided channel to version in markdown files." ,
87
+ Options : serpent.OptionSet {
88
+ {
89
+ Flag : "channel" ,
90
+ Description : "Channel to update." ,
91
+ Value : serpent .EnumOf (& channel , "mainline" , "stable" ),
92
+ },
93
+ },
94
+ Middleware : r .debugMiddleware , // Serpent doesn't support this on parent.
95
+ Handler : func (inv * serpent.Invocation ) error {
96
+ ctx := inv .Context ()
97
+ if len (inv .Args ) == 0 {
98
+ return xerrors .New ("version argument missing" )
99
+ }
100
+
101
+ err := r .autoversion (ctx , channel , inv .Args [0 ])
65
102
if err != nil {
66
103
return err
67
104
}
@@ -77,19 +114,36 @@ func main() {
77
114
if errors .Is (err , cliui .Canceled ) {
78
115
os .Exit (1 )
79
116
}
80
- logger .Error (context .Background (), "release command failed" , "err" , err )
117
+ r . logger .Error (context .Background (), "release command failed" , "err" , err )
81
118
os .Exit (1 )
82
119
}
83
120
}
84
121
122
+ type releaseCommand struct {
123
+ fs afero.Fs
124
+ logger slog.Logger
125
+ debug bool
126
+ ghToken string
127
+ dryRun bool
128
+ }
129
+
130
+ func (r * releaseCommand ) debugMiddleware (next serpent.HandlerFunc ) serpent.HandlerFunc {
131
+ return func (inv * serpent.Invocation ) error {
132
+ if r .debug {
133
+ r .logger = r .logger .Leveled (slog .LevelDebug )
134
+ }
135
+ return next (inv )
136
+ }
137
+ }
138
+
85
139
//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 {
140
+ func ( r * releaseCommand ) promoteVersionToStable (ctx context.Context , inv * serpent.Invocation , version string ) error {
87
141
client := github .NewClient (nil )
88
- if ghToken != "" {
89
- client = client .WithAuthToken (ghToken )
142
+ if r . ghToken != "" {
143
+ client = client .WithAuthToken (r . ghToken )
90
144
}
91
145
92
- logger = logger .With (slog .F ("dry_run" , dryRun ), slog .F ("version" , version ))
146
+ logger := r . logger .With (slog .F ("dry_run" , r . dryRun ), slog .F ("version" , version ))
93
147
94
148
logger .Info (ctx , "checking current stable release" )
95
149
@@ -161,7 +215,7 @@ func promoteVersionToStable(ctx context.Context, inv *serpent.Invocation, logger
161
215
updatedNewStable .Body = github .String (updatedBody )
162
216
updatedNewStable .Prerelease = github .Bool (false )
163
217
updatedNewStable .Draft = github .Bool (false )
164
- if ! dryRun {
218
+ if ! r . dryRun {
165
219
_ , _ , err = client .Repositories .EditRelease (ctx , owner , repo , newStable .GetID (), newStable )
166
220
if err != nil {
167
221
return xerrors .Errorf ("edit release failed: %w" , err )
@@ -221,3 +275,114 @@ func removeMainlineBlurb(body string) string {
221
275
222
276
return strings .Join (newBody , "\n " )
223
277
}
278
+
279
+ // autoversion automatically updates the provided channel to version in markdown
280
+ // files.
281
+ func (r * releaseCommand ) autoversion (ctx context.Context , channel , version string ) error {
282
+ var files []string
283
+
284
+ // For now, scope this to docs, perhaps we include README.md in the future.
285
+ if err := afero .Walk (r .fs , "docs" , func (path string , _ fs.FileInfo , err error ) error {
286
+ if err != nil {
287
+ return err
288
+ }
289
+ if strings .EqualFold (filepath .Ext (path ), ".md" ) {
290
+ files = append (files , path )
291
+ }
292
+ return nil
293
+ }); err != nil {
294
+ return xerrors .Errorf ("walk failed: %w" , err )
295
+ }
296
+
297
+ for _ , file := range files {
298
+ err := r .autoversionFile (ctx , file , channel , version )
299
+ if err != nil {
300
+ return xerrors .Errorf ("autoversion file failed: %w" , err )
301
+ }
302
+ }
303
+
304
+ return nil
305
+ }
306
+
307
+ // autoversionMarkdownPragmaRe matches the autoversion pragma in markdown files.
308
+ //
309
+ // Example:
310
+ //
311
+ // <!-- autoversion(stable): "--version [version]"" -->
312
+ //
313
+ // The channel is the first capture group and the match string is the second
314
+ // capture group. The string "[version]" is replaced with the new version.
315
+ var autoversionMarkdownPragmaRe = regexp .MustCompile (`<!-- ?autoversion\(([^)]+)\): ?"([^"]+)" ?-->` )
316
+
317
+ func (r * releaseCommand ) autoversionFile (ctx context.Context , file , channel , version string ) error {
318
+ version = strings .TrimPrefix (version , "v" )
319
+ logger := r .logger .With (slog .F ("file" , file ), slog .F ("channel" , channel ), slog .F ("version" , version ))
320
+
321
+ logger .Debug (ctx , "checking file for autoversion pragma" )
322
+
323
+ contents , err := afero .ReadFile (r .fs , file )
324
+ if err != nil {
325
+ return xerrors .Errorf ("read file failed: %w" , err )
326
+ }
327
+
328
+ lines := strings .Split (string (contents ), "\n " )
329
+ var matchRe * regexp.Regexp
330
+ for i , line := range lines {
331
+ if autoversionMarkdownPragmaRe .MatchString (line ) {
332
+ matches := autoversionMarkdownPragmaRe .FindStringSubmatch (line )
333
+ matchChannel := matches [1 ]
334
+ match := matches [2 ]
335
+
336
+ logger := logger .With (slog .F ("line_number" , i + 1 ), slog .F ("match_channel" , matchChannel ), slog .F ("match" , match ))
337
+
338
+ logger .Debug (ctx , "autoversion pragma detected" )
339
+
340
+ if matchChannel != channel {
341
+ logger .Debug (ctx , "channel mismatch, skipping" )
342
+ continue
343
+ }
344
+
345
+ logger .Info (ctx , "autoversion pragma found with channel match" )
346
+
347
+ match = strings .Replace (match , "[version]" , `(?P<version>[0-9]+\.[0-9]+\.[0-9]+)` , 1 )
348
+ logger .Debug (ctx , "compiling match regexp" , "match" , match )
349
+ matchRe , err = regexp .Compile (match )
350
+ if err != nil {
351
+ return xerrors .Errorf ("regexp compile failed: %w" , err )
352
+ }
353
+ }
354
+ if matchRe != nil {
355
+ // Apply matchRe and find the group named "version", then replace it with the new version.
356
+ // Utilize the index where the match was found to replace the correct part. The only
357
+ // match group is the version.
358
+ if match := matchRe .FindStringSubmatchIndex (line ); match != nil {
359
+ logger .Info (ctx , "updating version number" , "line_number" , i + 1 , "match" , match )
360
+ lines [i ] = line [:match [2 ]] + version + line [match [3 ]:]
361
+ matchRe = nil
362
+ break
363
+ }
364
+ }
365
+ }
366
+ if matchRe != nil {
367
+ return xerrors .Errorf ("match not found in file" )
368
+ }
369
+
370
+ updated := strings .Join (lines , "\n " )
371
+
372
+ // Only update the file if there are changes.
373
+ diff := cmp .Diff (string (contents ), updated )
374
+ if diff == "" {
375
+ return nil
376
+ }
377
+
378
+ if ! r .dryRun {
379
+ if err := afero .WriteFile (r .fs , file , []byte (updated ), 0o644 ); err != nil {
380
+ return xerrors .Errorf ("write file failed: %w" , err )
381
+ }
382
+ logger .Info (ctx , "file autoversioned" )
383
+ } else {
384
+ logger .Info (ctx , "dry-run: file not updated" , "uncommitted_changes" , diff )
385
+ }
386
+
387
+ return nil
388
+ }
0 commit comments