Files
tailscale/misc/bumpdeps/bumpdeps_test.go
T
Brad Fitzpatrick 63f625397b misc/bumpdeps, .github/workflows: unify the bumpdeps tool and bumpdep workflow
The bumpdep workflow grew 170 lines of inline shell that duplicated
much of what misc/bumpdeps already did in Go (resolving versions,
special branches, running go get), and disagreed with it in small ways
(the workflow fetched wireguard-go with GOPROXY=direct; the tool asked
the proxy for the branch head). It was also untestable except by
extracting the run block and running it by hand.

Move all of that logic into misc/bumpdeps and make the workflow a thin
wrapper that runs it with -github. The tool now:

  - accepts the workflow's argument forms: "go" for the toolchain (via
    ./pull-toolchain.sh), the "wireguard-go" and "gvisor" aliases,
    exact module paths, "path@version", and modules not yet in go.mod,
    alongside its existing substring filters; comma-separated arguments
    are split, so the workflow input passes straight through;
  - finds branch heads with git ls-remote and then asks the proxy for
    the pseudo-version of that commit, so a just-pushed commit is seen
    without ever cloning (GOPROXY=direct on github.com/gokrazy/kernel.*
    hung the workflow for hours);
  - refuses downgrades by diffing go.mod before and after go get,
    rather than grepping go get's output;
  - runs "make tidy" and "make updatedeps" itself (-tidy=false to skip);
  - renders the PR title, body (GitHub compare links derived from the
    module path, including subdirectory-tagged modules), and commit
    message, printing a suggested commit message locally and, with
    -github, writing step outputs and the step summary. -issue accepts
    the issue URL or "#N" form and becomes the "Updates" line.

The branch name changes from a slug of every module path, which
produced names like actions/bumpdep/github.com-gokrazy-kernel.amd64-
main-github.com-gokrazy-kernel.arm64-main-github, to
actions/<workflow>/<actor>/<yyyymmdd-hhmmss> (no actor for scheduled
runs). Each run is a fresh PR; delete-branch cleans up after merge or
close.

The bumpdep workflow gains an optional exclude-newer-than-days input for
the cooldown. All of the formerly-inline logic now has unit tests,
including a fake module proxy.

