1
2
3 package main
4
5 import (
6 "bufio"
7 "bytes"
8 "errors"
9 "fmt"
10 "io"
11 "log"
12 "math"
13 "net/http"
14 "net/url"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "runtime"
19 "strings"
20
21 "github.com/urfave/cli/v2"
22 )
23
24 const (
25 badNewsEmoji = "🚨"
26 goodNewsEmoji = "✨"
27 checksPassedEmoji = "✅"
28
29 gfmrunVersion = "v1.3.0"
30
31 v2diffWarning = `
32 # The unified diff above indicates that the public API surface area
33 # has changed. If you feel that the changes are acceptable and adhere
34 # to the semantic versioning promise of the v2.x series described in
35 # docs/CONTRIBUTING.md, please run the following command to promote
36 # the current go docs:
37 #
38 # make v2approve
39 #
40 `
41 )
42
43 func main() {
44 top, err := func() (string, error) {
45 if v, err := sh("git", "rev-parse", "--show-toplevel"); err == nil {
46 return strings.TrimSpace(v), nil
47 }
48
49 return os.Getwd()
50 }()
51 if err != nil {
52 log.Fatal(err)
53 }
54
55 app := &cli.App{
56 Name: "builder",
57 Usage: "Do a thing for urfave/cli! (maybe build?)",
58 Commands: cli.Commands{
59 {
60 Name: "vet",
61 Action: topRunAction("go", "vet", "./..."),
62 },
63 {
64 Name: "test",
65 Action: TestActionFunc,
66 },
67 {
68 Name: "gfmrun",
69 Flags: []cli.Flag{
70 &cli.BoolFlag{
71 Name: "walk",
72 Value: false,
73 Usage: "Walk the specified directory and perform validation on all markdown files",
74 },
75 },
76 Action: GfmrunActionFunc,
77 },
78 {
79 Name: "check-binary-size",
80 Action: checkBinarySizeActionFunc,
81 },
82 {
83 Name: "generate",
84 Action: GenerateActionFunc,
85 Usage: "generate API docs",
86 },
87 {
88 Name: "yamlfmt",
89 Flags: []cli.Flag{
90 &cli.BoolFlag{Name: "strict", Value: false, Usage: "require presence of yq"},
91 },
92 Action: YAMLFmtActionFunc,
93 },
94 {
95 Name: "diffcheck",
96 Action: DiffCheckActionFunc,
97 },
98 {
99 Name: "ensure-goimports",
100 Action: EnsureGoimportsActionFunc,
101 },
102 {
103 Name: "ensure-gfmrun",
104 Action: EnsureGfmrunActionFunc,
105 },
106 {
107 Name: "ensure-mkdocs",
108 Action: EnsureMkdocsActionFunc,
109 Flags: []cli.Flag{
110 &cli.BoolFlag{Name: "upgrade-pip"},
111 },
112 },
113 {
114 Name: "set-mkdocs-remote",
115 Action: SetMkdocsRemoteActionFunc,
116 Flags: []cli.Flag{
117 &cli.StringFlag{
118 Name: "github-token",
119 EnvVars: []string{"MKDOCS_REMOTE_GITHUB_TOKEN"},
120 Required: true,
121 },
122 },
123 },
124 {
125 Name: "deploy-mkdocs",
126 Action: topRunAction("mkdocs", "gh-deploy", "--force"),
127 },
128 {
129 Name: "lint",
130 Action: LintActionFunc,
131 },
132 {
133 Name: "v2diff",
134 Flags: []cli.Flag{
135 &cli.BoolFlag{Name: "color", Value: false},
136 },
137 Action: V2Diff,
138 },
139 {
140 Name: "v2approve",
141 Action: topRunAction(
142 "cp",
143 "-v",
144 "godoc-current.txt",
145 filepath.Join("testdata", "godoc-v2.x.txt"),
146 ),
147 },
148 },
149 Flags: []cli.Flag{
150 &cli.StringFlag{
151 Name: "tags",
152 Usage: "set build tags",
153 },
154 &cli.PathFlag{
155 Name: "top",
156 Value: top,
157 },
158 &cli.StringSliceFlag{
159 Name: "packages",
160 Value: cli.NewStringSlice("cli", "altsrc", "internal/build"),
161 },
162 },
163 }
164
165 if err := app.Run(os.Args); err != nil {
166 log.Fatal(err)
167 }
168 }
169
170 func sh(exe string, args ...string) (string, error) {
171 cmd := exec.Command(exe, args...)
172 cmd.Stdin = os.Stdin
173 cmd.Stderr = os.Stderr
174
175 fmt.Fprintf(os.Stderr, "# ---> %s\n", cmd)
176 outBytes, err := cmd.Output()
177 return string(outBytes), err
178 }
179
180 func topRunAction(arg string, args ...string) cli.ActionFunc {
181 return func(cCtx *cli.Context) error {
182 if err := os.Chdir(cCtx.Path("top")); err != nil {
183 return err
184 }
185
186 return runCmd(arg, args...)
187 }
188 }
189
190 func runCmd(arg string, args ...string) error {
191 cmd := exec.Command(arg, args...)
192
193 cmd.Stdin = os.Stdin
194 cmd.Stdout = os.Stdout
195 cmd.Stderr = os.Stderr
196
197 fmt.Fprintf(os.Stderr, "# ---> %s\n", cmd)
198 return cmd.Run()
199 }
200
201 func downloadFile(src, dest string, dirPerm, perm os.FileMode) error {
202 req, err := http.NewRequest(http.MethodGet, src, nil)
203 if err != nil {
204 return err
205 }
206
207 resp, err := http.DefaultClient.Do(req)
208 if err != nil {
209 return err
210 }
211
212 defer resp.Body.Close()
213
214 if resp.StatusCode >= 300 {
215 return fmt.Errorf("download response %[1]v", resp.StatusCode)
216 }
217
218 if err := os.MkdirAll(filepath.Dir(dest), dirPerm); err != nil {
219 return err
220 }
221
222 out, err := os.Create(dest)
223 if err != nil {
224 return err
225 }
226
227 if _, err := io.Copy(out, resp.Body); err != nil {
228 return err
229 }
230
231 if err := out.Close(); err != nil {
232 return err
233 }
234
235 return os.Chmod(dest, perm)
236 }
237
238 func VetActionFunc(cCtx *cli.Context) error {
239 return runCmd("go", "vet", cCtx.Path("top")+"/...")
240 }
241
242 func TestActionFunc(c *cli.Context) error {
243 tags := c.String("tags")
244
245 for _, pkg := range c.StringSlice("packages") {
246 packageName := "github.com/urfave/cli/v2"
247
248 if pkg != "cli" {
249 packageName = fmt.Sprintf("github.com/urfave/cli/v2/%s", pkg)
250 }
251
252 args := []string{"test"}
253 if tags != "" {
254 args = append(args, []string{"-tags", tags}...)
255 }
256
257 args = append(args, []string{
258 "-v",
259 "--coverprofile", pkg + ".coverprofile",
260 "--covermode", "count",
261 "--cover", packageName,
262 packageName,
263 }...)
264
265 if err := runCmd("go", args...); err != nil {
266 return err
267 }
268 }
269
270 return testCleanup(c.StringSlice("packages"))
271 }
272
273 func testCleanup(packages []string) error {
274 out := &bytes.Buffer{}
275
276 fmt.Fprintf(out, "mode: count\n")
277
278 for _, pkg := range packages {
279 filename := pkg + ".coverprofile"
280
281 lineBytes, err := os.ReadFile(filename)
282 if err != nil {
283 return err
284 }
285
286 lines := strings.Split(string(lineBytes), "\n")
287
288 fmt.Fprint(out, strings.Join(lines[1:], "\n"))
289
290 if err := os.Remove(filename); err != nil {
291 return err
292 }
293 }
294
295 return os.WriteFile("coverage.txt", out.Bytes(), 0644)
296 }
297
298 func GfmrunActionFunc(cCtx *cli.Context) error {
299 top := cCtx.Path("top")
300
301 bash, err := exec.LookPath("bash")
302 if err != nil {
303 return err
304 }
305
306 os.Setenv("SHELL", bash)
307
308 tmpDir, err := os.MkdirTemp("", "urfave-cli*")
309 if err != nil {
310 return err
311 }
312
313 wd, err := os.Getwd()
314 if err != nil {
315 return err
316 }
317
318 if err := os.Chdir(tmpDir); err != nil {
319 return err
320 }
321
322 fmt.Fprintf(cCtx.App.ErrWriter, "# ---> workspace/TMPDIR is %q\n", tmpDir)
323
324 if err := runCmd("go", "work", "init", top); err != nil {
325 return err
326 }
327
328 os.Setenv("TMPDIR", tmpDir)
329
330 if err := os.Chdir(wd); err != nil {
331 return err
332 }
333
334 dirPath := cCtx.Args().Get(0)
335 if dirPath == "" {
336 dirPath = "README.md"
337 }
338
339 walk := cCtx.Bool("walk")
340 sources := []string{}
341
342 if walk {
343
344 err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
345 if err != nil {
346 return err
347 }
348
349 if info.IsDir() {
350 return nil
351 }
352
353 if filepath.Ext(path) != ".md" {
354 return nil
355 }
356
357 sources = append(sources, path)
358 return nil
359 })
360 if err != nil {
361 return err
362 }
363 } else {
364 sources = append(sources, dirPath)
365 }
366
367 var counter int
368
369 for _, src := range sources {
370 file, err := os.Open(src)
371 if err != nil {
372 return err
373 }
374 defer file.Close()
375
376 scanner := bufio.NewScanner(file)
377 for scanner.Scan() {
378 if strings.Contains(scanner.Text(), "package main") {
379 counter++
380 }
381 }
382
383 err = file.Close()
384 if err != nil {
385 return err
386 }
387
388 err = scanner.Err()
389 if err != nil {
390 return err
391 }
392 }
393
394 gfmArgs := []string{
395 "--count",
396 fmt.Sprint(counter),
397 }
398 for _, src := range sources {
399 gfmArgs = append(gfmArgs, "--sources", src)
400 }
401
402 if err := runCmd("gfmrun", gfmArgs...); err != nil {
403 return err
404 }
405
406 return os.RemoveAll(tmpDir)
407 }
408
409
410
411
412 func checkBinarySizeActionFunc(c *cli.Context) (err error) {
413 const (
414 cliSourceFilePath = "./internal/example-cli/example-cli.go"
415 cliBuiltFilePath = "./internal/example-cli/built-example"
416 helloSourceFilePath = "./internal/example-hello-world/example-hello-world.go"
417 helloBuiltFilePath = "./internal/example-hello-world/built-example"
418 desiredMaxBinarySize = 2.2
419 mbStringFormatter = "%.1fMB"
420 )
421
422 desiredMinBinarySize := 1.675
423
424 tags := c.String("tags")
425
426 if strings.Contains(tags, "urfave_cli_no_docs") {
427 desiredMinBinarySize = 1.39
428 }
429
430
431 cliSize, err := getSize(cliSourceFilePath, cliBuiltFilePath, tags)
432 if err != nil {
433 return err
434 }
435
436
437 helloSize, err := getSize(helloSourceFilePath, helloBuiltFilePath, tags)
438 if err != nil {
439 return err
440 }
441
442
443
444 cliSizeDiff := cliSize - helloSize
445
446
447
448
449
450 fileSizeInMB := float64(cliSizeDiff) / float64(1000000)
451 roundedFileSize := math.Round(fileSizeInMB*10) / 10
452 roundedFileSizeString := fmt.Sprintf(mbStringFormatter, roundedFileSize)
453
454
455 isLessThanDesiredMin := roundedFileSize < desiredMinBinarySize
456 isMoreThanDesiredMax := roundedFileSize > desiredMaxBinarySize
457 desiredMinSizeString := fmt.Sprintf(mbStringFormatter, desiredMinBinarySize)
458 desiredMaxSizeString := fmt.Sprintf(mbStringFormatter, desiredMaxBinarySize)
459
460
461 fmt.Printf("\n%s is the current binary size\n", roundedFileSizeString)
462
463 if isLessThanDesiredMin {
464 fmt.Printf(" %s %s is the target min size\n", goodNewsEmoji, desiredMinSizeString)
465 fmt.Println("")
466 fmt.Println(" The binary is smaller than the target min size, which is great news!")
467 fmt.Println(" That means that your changes are shrinking the binary size.")
468 fmt.Println(" You'll want to go into ./internal/build/build.go and decrease")
469 fmt.Println(" the desiredMinBinarySize, and also probably decrease the ")
470 fmt.Println(" desiredMaxBinarySize by the same amount. That will ensure that")
471 fmt.Println(" future PRs will enforce the newly shrunk binary sizes.")
472 fmt.Println("")
473 os.Exit(1)
474 } else {
475 fmt.Printf(" %s %s is the target min size\n", checksPassedEmoji, desiredMinSizeString)
476 }
477
478 if isMoreThanDesiredMax {
479 fmt.Printf(" %s %s is the target max size\n", badNewsEmoji, desiredMaxSizeString)
480 fmt.Println("")
481 fmt.Println(" The binary is larger than the target max size.")
482 fmt.Println(" That means that your changes are increasing the binary size.")
483 fmt.Println(" The first thing you'll want to do is ask your yourself")
484 fmt.Println(" Is this change worth increasing the binary size?")
485 fmt.Println(" Larger binary sizes for this package can dissuade its use.")
486 fmt.Println(" If this change is worth the increase, then we can up the")
487 fmt.Println(" desired max binary size. To do that you'll want to go into")
488 fmt.Println(" ./internal/build/build.go and increase the desiredMaxBinarySize,")
489 fmt.Println(" and increase the desiredMinBinarySize by the same amount.")
490 fmt.Println("")
491 os.Exit(1)
492 } else {
493 fmt.Printf(" %s %s is the target max size\n", checksPassedEmoji, desiredMaxSizeString)
494 }
495
496 return nil
497 }
498
499 func GenerateActionFunc(cCtx *cli.Context) error {
500 top := cCtx.Path("top")
501
502 log.Println("--- generating godoc-current.txt API reference ---")
503 cliDocs, err := sh("go", "doc", "-all", top)
504 if err != nil {
505 return err
506 }
507
508 altsrcDocs, err := sh("go", "doc", "-all", filepath.Join(top, "altsrc"))
509 if err != nil {
510 return err
511 }
512
513 if err := os.WriteFile(
514 filepath.Join(top, "godoc-current.txt"),
515 []byte(cliDocs+altsrcDocs),
516 0644,
517 ); err != nil {
518 return err
519 }
520
521 log.Println("--- generating Go source files ---")
522 return runCmd("go", "generate", cCtx.Path("top")+"/...")
523 }
524
525 func YAMLFmtActionFunc(cCtx *cli.Context) error {
526 yqBin, err := exec.LookPath("yq")
527 if err != nil {
528 if !cCtx.Bool("strict") {
529 fmt.Fprintln(cCtx.App.ErrWriter, "# ---> no yq found; skipping")
530 return nil
531 }
532
533 return err
534 }
535
536 if err := os.Chdir(cCtx.Path("top")); err != nil {
537 return err
538 }
539
540 return runCmd(yqBin, "eval", "--inplace", "flag-spec.yaml")
541 }
542
543 func DiffCheckActionFunc(cCtx *cli.Context) error {
544 if err := os.Chdir(cCtx.Path("top")); err != nil {
545 return err
546 }
547
548 if err := runCmd("git", "diff", "--exit-code"); err != nil {
549 return err
550 }
551
552 return runCmd("git", "diff", "--cached", "--exit-code")
553 }
554
555 func EnsureGoimportsActionFunc(cCtx *cli.Context) error {
556 top := cCtx.Path("top")
557 if err := os.Chdir(top); err != nil {
558 return err
559 }
560
561 if err := runCmd(
562 "goimports",
563 "-d",
564 filepath.Join(top, "internal/build/build.go"),
565 ); err == nil {
566 return nil
567 }
568
569 os.Setenv("GOBIN", filepath.Join(top, ".local/bin"))
570
571 return runCmd("go", "install", "golang.org/x/tools/cmd/goimports@latest")
572 }
573
574 func EnsureGfmrunActionFunc(cCtx *cli.Context) error {
575 top := cCtx.Path("top")
576 gfmrunExe := filepath.Join(top, ".local/bin/gfmrun")
577
578 if err := os.Chdir(top); err != nil {
579 return err
580 }
581
582 if v, err := sh(gfmrunExe, "--version"); err == nil && strings.TrimSpace(v) == gfmrunVersion {
583 return nil
584 }
585
586 gfmrunURL, err := url.Parse(
587 fmt.Sprintf(
588 "https://github.com/urfave/gfmrun/releases/download/%[1]s/gfmrun-%[2]s-%[3]s-%[1]s",
589 gfmrunVersion, runtime.GOOS, runtime.GOARCH,
590 ),
591 )
592 if err != nil {
593 return err
594 }
595
596 return downloadFile(gfmrunURL.String(), gfmrunExe, 0755, 0755)
597 }
598
599 func EnsureMkdocsActionFunc(cCtx *cli.Context) error {
600 if err := os.Chdir(cCtx.Path("top")); err != nil {
601 return err
602 }
603
604 if err := runCmd("mkdocs", "--version"); err == nil {
605 return nil
606 }
607
608 if cCtx.Bool("upgrade-pip") {
609 if err := runCmd("pip", "install", "-U", "pip"); err != nil {
610 return err
611 }
612 }
613
614 return runCmd("pip", "install", "-r", "mkdocs-reqs.txt")
615 }
616
617 func SetMkdocsRemoteActionFunc(cCtx *cli.Context) error {
618 ghToken := strings.TrimSpace(cCtx.String("github-token"))
619 if ghToken == "" {
620 return errors.New("empty github token")
621 }
622
623 if err := os.Chdir(cCtx.Path("top")); err != nil {
624 return err
625 }
626
627 if err := runCmd("git", "remote", "rm", "origin"); err != nil {
628 return err
629 }
630
631 return runCmd(
632 "git", "remote", "add", "origin",
633 fmt.Sprintf("https://x-access-token:%[1]s@github.com/urfave/cli.git", ghToken),
634 )
635 }
636
637 func LintActionFunc(cCtx *cli.Context) error {
638 top := cCtx.Path("top")
639 if err := os.Chdir(top); err != nil {
640 return err
641 }
642
643 out, err := sh(filepath.Join(top, ".local/bin/goimports"), "-l", ".")
644 if err != nil {
645 return err
646 }
647
648 if strings.TrimSpace(out) != "" {
649 fmt.Fprintln(cCtx.App.ErrWriter, "# ---> goimports -l is non-empty:")
650 fmt.Fprintln(cCtx.App.ErrWriter, out)
651
652 return errors.New("goimports needed")
653 }
654
655 return nil
656 }
657
658 func V2Diff(cCtx *cli.Context) error {
659 if err := os.Chdir(cCtx.Path("top")); err != nil {
660 return err
661 }
662
663 err := runCmd(
664 "diff",
665 "--ignore-all-space",
666 "--minimal",
667 "--color="+func() string {
668 if cCtx.Bool("color") {
669 return "always"
670 }
671 return "auto"
672 }(),
673 "--unified",
674 "--label=a/godoc",
675 filepath.Join("testdata", "godoc-v2.x.txt"),
676 "--label=b/godoc",
677 "godoc-current.txt",
678 )
679
680 if err != nil {
681 fmt.Printf("# %v ---> Hey! <---\n", badNewsEmoji)
682 fmt.Println(strings.TrimSpace(v2diffWarning))
683 }
684
685 return err
686 }
687
688 func getSize(sourcePath, builtPath, tags string) (int64, error) {
689 args := []string{"build"}
690
691 if tags != "" {
692 args = append(args, []string{"-tags", tags}...)
693 }
694
695 args = append(args, []string{
696 "-o", builtPath,
697 "-ldflags", "-s -w",
698 sourcePath,
699 }...)
700
701 if err := runCmd("go", args...); err != nil {
702 fmt.Println("issue getting size for example binary")
703 return 0, err
704 }
705
706 fileInfo, err := os.Stat(builtPath)
707 if err != nil {
708 fmt.Println("issue getting size for example binary")
709 return 0, err
710 }
711
712 return fileInfo.Size(), nil
713 }
714
View as plain text