blob: 210426c914dc06a4c131958251115491e6dfc6b5 [file]
// Copyright 2026 The BoringSSL Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// update_bazel_deps updates dependencies in MODULE.bazel files to their latest
// stable versions from the Bazel Central Registry (BCR).
package main
import (
"bytes"
"cmp"
"encoding/json"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// Version represents a parsed non-prerelease Bazel module version. See
// https://bazel.build/external/module#version_format.
type Version struct {
components []string
raw string
}
var bazelVersionRE = regexp.MustCompile(`^([a-zA-Z0-9.]+)(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`)
var errPrerelease = errors.New("prerelease versions are not supported")
func parseVersion(v string) (Version, error) {
matches := bazelVersionRE.FindStringSubmatch(v)
if matches == nil {
return Version{}, fmt.Errorf("invalid version %q", v)
}
if matches[2] != "" {
return Version{}, fmt.Errorf("invalid version %q: %w", v, errPrerelease)
}
return Version{
components: strings.Split(matches[1], "."),
raw: v,
}, nil
}
func compareComponents(c1, c2 string) int {
// Numeric identifiers are compared numerically.
n1, err1 := strconv.Atoi(c1)
n2, err2 := strconv.Atoi(c2)
if err1 == nil && err2 == nil {
return cmp.Compare(n1, n2)
}
// Numeric has lower precedence than non-numeric.
if err1 == nil {
return -1
}
if err2 == nil {
return 1
}
// Non-numeric identifiers are compared lexically.
return cmp.Compare(c1, c2)
}
func compareVersions(v1, v2 Version) int {
minLen := min(len(v1.components), len(v2.components))
for i := 0; i < minLen; i++ {
if ret := compareComponents(v1.components[i], v2.components[i]); ret != 0 {
return ret
}
}
// If one version is a prefix of the other, the shorter compares first.
return cmp.Compare(len(v1.components), len(v2.components))
}
type moduleMetadata struct {
Versions []string `json:"versions"`
YankedVersions map[string]string `json:"yanked_versions"`
}
func getLatestVersion(dep string) (Version, error) {
url := fmt.Sprintf("https://bcr.bazel.build/modules/%s/metadata.json", dep)
resp, err := http.Get(url)
if err != nil {
return Version{}, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return Version{}, fmt.Errorf("bad status %d fetching metadata for %s", resp.StatusCode, dep)
}
var meta moduleMetadata
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
return Version{}, err
}
var latest Version
var found bool
for _, vStr := range meta.Versions {
if _, yanked := meta.YankedVersions[vStr]; yanked {
continue
}
v, err := parseVersion(vStr)
if err != nil {
if errors.Is(err, errPrerelease) {
// Ignore prerelease versions.
continue
}
return Version{}, err
}
if !found || compareVersions(v, latest) > 0 {
latest = v
found = true
}
}
if !found {
return Version{}, fmt.Errorf("no suitable version found for %s", dep)
}
return latest, nil
}
// Group 1: prefix up to name="
// Group 2: dep name
// Group 3: middle part between name and version
// Group 4: current version
// Group 5: suffix
var bazelDepRE = regexp.MustCompile(`^(\s*bazel_dep\(\s*name\s*=\s*")([^"]+)("\s*,\s*version\s*=\s*")([^"]+)("\s*.*)$`)
func updateFile(path string) (changed bool, err error) {
content, err := os.ReadFile(path)
if err != nil {
return false, err
}
lines := bytes.Split(content, []byte("\n"))
// Account for the trailing newline.
if len(lines) > 0 && len(lines[len(lines)-1]) == 0 {
lines = lines[:len(lines)-1]
}
var out bytes.Buffer
for _, line := range lines {
var lineChanged bool
matches := bazelDepRE.FindSubmatch(line)
if matches != nil {
depName := string(matches[2])
currentVerStr := string(matches[4])
currentVer, err := parseVersion(currentVerStr)
if err != nil {
return false, fmt.Errorf("could not parse current version for %s: %s", depName, err)
}
latestVer, err := getLatestVersion(depName)
if err != nil {
return false, fmt.Errorf("could not get latest version for %s: %s", depName, err)
}
if compareVersions(latestVer, currentVer) > 0 {
out.Write(matches[1])
out.Write(matches[2])
out.Write(matches[3])
out.WriteString(latestVer.raw)
out.Write(matches[5])
out.WriteByte('\n')
fmt.Printf("%s: Updating %s: %s -> %s\n", path, depName, currentVerStr, latestVer.raw)
lineChanged = true
changed = true
}
}
if !lineChanged {
out.Write(line)
out.WriteByte('\n')
}
}
if changed {
if err := os.WriteFile(path, out.Bytes(), 0666); err != nil {
return false, err
}
return true, nil
}
fmt.Printf("%s: All dependencies up to date.\n", path)
return false, nil
}
func updateLockfile(dir string) error {
cmd := exec.Command("bazelisk", "mod", "deps", "--lockfile_mode=refresh")
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
fmt.Printf("Running bazelisk mod deps --lockfile_mode=refresh in %s...\n", dir)
return cmd.Run()
}
func main() {
flag.Parse()
files := flag.Args()
if len(files) == 0 {
files = []string{"MODULE.bazel", "util/bazel-example/MODULE.bazel"}
}
var anyChanged bool
for _, file := range files {
changed, err := updateFile(file)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %s\n", file, err)
os.Exit(1)
}
anyChanged = anyChanged || changed
}
// If any dependencies changed, update all lock files. The lockfiles for
// the example project depend on the root project.
if anyChanged {
for _, file := range files {
dir := filepath.Dir(file)
if err := updateLockfile(dir); err != nil {
fmt.Fprintf(os.Stderr, "Error updating lockfile in %s: %s\n", dir, err)
os.Exit(1)
}
}
}
}