tools/releaser/main.go (412 lines of code) (raw):

// Copyright 2023 Uber Technologies, Inc. // Licensed under the MIT License // releaser is a tool for managing part of the process to release a new version // of hermetic_cc_toolchain. package main import ( "archive/tar" "compress/gzip" "crypto/sha256" "errors" "flag" "fmt" "io" "net/http" "os" "os/exec" "path" "regexp" "strings" "time" bzl "github.com/bazelbuild/buildtools/build" ) var ( // regexp for valid tags _tagRegexp = regexp.MustCompile(`^v([0-9]+)\.([0-9]+)(\.([0-9]+))(-rc([0-9]+))?$`) _errTag = errors.New("tag accepts the following formats: v1.0.0 v1.0.1-rc1") // releaser is able to calculate the hash of any existing release. However, // if the releaser was changed since cutting the hash (e.g. files got added // or removed, the tar format changed, etc), then the previously-released // tarball will not match the hash. Since we cannot change the hash since // it was released, we can hardcode it here. // // Normally you don't need to set this value, unless the CI job updates the // hashes of already-released versions. Then just hardcode it here. _tagHashes = map[string]string{ "v2.0.0-rc2": "40dff82816735e631e8bd51ede3af1c4ed1ad4646928ffb6a0e53e228e55738c", "v2.0.0": "57f03a6c29793e8add7bd64186fc8066d23b5ffd06fe9cc6b0b8c499914d3a65", "v2.1.0": "892b0dd7aa88c3504a8821e65c44fd22f32c16afab12d89e9942fff492720b37", "v2.1.1": "86ace5cd211d0ae49a729a11afb344843698b64464f2095a776c57ebbdf06698", "v2.1.3": "a5caccbf6d86d4f60afd45b541a05ca4cc3f5f523aec7d3f7711e584600fb075", "v2.2.1": "3b8107de0d017fe32e6434086a9568f97c60a111b49dc34fc7001e139c30fdea", "v3.0.0": "fe00bd126e57a4c3fec4efa620bf074e3d1f1fbd70b75113ca56a010d7a70d93", } _boilerplateFiles = []string{ "README.md", path.Join("examples", "rules_cc", "WORKSPACE"), } ) func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "error: %s\n", err) os.Exit(1) } } func log(msg string, format ...any) { fmt.Fprintf(flag.CommandLine.Output(), msg+"\n", format...) } func run() (_err error) { var ( repoRoot string tag string skipBranchCheck bool ) flag.StringVar(&repoRoot, "repoRoot", os.Getenv("BUILD_WORKSPACE_DIRECTORY"), "root directory of hermetic_cc_toolchain repo") flag.StringVar(&tag, "tag", "", "tag for this release") flag.BoolVar(&skipBranchCheck, "skipBranchCheck", false, "skip branch check (for testing the release tool)") flag.Usage = func() { fmt.Fprint(flag.CommandLine.Output(), `usage: bazel run //tools/releaser -- -repoRoot <repoRoot> -tag <tag> This utility is intended to handle many of the steps to release a new version. `) flag.PrintDefaults() } flag.Parse() if tag == "" { return fmt.Errorf("tag is required") } if !_tagRegexp.MatchString(tag) { return _errTag } type checkType struct { args []string wantOut string } checks := []checkType{{[]string{"diff", "--stat", "--exit-code"}, ""}} if !skipBranchCheck { checks = append( checks, checkType{[]string{"branch", "--show-current"}, "main\n"}, ) } log("checking if git tree is ready for the release") for _, c := range checks { out, err := git(repoRoot, c.args...) if err != nil { return err } if string(out) == c.wantOut { continue } return fmt.Errorf( "unexpected output for %q. Expected %q, got:\n---\n%s\n---\n", "git "+strings.Join(c.args, " "), c.wantOut, out, ) } if err := checkZigMirrored(repoRoot); err != nil { return fmt.Errorf("zig is correctly mirrored: %w", err) } // if the tag already exists, do not cut a new one. tagAlreadyExists := false // cut a new tag if the tag does not already exist. if out, err := git(repoRoot, "tag", "-l", tag); err != nil { return err } else { tagAlreadyExists = strings.TrimSpace(out) == tag } if hash, ok := _tagHashes[tag]; ok { log("Asked for a pre-existing release which has a hardcoded hash. " + "Running in 'check-only' mode.") boilerplate := genBoilerplate(tag, hash) if err := updateBoilerplate(repoRoot, boilerplate); err != nil { return fmt.Errorf("update boilerplate: %w", err) } log("updated %s", strings.Join(_boilerplateFiles, " and ")) sep := strings.Repeat("-", 72) log("Release boilerplate:\n%[1]s\n%[2]s%[1]s\n", sep, boilerplate) return nil } if err := updateModuleVersion(repoRoot, tag); err != nil { return err } releaseRef := "HEAD" if tagAlreadyExists { releaseRef = tag } hash1, err := makeTgz(io.Discard, repoRoot, releaseRef) if err != nil { return fmt.Errorf("calculate hash1 of release tarball: %w", err) } boilerplate := genBoilerplate(tag, hash1) if err := updateBoilerplate(repoRoot, boilerplate); err != nil { return fmt.Errorf("update boilerplate: %w", err) } // If tag does not exist, create a new commit with the updated hashes // and cut the new tag. // // If the tag exists, skip committing the tag; we will just verify // that the hashes in the README and examples/ are up to date. if !tagAlreadyExists { commitMsg := fmt.Sprintf("Releasing hermetic_cc_toolchain %s", tag) if _, err := git(repoRoot, "commit", "-am", commitMsg); err != nil { return err } if _, err := git(repoRoot, "tag", tag); err != nil { return err } } // Cut the final release and compare hash1 and hash2 just in case. fpath := path.Join(repoRoot, fmt.Sprintf("hermetic_cc_toolchain-%s.tar.gz", tag)) tgz, err := os.Create(fpath) if err != nil { return err } hash2, err := makeTgz(tgz, repoRoot, tag) if err != nil { return fmt.Errorf("make release tarball: %w", err) } if err := tgz.Close(); err != nil { return err } if hash1 != hash2 { // This may happen if the release tarball depends on the boilerplate // that gets updated with the new tag. Don't do this. We want the // release commit to point to the correct hashes for that release. return fmt.Errorf( "hashes before and after release differ: %s %s", hash1, hash2, ) } log("wrote %s, sha256: %s", fpath, hash2) sep := strings.Repeat("-", 72) log("Release boilerplate:\n%[1]s\n%[2]s%[1]s\n", sep, boilerplate) return nil } func genBoilerplate(version, shasum string) string { return fmt.Sprintf(`load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") HERMETIC_CC_TOOLCHAIN_VERSION = "%[1]s" http_archive( name = "hermetic_cc_toolchain", sha256 = "%[2]s", urls = [ "https://mirror.bazel.build/github.com/uber/hermetic_cc_toolchain/releases/download/{0}/hermetic_cc_toolchain-{0}.tar.gz".format(HERMETIC_CC_TOOLCHAIN_VERSION), "https://github.com/uber/hermetic_cc_toolchain/releases/download/{0}/hermetic_cc_toolchain-{0}.tar.gz".format(HERMETIC_CC_TOOLCHAIN_VERSION), ], ) load("@hermetic_cc_toolchain//toolchain:defs.bzl", zig_toolchains = "toolchains") # Plain zig_toolchains() will pick reasonable defaults. See # toolchain/defs.bzl:toolchains on how to change the Zig SDK version and # download URL. zig_toolchains() `, version, shasum) } // updateBoilerplate updates all example files with the given version. func updateBoilerplate(repoRoot string, boilerplate string) error { const ( startMarker = `load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")` + "\n" endMarker = "zig_toolchains()\n" ) for _, gotpath := range _boilerplateFiles { f := path.Join(repoRoot, gotpath) data, err := os.ReadFile(f) if err != nil { return err } dataStr := string(data) // all boilerplate starts with startMarker and ends with endMarker. // Our goal is to write the right string between the two. startMarkerIdx := strings.Index(dataStr, startMarker) if startMarkerIdx == -1 { return fmt.Errorf("%q does not contain start marker %q...", gotpath, startMarker[0:16]) } endMarkerIdx := strings.Index(dataStr, endMarker) if endMarkerIdx == -1 { return fmt.Errorf("%q does not contain end marker %q...", gotpath, endMarker[0:16]) } preamble := dataStr[0:startMarkerIdx] epilogue := dataStr[endMarkerIdx+len(endMarker):] newBoilerplate := preamble + boilerplate + epilogue if err := os.WriteFile(f, []byte(newBoilerplate), 0644); err != nil { return fmt.Errorf("write %q: %w", f, err) } } return nil } func updateModuleVersion(repoRoot string, tag string) error { modulePath := path.Join(repoRoot, "MODULE.bazel") data, err := os.ReadFile(modulePath) if err != nil { return err } modFile, err := bzl.ParseModule(modulePath, data) if err != nil { return err } moduleName := "hermetic_cc_toolchain" moduleRule := modFile.RuleNamed(moduleName) if moduleRule == nil { return fmt.Errorf("%q does not declare module %q", modulePath, moduleName) } moduleRule.SetAttr("version", &bzl.StringExpr{Value: strings.TrimPrefix(tag, "v")}) return os.WriteFile(modulePath, bzl.Format(modFile), 0644) } func git(repoRoot string, args ...string) (string, error) { cmd := exec.Command("git", args...) cmd.Dir = repoRoot out, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf( "git %s: %v\n---\n%s\n---\n", strings.Join(args, " "), err, out, ) } return string(out), nil } func makeTgz(w io.Writer, repoRoot string, ref string) (string, error) { hashw := sha256.New() gzw, err := gzip.NewWriterLevel(io.MultiWriter(w, hashw), gzip.BestCompression) if err != nil { return "", fmt.Errorf("create gzip writer: %w", err) } tw := tar.NewWriter(gzw) // WORKSPACE in the resulting tarball needs to be much // smaller than of hermetic_cc_toolchain. See #15. // See that README why we are not adding the top-level README.md. // These files will become top-level during processing. substitutes := map[string]string{ "tools/releaser/data/WORKSPACE": "WORKSPACE", "tools/releaser/data/README": "README", } removals := map[string]struct{}{ "tools/": struct{}{}, "tools/releaser/": struct{}{}, "tools/releaser/data/": struct{}{}, } updates := map[string]struct{}{ "MODULE.bazel": {}, } // Paths to be included to the release cmd := exec.Command( "git", "archive", "--format=tar", ref, "LICENSE", "toolchain/*", // files to be renamed "tools/releaser/data/WORKSPACE", "tools/releaser/data/README", // files to be updated "MODULE.bazel", ) // the tarball produced by `git archive` has too many artifacts: // - file metadata is different when different SHAs are used. // - the archive contains the repo SHA as a "comment". // Therefore, parse whatever `git archive` outputs and sanitize it. cmd.Dir = repoRoot cmd.Stderr = os.Stderr stdout, err := cmd.StdoutPipe() if err != nil { return "", fmt.Errorf("StdoutPipe: %w", err) } if err := cmd.Start(); err != nil { return "", fmt.Errorf("git archive: %w", err) } tr := tar.NewReader(stdout) for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { return "", fmt.Errorf("read archive: %w", err) } // pax headers contain things we want to clean in // the first place. if hdr.Typeflag == tar.TypeXGlobalHeader { continue } name := hdr.Name if _, ok := removals[name]; ok { continue } if n, ok := substitutes[name]; ok { name = n } source := io.NopCloser(tr) size := hdr.Size if _, ok := updates[name]; ok { newFile, err := os.Open(path.Join(repoRoot, name)) if err != nil { return "", err } source = newFile fileInfo, err := newFile.Stat() if err != nil { return "", err } size = fileInfo.Size() } if err := tw.WriteHeader(&tar.Header{ Name: name, Mode: int64(hdr.Mode & 0777), Size: size, ModTime: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), Format: tar.FormatGNU, }); err != nil { return "", err } _, err = io.Copy(tw, source) _ = source.Close() if err != nil { return "", fmt.Errorf("writing %q to archive: %w", name, err) } } if err := tw.Close(); err != nil { return "", fmt.Errorf("close tar writer: %w", err) } if err := gzw.Close(); err != nil { return "", fmt.Errorf("close gzip stream: %w", err) } // If at this point cmd.Wait() is called, it gets stuck // on Windows. Since there isn't any value from the subprocess // at this point, do the best-effort Kill followed by Wait. _ = cmd.Process.Kill() _ = cmd.Wait() return fmt.Sprintf("%x", hashw.Sum(nil)), nil } type zigUpstream struct { version string // e.g. 0.11.0-dev.2619+bd3e248c7 urlTemplate string // https://mirror.bazel.build/ziglang.org/builds/zig-{host_platform}-{version}.{_ext} } // check if zig sdk is properly mirrored func checkZigMirrored(repoRoot string) error { upstream, err := parseZigUpstream(path.Join(repoRoot, "toolchain", "private", "zig_sdk.bzl")) if err != nil { return err } // spot-checking only windows-x86_64, because: // - to check all platforms, we should parse much more of zig_sdk.bzl. // - so we'd rather pick a single platform and test it. // - because windows coverage is smallest, let's take the windows platform. url := strings.Replace(upstream.urlTemplate, "{host_platform}", "windows-x86_64", 1) url = strings.ReplaceAll(url, "{version}", upstream.version) url = strings.Replace(url, "{_ext}", "zip", 1) log("checking if zig is mirorred in %q", url) resp, err := http.Head(url) if err != nil { return err } if resp.StatusCode != 200 { return fmt.Errorf("got non-200: %s", resp.Status) } return nil } // parseZigUpstrem parses "VERSION" from toolchain/private/zig_sdk.bzl func parseZigUpstream(defsPath string) (zigUpstream, error) { ret := zigUpstream{} data, err := os.ReadFile(defsPath) if err != nil { return zigUpstream{}, err } parsed, err := bzl.Parse(defsPath, data) if err != nil { return zigUpstream{}, err } var nightlyFormat, releaseFormat string for _, expr := range parsed.Stmt { def, ok := expr.(*bzl.AssignExpr) if !ok { continue } key := def.LHS.(*bzl.Ident) var to *string switch key.Name { case "VERSION": to = &ret.version case "URL_FORMAT_RELEASE": to = &releaseFormat case "URL_FORMAT_NIGHTLY": to = &nightlyFormat default: continue } value, ok := def.RHS.(*bzl.StringExpr) if !ok { return zigUpstream{}, errors.New("got a non-string expression") } *to = value.Value } if ret.version == "" { return zigUpstream{}, errors.New("VERSION not found") } if strings.Contains(ret.version, "dev") { ret.urlTemplate = nightlyFormat } else { ret.urlTemplate = releaseFormat } if ret.urlTemplate == "" { return zigUpstream{}, fmt.Errorf("url format for %q not found", ret.version) } ret.urlTemplate = strings.Replace(ret.urlTemplate, "https://ziglang.org/", "https://mirror.bazel.build/ziglang.org/", 1, ) return ret, nil }