Also add a gokrazy-bump workflow that runs the same tool every Sunday
night on the direct github.com/gokrazy/* modules (kernels, firmware,
gokrazy itself) and opens a PR labeled run-natlab-tests, so the natlab
VM tests boot the new kernels before merge. Substring filters now skip
indirect dependencies unless -indirect is set, so that "github.com/
gokrazy/" doesn't drag in gokapi; naming an indirect module exactly
still selects it.

Updates tailscale/corp#48312
Updates #8043
Updates #1866

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I7c3e91a4d2f58b0e6a1c9d47f3b2e8a5c6d0f1e2
2026-09-23 06:42:52 -07:00

730 lines
22 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
)
const testGoMod = `module example.com/app
go 1.27
require (
github.com/gokrazy/kernel.amd64 v0.0.0-20260101000000-aaaaaaaaaaaa
github.com/gokrazy/kernel.arm64 v0.0.0-20260101000000-bbbbbbbbbbbb
github.com/tailscale/wireguard-go v0.0.0-20260201000000-cccccccccccc
golang.org/x/net v0.50.0
gvisor.dev/gvisor v0.0.0-20260301000000-dddddddddddd
)
require github.com/gobwas/glob v0.2.3 // indirect
require github.com/replaced/mod v1.0.0
replace github.com/replaced/mod => ../mod
`
func testGoModParsed(t *testing.T) *goMod {
t.Helper()
gm, err := parseGoMod([]byte(testGoMod))
if err != nil {
t.Fatal(err)
}
return gm
}
func TestParseArgs(t *testing.T) {
gm := testGoModParsed(t)
paths := func(mods []module.Version) []string {
var out []string
for _, m := range mods {
out = append(out, m.Path)
}
return out
}
tests := []struct {
name string
args []string
indirect bool
wantLookups []string
wantExplicit []update
wantTC bool
wantNames []string
wantErr string
}{
{
name: "no_args_selects_direct_deps",
wantLookups: []string{"github.com/gokrazy/kernel.amd64", "github.com/gokrazy/kernel.arm64", "github.com/tailscale/wireguard-go", "golang.org/x/net", "gvisor.dev/gvisor"},
},
{
name: "indirect_flag",
indirect: true,
wantLookups: []string{"github.com/gokrazy/kernel.amd64", "github.com/gokrazy/kernel.arm64", "github.com/tailscale/wireguard-go", "golang.org/x/net", "gvisor.dev/gvisor", "github.com/gobwas/glob"},
},
{
name: "substring_filter",
args: []string{"Kernel"},
wantLookups: []string{"github.com/gokrazy/kernel.amd64", "github.com/gokrazy/kernel.arm64"},
wantNames: []string{"Kernel"},
},
{
name: "substring_filter_skips_indirect",
args: []string{"glob"},
wantErr: `no modules in go.mod match "glob"`,
},
{
name: "substring_filter_matches_indirect_with_flag",
args: []string{"glob"},
indirect: true,
wantLookups: []string{"github.com/gobwas/glob"},
wantNames: []string{"glob"},
},
{
name: "exact_path_selects_indirect",
args: []string{"github.com/gobwas/glob"},
wantLookups: []string{"github.com/gobwas/glob"},
wantNames: []string{"github.com/gobwas/glob"},
},
{
name: "aliases_and_toolchain_comma_separated",
args: []string{"go, gvisor,wireguard-go"},
wantLookups: []string{"gvisor.dev/gvisor", "github.com/tailscale/wireguard-go"},
wantTC: true,
wantNames: []string{"Go toolchain", "gvisor", "wireguard-go"},
},
{
name: "exact_path",
args: []string{"golang.org/x/net"},
wantLookups: []string{"golang.org/x/net"},
wantNames: []string{"golang.org/x/net"},
},
{
name: "explicit_version",
args: []string{"github.com/gokrazy/kernel.amd64@main"},
wantExplicit: []update{{"github.com/gokrazy/kernel.amd64", "v0.0.0-20260101000000-aaaaaaaaaaaa", "main"}},
wantNames: []string{"github.com/gokrazy/kernel.amd64"},
},
{
name: "explicit_latest_is_a_lookup",
args: []string{"golang.org/x/net@latest"},
wantLookups: []string{"golang.org/x/net"},
wantNames: []string{"golang.org/x/net"},
},
{
name: "new_module",
args: []string{"github.com/new/mod"},
wantLookups: []string{"github.com/new/mod"},
wantNames: []string{"github.com/new/mod"},
},
{
name: "new_module_at_version",
args: []string{"github.com/new/mod@v1.2.3"},
wantExplicit: []update{{"github.com/new/mod", "", "v1.2.3"}},
wantNames: []string{"github.com/new/mod"},
},
{
name: "duplicate_selection_collapses",
args: []string{"gvisor", "gvisor.dev/gvisor", "gvisor"},
wantLookups: []string{"gvisor.dev/gvisor"},
wantNames: []string{"gvisor", "gvisor.dev/gvisor", "gvisor"},
},
{
name: "no_match",
args: []string{"nonesuch"},
wantErr: `no modules in go.mod match "nonesuch"`,
},
{
name: "replaced_explicit",
args: []string{"github.com/replaced/mod@v2.0.0"},
wantErr: "replace directive",
},
{
name: "empty_version",
args: []string{"golang.org/x/net@"},
wantErr: "empty version",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sel, err := parseArgs(gm, tt.args, tt.indirect)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v; want containing %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatal(err)
}
if got := paths(sel.lookups); !reflect.DeepEqual(got, tt.wantLookups) {
t.Errorf("lookups = %q; want %q", got, tt.wantLookups)
}
if !reflect.DeepEqual(sel.explicit, tt.wantExplicit) {
t.Errorf("explicit = %+v; want %+v", sel.explicit, tt.wantExplicit)
}
if sel.toolchain != tt.wantTC {
t.Errorf("toolchain = %v; want %v", sel.toolchain, tt.wantTC)
}
if !reflect.DeepEqual(sel.names, tt.wantNames) {
t.Errorf("names = %q; want %q", sel.names, tt.wantNames)
}
})
}
}
func TestParseArgsKeepsCurrentVersions(t *testing.T) {
gm := testGoModParsed(t)
sel, err := parseArgs(gm, []string{"golang.org/x/net", "github.com/new/mod"}, false)
if err != nil {
t.Fatal(err)
}
want := []module.Version{
{Path: "golang.org/x/net", Version: "v0.50.0"},
{Path: "github.com/new/mod"},
}
if !reflect.DeepEqual(sel.lookups, want) {
t.Errorf("lookups = %+v; want %+v", sel.lookups, want)
}
}
func TestCheckDowngrades(t *testing.T) {
before := testGoModParsed(t)
after, err := parseGoMod([]byte(strings.NewReplacer(
"golang.org/x/net v0.50.0", "golang.org/x/net v0.49.0",
"gvisor.dev/gvisor v0.0.0-20260301000000-dddddddddddd", "gvisor.dev/gvisor v0.0.0-20260401000000-eeeeeeeeeeee",
).Replace(testGoMod)))
if err != nil {
t.Fatal(err)
}
err = checkDowngrades(before, after)
if err == nil || !strings.Contains(err.Error(), "golang.org/x/net v0.50.0 => v0.49.0") {
t.Fatalf("err = %v; want x/net downgrade", err)
}
if strings.Contains(err.Error(), "gvisor") {
t.Errorf("upgrade reported as downgrade: %v", err)
}
if err := checkDowngrades(before, before); err != nil {
t.Errorf("unchanged go.mod: %v", err)
}
}
func TestParseIssueRef(t *testing.T) {
tests := []struct {
in, want string
}{
{"https://github.com/tailscale/tailscale/issues/12345", "#12345"},
{" https://github.com/tailscale/tailscale/issues/12345\n", "#12345"},
{"https://github.com/tailscale/corp/issues/7", "tailscale/corp#7"},
{"#42", "#42"},
{"tailscale/corp#42", "tailscale/corp#42"},
{"tailscale/tailscale#42", "#42"},
{"https://github.com/tailscale/tailscale/pull/12345", ""},
{"12345", ""},
{"", ""},
}
for _, tt := range tests {
got, err := parseIssueRef(tt.in, "tailscale/tailscale")
if tt.want == "" {
if err == nil {
t.Errorf("%q: got %q, want error", tt.in, got)
}
continue
}
if err != nil {
t.Errorf("%q: %v", tt.in, err)
} else if got != tt.want {
t.Errorf("%q = %q; want %q", tt.in, got, tt.want)
}
}
}
func TestDescribeChange(t *testing.T) {
tests := []struct {
u update
want string
}{
{
update{"github.com/gokrazy/kernel.amd64", "v0.0.0-20260101000000-aaaaaaaaaaaa", "v0.0.0-20260201000000-bbbbbbbbbbbb"},
"https://github.com/gokrazy/kernel.amd64/compare/aaaaaaaaaaaa...bbbbbbbbbbbb",
},
{
update{"gvisor.dev/gvisor", "v0.0.0-20260101000000-aaaaaaaaaaaa", "v0.0.0-20260201000000-bbbbbbbbbbbb"},
"https://github.com/google/gvisor/compare/aaaaaaaaaaaa...bbbbbbbbbbbb",
},
{
update{"github.com/foo/bar", "v1.2.3", "v1.3.0"},
"https://github.com/foo/bar/compare/v1.2.3...v1.3.0",
},
{
update{"github.com/foo/bar/v2", "v2.0.0", "v2.1.0"},
"https://github.com/foo/bar/compare/v2.0.0...v2.1.0",
},
{
update{"github.com/foo/bar/sub", "v1.0.0", "v1.1.0"},
"https://github.com/foo/bar/compare/sub/v1.0.0...sub/v1.1.0",
},
{
update{"github.com/foo/bar/sub/v3", "v3.0.0", "v3.1.0"},
"https://github.com/foo/bar/compare/sub/v3.0.0...sub/v3.1.0",
},
{
update{"github.com/foo/bar", "v2.0.0+incompatible", "v2.1.0+incompatible"},
"https://github.com/foo/bar/compare/v2.0.0...v2.1.0",
},
{
update{"golang.org/x/net", "v0.50.0", "v0.51.0"},
"v0.50.0 to v0.51.0",
},
{
update{"golang.org/x/net", "", "v0.51.0"},
"added at v0.51.0",
},
}
for _, tt := range tests {
if got := describeChange(tt.u); got != tt.want {
t.Errorf("%+v = %q; want %q", tt.u, got, tt.want)
}
}
}
func TestBranchName(t *testing.T) {
now := time.Date(2026, 9, 17, 15, 4, 5, 0, time.FixedZone("PDT", -7*3600))
tests := []struct {
env githubEnv
want string
}{
{githubEnv{workflow: "bumpdep", event: "workflow_dispatch", actor: "bradfitz"}, "actions/bumpdep/bradfitz/20260917-220405"},
{githubEnv{workflow: "bumpdep", event: "workflow_dispatch", actor: "some one/../evil"}, "actions/bumpdep/some-one-evil/20260917-220405"},
{githubEnv{workflow: "bumpdep", event: "workflow_dispatch"}, "actions/bumpdep/unknown/20260917-220405"},
{githubEnv{event: "workflow_dispatch", actor: "bradfitz"}, "actions/bumpdep/bradfitz/20260917-220405"},
{githubEnv{workflow: "gokrazy-bump", event: "schedule", actor: "bradfitz"}, "actions/gokrazy-bump/20260917-220405"},
{githubEnv{workflow: "gokrazy-bump", event: "workflow_dispatch", actor: "bradfitz"}, "actions/gokrazy-bump/bradfitz/20260917-220405"},
}
for _, tt := range tests {
if got := branchName(&tt.env, now); got != tt.want {
t.Errorf("branchName(%+v) = %q; want %q", tt.env, got, tt.want)
}
}
}
func TestBuildReport(t *testing.T) {
now := time.Date(2026, 9, 17, 22, 4, 5, 0, time.UTC)
env := &githubEnv{workflow: "bumpdep", event: "workflow_dispatch", actor: "bradfitz", repo: "tailscale/tailscale"}
cronEnv := &githubEnv{workflow: "gokrazy-bump", event: "schedule", actor: "bradfitz", repo: "tailscale/tailscale"}
held := &heldError{"v0.52.0", time.Now().Add(-36 * time.Hour)}
tests := []struct {
name string
in reportInput
wantTitle string
wantBranch string
wantBody string
}{
{
name: "workflow_run_with_everything",
in: reportInput{
updated: []update{
{"github.com/gokrazy/kernel.amd64", "v0.0.0-20260101000000-aaaaaaaaaaaa", "v0.0.0-20260201000000-bbbbbbbbbbbb"},
{"github.com/new/mod", "", "v1.0.0"},
},
unchanged: []string{"github.com/gokrazy/kernel.arm64"},
held: []heldInfo{{"golang.org/x/net", held}},
toolchain: &toolchainBump{"1111111", "2222222"},
issueRef: "#123",
env: env,
now: now,
},
wantTitle: "go.mod,go.toolchain.rev: bump github.com/gokrazy/kernel.amd64, github.com/new/mod, Go toolchain",
wantBranch: "actions/bumpdep/bradfitz/20260917-220405",
wantBody: `* github.com/gokrazy/kernel.amd64: https://github.com/gokrazy/kernel.amd64/compare/aaaaaaaaaaaa...bbbbbbbbbbbb
* github.com/new/mod: added at v1.0.0
* github.com/gokrazy/kernel.arm64: already current
* golang.org/x/net: held by the cooldown; v0.52.0 is only 1.5 days old
* Go toolchain: https://github.com/tailscale/go/compare/1111111...2222222
Triggered by @bradfitz via the bumpdep workflow.
Updates #123
`,
},
{
name: "local_run_many_modules_no_issue",
in: reportInput{
updated: []update{
{"a.example/a", "v1.0.0", "v1.1.0"},
{"b.example/b", "v1.0.0", "v1.1.0"},
{"c.example/c", "v1.0.0", "v1.1.0"},
{"d.example/d", "v1.0.0", "v1.1.0"},
},
now: now,
},
wantTitle: "go.mod: bump 4 modules",
wantBody: `* a.example/a: v1.0.0 to v1.1.0
* b.example/b: v1.0.0 to v1.1.0
* c.example/c: v1.0.0 to v1.1.0
* d.example/d: v1.0.0 to v1.1.0
`,
},
{
name: "scheduled_run",
in: reportInput{
updated: []update{
{"github.com/gokrazy/kernel.amd64", "v0.0.0-20260101000000-aaaaaaaaaaaa", "v0.0.0-20260201000000-bbbbbbbbbbbb"},
},
issueRef: "#1866",
env: cronEnv,
now: now,
},
wantTitle: "go.mod: bump github.com/gokrazy/kernel.amd64",
wantBranch: "actions/gokrazy-bump/20260917-220405",
wantBody: `* github.com/gokrazy/kernel.amd64: https://github.com/gokrazy/kernel.amd64/compare/aaaaaaaaaaaa...bbbbbbbbbbbb
Triggered by the gokrazy-bump workflow's schedule.
Updates #1866
`,
},
{
name: "toolchain_only",
in: reportInput{
toolchain: &toolchainBump{"1111111", "2222222"},
issueRef: "tailscale/corp#9",
now: now,
},
wantTitle: "go.toolchain.rev: bump Go toolchain",
wantBody: `* Go toolchain: https://github.com/tailscale/go/compare/1111111...2222222
Updates tailscale/corp#9
`,
},
{
name: "nothing_changed",
in: reportInput{
unchanged: []string{"gvisor.dev/gvisor"},
requested: []string{"gvisor", "Go toolchain"},
toolchain: &toolchainBump{"1111111", "1111111"},
env: env,
now: now,
},
wantTitle: "go.mod: bump gvisor, Go toolchain",
wantBranch: "actions/bumpdep/bradfitz/20260917-220405",
wantBody: `* gvisor.dev/gvisor: already current
* Go toolchain: already at 1111111
Triggered by @bradfitz via the bumpdep workflow.
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := buildReport(tt.in)
if r.title != tt.wantTitle {
t.Errorf("title = %q; want %q", r.title, tt.wantTitle)
}
if r.branch != tt.wantBranch {
t.Errorf("branch = %q; want %q", r.branch, tt.wantBranch)
}
if r.body != tt.wantBody {
t.Errorf("body = %q; want %q", r.body, tt.wantBody)
}
})
}
}
func TestWriteGitHubOutputs(t *testing.T) {
dir := t.TempDir()
env := &githubEnv{
actor: "bradfitz",
repo: "tailscale/tailscale",
outputFile: filepath.Join(dir, "output"),
summaryFile: filepath.Join(dir, "summary"),
tempDir: dir,
}
r := &report{
title: "go.mod: bump gvisor",
branch: "actions/bumpdep/bradfitz/20260917-220405",
body: "* gvisor.dev/gvisor: v1 to v2\n\nUpdates #1\n",
}
if err := r.writeGitHubOutputs(env); err != nil {
t.Fatal(err)
}
out, err := os.ReadFile(env.outputFile)
if err != nil {
t.Fatal(err)
}
got := string(out)
for _, want := range []string{
"title=go.mod: bump gvisor\n",
"branch=actions/bumpdep/bradfitz/20260917-220405\n",
"body-path=" + filepath.Join(dir, "bumpdeps-pr-body.md") + "\n",
"commit-message<<BUMPDEPS_",
"\ngo.mod: bump gvisor\n\n* gvisor.dev/gvisor: v1 to v2\n\nUpdates #1\nBUMPDEPS_",
} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q:\n%s", want, got)
}
}
// The heredoc delimiter must open and close with the same token.
lines := strings.Split(strings.TrimSpace(got), "\n")
_, delim, _ := strings.Cut(lines[3], "<<")
if lines[len(lines)-1] != delim {
t.Errorf("heredoc not closed with %q; last line %q", delim, lines[len(lines)-1])
}
body, err := os.ReadFile(filepath.Join(dir, "bumpdeps-pr-body.md"))
if err != nil {
t.Fatal(err)
}
if string(body) != r.body {
t.Errorf("body file = %q; want %q", body, r.body)
}
summary, err := os.ReadFile(env.summaryFile)
if err != nil {
t.Fatal(err)
}
if want := "### go.mod: bump gvisor\n\n" + r.body; string(summary) != want {
t.Errorf("summary = %q; want %q", summary, want)
}
}
// fakeProxy is an in-memory Go module proxy serving a fixed set of
// versions per module.
type fakeProxy struct {
// versions maps module path to its known versions; the .mod content
// for each declares modPath[path] if set, else path.
versions map[string][]versionInfo
modPath map[string]string
// commits maps a module path and commit hash to the pseudo-version
// the proxy would mint for it.
commits map[string]versionInfo
}
func (p *fakeProxy) ServeHTTP(w http.ResponseWriter, req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/")
modPath, rest, ok := strings.Cut(path, "/@")
if !ok {
http.NotFound(w, req)
return
}
modPath, _ = module.UnescapePath(modPath)
vers, known := p.versions[modPath]
if !known {
http.Error(w, "not found: unknown module", http.StatusNotFound)
return
}
switch {
case rest == "latest":
var latest versionInfo
for _, v := range vers {
if module.IsPseudoVersion(v.Version) || semver.Prerelease(v.Version) != "" {
continue
}
if latest.Version == "" || compareVersions(v.Version, latest.Version) > 0 {
latest = v
}
}
json.NewEncoder(w).Encode(latest)
case rest == "v/list":
for _, v := range vers {
if !module.IsPseudoVersion(v.Version) {
fmt.Fprintln(w, v.Version)
}
}
case strings.HasSuffix(rest, ".info"):
q := strings.TrimSuffix(strings.TrimPrefix(rest, "v/"), ".info")
if info, ok := p.commits[modPath+"@"+q]; ok {
json.NewEncoder(w).Encode(info)
return
}
for _, v := range vers {
if v.Version == q {
json.NewEncoder(w).Encode(v)
return
}
}
http.Error(w, "not found: unknown revision", http.StatusNotFound)
case strings.HasSuffix(rest, ".mod"):
declared := modPath
if mp, ok := p.modPath[modPath]; ok {
declared = mp
}
fmt.Fprintf(w, "module %s\n", declared)
default:
http.NotFound(w, req)
}
}
func compareVersions(a, b string) int {
return strings.Compare(a, b) // good enough for the single-digit versions in tests
}
func TestLookupNewer(t *testing.T) {
now := time.Now()
day := 24 * time.Hour
proxy := &fakeProxy{
versions: map[string][]versionInfo{
"example.com/tagged": {
{"v1.0.0", now.Add(-30 * day)},
{"v1.1.0", now.Add(-10 * day)},
{"v1.2.0", now.Add(-2 * day)},
{"v1.3.0-rc1", now.Add(-1 * day)},
},
"example.com/stray": {
{"v0.91.0", now.Add(-700 * day)},
},
"example.com/renamed": {
{"v1.0.0", now.Add(-100 * day)},
{"v2.0.0", now.Add(-10 * day)},
},
"gvisor.dev/gvisor": {
{"v0.0.0-20260301000000-dddddddddddd", now.Add(-20 * day)},
},
},
modPath: map[string]string{
"example.com/renamed": "example.com/newname",
},
commits: map[string]versionInfo{
"gvisor.dev/gvisor@" + strings.Repeat("e", 40): {"v0.0.0-20260401000000-eeeeeeeeeeee", now.Add(-5 * day)},
"gvisor.dev/gvisor@go": {"v0.0.0-20260301000000-dddddddddddd", now.Add(-20 * day)},
},
}
srv := httptest.NewServer(proxy)
defer srv.Close()
newResolver := func(cooldownDays int, lsRemote func(ctx context.Context, repo, branch string) (string, error)) *resolver {
r := &resolver{client: srv.Client(), base: srv.URL, lsRemote: lsRemote}
if cooldownDays > 0 {
r.cutoff = now.Add(-time.Duration(cooldownDays) * day)
}
return r
}
headIsE := func(ctx context.Context, repo, branch string) (string, error) {
if repo != "https://github.com/google/gvisor" || branch != "go" {
return "", fmt.Errorf("unexpected ls-remote %s %s", repo, branch)
}
return strings.Repeat("e", 40), nil
}
lsRemoteFails := func(context.Context, string, string) (string, error) {
return "", errors.New("no git")
}
tests := []struct {
name string
r *resolver
mod module.Version
want string
wantHeld bool
wantErr string
}{
{
name: "newer_tagged_release",
r: newResolver(0, nil),
mod: module.Version{Path: "example.com/tagged", Version: "v1.1.0"},
want: "v1.2.0",
},
{
name: "already_latest",
r: newResolver(0, nil),
mod: module.Version{Path: "example.com/tagged", Version: "v1.2.0"},
want: "",
},
{
name: "new_module_gets_latest",
r: newResolver(0, nil),
mod: module.Version{Path: "example.com/tagged"},
want: "v1.2.0",
},
{
name: "cooldown_falls_back_to_aged_release",
r: newResolver(7, nil),
mod: module.Version{Path: "example.com/tagged", Version: "v1.0.0"},
want: "v1.1.0",
},
{
name: "cooldown_holds_when_nothing_aged_is_newer",
r: newResolver(7, nil),
mod: module.Version{Path: "example.com/tagged", Version: "v1.1.0"},
wantHeld: true,
},
{
name: "stray_old_tag_is_rejected",
r: newResolver(0, nil),
mod: module.Version{Path: "example.com/stray", Version: "v0.0.0-20260101000000-aaaaaaaaaaaa"},
wantErr: "probably a stray tag",
},
{
name: "renamed_module_is_rejected",
r: newResolver(0, nil),
mod: module.Version{Path: "example.com/renamed", Version: "v1.0.0"},
wantErr: "declares module path example.com/newname",
},
{
name: "unknown_module",
r: newResolver(0, nil),
mod: module.Version{Path: "example.com/private", Version: "v1.0.0"},
wantErr: "proxy returned 404",
},
{
name: "branch_head_via_ls-remote",
r: newResolver(0, headIsE),
mod: module.Version{Path: "gvisor.dev/gvisor", Version: "v0.0.0-20260301000000-dddddddddddd"},
want: "v0.0.0-20260401000000-eeeeeeeeeeee",
},
{
name: "branch_head_held_by_cooldown",
r: newResolver(7, headIsE),
mod: module.Version{Path: "gvisor.dev/gvisor", Version: "v0.0.0-20260301000000-dddddddddddd"},
wantHeld: true,
},
{
name: "branch_head_falls_back_to_proxy_when_git_fails",
r: newResolver(0, lsRemoteFails),
mod: module.Version{Path: "gvisor.dev/gvisor", Version: "v0.0.0-20260301000000-dddddddddddd"},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.r.lookupNewer(context.Background(), tt.mod)
var held *heldError
switch {
case tt.wantHeld:
if !errors.As(err, &held) {
t.Fatalf("err = %v; want heldError", err)
}
case tt.wantErr != "":
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v; want containing %q", err, tt.wantErr)
}
case err != nil:
t.Fatal(err)
case got != tt.want:
t.Errorf("got %q; want %q", got, tt.want)
}
})
}
}
func TestSplitArgs(t *testing.T) {
got := splitArgs([]string{"go, gvisor,,wireguard-go", " a@v1 \n", "", "b"})
want := []string{"go", "gvisor", "wireguard-go", "a@v1", "b"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %q; want %q", got, want)
}
}