blob: 1f5400bfdb73e8b255fec90c9ffba9b20c46da35 [file] [log] [blame]
// 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)
}
}