From fb61601c5583583140a3b500f8ec9df4829675cf Mon Sep 17 00:00:00 2001 From: David Benjamin Date: Sat, 14 Sep 2024 23:36:08 -0400 Subject: [PATCH] Write custom tooling for publishing to BCR I had hoped to use Publish to BCR, but there are a few issues with it. First, a security issue: Publish to BCR requires granting a third-party app write access to the GitHub repository, even though it only reads from the repository, which requires no special privileges to read a repository: https://github.com/bazel-contrib/publish-to-bcr/issues/157 Second, merely cutting a release is not sufficient to satisfy https://blog.bazel.build/2023/02/15/github-archive-checksum.html One needs to manually upload a release tarball that GitHub then stores explicitly. (Perhaps someone should define a deterministic tarball creation process for git revisions and end this silliness.) Since that tarball is added by an individual developer, it seems poor that nothing checks it against the git repository. The BCR repository itself has some tooling for making a release. It works by interactively asking questions (not automatable), but then saves an undocumented JSON file with the answers. I've written a script that generates the JSON file we need from a git tag. These JSON files need to reference file paths, so they cannot be made standalone. (See https://github.com/bazelbuild/bazel-central-registry/issues/2781) Instead, the script drops everything into a temporary directory. Since BCR's limitations force us to do a lot of custom processing anyway, I made the script check that: 1. The release tarball matches the archive tarball, which are stable enough in practice. This allows anyone to perform an easy (still GitHub-dependent) check that they match, unless GitHub changes the hash. 2. The tarball's contents match the git tag in the local repository, so we verify GitHub against the developer's workstation. The script then prints a command to run in a local fork of the bazel-central-registry repository to make a PR. Alas, even downloading the tarball from GitHub takes a few seconds, so I had a bit of fun with the script output. Change-Id: I2a748309f63848ff097ee3c3e93e11751ef65cd7 Reviewed-on: https://boringssl-review.googlesource.com/c/boringssl/+/71307 Reviewed-by: Adam Langley Auto-Submit: David Benjamin Commit-Queue: David Benjamin --- .bcr/README.md | 10 +- docs/releasing.md | 26 ++ util/prepare_bcr_module/git.go | 225 ++++++++++ util/prepare_bcr_module/prepare_bcr_module.go | 402 ++++++++++++++++++ util/prepare_bcr_module/progress.go | 115 +++++ 5 files changed, 773 insertions(+), 5 deletions(-) create mode 100644 docs/releasing.md create mode 100644 util/prepare_bcr_module/git.go create mode 100644 util/prepare_bcr_module/prepare_bcr_module.go create mode 100644 util/prepare_bcr_module/progress.go 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 +}