diff --git a/tools/bump/area.go b/tools/bump/area.go new file mode 100644 index 00000000..7702a522 --- /dev/null +++ b/tools/bump/area.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "log" +) + +type state string + +const ( + stateApplied state = "applied" + statePartial state = "partial" + stateEmpty state = "unchanged" + stateDropped state = "dropped" + stateSkipped state = "skipped" +) + +// change is what an area did, in a shape the report can render directly. +type change struct { + Summary string + Detail []string + // Drops are sub-units the area rewound on its own, such as a single + // dependency that failed to build. + Drops []dropped + Empty bool +} + +// area is one independently revertible unit of work. Areas are the granularity +// at which the bot succeeds or fails: each becomes its own commit, so a broken +// one can be dropped without disturbing the others. +type area struct { + Name string + Needs []string + // Message renders the commit subject, in the repository's + // "package: imperative description" style. + Message func(change) string + Apply func(ctx context.Context, r *repo) (change, error) + // Gate is a cheap structural check. Anything that needs a build belongs + // in the final gate instead. + Gate func(ctx context.Context, r *repo) error +} + +type result struct { + Area string + State state + Change change + Reason string + Log string + Commit string +} + +func coreAreas() []area { + return []area{ + { + Name: "flake", + Apply: applyFlake, + Gate: gateFlake, + Message: func(c change) string { return "flake.lock: update " + c.Summary }, + }, + { + Name: "gomod", + Needs: []string{"flake"}, + Apply: applyGoMod, + Gate: gateGoMod, + Message: func(c change) string { return "go.mod: " + c.Summary }, + }, + { + Name: "docker-go", + Needs: []string{"flake"}, + Apply: applyDockerGo, + Gate: gateDockerGo, + Message: func(c change) string { return "Dockerfile: bump " + c.Summary }, + }, + { + Name: "generate", + Needs: []string{"gomod"}, + Apply: applyGenerate, + Gate: gateGenerate, + Message: func(change) string { return "all: regenerate generated files" }, + }, + } +} + +// runAreas applies each area in order, committing the ones that hold and +// rewinding the ones that do not. A dropped area leaves no residue because the +// rewind is a hard reset to a commit that is known good. +func runAreas(ctx context.Context, r *repo, areas []area, want func(string) bool) ([]result, error) { + states := make(map[string]state, len(areas)) + results := make([]result, 0, len(areas)) + + record := func(res result) { + states[res.Area] = res.State + results = append(results, res) + } + + for _, a := range areas { + if !want(a.Name) { + record(result{Area: a.Name, State: stateSkipped, Reason: "not selected"}) + + continue + } + + if blocker, ok := blockedBy(a, states); ok { + record(result{Area: a.Name, State: stateSkipped, Reason: "depends on dropped " + blocker}) + + continue + } + + res, err := runArea(ctx, r, a) + if err != nil { + return results, err + } + + record(res) + } + + return results, nil +} + +// blockedBy reports the first dependency that did not survive. +func blockedBy(a area, states map[string]state) (string, bool) { + for _, need := range a.Needs { + if states[need] == stateDropped { + return need, true + } + } + + return "", false +} + +func runArea(ctx context.Context, r *repo, a area) (result, error) { + snapshot, err := headSHA(ctx, r) + if err != nil { + return result{}, err + } + + log.Printf("area %s: applying", a.Name) + + rewind := func(res result) (result, error) { + err := resetTo(ctx, r, snapshot) + if err != nil { + return result{}, err + } + + return res, nil + } + + ch, err := a.Apply(ctx, r) + if err != nil { + return rewind(result{ + Area: a.Name, State: stateDropped, + Reason: reasonOf(err), Log: logOf(err), + }) + } + + if ch.Empty { + return rewind(result{Area: a.Name, State: stateEmpty, Change: ch}) + } + + if a.Gate != nil { + err := a.Gate(ctx, r) + if err != nil { + return rewind(result{ + Area: a.Name, State: stateDropped, Change: ch, + Reason: reasonOf(err), Log: logOf(err), + }) + } + } + + err = commitAll(ctx, r, a.Message(ch)) + if err != nil { + return result{}, err + } + + sha, err := headSHA(ctx, r) + if err != nil { + return result{}, err + } + + st := stateApplied + if len(ch.Drops) > 0 { + st = statePartial + } + + return result{Area: a.Name, State: st, Change: ch, Commit: sha}, nil +} diff --git a/tools/bump/bisect.go b/tools/bump/bisect.go new file mode 100644 index 00000000..185fb957 --- /dev/null +++ b/tools/bump/bisect.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// dropped records an atom that was rewound, and why, so the pull request body +// can say what did not move instead of leaving it silently stale. +type dropped struct { + Name string + Reason string + Log string +} + +// applyAtoms keeps every atom it can. The optimistic path applies the whole set +// at once; only when that fails does it split, so a healthy day costs one gate +// run and a bad day costs log2(n) rather than n. +// +// The split assumes a failure is attributable to one side. A genuine +// interaction between two atoms shows up as both being dropped, which is the +// safe direction to be wrong in. +func applyAtoms(ctx context.Context, r *repo, atoms []atom) ([]string, []dropped, error) { + if len(atoms) == 0 { + return nil, nil, nil + } + + base, err := saveModState(r) + if err != nil { + return nil, nil, err + } + + summaries, applyErr := applySet(ctx, r, atoms) + if applyErr == nil { + return summaries, nil, nil + } + + err = base.restore(r) + if err != nil { + return nil, nil, err + } + + if len(atoms) == 1 { + return nil, []dropped{{ + Name: atoms[0].Name, + Reason: reasonOf(applyErr), + Log: logOf(applyErr), + }}, nil + } + + mid := len(atoms) / 2 + + keptFirst, dropFirst, err := applyAtoms(ctx, r, atoms[:mid]) + if err != nil { + return nil, nil, err + } + + keptSecond, dropSecond, err := applyAtoms(ctx, r, atoms[mid:]) + if err != nil { + return nil, nil, err + } + + return append(keptFirst, keptSecond...), append(dropFirst, dropSecond...), nil +} + +// applySet applies every atom, settles go.mod and asks the atom gate whether +// the result is viable. +func applySet(ctx context.Context, r *repo, atoms []atom) ([]string, error) { + summaries := make([]string, 0, len(atoms)) + + for _, a := range atoms { + summary, err := a.Apply(ctx, r) + if err != nil { + return nil, fmt.Errorf("%s: %w", a.Name, err) + } + + summaries = append(summaries, summary) + } + + err := settle(ctx, r) + if err != nil { + return nil, err + } + + err = atomGate(ctx, r) + if err != nil { + return nil, err + } + + return summaries, nil +} + +// reasonOf renders a one-line cause for the report. +func reasonOf(err error) string { + if ce, ok := errors.AsType[*cmdError](err); ok { + return fmt.Sprintf("`%s` failed", strings.Join(ce.Argv, " ")) + } + + return strings.SplitN(err.Error(), "\n", 2)[0] +} + +// logOf returns the captured output of a failing command, if there was one. +func logOf(err error) string { + if ce, ok := errors.AsType[*cmdError](err); ok { + return ce.Tail + } + + return "" +} diff --git a/tools/bump/docker.go b/tools/bump/docker.go new file mode 100644 index 00000000..555a431c --- /dev/null +++ b/tools/bump/docker.go @@ -0,0 +1,261 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "golang.org/x/mod/semver" +) + +// tailscaleGoModURL is the go directive that the two builder stages cloning +// tailscale must be able to satisfy. +const tailscaleGoModURL = "https://raw.githubusercontent.com/tailscale/tailscale/main/go.mod" + +const dockerHubTagURL = "https://hub.docker.com/v2/repositories/library/%s/tags/%s" + +var ( + errNoGoDirective = errors.New("no go directive found") + errTagMissing = errors.New("image tag does not exist") +) + +// golangFrom matches the version inside a golang builder image reference, +// leaving any registry prefix and OS suffix untouched. +var golangFrom = regexp.MustCompile(`(?m)^(FROM\s+\S*golang:)(\d[\w.]*)(-[\w.]+)?`) + +// goPin is a Dockerfile whose golang builder must satisfy some minimum. +type goPin struct { + File string + // Why names the thing that sets the floor, for the report. + Why string +} + +// tailscaleBuilders clone tailscale from an unpinned branch, so their floor is +// upstream's go directive rather than ours. +var tailscaleBuilders = []goPin{ + {File: "Dockerfile.tailscale-HEAD", Why: "tailscale main"}, + {File: "Dockerfile.derper", Why: "tailscale main"}, +} + +// localBuilders compile this repository, so they track the toolchain the nix +// build uses and must not fall below go.mod's own directive. +var localBuilders = []goPin{ + {File: "Dockerfile.integration", Why: "devShell Go"}, + {File: "Dockerfile.wasmclient", Why: "devShell Go"}, +} + +// currentGolangTag reports the version pinned in a Dockerfile's builder stage. +func currentGolangTag(content string) (string, bool) { + m := golangFrom.FindStringSubmatch(content) + if m == nil { + return "", false + } + + return m[2], true +} + +// golangTagSuffix reports the OS variant of a builder image, such as "-alpine". +func golangTagSuffix(content string) string { + m := golangFrom.FindStringSubmatch(content) + if m == nil { + return "" + } + + return m[3] +} + +// setGolangTag rewrites every golang builder reference to want. +func setGolangTag(content, want string) string { + return golangFrom.ReplaceAllString(content, "${1}"+want+"${3}") +} + +// goDirective extracts the version from a go.mod's go line. +func goDirective(goMod string) (string, error) { + for line := range strings.Lines(goMod) { + if rest, ok := strings.CutPrefix(line, "go "); ok { + fields := strings.Fields(rest) + if len(fields) > 0 { + return fields[0], nil + } + } + } + + return "", errNoGoDirective +} + +// tailscaleGo reads the go directive at the tip of tailscale's default branch. +func tailscaleGo(ctx context.Context) (string, error) { + body, err := fetch(ctx, tailscaleGoModURL) + if err != nil { + return "", err + } + + return goDirective(string(body)) +} + +// tagExists asks the registry whether a tag is published. Rewriting a +// Dockerfile to a tag that has not been pushed yet turns a bump into an outage. +func tagExists(ctx context.Context, image, tag string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf(dockerHubTagURL, image, tag), nil) + if err != nil { + return fmt.Errorf("building tag request: %w", err) + } + + client := &http.Client{Timeout: proxyTimeout} + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("checking %s:%s: %w", image, tag, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%w: %s:%s (%s)", errTagMissing, image, tag, resp.Status) + } + + return nil +} + +// higher returns whichever Go version sorts later. +func higher(a, b string) string { + if semver.Compare("v"+a, "v"+b) >= 0 { + return a + } + + return b +} + +// bumpBuilders raises each pin to want, and reports what moved. +func bumpBuilders(ctx context.Context, r *repo, pins []goPin, want string) ([]string, error) { + var moved []string + + for _, pin := range pins { + content, err := r.readFile(pin.File) + if err != nil { + return nil, err + } + + have, ok := currentGolangTag(content) + if !ok { + return nil, fmt.Errorf("%s: %w", pin.File, errNoGoDirective) + } + + if semver.Compare("v"+have, "v"+want) >= 0 { + continue + } + + // Probe only a tag that is about to be written. nixpkgs can ship a Go + // release before the image is published, and a pin that needs no change + // must not be held up by a tag nobody is going to reference. + err = tagExists(ctx, "golang", want+golangTagSuffix(content)) + if err != nil { + return nil, err + } + + err = r.writeFile(pin.File, setGolangTag(content, want)) + if err != nil { + return nil, err + } + + moved = append(moved, fmt.Sprintf("%s golang %s -> %s (%s)", pin.File, have, want, pin.Why)) + } + + return moved, nil +} + +// applyDockerGo brings every golang builder image up to the version its +// consumer requires. The relation is a floor, not equality: a newer toolchain +// compiles an older module, and only the reverse fails. +func applyDockerGo(ctx context.Context, r *repo) (change, error) { + tsGo, err := tailscaleGo(ctx) + if err != nil { + return change{}, err + } + + nixGo, err := goVersion(ctx, r) + if err != nil { + return change{}, err + } + + ourMod, err := r.readFile("go.mod") + if err != nil { + return change{}, err + } + + ourGo, err := goDirective(ourMod) + if err != nil { + return change{}, err + } + + localWant := higher(nixGo, ourGo) + + moved, err := bumpBuilders(ctx, r, tailscaleBuilders, tsGo) + if err != nil { + return change{}, err + } + + movedLocal, err := bumpBuilders(ctx, r, localBuilders, localWant) + if err != nil { + return change{}, err + } + + moved = append(moved, movedLocal...) + if len(moved) == 0 { + return change{Empty: true}, nil + } + + return change{ + Summary: fmt.Sprintf("golang builders to %s / %s", tsGo, localWant), + Detail: moved, + }, nil +} + +// gateDockerGo re-reads what was written and re-checks the floors, so a bad +// rewrite is caught before anything is committed. +func gateDockerGo(ctx context.Context, r *repo) error { + tsGo, err := tailscaleGo(ctx) + if err != nil { + return err + } + + ourMod, err := r.readFile("go.mod") + if err != nil { + return err + } + + ourGo, err := goDirective(ourMod) + if err != nil { + return err + } + + for _, check := range []struct { + pins []goPin + floor string + }{ + {tailscaleBuilders, tsGo}, + {localBuilders, ourGo}, + } { + for _, pin := range check.pins { + content, err := r.readFile(pin.File) + if err != nil { + return err + } + + have, ok := currentGolangTag(content) + if !ok { + return fmt.Errorf("%s: %w", pin.File, errNoGoDirective) + } + + if semver.Compare("v"+have, "v"+check.floor) < 0 { + return fmt.Errorf("%s: golang %s is below the required %s: %w", + pin.File, have, check.floor, errTagMissing) + } + } + } + + return nil +} diff --git a/tools/bump/docker_test.go b/tools/bump/docker_test.go new file mode 100644 index 00000000..f7e53cd4 --- /dev/null +++ b/tools/bump/docker_test.go @@ -0,0 +1,188 @@ +package main + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestGolangTagRewrite(t *testing.T) { + tests := []struct { + name string + content string + want string + found bool + bumped string + }{ + { + name: "alpine builder", + content: "FROM golang:1.27.1-alpine AS build-env\n", + want: "1.27.1", + found: true, + bumped: "FROM golang:1.28.0-alpine AS build-env\n", + }, + { + name: "registry qualified trixie builder", + content: "FROM docker.io/golang:1.27.0-trixie AS builder\n", + want: "1.27.0", + found: true, + bumped: "FROM docker.io/golang:1.28.0-trixie AS builder\n", + }, + { + name: "no suffix", + content: "FROM golang:1.27.0\n", + want: "1.27.0", + found: true, + bumped: "FROM golang:1.28.0\n", + }, + { + // A floating tag has no version to rewrite, so the pattern must + // leave it alone rather than mangle it. + content: "FROM golang:alpine\n", + name: "floating tag", + found: false, + bumped: "FROM golang:alpine\n", + }, + { + name: "unrelated base image", + content: "FROM alpine:3.23\n", + found: false, + bumped: "FROM alpine:3.23\n", + }, + { + // Only the builder stage carries a golang reference; the runtime + // stage must survive untouched. + name: "multi stage", + content: "FROM golang:1.27.1-alpine AS build-env\nRUN true\nFROM alpine:3.23\n", + want: "1.27.1", + found: true, + bumped: "FROM golang:1.28.0-alpine AS build-env\nRUN true\nFROM alpine:3.23\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, ok := currentGolangTag(test.content) + if ok != test.found { + t.Fatalf("currentGolangTag found = %v, want %v", ok, test.found) + } + + if got != test.want { + t.Errorf("currentGolangTag = %q, want %q", got, test.want) + } + + if bumped := setGolangTag(test.content, "1.28.0"); bumped != test.bumped { + t.Errorf("setGolangTag =\n%q\nwant\n%q", bumped, test.bumped) + } + }) + } +} + +func TestGoDirective(t *testing.T) { + tests := []struct { + name string + goMod string + want string + wantErr bool + }{ + { + name: "plain", + goMod: "module example.com/x\n\ngo 1.27.1\n\nrequire (\n)\n", + want: "1.27.1", + }, + { + // "gopkg.in/..." lines start with "go" too; only the directive counts. + name: "require line starting with go", + goMod: "module x\n\ngo 1.27.0\n\nrequire gopkg.in/yaml.v3 v3.0.1\n", + want: "1.27.0", + }, + { + name: "missing", + goMod: "module example.com/x\n", + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := goDirective(test.goMod) + if (err != nil) != test.wantErr { + t.Fatalf("goDirective error = %v, wantErr %v", err, test.wantErr) + } + + if got != test.want { + t.Errorf("goDirective = %q, want %q", got, test.want) + } + }) + } +} + +func TestHigher(t *testing.T) { + tests := []struct{ a, b, want string }{ + {"1.27.0", "1.27.1", "1.27.1"}, + {"1.27.1", "1.27.0", "1.27.1"}, + {"1.27.0", "1.27.0", "1.27.0"}, + {"1.28.0", "1.9.0", "1.28.0"}, + } + + for _, test := range tests { + t.Run(test.a+"/"+test.b, func(t *testing.T) { + if got := higher(test.a, test.b); got != test.want { + t.Errorf("higher(%q, %q) = %q, want %q", test.a, test.b, got, test.want) + } + }) + } +} + +func TestLockDiff(t *testing.T) { + node := func(rev string) lockNode { + var n lockNode + + n.Locked.Rev = rev + + return n + } + + before := flakeLock{Nodes: map[string]lockNode{ + "nixpkgs": node("aaaaaaaaaaaaaaaa"), + "flake-utils": node("bbbbbbbbbbbbbbbb"), + "flake-checks": node("cccccccccccccccc"), + }} + after := flakeLock{Nodes: map[string]lockNode{ + "nixpkgs": node("dddddddddddddddd"), + "flake-utils": node("bbbbbbbbbbbbbbbb"), + "flake-checks": node("eeeeeeeeeeeeeeee"), + }} + + detail, names := lockDiff(before, after) + + if diff := cmp.Diff([]string{"flake-checks", "nixpkgs"}, names); diff != "" { + t.Errorf("names mismatch (-want +got):\n%s", diff) + } + + want := []string{"flake-checks ccccccc -> eeeeeee", "nixpkgs aaaaaaa -> ddddddd"} + if diff := cmp.Diff(want, detail); diff != "" { + t.Errorf("detail mismatch (-want +got):\n%s", diff) + } +} + +func TestGolangTagSuffix(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + {name: "alpine", content: "FROM golang:1.27.1-alpine AS build-env\n", want: "-alpine"}, + {name: "trixie", content: "FROM docker.io/golang:1.27.0-trixie AS builder\n", want: "-trixie"}, + {name: "none", content: "FROM golang:1.27.0\n"}, + {name: "no golang image", content: "FROM alpine:3.23\n"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := golangTagSuffix(test.content); got != test.want { + t.Errorf("golangTagSuffix = %q, want %q", got, test.want) + } + }) + } +} diff --git a/tools/bump/flake.go b/tools/bump/flake.go new file mode 100644 index 00000000..33049229 --- /dev/null +++ b/tools/bump/flake.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" +) + +// lockNode is the slice of a flake.lock entry that identifies what an input is +// pinned to. +type lockNode struct { + Locked struct { + Rev string `json:"rev"` + LastModified int64 `json:"lastModified"` + } `json:"locked"` +} + +type flakeLock struct { + Nodes map[string]lockNode `json:"nodes"` +} + +func readLock(r *repo) (flakeLock, error) { + var lock flakeLock + + b, err := r.readFile("flake.lock") + if err != nil { + return lock, err + } + + if err := json.Unmarshal([]byte(b), &lock); err != nil { //nolint:noinlineerr + return lock, fmt.Errorf("parsing flake.lock: %w", err) + } + + return lock, nil +} + +// lockDiff reports the inputs whose revision moved: one detailed line each, +// plus the bare names for the commit subject. +func lockDiff(before, after flakeLock) ([]string, []string) { + var detail, names []string + + for name, post := range after.Nodes { + pre, ok := before.Nodes[name] + if !ok || post.Locked.Rev == "" || pre.Locked.Rev == post.Locked.Rev { + continue + } + + detail = append(detail, fmt.Sprintf("%s %s -> %s", name, short(pre.Locked.Rev), short(post.Locked.Rev))) + names = append(names, name) + } + + sort.Strings(detail) + sort.Strings(names) + + return detail, names +} + +func short(rev string) string { + if len(rev) > 7 { + return rev[:7] + } + + return rev +} + +// applyFlake refreshes every flake input. Because flake.nix asks for +// buildGoLatestModule and go_latest, this is also how Go and every devShell +// tool get a new version. +func applyFlake(ctx context.Context, r *repo) (change, error) { + before, err := readLock(r) + if err != nil { + return change{}, err + } + + if _, err := r.run(ctx, "nix", "flake", "update"); err != nil { //nolint:noinlineerr + return change{}, err + } + + after, err := readLock(r) + if err != nil { + return change{}, err + } + + moved, names := lockDiff(before, after) + if len(moved) == 0 { + return change{Empty: true}, nil + } + + detail := moved + + if tools, err := toolVersions(ctx, r); err == nil { //nolint:noinlineerr + detail = append(detail, tools...) + } + + return change{ + Summary: shortList(names), + Detail: detail, + }, nil +} + +// shortList keeps a commit subject readable when many inputs move at once. +func shortList(names []string) string { + const maxNamed = 3 + + if len(names) <= maxNamed { + return strings.Join(names, ", ") + } + + return fmt.Sprintf("%s and %d more", strings.Join(names[:maxNamed], ", "), len(names)-maxNamed) +} + +// gateFlake proves the flake still evaluates before anything expensive runs. +func gateFlake(ctx context.Context, r *repo) error { + _, err := r.run(ctx, "nix", "eval", "--raw", + fmt.Sprintf(".#packages.%s.headscale.name", r.System)) + + return err +} + +// goVersion is the Go the devShell now provides, without the "go" prefix. +func goVersion(ctx context.Context, r *repo) (string, error) { + out, err := r.nixRun(ctx, "go", "env", "GOVERSION") + if err != nil { + return "", err + } + + return strings.TrimPrefix(strings.TrimSpace(out), "go"), nil +} + +// toolVersions reports the versions that most often decide whether a lock bump +// turns the pull request red. +func toolVersions(ctx context.Context, r *repo) ([]string, error) { + goVer, err := goVersion(ctx, r) + if err != nil { + return nil, err + } + + versions := []string{"go " + goVer} + + if out, err := r.nixRun(ctx, "golangci-lint", "version"); err == nil { //nolint:noinlineerr + versions = append(versions, strings.TrimSpace(strings.SplitN(out, "\n", 2)[0])) + } + + return versions, nil +} diff --git a/tools/bump/gate.go b/tools/bump/gate.go new file mode 100644 index 00000000..97fa1ccc --- /dev/null +++ b/tools/bump/gate.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" +) + +// Gate levels, from cheapest to most thorough. +const ( + gateNone = "none" + gateQuick = "quick" + gateFull = "full" +) + +var errUnknownGate = errors.New("unknown gate level (want none|quick|full)") + +// flakeChecks are the same derivations nix-checks.yml evaluates on a pull +// request. Running them here is close to free: this job runs on the default +// branch, so its binary-cache writes are readable by every later pull request +// job, which then gets a cache hit instead of a rebuild. +var flakeChecks = []string{"build", "gotest", "golangci-lint", "formatting"} + +// dockerGates stand in for the integration matrix. They are the two images +// whose builder pin actually breaks, plus the wasm client, which is cheap and +// exercises the go.mod pairing. +var dockerGates = []struct { + File string + Target string +}{ + {File: "Dockerfile.tailscale-HEAD", Target: "build-env"}, + {File: "Dockerfile.derper"}, + {File: "Dockerfile.wasmclient"}, +} + +// finalGate judges the accumulated tree. The ~170-job arm integration matrix is +// deliberately left to the pull request's own CI rather than duplicated here. +func finalGate(ctx context.Context, r *repo, level string) error { + switch level { + case gateNone: + return nil + case gateQuick: + return nixCheck(ctx, r, "build") + case gateFull: + default: + return fmt.Errorf("%w: %s", errUnknownGate, level) + } + + for _, check := range flakeChecks { + err := nixCheck(ctx, r, check) + if err != nil { + return err + } + } + + for _, d := range dockerGates { + argv := []string{"docker", "build", "--file", d.File} + if d.Target != "" { + argv = append(argv, "--target", d.Target) + } + + log.Printf("gate: %s", d.File) + + if _, err := r.run(ctx, append(argv, ".")...); err != nil { //nolint:noinlineerr + return err + } + } + + return nil +} + +func nixCheck(ctx context.Context, r *repo, name string) error { + log.Printf("gate: nix check %s", name) + + _, err := r.run(ctx, "nix", "build", "--fallback", "-L", + fmt.Sprintf(".#checks.%s.%s", r.System, name)) + + return err +} + +// enforceFinalGate drops committed areas newest-first until the tree passes. +// Popping from the tip is safe because every area is exactly one commit, and it +// is the cheapest correct answer: the gate cannot say which area broke, only +// that the combination did. +func enforceFinalGate(ctx context.Context, r *repo, results []result, level string) ([]result, error) { + for { + err := finalGate(ctx, r, level) + if err == nil { + return results, nil + } + + newest := -1 + + for i, res := range results { + if res.Commit != "" { + newest = i + } + } + + if newest < 0 { + return results, fmt.Errorf("gate fails on an unmodified tree: %w", err) + } + + log.Printf("gate failed, dropping area %s", results[newest].Area) + + if _, err := r.run(ctx, "git", "reset", "--hard", "HEAD~1"); err != nil { //nolint:noinlineerr + return results, err + } + + results[newest].State = stateDropped + results[newest].Reason = reasonOf(err) + results[newest].Log = logOf(err) + results[newest].Commit = "" + } +} diff --git a/tools/bump/generate.go b/tools/bump/generate.go new file mode 100644 index 00000000..82d0e049 --- /dev/null +++ b/tools/bump/generate.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "regexp" + "strings" +) + +// generatedPaths is every file the generators are allowed to touch. Anything +// outside it means a generator reached further than expected, and the area is +// dropped rather than committed. +var generatedPaths = map[string]bool{ + "hscontrol/types/types_clone.go": true, + "hscontrol/types/types_view.go": true, + "hscontrol/capver/capver_generated.go": true, + "hscontrol/capver/capver_test_data.go": true, + "gen/client/v1/client.gen.go": true, + "gen/client/v2/client.gen.go": true, + ".github/workflows/test-integration.yaml": true, +} + +// oapiPin finds the oapi-codegen version the Makefile pins, so the clients are +// regenerated with the same tool a developer running `make client` would get. +var oapiPin = regexp.MustCompile(`github\.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@(v[\w.\-]+)`) + +var ( + errNoOapiPin = errors.New("no oapi-codegen pin found in Makefile") + errStrayWrite = errors.New("generator wrote outside the generated set") +) + +func oapiVersion(r *repo) (string, error) { + mk, err := r.readFile("Makefile") + if err != nil { + return "", err + } + + m := oapiPin.FindStringSubmatch(mk) + if m == nil { + return "", errNoOapiPin + } + + return m[1], nil +} + +// generateClients mirrors the Makefile's client recipe. The bot does not shell +// out to make, but check-generated.yml still runs the real target and diffs, so +// any drift between the two surfaces on the bot's own pull request. +func generateClients(ctx context.Context, r *repo) error { + version, err := oapiVersion(r) + if err != nil { + return err + } + + tmp, err := os.CreateTemp("", "headscale-openapi-3.0.*.yaml") + if err != nil { + return fmt.Errorf("creating temporary spec: %w", err) + } + + spec := tmp.Name() + + tmp.Close() + defer os.Remove(spec) + + tool := "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@" + version + + for _, step := range [][]string{ + {"go", "run", "./cmd/gen-openapi", "-downgrade", spec}, + {"go", "run", tool, "-generate", "types,client", "-package", "clientv1", "-o", "gen/client/v1/client.gen.go", spec}, + {"go", "run", "./cmd/gen-openapi", "-api", "v2", "-downgrade", spec}, + {"go", "run", tool, "-generate", "types,client", "-package", "clientv2", "-o", "gen/client/v2/client.gen.go", spec}, + } { + if _, err := r.nixRun(ctx, step...); err != nil { + return err + } + } + + return nil +} + +// applyGenerate refreshes every checked-in generated file. Most of the churn +// here is not caused by this repository at all: the capability-version table is +// scraped from tailscale's published tags, so it goes stale on an untouched +// tree the moment upstream ships a release. +func applyGenerate(ctx context.Context, r *repo) (change, error) { + if _, err := r.nixRun(ctx, "go", "generate", "./..."); err != nil { + return change{}, err + } + + if err := generateClients(ctx, r); err != nil { + return change{}, err + } + + // go generate ./... skips dot-directories, so the integration matrix + // generator has to be invoked from inside .github/workflows. + if _, err := r.nixRunIn(ctx, ".github/workflows", "go", "generate"); err != nil { + return change{}, err + } + + touched, err := changedFiles(ctx, r) + if err != nil { + return change{}, err + } + + if len(touched) == 0 { + return change{Empty: true}, nil + } + + return change{ + Summary: strings.Join(touched, ", "), + Detail: touched, + }, nil +} + +// gateGenerate refuses a generator run that reached outside the known set. +func gateGenerate(ctx context.Context, r *repo) error { + touched, err := changedFiles(ctx, r) + if err != nil { + return err + } + + for _, f := range touched { + if !generatedPaths[f] { + return fmt.Errorf("%w: %s", errStrayWrite, f) + } + } + + return nil +} diff --git a/tools/bump/git.go b/tools/bump/git.go new file mode 100644 index 00000000..03f242a2 --- /dev/null +++ b/tools/bump/git.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "fmt" + "strings" +) + +// changedFiles lists paths that differ from HEAD, tracked or not. +func changedFiles(ctx context.Context, r *repo) ([]string, error) { + out, err := r.run(ctx, "git", "status", "--porcelain", "--untracked-files=all") + if err != nil { + return nil, err + } + + var files []string + + for line := range strings.Lines(out) { + line = strings.TrimRight(line, "\n") + if len(line) < 4 { + continue + } + + path := strings.TrimSpace(line[3:]) + // Renames read as "old -> new"; the destination is what matters. + if _, dst, ok := strings.Cut(path, " -> "); ok { + path = dst + } + + files = append(files, strings.Trim(path, `"`)) + } + + return files, nil +} + +func headSHA(ctx context.Context, r *repo) (string, error) { + out, err := r.run(ctx, "git", "rev-parse", "HEAD") + + return strings.TrimSpace(out), err +} + +// treeSHA identifies the content of the working commit, independent of message +// or parentage. It is what the pull request marker records so a rerun can tell +// "nothing new" from "same change, previously rejected". +func treeSHA(ctx context.Context, r *repo) (string, error) { + out, err := r.run(ctx, "git", "rev-parse", "HEAD^{tree}") + + return strings.TrimSpace(out), err +} + +// resetTo rewinds the worktree to sha, leaving nothing behind. Every dropped +// area goes through here, which is why a drop cannot leave a partial edit in +// the pull request. +func resetTo(ctx context.Context, r *repo, sha string) error { + if _, err := r.run(ctx, "git", "reset", "--hard", sha); err != nil { //nolint:noinlineerr + return err + } + + _, err := r.run(ctx, "git", "clean", "-fd") + + return err +} + +func commitAll(ctx context.Context, r *repo, message string) error { + if _, err := r.run(ctx, "git", "add", "--all"); err != nil { //nolint:noinlineerr + return err + } + + // The bot's own commits are machine-generated and already gated; the + // hooks re-run the same checks far more slowly. + _, err := r.run(ctx, "git", "commit", "--no-verify", "--message", message) + + return err +} + +// startBranch rebuilds the working branch from the base every run. The branch +// therefore never accumulates history, never conflicts, and the bot keeps no +// state on disk between runs. +func startBranch(ctx context.Context, r *repo, remote, base, branch string) error { + if _, err := r.run(ctx, "git", "fetch", remote, base); err != nil { //nolint:noinlineerr + return err + } + + _, err := r.run(ctx, "git", "checkout", "-B", branch, remote+"/"+base) + + return err +} + +// remoteBranchSHA is the tip of branch on remote, or "" when it does not exist. +func remoteBranchSHA(ctx context.Context, r *repo, remote, branch string) string { + out, err := r.run(ctx, "git", "ls-remote", "--heads", remote, branch) + if err != nil { + return "" + } + + fields := strings.Fields(out) + if len(fields) == 0 { + return "" + } + + return fields[0] +} + +func forcePush(ctx context.Context, r *repo, remote, branch string) error { + _, err := r.run(ctx, "git", "push", "--force", remote, "HEAD:refs/heads/"+branch) + if err != nil { + return fmt.Errorf("pushing %s: %w", branch, err) + } + + return nil +} diff --git a/tools/bump/gomod.go b/tools/bump/gomod.go new file mode 100644 index 00000000..255d3674 --- /dev/null +++ b/tools/bump/gomod.go @@ -0,0 +1,442 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "strings" + + "golang.org/x/mod/modfile" +) + +// Modules whose versions are not independent. Each pair moves as one unit or +// not at all; see the NOTE blocks in go.mod for why. +const ( + modTailscale = "tailscale.com" + modGvisor = "gvisor.dev/gvisor" + modSqlite = "modernc.org/sqlite" + modLibc = "modernc.org/libc" + modTSClient = "tailscale.com/client/tailscale/v2" +) + +var errLockstepDrift = errors.New("lockstep pair drifted after tidy") + +// atom is a set of modules that must be upgraded together. Splitting a pair +// across two atoms would let the bisect keep one half of a lockstep rule. +type atom struct { + Name string + Apply func(ctx context.Context, r *repo) (string, error) +} + +// modState is the pair of files a dependency update touches, held in memory so +// the bisect can rewind to an intermediate point that was never committed. +type modState struct { + mod []byte + sum []byte +} + +func saveModState(r *repo) (modState, error) { + mod, err := os.ReadFile(r.path("go.mod")) + if err != nil { + return modState{}, fmt.Errorf("reading go.mod: %w", err) + } + + sum, err := os.ReadFile(r.path("go.sum")) + if err != nil { + return modState{}, fmt.Errorf("reading go.sum: %w", err) + } + + return modState{mod: mod, sum: sum}, nil +} + +func (s modState) restore(r *repo) error { + err := os.WriteFile(r.path("go.mod"), s.mod, 0o644) //nolint:gosec // tracked source file + if err != nil { + return fmt.Errorf("restoring go.mod: %w", err) + } + + err = os.WriteFile(r.path("go.sum"), s.sum, 0o644) //nolint:gosec // tracked source file + if err != nil { + return fmt.Errorf("restoring go.sum: %w", err) + } + + return nil +} + +// parseGoMod reads and parses the repository's go.mod. +func parseGoMod(r *repo) (*modfile.File, error) { + b, err := os.ReadFile(r.path("go.mod")) + if err != nil { + return nil, fmt.Errorf("reading go.mod: %w", err) + } + + f, err := modfile.Parse("go.mod", b, nil) + if err != nil { + return nil, fmt.Errorf("parsing go.mod: %w", err) + } + + return f, nil +} + +// moduleVersion asks the go command what a module currently resolves to, +// which is the authority after MVS has had its say. +func moduleVersion(ctx context.Context, r *repo, path string) (string, error) { + out, err := r.nixRun(ctx, "go", "list", "-m", "-f", "{{.Version}}", path) + if err != nil { + return "", err + } + + return strings.TrimSpace(out), nil +} + +// tailscaleAtom moves tailscale.com to the tip of main and drags gvisor to +// whatever that exact commit requires. +// +// `go get -u tailscale.com` is wrong here: the pin is a pseudo-version that +// sorts above the newest release tag, so -u either no-ops or downgrades. +func tailscaleAtom(ctx context.Context, r *repo) (string, error) { + before, err := moduleVersion(ctx, r, modTailscale) + if err != nil { + return "", err + } + + if _, err := r.nixRun(ctx, "go", "get", modTailscale+"@main"); err != nil { //nolint:noinlineerr + return "", err + } + + after, err := moduleVersion(ctx, r, modTailscale) + if err != nil { + return "", err + } + + gvisor, err := partnerVersion(ctx, modTailscale, after, modGvisor) + if err != nil { + return "", err + } + + if _, err := r.nixRun(ctx, "go", "get", modGvisor+"@"+gvisor); err != nil { //nolint:noinlineerr + return "", err + } + + // A separate module with ordinary release tags, so -u is correct. + if _, err := r.nixRun(ctx, "go", "get", "-u", modTSClient); err != nil { //nolint:noinlineerr + return "", err + } + + if before == after { + return "tailscale.com unchanged", nil + } + + return fmt.Sprintf("tailscale.com %s -> %s (gvisor %s)", before, after, gvisor), nil +} + +// sqliteAtom moves modernc.org/sqlite and pins modernc.org/libc to the version +// that release requires. See go.mod's NOTE block: a mismatched libc breaks at +// runtime on some architectures rather than at build time. +func sqliteAtom(ctx context.Context, r *repo) (string, error) { + before, err := moduleVersion(ctx, r, modSqlite) + if err != nil { + return "", err + } + + latest, err := latestVersion(ctx, modSqlite) + if err != nil { + return "", fmt.Errorf("%w: %w", errNoLockstepSource, err) + } + + libc, err := partnerVersion(ctx, modSqlite, latest, modLibc) + if err != nil { + return "", err + } + + // One invocation: resolving them separately lets MVS see an inconsistent + // intermediate state. + if _, err := r.nixRun(ctx, "go", "get", modLibc+"@"+libc, modSqlite+"@"+latest); err != nil { //nolint:noinlineerr + return "", err + } + + if before == latest { + return "modernc.org/sqlite unchanged", nil + } + + return fmt.Sprintf("modernc.org/sqlite %s -> %s (libc %s)", before, latest, libc), nil +} + +// restAtom upgrades every direct requirement that is not owned by a lockstep +// atom. +func restAtom(ctx context.Context, r *repo) (string, error) { + f, err := parseGoMod(r) + if err != nil { + return "", err + } + + owned := map[string]bool{ + modTailscale: true, + modTSClient: true, + modSqlite: true, + modGvisor: true, + modLibc: true, + } + + var paths []string + + for _, req := range f.Require { + if req.Indirect || owned[req.Mod.Path] { + continue + } + + paths = append(paths, req.Mod.Path) + } + + if len(paths) == 0 { + return "no direct requirements", nil + } + + if _, err := r.nixRun(ctx, append([]string{"go", "get", "-u"}, paths...)...); err != nil { //nolint:noinlineerr + return "", err + } + + return fmt.Sprintf("%d direct requirements", len(paths)), nil +} + +func goModAtoms() []atom { + return []atom{ + {Name: "tailscale", Apply: tailscaleAtom}, + {Name: "sqlite", Apply: sqliteAtom}, + {Name: "rest", Apply: restAtom}, + } +} + +// lockstepPairs are the indirect dependencies whose version is dictated by +// another module rather than by minimal version selection. +var lockstepPairs = []struct{ owner, dep string }{ + {modTailscale, modGvisor}, + {modSqlite, modLibc}, +} + +// repin drags each lockstep dependency back to the version its owner requires. +// Upgrading unrelated modules routinely raises a shared indirect past its +// owner's pin, so without this the common case is a whole dependency batch +// failing the assertion below and being dropped wholesale. +func repin(ctx context.Context, r *repo) error { + for _, p := range lockstepPairs { + ownerVer, err := moduleVersion(ctx, r, p.owner) + if err != nil { + return err + } + + want, err := partnerVersion(ctx, p.owner, ownerVer, p.dep) + if err != nil { + return err + } + + have, err := moduleVersion(ctx, r, p.dep) + if err != nil { + return err + } + + if have == want { + continue + } + + _, err = r.nixRun(ctx, "go", "get", p.dep+"@"+want) + if err != nil { + return err + } + } + + return nil +} + +// checkLockstep re-reads the resolved versions and asserts the pairs still +// agree. MVS is allowed to raise an indirect above what its owner pins when a +// third module demands it; that is exactly the failure this catches. +func checkLockstep(ctx context.Context, r *repo) error { + for _, p := range lockstepPairs { + ownerVer, err := moduleVersion(ctx, r, p.owner) + if err != nil { + return err + } + + want, err := partnerVersion(ctx, p.owner, ownerVer, p.dep) + if err != nil { + return err + } + + got, err := moduleVersion(ctx, r, p.dep) + if err != nil { + return err + } + + if got != want { + return fmt.Errorf("%w: %s requires %s %s, go.mod resolved %s", + errLockstepDrift, p.owner, p.dep, want, got) + } + } + + return nil +} + +// lockstepNotes are the prose blocks in go.mod that explain why the pairs +// exist. `go mod tidy` re-sorts requires and can detach a comment from the line +// it documents, silently dropping the reasoning; assert attachment, not mere +// presence. +var lockstepNotes = []struct{ module, needle string }{ + {modSqlite, "issues/2188"}, + {modGvisor, "gvisor must be updated in lockstep"}, + {modLibc, "keep in lockstep with modernc.org/sqlite"}, +} + +var ( + errNoteDetached = errors.New("lockstep note no longer attached to its require") + errToolBlockOne = errors.New("go.mod tool block disappeared") +) + +func checkModComments(r *repo) error { + f, err := parseGoMod(r) + if err != nil { + return err + } + + for _, note := range lockstepNotes { + if !noteAttached(f, note.module, note.needle) { + return fmt.Errorf("%w: %s (%q)", errNoteDetached, note.module, note.needle) + } + } + + if len(f.Tool) == 0 { + return errToolBlockOne + } + + return nil +} + +// noteAttached reports whether the require line for module carries a preceding +// comment containing needle. +func noteAttached(f *modfile.File, module, needle string) bool { + for _, req := range f.Require { + if req.Mod.Path != module || req.Syntax == nil { + continue + } + + var sb strings.Builder + for _, c := range req.Syntax.Before { + sb.WriteString(c.Token) + sb.WriteString("\n") + } + + if strings.Contains(sb.String(), needle) { + return true + } + } + + return false +} + +// settle runs the steps every dependency change needs before it can be judged: +// tidy, restore the lockstep pins that the upgrade may have disturbed, tidy +// again, then assert go.mod's hand-written rules survived. +func settle(ctx context.Context, r *repo) error { + _, err := r.nixRun(ctx, "go", "mod", "tidy") + if err != nil { + return err + } + + err = repin(ctx, r) + if err != nil { + return err + } + + _, err = r.nixRun(ctx, "go", "mod", "tidy") + if err != nil { + return err + } + + err = checkLockstep(ctx, r) + if err != nil { + return err + } + + return checkModComments(r) +} + +// atomGate is the signal that one dependency set is viable. It runs once per +// bisect step, so it stays well short of the full nix checks the final gate +// runs over the finished tree. +func atomGate(ctx context.Context, r *repo) error { + if _, err := r.nixRun(ctx, "go", "build", "./..."); err != nil { //nolint:noinlineerr + return err + } + + if _, err := r.nixRun(ctx, "go", "vet", "./..."); err != nil { //nolint:noinlineerr + return err + } + + // Lint belongs here, not only in the final gate. A dependency that + // deprecates an API the tree still uses compiles and vets cleanly and fails + // staticcheck, so without this the whole area is dropped for one module's + // sake instead of the bisect narrowing to that module. + _, err := r.nixRun(ctx, "golangci-lint", "run", "--timeout", "10m") + + return err +} + +// applyGoMod upgrades dependencies, then refreshes the vendor hash that +// flake.nix reads. Skipping that refresh is the classic way to hand over a +// pull request that cannot nix build. +func applyGoMod(ctx context.Context, r *repo) (change, error) { + kept, drops, err := applyAtoms(ctx, r, goModAtoms()) + if err != nil { + return change{}, err + } + + touched, err := changedFiles(ctx, r) + if err != nil { + return change{}, err + } + + if len(touched) == 0 { + return change{Empty: true, Drops: drops}, nil + } + + if _, err := r.nixRun(ctx, "go", "run", "./cmd/vendorhash", "update"); err != nil { //nolint:noinlineerr + return change{}, err + } + + return change{ + Summary: "update dependencies", + Detail: kept, + Drops: drops, + }, nil +} + +var errTidyNotIdempotent = errors.New("go mod tidy is not idempotent") + +// gateGoMod re-runs the settling steps and asserts they are a no-op. A tidy +// that still has work to do means the committed go.mod is not what the go +// command would produce, and check-generated would say so later and louder. +func gateGoMod(ctx context.Context, r *repo) error { + before, err := saveModState(r) + if err != nil { + return err + } + + if err := settle(ctx, r); err != nil { //nolint:noinlineerr + return err + } + + after, err := saveModState(r) + if err != nil { + return err + } + + if !bytes.Equal(before.mod, after.mod) || !bytes.Equal(before.sum, after.sum) { + return errTidyNotIdempotent + } + + _, err = r.nixRun(ctx, "go", "run", "./cmd/vendorhash", "check") + + return err +} diff --git a/tools/bump/gomod_test.go b/tools/bump/gomod_test.go new file mode 100644 index 00000000..64afe0be --- /dev/null +++ b/tools/bump/gomod_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "testing" + + "golang.org/x/mod/modfile" +) + +// The lockstep notes in go.mod are load-bearing prose: they are the only record +// of why the pairs exist. `go mod tidy` re-sorts requires and can leave a note +// stranded above the wrong line, which reads fine in a diff and is wrong. +func TestNoteAttached(t *testing.T) { + const attached = `module example.com/x + +go 1.27.0 + +require ( + // NOTE: modernc sqlite has a fragile dependency chain: + // https://github.com/juanfont/headscale/issues/2188 + modernc.org/sqlite v1.52.0 + pgregory.net/rapid v1.3.0 +) +` + + // The note is still in the file, but now documents the wrong module. + const detached = `module example.com/x + +go 1.27.0 + +require ( + // NOTE: modernc sqlite has a fragile dependency chain: + // https://github.com/juanfont/headscale/issues/2188 + pgregory.net/rapid v1.3.0 + + modernc.org/sqlite v1.52.0 +) +` + + tests := []struct { + name string + content string + want bool + }{ + {name: "attached", content: attached, want: true}, + {name: "detached", content: detached, want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f, err := modfile.Parse("go.mod", []byte(test.content), nil) + if err != nil { + t.Fatalf("parsing fixture: %v", err) + } + + if got := noteAttached(f, "modernc.org/sqlite", "issues/2188"); got != test.want { + t.Errorf("noteAttached = %v, want %v", got, test.want) + } + }) + } +} + +func TestRequiredVersion(t *testing.T) { + const goMod = `module modernc.org/sqlite + +go 1.25.0 + +require ( + modernc.org/libc v1.75.6 + modernc.org/mathutil v1.7.1 +) +` + + f, err := modfile.Parse("go.mod", []byte(goMod), nil) + if err != nil { + t.Fatalf("parsing fixture: %v", err) + } + + tests := []struct { + name string + dep string + want string + found bool + }{ + {name: "present", dep: "modernc.org/libc", want: "v1.75.6", found: true}, + {name: "absent", dep: "gvisor.dev/gvisor", found: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, ok := requiredVersion(f, test.dep) + if ok != test.found { + t.Fatalf("requiredVersion found = %v, want %v", ok, test.found) + } + + if got != test.want { + t.Errorf("requiredVersion = %q, want %q", got, test.want) + } + }) + } +} + +// The repository's own go.mod is the case that actually matters: the notes must +// survive whatever the last tidy did to the require blocks. +func TestRepoLockstepNotesAttached(t *testing.T) { + b, err := modfile.Parse("../../go.mod", mustRead(t, "../../go.mod"), nil) + if err != nil { + t.Fatalf("parsing go.mod: %v", err) + } + + for _, note := range lockstepNotes { + if !noteAttached(b, note.module, note.needle) { + t.Errorf("go.mod: note %q is not attached to %s", note.needle, note.module) + } + } + + if len(b.Tool) == 0 { + t.Error("go.mod: tool block is missing") + } +} diff --git a/tools/bump/main.go b/tools/bump/main.go new file mode 100644 index 00000000..08adb000 --- /dev/null +++ b/tools/bump/main.go @@ -0,0 +1,202 @@ +// Command bump keeps headscale's pinned versions current and puts the result +// in front of a maintainer as a single reviewable pull request. +// +// The pins are interlocked. flake.nix asks nixpkgs for the newest Go, so a lock +// update moves the compiler and every devShell tool at once. Two Dockerfiles +// compile a tailscale tree cloned from an unpinned branch, so their builder +// image has to keep up with upstream's go directive. go.mod carries two pairs +// that must move together. The capability-version table is scraped from +// tailscale's published tags, so it goes stale without anyone touching the +// repository. +// +// Each of those is an area: applied, gated, and committed on its own, so one +// failure costs one commit rather than the whole pull request. +// +// bump plan resolve every source of truth and print what would change +// bump run apply, gate, and open or update the pull request +// bump verify assert the pins are mutually consistent +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + + "github.com/creachadair/command" + "github.com/creachadair/flax" +) + +type runFlags struct { + DryRun bool `flag:"dry-run,default=false,Apply and report, but run no final gate and touch no remote"` + NoPR bool `flag:"no-pr,default=false,Push nothing and open no pull request"` + Areas string `flag:"areas,Comma-separated areas to run (default: all)"` + Skip string `flag:"skip,Comma-separated areas to skip"` + Force bool `flag:"force,default=false,Open a pull request even if an identical one was closed unmerged"` + Branch string `flag:"branch,default=automation/version-bump,Branch to push"` + Base string `flag:"base,default=main,Base branch"` + Remote string `flag:"remote,default=origin,Git remote"` + Repo string `flag:"repo,GitHub repository (default: the one the job runs in)"` + Gate string `flag:"gate,default=full,Final gate level: none, quick or full"` +} + +var runCfg runFlags + +func main() { + log.SetFlags(0) + log.SetPrefix("bump: ") + + root := command.C{ + Name: "bump", + Help: "Keep headscale's pinned versions current", + Commands: []*command.C{ + { + Name: "plan", + Help: "Resolve every source of truth and print what would change", + Run: func(env *command.Env) error { return cmdPlan(env.Context()) }, + }, + { + Name: "run", + Help: "Apply, gate, and open or update the pull request", + SetFlags: command.Flags(flax.MustBind, &runCfg), + Run: func(env *command.Env) error { return cmdRun(env.Context()) }, + }, + { + Name: "verify", + Help: "Assert the pins are mutually consistent", + Run: func(env *command.Env) error { return cmdVerify(env.Context()) }, + }, + command.HelpCommand(nil), + }, + } + + command.RunOrFail(root.NewEnv(nil), os.Args[1:]) +} + +// selector turns the --areas and --skip flags into a predicate. +func selector(only, skip string) func(string) bool { + set := func(s string) map[string]bool { + m := map[string]bool{} + + for part := range strings.SplitSeq(s, ",") { + if part = strings.TrimSpace(part); part != "" { + m[part] = true + } + } + + return m + } + + wanted, skipped := set(only), set(skip) + + return func(name string) bool { + if skipped[name] { + return false + } + + return len(wanted) == 0 || wanted[name] + } +} + +func cmdRun(ctx context.Context) error { + gate := runCfg.Gate + noPR := runCfg.NoPR + + if runCfg.DryRun { + gate, noPR = gateNone, true + } + + r, err := openRepo(ctx) + if err != nil { + return err + } + + if err := startBranch(ctx, r, runCfg.Remote, runCfg.Base, runCfg.Branch); err != nil { //nolint:noinlineerr + return err + } + + results, err := runAreas(ctx, r, coreAreas(), selector(runCfg.Areas, runCfg.Skip)) + if err != nil { + return err + } + + results, err = enforceFinalGate(ctx, r, results, gate) + if err != nil { + return err + } + + tree, err := treeSHA(ctx, r) + if err != nil { + return err + } + + head, err := headSHA(ctx, r) + if err != nil { + return err + } + + body := renderBody(results, markerOf(results, tree, head)) + + if err := writeStepSummary(body); err != nil { //nolint:noinlineerr + return err + } + + if !anyApplied(results) { + log.Print("nothing moved") + + return nil + } + + if noPR { + fmt.Print(body) + + return nil + } + + slug, err := currentSlug(ctx, r, runCfg.Repo) + if err != nil { + return err + } + + return publish(ctx, r, publishOptions{ + Slug: slug, + Remote: runCfg.Remote, + Branch: runCfg.Branch, + Base: runCfg.Base, + Title: "all: bump pinned versions", + Force: runCfg.Force, + }, results) +} + +func anyApplied(results []result) bool { + for _, res := range results { + if res.Commit != "" { + return true + } + } + + return false +} + +// writeStepSummary mirrors the report into the workflow run page, so a run that +// opens no pull request still says why. +func writeStepSummary(body string) error { + path := os.Getenv("GITHUB_STEP_SUMMARY") + if path == "" { + return nil + } + + //nolint:gosec // the path is the workflow runner's own summary file + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("opening step summary: %w", err) + } + defer f.Close() + + if _, err := f.WriteString(body); err != nil { //nolint:noinlineerr + return fmt.Errorf("writing step summary: %w", err) + } + + return nil +} diff --git a/tools/bump/modproxy.go b/tools/bump/modproxy.go new file mode 100644 index 00000000..eaa803f5 --- /dev/null +++ b/tools/bump/modproxy.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" +) + +// proxyBase is the module mirror. It is deliberately the same source the go +// command already trusts: immutable per version, so the go.mod read back here +// is the one that will actually be resolved, not whatever a branch tip happens +// to hold at fetch time. +const proxyBase = "https://proxy.golang.org" + +const proxyTimeout = 30 * time.Second + +// errNoLockstepSource means the partner version could not be established. The +// caller must move neither half of the pair: a libc without its sqlite is the +// exact breakage the lockstep rule exists to prevent. +var errNoLockstepSource = errors.New("lockstep source unavailable") + +// fetch performs a GET and returns the body, or an error naming the status. +func fetch(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building request for %s: %w", url, err) + } + + client := &http.Client{Timeout: proxyTimeout} + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: %w: %s", url, errHTTPStatus, resp.Status) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", url, err) + } + + return b, nil +} + +var errHTTPStatus = errors.New("unexpected status") + +// latestVersion resolves a module's newest version through the proxy. +func latestVersion(ctx context.Context, path string) (string, error) { + esc, err := module.EscapePath(path) + if err != nil { + return "", fmt.Errorf("escaping %s: %w", path, err) + } + + body, err := fetch(ctx, proxyBase+"/"+esc+"/@latest") + if err != nil { + return "", err + } + + var info struct { + Version string `json:"Version"` + } + + if err := json.Unmarshal(body, &info); err != nil { //nolint:noinlineerr + return "", fmt.Errorf("decoding @latest for %s: %w", path, err) + } + + if info.Version == "" { + return "", fmt.Errorf("%w: empty @latest for %s", errNoLockstepSource, path) + } + + return info.Version, nil +} + +// modFileOf fetches and parses the go.mod of the exact path@version. +func modFileOf(ctx context.Context, path, version string) (*modfile.File, error) { + escPath, err := module.EscapePath(path) + if err != nil { + return nil, fmt.Errorf("escaping %s: %w", path, err) + } + + escVer, err := module.EscapeVersion(version) + if err != nil { + return nil, fmt.Errorf("escaping version %s: %w", version, err) + } + + body, err := fetch(ctx, proxyBase+"/"+escPath+"/@v/"+escVer+".mod") + if err != nil { + return nil, err + } + + f, err := modfile.Parse(path+"@"+version+"/go.mod", body, nil) + if err != nil { + return nil, fmt.Errorf("parsing go.mod of %s@%s: %w", path, version, err) + } + + return f, nil +} + +// requiredVersion reports the version of dep that f requires. +func requiredVersion(f *modfile.File, dep string) (string, bool) { + for _, req := range f.Require { + if req.Mod.Path == dep { + return req.Mod.Version, true + } + } + + return "", false +} + +// partnerVersion resolves the version of dep that owner@ownerVersion pins. It +// is the whole of the lockstep rule: the partner is never guessed, only read +// off the owner's own go.mod. +func partnerVersion(ctx context.Context, owner, ownerVersion, dep string) (string, error) { + f, err := modFileOf(ctx, owner, ownerVersion) + if err != nil { + return "", fmt.Errorf("%w: %w", errNoLockstepSource, err) + } + + v, ok := requiredVersion(f, dep) + if !ok { + return "", fmt.Errorf("%w: %s@%s does not require %s", errNoLockstepSource, owner, ownerVersion, dep) + } + + return v, nil +} diff --git a/tools/bump/plan.go b/tools/bump/plan.go new file mode 100644 index 00000000..da1b84cd --- /dev/null +++ b/tools/bump/plan.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "fmt" +) + +// cmdPlan resolves every upstream the bump reads and prints the gap against +// what is committed. It writes nothing, so it is the safe way to ask "what +// would tonight's run do" before trusting the automation. +func cmdPlan(ctx context.Context) error { + r, err := openRepo(ctx) + if err != nil { + return err + } + + for _, line := range planLines(ctx, r) { + fmt.Println(line) //nolint:forbidigo // plan output is the point + } + + return nil +} + +func planLines(ctx context.Context, r *repo) []string { + lines := []string{"flake.lock: `nix flake update` moves nixpkgs, flake-utils and flake-checks"} + + lines = append(lines, planBuilders(ctx, r)...) + lines = append(lines, planLockstep(ctx, r)...) + + if version, err := oapiVersion(r); err != nil { //nolint:noinlineerr + lines = append(lines, "oapi-codegen: "+err.Error()) + } else if latest, err := latestVersion(ctx, "github.com/oapi-codegen/oapi-codegen/v2"); err != nil { //nolint:noinlineerr + lines = append(lines, "oapi-codegen: "+err.Error()) + } else { + lines = append(lines, gap("Makefile oapi-codegen", version, latest)) + } + + return lines +} + +func planBuilders(ctx context.Context, r *repo) []string { + var lines []string + + tsGo, err := tailscaleGo(ctx) + if err != nil { + return []string{"tailscale go directive: " + err.Error()} + } + + nixGo, err := goVersion(ctx, r) + if err != nil { + return []string{"devShell Go: " + err.Error()} + } + + ourMod, err := r.readFile("go.mod") + if err != nil { + return []string{err.Error()} + } + + ourGo, err := goDirective(ourMod) + if err != nil { + return []string{err.Error()} + } + + lines = append(lines, fmt.Sprintf("go: go.mod %s, devShell %s, tailscale main %s", ourGo, nixGo, tsGo)) + + for _, set := range []struct { + pins []goPin + want string + }{ + {tailscaleBuilders, tsGo}, + {localBuilders, higher(nixGo, ourGo)}, + } { + for _, pin := range set.pins { + content, err := r.readFile(pin.File) + if err != nil { + lines = append(lines, err.Error()) + + continue + } + + have, ok := currentGolangTag(content) + if !ok { + lines = append(lines, pin.File+": no golang builder image found") + + continue + } + + lines = append(lines, gap(pin.File+" golang", have, set.want)) + } + } + + return lines +} + +func planLockstep(ctx context.Context, r *repo) []string { + var lines []string + + for _, pair := range []struct{ owner, dep, target string }{ + {modSqlite, modLibc, ""}, + {modTailscale, modGvisor, "main"}, + } { + have, err := moduleVersion(ctx, r, pair.owner) + if err != nil { + lines = append(lines, pair.owner+": "+err.Error()) + + continue + } + + want := have + + if pair.target == "" { + if latest, err := latestVersion(ctx, pair.owner); err == nil { //nolint:noinlineerr + want = latest + } + } + + partner, err := partnerVersion(ctx, pair.owner, want, pair.dep) + if err != nil { + lines = append(lines, pair.dep+": "+err.Error()) + + continue + } + + haveDep, err := moduleVersion(ctx, r, pair.dep) + if err != nil { + lines = append(lines, pair.dep+": "+err.Error()) + + continue + } + + lines = append(lines, gap(pair.owner, have, want), gap(pair.dep+" (pinned by "+pair.owner+")", haveDep, partner)) + } + + return lines +} + +// gap renders one pin as either current or lagging. +func gap(what, have, want string) string { + if have == want { + return fmt.Sprintf("%s: %s (current)", what, have) + } + + return fmt.Sprintf("%s: %s -> %s", what, have, want) +} diff --git a/tools/bump/pr.go b/tools/bump/pr.go new file mode 100644 index 00000000..b30ef715 --- /dev/null +++ b/tools/bump/pr.go @@ -0,0 +1,194 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strconv" + "strings" + "time" +) + +// skipLabel lets a maintainer park the bot on a pull request without closing it. +const skipLabel = "no-autoupdate" + +// closedCooldown is how long a rejected change is left alone. Reopening the +// same diff the next morning is how an automation earns itself an ignore rule. +const closedCooldown = 7 * 24 * time.Hour + +var errHumanCommits = errors.New("branch has commits the bot did not push") + +type prState struct { + Number int `json:"number"` + State string `json:"state"` + Body string `json:"body"` + HeadRefOid string `json:"headRefOid"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + ClosedAt time.Time `json:"closedAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (p *prState) hasLabel(name string) bool { + for _, l := range p.Labels { + if l.Name == name { + return true + } + } + + return false +} + +// lookupPR finds the most recent pull request for branch, in any state. +func lookupPR(ctx context.Context, r *repo, slug, branch string) (*prState, error) { + out, err := r.run(ctx, "gh", "pr", "list", + "--repo", slug, + "--head", branch, + "--state", "all", + "--limit", "5", + "--json", "number,state,body,headRefOid,labels,closedAt,updatedAt") + if err != nil { + return nil, err + } + + var prs []prState + if err := json.Unmarshal([]byte(out), &prs); err != nil { //nolint:noinlineerr + return nil, fmt.Errorf("decoding gh pr list: %w", err) + } + + if len(prs) == 0 { + return nil, nil //nolint:nilnil // absence is the normal first-run case + } + + newest := &prs[0] + for i := range prs { + if prs[i].UpdatedAt.After(newest.UpdatedAt) { + newest = &prs[i] + } + } + + return newest, nil +} + +// publishOptions carries everything the pull request lifecycle needs that is +// not derived from the working tree. +type publishOptions struct { + Slug string + Remote string + Branch string + Base string + Title string + Force bool +} + +// publish force-pushes the rebuilt branch and puts it in front of a human: +// reusing the open pull request if there is one, opening a new one once the +// last was merged, and staying out of the way when the last was rejected. +func publish(ctx context.Context, r *repo, opt publishOptions, results []result) error { + tree, err := treeSHA(ctx, r) + if err != nil { + return err + } + + head, err := headSHA(ctx, r) + if err != nil { + return err + } + + now := markerOf(results, tree, head) + body := renderBody(results, now) + + pr, err := lookupPR(ctx, r, opt.Slug, opt.Branch) + if err != nil { + return err + } + + if pr != nil && pr.hasLabel(skipLabel) { + log.Printf("pull request #%d is labelled %s; leaving it alone", pr.Number, skipLabel) + + return nil + } + + if pr != nil && pr.State == "OPEN" { + return updateOpen(ctx, r, opt, pr, body, now) + } + + if pr != nil && pr.State == "CLOSED" { + prev, ok := parseMarker(pr.Body) + if ok && prev.Tree == tree && !opt.Force && time.Since(pr.ClosedAt) < closedCooldown { + log.Printf("identical tree was closed unmerged in #%d on %s; skipping", + pr.Number, pr.ClosedAt.Format(time.DateOnly)) + + return nil + } + + body = fmt.Sprintf("Previously closed unmerged: #%d\n\n%s", pr.Number, body) + } + + if err := forcePush(ctx, r, opt.Remote, opt.Branch); err != nil { //nolint:noinlineerr + return err + } + + return createPR(ctx, r, opt, body) +} + +// updateOpen refreshes an existing pull request in place, but refuses to +// force-push over work the bot did not author. +func updateOpen(ctx context.Context, r *repo, opt publishOptions, pr *prState, body string, now marker) error { + prev, hadMarker := parseMarker(pr.Body) + + remoteTip := remoteBranchSHA(ctx, r, opt.Remote, opt.Branch) + if hadMarker && prev.Head != "" && remoteTip != "" && remoteTip != prev.Head { + _, _ = r.run(ctx, "gh", "pr", "comment", strconv.Itoa(pr.Number), "--repo", opt.Slug, + "--body", "This branch has commits the automation did not push, so it has paused. "+ + "Merge or close this pull request to resume.") + + return fmt.Errorf("%w: %s is at %s, expected %s", errHumanCommits, opt.Branch, remoteTip, prev.Head) + } + + err := forcePush(ctx, r, opt.Remote, opt.Branch) + if err != nil { + return err + } + + if _, err := r.run(ctx, "gh", "pr", "edit", strconv.Itoa(pr.Number), "--repo", opt.Slug, "--body", body); err != nil { //nolint:noinlineerr + return err + } + + // Only speak up when the outcome actually changed. A daily "still the + // same" comment trains the reader to ignore the thread. + if hadMarker && changedAreas(prev, now) { + if _, err := r.run(ctx, "gh", "pr", "comment", strconv.Itoa(pr.Number), "--repo", opt.Slug, //nolint:noinlineerr + "--body", "The set of applied areas changed since the last run; see the updated description."); err != nil { + return err + } + } + + log.Printf("updated pull request #%d", pr.Number) + + return nil +} + +func createPR(ctx context.Context, r *repo, opt publishOptions, body string) error { + out, err := r.run(ctx, "gh", "pr", "create", + "--repo", opt.Slug, + "--base", opt.Base, + "--head", opt.Branch, + "--title", opt.Title, + "--body", body) + if err != nil { + // The branch is already pushed at this point, so a failure here costs + // the pull request, not the work. Say so: the usual cause is a token + // without pull request write, and the fix is a token change plus a + // re-run, not redoing the bump by hand. + return fmt.Errorf("%w (branch %s is pushed; open the pull request by hand or fix the token and re-run)", + err, opt.Branch) + } + + log.Printf("opened pull request %s", strings.TrimSpace(out)) + + return nil +} diff --git a/tools/bump/repo.go b/tools/bump/repo.go new file mode 100644 index 00000000..90c04156 --- /dev/null +++ b/tools/bump/repo.go @@ -0,0 +1,149 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// repo is a checkout the bump operates on. Every helper runs from the +// repository root so relative paths in the Makefile recipes and the +// //go:generate directives resolve the way they do for a developer. +type repo struct { + Root string + // System is the Nix system double (e.g. x86_64-linux) used to address + // flake checks. + System string +} + +// tailLines is how much of a failing command's output is carried into the +// report. Enough to see a compiler error, short enough to paste into a pull +// request body. +const tailLines = 40 + +// cmdError carries the tail of a failed command's output so the reason an +// area was dropped survives all the way into the pull request body. +type cmdError struct { + Argv []string + Tail string + Err error +} + +func (e *cmdError) Error() string { + return fmt.Sprintf("%s: %v\n%s", strings.Join(e.Argv, " "), e.Err, e.Tail) +} + +func (e *cmdError) Unwrap() error { return e.Err } + +func openRepo(ctx context.Context) (*repo, error) { + out, err := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel").Output() + if err != nil { + return nil, fmt.Errorf("locating repository root: %w", err) + } + + r := &repo{Root: strings.TrimSpace(string(out))} + + sys, err := r.run(ctx, "nix", "eval", "--impure", "--raw", "--expr", "builtins.currentSystem") + if err != nil { + return nil, err + } + + r.System = strings.TrimSpace(sys) + + return r, nil +} + +// run executes argv in the repository root and returns its combined output. +func (r *repo) run(ctx context.Context, argv ...string) (string, error) { + return r.runIn(ctx, r.Root, argv...) +} + +// runIn executes argv in dir, which may be relative to the repository root. +func (r *repo) runIn(ctx context.Context, dir string, argv ...string) (string, error) { + if !filepath.IsAbs(dir) { + dir = filepath.Join(r.Root, dir) + } + + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) //nolint:gosec // argv is built from repo state, not user input + cmd.Dir = dir + + // Streams stay separate: nix writes progress and a dirty-tree warning to + // stderr, and folding that into stdout corrupts every value parsed out of + // a command run through the devShell. + var stdout, stderr bytes.Buffer + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + cmd.Env = append(os.Environ(), "GOWORK=off", "NIX_CONFIG=warn-dirty = false") + + err := cmd.Run() + if err != nil { + return stdout.String(), &cmdError{ + Argv: argv, + Tail: tail(stderr.String() + stdout.String()), + Err: err, + } + } + + return stdout.String(), nil +} + +// nixRun executes argv inside `nix develop`. It re-enters the shell on every +// call on purpose: a single long-lived shell would pin every later command to +// the toolchain that was locked before the flake area ran. +func (r *repo) nixRun(ctx context.Context, argv ...string) (string, error) { + return r.nixRunIn(ctx, r.Root, argv...) +} + +func (r *repo) nixRunIn(ctx context.Context, dir string, argv ...string) (string, error) { + full := append([]string{"nix", "develop", "--fallback", "--command"}, argv...) + + return r.runIn(ctx, dir, full...) +} + +// path joins a repository-relative path onto the root. +func (r *repo) path(rel ...string) string { + return filepath.Join(append([]string{r.Root}, rel...)...) +} + +// readFile reads a repository-relative file. +func (r *repo) readFile(rel string) (string, error) { + b, err := os.ReadFile(r.path(rel)) + if err != nil { + return "", fmt.Errorf("reading %s: %w", rel, err) + } + + return string(b), nil +} + +// writeFile writes a repository-relative file, preserving its current mode. +func (r *repo) writeFile(rel, content string) error { + name := r.path(rel) + + mode := os.FileMode(0o644) + if fi, err := os.Stat(name); err == nil { //nolint:noinlineerr + mode = fi.Mode().Perm() + } + + err := os.WriteFile(name, []byte(content), mode) + if err != nil { + return fmt.Errorf("writing %s: %w", rel, err) + } + + return nil +} + +// tail returns the last tailLines lines of s. +func tail(s string) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > tailLines { + lines = lines[len(lines)-tailLines:] + } + + return strings.Join(lines, "\n") +} diff --git a/tools/bump/report.go b/tools/bump/report.go new file mode 100644 index 00000000..6a084867 --- /dev/null +++ b/tools/bump/report.go @@ -0,0 +1,192 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" +) + +// markerPrefix tags the machine-readable footer of a bot-authored pull request +// body. It is the bot's only persistent state: everything else is rebuilt from +// the base branch on every run. +const markerPrefix = "" +} + +// parseMarker recovers the marker from a pull request body. A body without one +// was not written by this tool. +func parseMarker(body string) (marker, bool) { + _, rest, ok := strings.Cut(body, markerPrefix) + if !ok { + return marker{}, false + } + + payload, _, ok := strings.Cut(rest, " -->") + if !ok { + return marker{}, false + } + + var m marker + + err := json.Unmarshal([]byte(payload), &m) + if err != nil { + return marker{}, false + } + + return m, true +} + +func markerOf(results []result, tree, head string) marker { + areas := make(map[string]string, len(results)) + for _, res := range results { + areas[res.Area] = string(res.State) + } + + return marker{Tree: tree, Head: head, Areas: areas} +} + +// renderBody writes the pull request body. It leads with what landed and what +// did not, because the point of the bot is that the reader can decide from the +// body plus the checks without reproducing the run. +func renderBody(results []result, m marker) string { + var sb strings.Builder + + sb.WriteString("Automated version bump.\n\n") + sb.WriteString("| Area | Result | Change |\n|---|---|---|\n") + + for _, res := range results { + summary := res.Change.Summary + if summary == "" { + summary = res.Reason + } + + fmt.Fprintf(&sb, "| `%s` | %s | %s |\n", res.Area, res.State, cell(summary)) + } + + writeDetails(&sb, results) + writeDropped(&sb, results) + + sb.WriteString("\nThe integration matrix is left to this pull request's own CI; ") + sb.WriteString("the nix checks and the tailscale builder images were already run in the bump job.\n") + sb.WriteString("\n") + sb.WriteString(m.render()) + sb.WriteString("\n") + + return sb.String() +} + +func writeDetails(sb *strings.Builder, results []result) { + var opened bool + + for _, res := range results { + if len(res.Change.Detail) == 0 { + continue + } + + if !opened { + sb.WriteString("\n
What moved\n\n") + + opened = true + } + + fmt.Fprintf(sb, "**%s**\n\n", res.Area) + + for _, line := range res.Change.Detail { + fmt.Fprintf(sb, "- %s\n", line) + } + + sb.WriteString("\n") + } + + if opened { + sb.WriteString("
\n") + } +} + +func writeDropped(sb *strings.Builder, results []result) { + var opened bool + + open := func() { + if !opened { + sb.WriteString("\n### Dropped\n\n") + + opened = true + } + } + + for _, res := range results { + if res.State == stateDropped { + open() + fmt.Fprintf(sb, "- **`%s`** — %s\n", res.Area, res.Reason) + writeLog(sb, res.Log) + } + + for _, d := range res.Change.Drops { + open() + fmt.Fprintf(sb, "- **`%s`** (in `%s`) — %s\n", d.Name, res.Area, d.Reason) + writeLog(sb, d.Log) + } + } +} + +func writeLog(sb *strings.Builder, log string) { + if log == "" { + return + } + + sb.WriteString("\n ```\n") + + for line := range strings.Lines(log) { + fmt.Fprintf(sb, " %s", line) + } + + sb.WriteString("\n ```\n") +} + +// cell keeps a markdown table cell from breaking the table. +func cell(s string) string { + s = strings.ReplaceAll(s, "|", "\\|") + s = strings.ReplaceAll(s, "\n", " ") + + const maxCell = 160 + if len(s) > maxCell { + s = s[:maxCell] + "…" + } + + if s == "" { + return "—" + } + + return s +} + +// changedAreas reports whether the outcome differs from the last run, which is +// what decides between silently updating the pull request and commenting on it. +func changedAreas(prev marker, now marker) bool { + if len(prev.Areas) != len(now.Areas) { + return true + } + + for name, st := range now.Areas { + if prev.Areas[name] != st { + return true + } + } + + return false +} diff --git a/tools/bump/report_test.go b/tools/bump/report_test.go new file mode 100644 index 00000000..5f4427dc --- /dev/null +++ b/tools/bump/report_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +func mustRead(t *testing.T, path string) []byte { + t.Helper() + + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + + return b +} + +// The marker is the bot's only persistent state. If it does not survive a round +// trip through a pull request body, every run looks like a first run. +func TestMarkerRoundTrip(t *testing.T) { + results := []result{ + {Area: "flake", State: stateApplied, Commit: "abc"}, + {Area: "gomod", State: stateDropped, Reason: "`go build ./...` failed"}, + } + + want := markerOf(results, "tree-sha", "head-sha") + body := renderBody(results, want) + + got, ok := parseMarker(body) + if !ok { + t.Fatalf("parseMarker found no marker in:\n%s", body) + } + + if got.Tree != want.Tree || got.Head != want.Head { + t.Errorf("marker = %+v, want %+v", got, want) + } + + if got.Areas["gomod"] != string(stateDropped) { + t.Errorf("gomod state = %q, want %q", got.Areas["gomod"], stateDropped) + } +} + +func TestParseMarkerAbsent(t *testing.T) { + for _, body := range []string{"", "a human wrote this", markerPrefix + "not json -->"} { + if _, ok := parseMarker(body); ok { + t.Errorf("parseMarker(%q) reported a marker", body) + } + } +} + +func TestChangedAreas(t *testing.T) { + base := marker{Areas: map[string]string{"flake": "applied", "gomod": "applied"}} + + tests := []struct { + name string + now marker + want bool + }{ + {name: "identical", now: marker{Areas: map[string]string{"flake": "applied", "gomod": "applied"}}}, + {name: "state changed", now: marker{Areas: map[string]string{"flake": "applied", "gomod": "dropped"}}, want: true}, + {name: "area added", now: marker{Areas: map[string]string{"flake": "applied", "gomod": "applied", "generate": "applied"}}, want: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := changedAreas(base, test.now); got != test.want { + t.Errorf("changedAreas = %v, want %v", got, test.want) + } + }) + } +} + +// A summary containing a pipe would otherwise split the markdown table. +func TestCellEscapesTableBreakers(t *testing.T) { + got := cell("a | b\nc") + if strings.ContainsAny(got, "|\n") && !strings.Contains(got, `\|`) { + t.Errorf("cell = %q, still breaks the table", got) + } + + if cell("") != "—" { + t.Errorf("cell(\"\") = %q, want an em dash", cell("")) + } +} + +func TestSelector(t *testing.T) { + tests := []struct { + name string + only string + skip string + area string + want bool + }{ + {name: "default runs everything", area: "flake", want: true}, + {name: "explicit selection", only: "flake,gomod", area: "flake", want: true}, + {name: "not selected", only: "flake", area: "gomod"}, + {name: "skip wins over selection", only: "flake", skip: "flake", area: "flake"}, + {name: "whitespace tolerated", only: " flake , gomod ", area: "gomod", want: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := selector(test.only, test.skip)(test.area); got != test.want { + t.Errorf("selector(%q, %q)(%q) = %v, want %v", test.only, test.skip, test.area, got, test.want) + } + }) + } +} diff --git a/tools/bump/verify.go b/tools/bump/verify.go new file mode 100644 index 00000000..74324cdc --- /dev/null +++ b/tools/bump/verify.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "strings" + + "golang.org/x/mod/semver" +) + +var errInvariants = errors.New("version pins are inconsistent") + +// finding is one violated invariant, phrased so the fix is obvious. +type finding string + +// cmdVerify asserts the pins agree with each other and with their upstreams. It +// is deliberately separate from run: the same checks catch a hand-written +// commit that breaks a lockstep rule, not just a bad automated one. +func cmdVerify(ctx context.Context) error { + r, err := openRepo(ctx) + if err != nil { + return err + } + + findings := make([]finding, 0, 4) + + findings = append(findings, verifyLockstep(ctx, r)...) + findings = append(findings, verifyBuilders(ctx, r)...) + findings = append(findings, verifyToolchain(ctx, r)...) + findings = append(findings, verifyVendorHash(ctx, r)...) + + if len(findings) == 0 { + log.Print("all version pins agree") + + return nil + } + + for _, f := range findings { + log.Printf("- %s", f) + } + + return fmt.Errorf("%w: %d finding(s)", errInvariants, len(findings)) +} + +func verifyLockstep(ctx context.Context, r *repo) []finding { + var findings []finding + + err := checkLockstep(ctx, r) + if err != nil { + findings = append(findings, finding(err.Error())) + } + + err = checkModComments(r) + if err != nil { + findings = append(findings, finding(err.Error())) + } + + return findings +} + +// verifyBuilders checks the floor relation, not equality: a newer builder +// compiles an older module, and only the reverse fails. +func verifyBuilders(ctx context.Context, r *repo) []finding { + var findings []finding + + ourMod, err := r.readFile("go.mod") + if err != nil { + return []finding{finding(err.Error())} + } + + ourGo, err := goDirective(ourMod) + if err != nil { + return []finding{finding(err.Error())} + } + + tsGo, tsErr := tailscaleGo(ctx) + + for _, check := range []struct { + pins []goPin + floor string + err error + }{ + {tailscaleBuilders, tsGo, tsErr}, + {localBuilders, ourGo, nil}, + } { + if check.err != nil { + findings = append(findings, finding("could not resolve the tailscale go directive: "+check.err.Error())) + + continue + } + + for _, pin := range check.pins { + content, err := r.readFile(pin.File) + if err != nil { + findings = append(findings, finding(err.Error())) + + continue + } + + have, ok := currentGolangTag(content) + if !ok { + findings = append(findings, finding(pin.File+": no golang builder image found")) + + continue + } + + if semver.Compare("v"+have, "v"+check.floor) < 0 { + findings = append(findings, finding(fmt.Sprintf( + "%s: golang %s is below the %s required by %s", pin.File, have, check.floor, pin.Why))) + } + } + } + + return findings +} + +// verifyToolchain reports, but never edits, a go.mod directive that has fallen +// behind the toolchain the build actually uses. Raising it is a promise to +// downstream packagers, so it stays a human decision. +func verifyToolchain(ctx context.Context, r *repo) []finding { + nixGo, err := goVersion(ctx, r) + if err != nil { + return []finding{finding(err.Error())} + } + + ourMod, err := r.readFile("go.mod") + if err != nil { + return []finding{finding(err.Error())} + } + + ourGo, err := goDirective(ourMod) + if err != nil { + return []finding{finding(err.Error())} + } + + if semver.Compare("v"+ourGo, "v"+nixGo) > 0 { + return []finding{finding(fmt.Sprintf( + "go.mod requires go %s but the devShell provides %s", ourGo, nixGo))} + } + + return nil +} + +func verifyVendorHash(ctx context.Context, r *repo) []finding { + out, err := r.nixRun(ctx, "go", "run", "./cmd/vendorhash", "check") + if err != nil { + return []finding{finding("flakehashes.json is stale: " + strings.TrimSpace(out))} + } + + return nil +}