diff --git a/.bcr/README.md b/.bcr/README.md index 2f619a830a..fa7d4f4a67 100644 --- a/.bcr/README.md +++ b/.bcr/README.md @@ -1,6 +1,6 @@ -# Publish to BCR Configuration +# BCR Configuration -This directory contains configuration for the Publish to BCR app, which -automates publishing releases to the Bazel Central Registry. See -https://github.com/bazel-contrib/publish-to-bcr/tree/main/templates for -details. +This directory contains configuration information for BCR. It is patterned after +the [Publish to BCR app](https://github.com/bazel-contrib/publish-to-bcr/tree/main/templates), +which we have [opted not to use](https://github.com/bazel-contrib/publish-to-bcr/issues/157). +However, `presubmit.yml` is used by [our own BCR tooling](../docs/releasing.md). diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000000..e524197463 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,26 @@ +# Cutting Periodic "Releases" + +The [Bazel Central Registry](https://github.com/bazelbuild/bazel-central-registry) +needs versioned snapshots and cannot consume git revisions directly. To cut a +release, do the following: + +1. Pick a new version. The current scheme is `0.YYYYMMDD.0`. If we need to cut + multiple releases in one day, increment the third digit. + +2. Update `MODULE.bazel` with the new version and upload to Gerrit. + +3. Once that CL lands, make a annotated git tag at the revision. This can be + [done from Gerrit](https://boringssl-review.googlesource.com/admin/repos/boringssl,tags). + The "Annotation" field must be non-empty. (Just using the name of the tag + again is fine.) + +4. Create a corresponding GitHub [release](https://github.com/google/boringssl/releases/new). + +5. Download the "Source code (tar.gz)" archive from the new release and + re-attach it to the release. (The next step will check that the archive is + correct.) + +6. Run `go run ./util/prepare_bcr_module TAG` and follow the instructions. The + tool does not require special privileges, though it does fetch URLs from + GitHub and read the local checkout. It outputs a JSON file for BCR's tooling + to consume. diff --git a/util/prepare_bcr_module/git.go b/util/prepare_bcr_module/git.go new file mode 100644 index 0000000000..cd0fef875e --- /dev/null +++ b/util/prepare_bcr_module/git.go @@ -0,0 +1,225 @@ +// Copyright (c) 2024, Google Inc. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "bytes" + "cmp" + "crypto/sha256" + "fmt" + "os/exec" + "slices" + "strings" + "sync" +) + +type treeEntryMode int + +const ( + treeEntryRegular treeEntryMode = iota + treeEntryExecutable + treeEntrySymlink +) + +func (m treeEntryMode) String() string { + switch m { + case treeEntryRegular: + return "regular file" + case treeEntryExecutable: + return "executable file" + case treeEntrySymlink: + return "symbolic link" + } + panic(fmt.Sprintf("unknown mode %d", m)) +} + +type treeEntry struct { + path string + mode treeEntryMode + sha256 []byte +} + +func sortTree(tree []treeEntry) { + slices.SortFunc(tree, func(a, b treeEntry) int { return cmp.Compare(a.path, b.path) }) +} + +func compareTrees(got, want []treeEntry) error { + // Check for duplicate files. + for i := 0; i < len(got)-1; i++ { + if got[i].path == got[i+1].path { + return fmt.Errorf("duplicate file %q in archive", got[i].path) + } + } + + // Check for differences between the two trees. + for i := 0; i < len(got) && i < len(want); i++ { + if got[i].path == want[i].path { + if got[i].mode != want[i].mode { + return fmt.Errorf("file %q was a %s but should have been a %s", got[i].path, got[i].mode, want[i].mode) + } + if !bytes.Equal(got[i].sha256, want[i].sha256) { + return fmt.Errorf("hash of %q was %x but should have been %x", got[i].path, got[i].sha256, want[i].sha256) + } + } else if got[i].path < want[i].path { + return fmt.Errorf("unexpected file %q", got[i].path) + } else { + return fmt.Errorf("missing file %q", want[i].path) + } + } + if len(want) < len(got) { + return fmt.Errorf("unexpected file %q", got[len(want)].path) + } + if len(got) < len(want) { + return fmt.Errorf("missing file %q", want[len(got)].path) + } + return nil +} + +type gitTreeEntry struct { + path string + mode treeEntryMode + objectName string +} + +func gitListTree(treeish string) ([]gitTreeEntry, error) { + var stdout, stderr bytes.Buffer + cmd := exec.Command("git", "ls-tree", "-r", "-z", treeish) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("error listing git tree %q: %w\n%s\n", treeish, err, stderr.String()) + } + lines := strings.Split(stdout.String(), "\x00") + ret := make([]gitTreeEntry, 0, len(lines)) + for _, line := range lines { + if len(line) == 0 { + continue + } + + idx := strings.IndexByte(line, '\t') + if idx < 0 { + return nil, fmt.Errorf("could not parse ls-tree output %q", line) + } + + info, path := line[:idx], line[idx+1:] + infos := strings.Split(info, " ") + if len(infos) != 3 { + return nil, fmt.Errorf("could not parse ls-tree output %q", line) + } + + perms, objectType, objectName := infos[0], infos[1], infos[2] + if objectType != "blob" { + return nil, fmt.Errorf("unexpected object type in ls-tree output %q", line) + } + + var mode treeEntryMode + switch perms { + case "100644": + mode = treeEntryRegular + case "100755": + mode = treeEntryExecutable + case "120000": + mode = treeEntrySymlink + default: + return nil, fmt.Errorf("unexpected file mode in ls-tree output %q", line) + } + + ret = append(ret, gitTreeEntry{path: path, mode: mode, objectName: objectName}) + } + return ret, nil +} + +func gitHashBlob(objectName string) ([]byte, error) { + h := sha256.New() + var stderr bytes.Buffer + cmd := exec.Command("git", "cat-file", "blob", objectName) + cmd.Stdout = h + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("error hashing git object %q: %w\n%s\n", objectName, err, stderr.String()) + } + return h.Sum(nil), nil +} + +func gitHashTree(s *stepPrinter, treeish string) ([]treeEntry, error) { + gitTree, err := gitListTree(treeish) + if err != nil { + return nil, err + } + + s.setTotal(len(gitTree)) + + // Hashing objects one by one is slow, so parallelize. Ideally we could + // just use the object name, but git uses SHA-1, so checking a SHA-265 + // hash seems prudent. + var workerErr error + var workerLock sync.Mutex + + var wg sync.WaitGroup + jobs := make(chan gitTreeEntry, *numWorkers) + results := make(chan treeEntry, *numWorkers) + for i := 0; i < *numWorkers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for job := range jobs { + workerLock.Lock() + shouldStop := workerErr != nil + workerLock.Unlock() + if shouldStop { + break + } + + sha256, err := gitHashBlob(job.objectName) + if err != nil { + workerLock.Lock() + if workerErr == nil { + workerErr = err + } + workerLock.Unlock() + break + } + + results <- treeEntry{path: job.path, mode: job.mode, sha256: sha256} + } + }() + } + + go func() { + for _, job := range gitTree { + jobs <- job + } + close(jobs) + wg.Wait() + close(results) + }() + + tree := make([]treeEntry, 0, len(gitTree)) + for result := range results { + s.addProgress(1) + tree = append(tree, result) + } + + if workerErr != nil { + return nil, workerErr + } + + if len(tree) != len(gitTree) { + panic("input and output sizes did not match") + } + + sortTree(tree) + return tree, nil +} diff --git a/util/prepare_bcr_module/prepare_bcr_module.go b/util/prepare_bcr_module/prepare_bcr_module.go new file mode 100644 index 0000000000..1f5400bfdb --- /dev/null +++ b/util/prepare_bcr_module/prepare_bcr_module.go @@ -0,0 +1,402 @@ +// Copyright (c) 2024, Google Inc. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +// prepare_bcr_module prepares for a BCR release. It outputs a JSON +// configuration file that may be used by BCR's add_module tool. +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +var ( + outDir = flag.String("out-dir", "", "The directory to place the script output, or a temporary directory if unspecified.") + numWorkers = flag.Int("num-workers", runtime.NumCPU(), "Runs the given number of workers") + + moduleOverride = flag.String("module-override", "", "The path to a file that overrides the MODULE.bazel file in the archve.") + presubmitOverride = flag.String("presubmit-override", "", "The path to a file that overrides the presubmit.yml file in the archve.") + skipArchiveCheck = flag.Bool("skip-archive-check", false, "Skips checking the release tarball against the (potentially unstable) archive tarball.") + pipe = flag.Bool("pipe", false, "Prints output suitable for writing to a pipe instead of a terminal") + + githubOrg = flag.String("github-org", "google", "The organization where the GitHub repository lives") + githubRepo = flag.String("github-repo", "boringssl", "The name of the GitHub repository") + moduleName = flag.String("module-name", "boringssl", "The name of the BCR module") + compatibilityLevel = flag.String("compatibility-level", "2", "The compatibility_level setting for the BCR module") +) + +// A bcrConfig is a configuration file for BCR's add_module tool. This is +// undocumented but can be seen in the Module Python class. (The JSON struct is +// simply the object's __dict__.) +type bcrConfig struct { + Name string `json:"name"` + Version string `json:"version"` + CompatibilityLevel string `json:"compatibility_level"` + ModuleDotBazel *string `json:"module_dot_bazel"` + URL *string `json:"url"` + StripPrefix *string `json:"strip_prefix"` + Deps []string `json:"deps"` + Patches []string `json:"patches"` + PatchStrip int `json:"patch_strip"` + BuildFile *string `json:"build_file"` + PresubmitYml *string `json:"presubmit_yml"` + BuildTargets []string `json:"build_targets"` + TestModulePath *string `json:"test_module_path"` + TestModuleBuildTargets []string `json:"test_module_build_targets"` + TestModuleTestTargets []string `json:"test_module_test_targets"` +} + +func ptr[T any](t T) *T { return &t } + +func archiveURL(tag string) string { + return fmt.Sprintf("https://github.com/%s/%s/archive/refs/tags/%s.tar.gz", *githubOrg, *githubRepo, tag) +} + +func releaseURL(tag string) string { + return fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s-%s.tar.gz", *githubOrg, *githubRepo, tag, *githubRepo, tag) +} + +func releaseViewURL(tag string) string { + return fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", *githubOrg, *githubRepo, tag) +} + +func releaseEditURL(tag string) string { + return fmt.Sprintf("https://github.com/%s/%s/releases/edit/%s", *githubOrg, *githubRepo, tag) +} + +func fetch(url string) (*http.Response, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + resp.Body.Close() + return nil, fmt.Errorf("got status code of %d from %q instead of 200", resp.StatusCode, url) + } + return resp, nil +} + +type releaseFetchError struct{ error } +type releaseMismatchError struct{ error } + +func sha256Reader(r io.Reader) ([]byte, error) { + h := sha256.New() + if _, err := io.Copy(h, r); err != nil { + return nil, err + } + return h.Sum(nil), nil +} + +func run(tag string) error { + // Check the tag does not contain any characters that would break the URL + // or filesystem. + for _, c := range tag { + if c != '.' && !('0' <= c && c <= '9') && !('a' <= c && c <= 'z') && !('A' <= c && c <= 'Z') { + return fmt.Errorf("invalid tag %q", tag) + } + } + + // Read the tag from git. We will use this to ensure the archive is correct. + var expectedTree []treeEntry + if err := step("Hashing tree from git", func(s *stepPrinter) error { + var err error + expectedTree, err = gitHashTree(s, tag) + return err + }); err != nil { + return err + } + + // Hash the archive tarball. + // + // BCR does not accept archive tarballs, due to concerns that GitHub may + // change the hash, and instead prefers release tarballs. Release tarballs, + // however, are uploaded by individual developers, with no guaranteed they + // match the contents of the tag. + // + // This script checks the release tarball against the tag in the on-disk git + // repository, so we validate the contents independent of GitHub. We + // additionally check that release tarball matches the archive tarball. The + // archive tarballs are stable in practice, and this is an easy, though + // still GitHub-dependent, property that anyone can check. (This script + // assumes GitHub did not change their tarballs in the short window between + // when the release tarball was uploaded and this script runs.) + var archiveSHA256 []byte + if !*skipArchiveCheck { + if err := step("Fetching archive tarball", func(s *stepPrinter) error { + archive, err := fetch(archiveURL(tag)) + if err != nil { + return err + } + defer archive.Body.Close() + archiveSHA256, err = sha256Reader(s.httpBodyWithProgress(archive)) + return err + }); err != nil { + return err + } + } + + // Prepare an output directory. + var dir string + var err error + if len(*outDir) != 0 { + dir, err = filepath.Abs(*outDir) + } else { + dir, err = os.MkdirTemp("", "boringssl_bcr") + } + if err != nil { + return err + } + + // Fetch the release tarball. As we stream it, we do three things: + // + // 1. Compute the overall SHA-256 sum. This hash must be saved in the BCR + // configuration. + // + // 2. Hash the contents of each file in the tarball, to compare against the + // contents in git. + // + // 3. Extract MODULE.bazel and presubmit.yml, to save in the temporary + // directory. This is needed to work around limitations in BCR's tooling. + // See https://github.com/bazelbuild/bazel-central-registry/issues/2781 + var releaseTree []treeEntry + releaseHash := sha256.New() + stripPrefix := fmt.Sprintf("%s-%s/", *githubRepo, tag) + if err := step("Fetching release tarball", func(s *stepPrinter) error { + release, err := fetch(releaseURL(tag)) + if err != nil { + return releaseFetchError{err} + } + defer release.Body.Close() + + // Hash the tarball as we read it. + reader := s.httpBodyWithProgress(release) + reader = io.TeeReader(reader, releaseHash) + + zlibReader, err := gzip.NewReader(reader) + if err != nil { + return fmt.Errorf("error reading release tarball: %w", err) + } + + tarReader := tar.NewReader(zlibReader) + var seenModule, seenPresubmit bool + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("error reading release tarball: %w", err) + } + + var mode treeEntryMode + var fileReader io.Reader + switch header.Typeflag { + case tar.TypeDir: + // Check directories have a suitable prefix, but otherwise ignore + // them. + if !strings.HasPrefix(header.Name, stripPrefix) { + return fmt.Errorf("release tarball contained path %q which did not begin with %q", header.Name, stripPrefix) + } + continue + case tar.TypeXGlobalHeader: + continue + case tar.TypeReg: + if header.Mode&1 != 0 { + mode = treeEntryExecutable + } else { + mode = treeEntryRegular + } + fileReader = tarReader + case tar.TypeSymlink: + mode = treeEntrySymlink + fileReader = strings.NewReader(header.Linkname) + default: + return fmt.Errorf("path %q in release archive had unknown type %d", header.Name, header.Typeflag) + } + + path, ok := strings.CutPrefix(header.Name, stripPrefix) + if !ok { + return fmt.Errorf("release tarball contained path %q which did not begin with %q", header.Name, stripPrefix) + } + + var saveFile *os.File + if mode == treeEntryRegular && path == "MODULE.bazel" { + if seenModule { + return fmt.Errorf("release tarball contained duplicate MODULE.bazel file") + } + saveFile, err = os.Create(filepath.Join(dir, "MODULE.bazel")) + if err != nil { + return err + } + seenModule = true + } else if mode == treeEntryRegular && path == ".bcr/presubmit.yml" { + if seenPresubmit { + return fmt.Errorf("release tarball contained duplicate .bcr/presubmit.yml file") + } + saveFile, err = os.Create(filepath.Join(dir, "presubmit.yml")) + if err != nil { + return err + } + seenPresubmit = true + } + + if saveFile != nil { + fileReader = io.TeeReader(fileReader, saveFile) + } + + sha256, err := sha256Reader(fileReader) + saveFile.Close() + if err != nil { + return fmt.Errorf("error reading %q in release archive: %w", header.Name, err) + } + + releaseTree = append(releaseTree, treeEntry{path: path, mode: mode, sha256: sha256}) + } + + sortTree(releaseTree) + + // Check the zlib checksum is correct. + if err := zlibReader.Close(); err != nil { + return fmt.Errorf("error reading release tarball: %w", err) + } + + // Ensure we have read (and thus hashed) the entire archive. + if _, err := io.Copy(io.Discard, reader); err != nil { + return fmt.Errorf("error reading release archive: %w", err) + } + + if !seenModule && len(*moduleOverride) == 0 { + return fmt.Errorf("could not find MODULE.bazel in release tarball") + } + if !seenPresubmit && len(*presubmitOverride) == 0 { + return fmt.Errorf("could not find .bcr/presubmit.yml in release tarball") + } + return nil + }); err != nil { + return err + } + + releaseSHA256 := releaseHash.Sum(nil) + if !*skipArchiveCheck && !bytes.Equal(archiveSHA256, releaseSHA256) { + return releaseMismatchError{fmt.Errorf("release hash was %x, which did not match archive hash was %x", archiveSHA256, releaseSHA256)} + } + + if err := compareTrees(releaseTree, expectedTree); err != nil { + return err + } + + config := bcrConfig{ + Name: *moduleName, + Version: tag, + CompatibilityLevel: *compatibilityLevel, + ModuleDotBazel: ptr(filepath.Join(dir, "MODULE.bazel")), + URL: ptr(releaseURL(tag)), + StripPrefix: &stripPrefix, + PresubmitYml: ptr(filepath.Join(dir, "presubmit.yml")), + // encoding/json will encode nil slices as null instead of the empty array. + Deps: []string{}, + Patches: []string{}, + BuildTargets: []string{}, + TestModuleBuildTargets: []string{}, + TestModuleTestTargets: []string{}, + } + + if len(*moduleOverride) != 0 { + override, err := filepath.Abs(*moduleOverride) + if err != nil { + return err + } + config.ModuleDotBazel = &override + } + if len(*presubmitOverride) != 0 { + override, err := filepath.Abs(*presubmitOverride) + if err != nil { + return err + } + config.PresubmitYml = &override + } + + configJSON, err := json.Marshal(config) + if err != nil { + return err + } + + jsonPath := filepath.Join(dir, "bcr.json") + if err := os.WriteFile(jsonPath, configJSON, 0666); err != nil { + return err + } + + fmt.Printf("\n") + fmt.Printf("BCR configuration written to %q\n", dir) + fmt.Printf("\n") + fmt.Printf("Clone the BCR repository at:\n") + fmt.Printf(" https://github.com/bazelbuild/bazel-central-registry\n") + fmt.Printf("\n") + fmt.Printf("Then, run the following command to prepare the module update:\n") + fmt.Printf(" bazelisk run //tools:add_module -- --input %s\n", jsonPath) + fmt.Printf("\n") + fmt.Printf("Finally, commit the result and send the BCR repository a PR.\n") + return nil +} + +func main() { + flag.Usage = func() { + fmt.Fprint(os.Stderr, "Usage: go run ./util/prepare_bcr_module [FLAGS...] TAG\n") + flag.PrintDefaults() + } + flag.Parse() + if flag.NArg() != 1 { + fmt.Fprintf(os.Stderr, "Expected exactly one tag specified.\n") + flag.Usage() + os.Exit(1) + } + + tag := flag.Arg(0) + if err := run(tag); err != nil { + if _, ok := err.(releaseFetchError); ok { + fmt.Fprintf(os.Stderr, "Error fetching release URL for %q: %s\n", tag, err) + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, "To fix this, follow the following steps:\n") + fmt.Fprintf(os.Stderr, "1. Open %s in a browser.\n", releaseViewURL(tag)) + fmt.Fprintf(os.Stderr, "2. Download the \"Source code (tar.gz)\" archive.\n") + fmt.Fprintf(os.Stderr, "3. Click the edit icon, or open %s in your browser.\n", releaseEditURL(tag)) + fmt.Fprintf(os.Stderr, "4. Attach the downloaded boringssl-%s.tar.gz to the release.\n", tag) + fmt.Fprintf(os.Stderr, "\n") + } else if _, ok := err.(releaseMismatchError); ok { + fmt.Fprintf(os.Stderr, "Invalid release tarball for %q: %s\n", tag, err) + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, "To fix this, follow the following steps:\n") + fmt.Fprintf(os.Stderr, "1. Open %s in a browser.\n", releaseViewURL(tag)) + fmt.Fprintf(os.Stderr, "2. Download the \"Source code (tar.gz)\" archive.\n") + fmt.Fprintf(os.Stderr, "3. Click the edit icon, or open %s in your browser.\n", releaseEditURL(tag)) + fmt.Fprintf(os.Stderr, "4. Delete the old boringssl-%s.tar.gz from the release.\n", tag) + fmt.Fprintf(os.Stderr, "5. Re-attach the downloaded boringssl-%s.tar.gz to the release.\n", tag) + fmt.Fprintf(os.Stderr, "\n") + } else { + fmt.Fprintf(os.Stderr, "Error preparing release %q: %s\n", tag, err) + } + os.Exit(1) + } +} diff --git a/util/prepare_bcr_module/progress.go b/util/prepare_bcr_module/progress.go new file mode 100644 index 0000000000..5f226244b5 --- /dev/null +++ b/util/prepare_bcr_module/progress.go @@ -0,0 +1,115 @@ +// Copyright (c) 2024, Google Inc. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "fmt" + "io" + "net/http" + "strings" +) + +func step(desc string, f func(*stepPrinter) error) error { + fmt.Printf("%s...", desc) + if *pipe { + fmt.Printf("\n") + } else { + fmt.Printf(" ") + } + s := stepPrinter{lastPercent: -1} + err := f(&s) + s.erasePercent() + if err != nil { + fmt.Printf("ERROR\n") + } else { + fmt.Printf("OK\n") + } + return err +} + +type stepPrinter struct { + lastPercent int + percentLen int + progress, total int +} + +func (s *stepPrinter) erasePercent() { + if !*pipe && s.percentLen > 0 { + var erase strings.Builder + for i := 0; i < s.percentLen; i++ { + erase.WriteString("\b \b") + } + fmt.Printf("%s", erase.String()) + s.percentLen = 0 + } +} + +func (s *stepPrinter) setTotal(total int) { + s.progress = 0 + s.total = total + s.printPercent() +} + +func (s *stepPrinter) addProgress(delta int) { + s.progress += delta + s.printPercent() +} + +func (s *stepPrinter) printPercent() { + if s.total <= 0 { + return + } + + percent := 100 + if s.progress < s.total { + percent = 100 * s.progress / s.total + } + if *pipe { + percent -= percent % 10 + } + if percent == s.lastPercent { + return + } + + s.erasePercent() + + s.lastPercent = percent + str := fmt.Sprintf("%d%%", percent) + s.percentLen = len(str) + fmt.Printf("%s", str) + if *pipe { + fmt.Printf("\n") + } +} + +func (s *stepPrinter) progressWriter(total int) io.Writer { + s.setTotal(total) + return &progressWriter{step: s} +} + +func (s *stepPrinter) httpBodyWithProgress(r *http.Response) io.Reader { + // This does not always give any progress. It seems GitHub will sometimes + // provide a Content-Length header and sometimes not, for the same URL. + return io.TeeReader(r.Body, s.progressWriter(int(r.ContentLength))) +} + +type progressWriter struct { + step *stepPrinter +} + +func (p *progressWriter) Write(b []byte) (int, error) { + p.step.addProgress(len(b)) + return len(b), nil +}