Files
headscale/tools/bump/generate.go
T
Kristoffer Dalby 4657a8e8af tools/bump: run the pre-commit hooks before opening the pull request
make fmt does not cover nixpkgs-fmt, trailing whitespace, end-of-file or
the YAML checks, and the bot rewrites workflow files and Dockerfiles.
2026-09-25 15:52:26 +02:00

167 lines
4.8 KiB
Go

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 repeats 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
}
// applyFormat re-runs everything that decides whether the tree is acceptable to
// CI, and commits whatever it rewrites. Two reasons it cannot be skipped:
// nixpkgs picks which prettier and gofumpt the tree is formatted with, so a
// lock bump reformats files no area touched; and the bot rewrites workflow YAML
// and Dockerfiles, where a stray trailing space or an unformatted .nix fails a
// hook rather than the compiler.
//
// The first hook pass is expected to fail, because a hook that rewrites a file
// reports failure. The second pass is the real verdict.
func applyFormat(ctx context.Context, r *repo) (change, error) {
if _, err := r.nixRun(ctx, "make", "fmt"); err != nil { //nolint:noinlineerr
return change{}, err
}
_, _ = r.nixRun(ctx, "prek", "run", "--all-files")
if _, err := r.nixRun(ctx, "prek", "run", "--all-files"); err != nil { //nolint:noinlineerr
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: fmt.Sprintf("%d file(s) reformatted", len(touched)),
Detail: touched,
}, nil
}