tools/bump: add the version bump tool

Keeps the interlocked pins current and reports what it could not move.
Areas apply, gate and commit one at a time, so a dependency that breaks
the build costs one commit rather than the whole pull request.
This commit is contained in:
Kristoffer Dalby
2026-09-04 14:26:48 +00:00
parent 23cb9883f5
commit c3d771e23e
18 changed files with 3091 additions and 0 deletions
+186
View File
@@ -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
}
+111
View File
@@ -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 ""
}
+261
View File
@@ -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
}
+188
View File
@@ -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)
}
})
}
}
+147
View File
@@ -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
}
+116
View File
@@ -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 = ""
}
}
+131
View File
@@ -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
}
+111
View File
@@ -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
}
+442
View File
@@ -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
}
+119
View File
@@ -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")
}
}
+202
View File
@@ -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
}
+136
View File
@@ -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
}
+144
View File
@@ -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)
}
+194
View File
@@ -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
}
+149
View File
@@ -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")
}
+192
View File
@@ -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 = "<!-- versionbump:v1 "
// marker records what a run produced, so the next run can tell "nothing new"
// from "the same change, already rejected".
type marker struct {
Tree string `json:"tree"`
Head string `json:"head"`
Areas map[string]string `json:"areas"`
}
func (m marker) render() string {
b, err := json.Marshal(m)
if err != nil {
return ""
}
return markerPrefix + string(b) + " -->"
}
// 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<details><summary>What moved</summary>\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("</details>\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
}
+109
View File
@@ -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)
}
})
}
}
+153
View File
@@ -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
}