mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-26 10:14:52 +09:00
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:
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user