From 24e43d0a5a5bb4eb98389ec827e0494097e3a6ad Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 4 Sep 2026 14:45:02 +0000 Subject: [PATCH] tools/bump: bump the pinned developer tools The oapi-codegen pin stays the single source of truth: the generate area reads it back out of the Makefile, so the clients are regenerated with whatever this lands on. --- tools/bump/area.go | 1 + tools/bump/github.go | 91 +++++++++++++++ tools/bump/tools.go | 232 +++++++++++++++++++++++++++++++++++++++ tools/bump/tools_test.go | 78 +++++++++++++ 4 files changed, 402 insertions(+) create mode 100644 tools/bump/github.go create mode 100644 tools/bump/tools.go create mode 100644 tools/bump/tools_test.go diff --git a/tools/bump/area.go b/tools/bump/area.go index 02cbbcc8..c2a1fd9e 100644 --- a/tools/bump/area.go +++ b/tools/bump/area.go @@ -62,6 +62,7 @@ func allAreas() []area { }, } + areas = append(areas, toolAreas()...) areas = append(areas, area{ Name: "gomod", diff --git a/tools/bump/github.go b/tools/bump/github.go new file mode 100644 index 00000000..98a76f33 --- /dev/null +++ b/tools/bump/github.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + "sync" +) + +const githubAPI = "https://api.github.com" + +var errNoRelease = errors.New("no published release") + +// githubToken is resolved once. Unauthenticated GitHub allows sixty requests an +// hour, which the action-pin sweep alone would exhaust, so a token is worth +// looking for even outside CI. +var githubToken = sync.OnceValue(func() string { + for _, env := range []string{"GH_TOKEN", "GITHUB_TOKEN"} { + if v := os.Getenv(env); v != "" { + return v + } + } + + out, err := exec.Command("gh", "auth", "token").Output() + if err != nil { + return "" + } + + return strings.TrimSpace(string(out)) +}) + +// githubJSON performs an authenticated GET against the GitHub REST API. +func githubJSON(ctx context.Context, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPI+path, nil) + if err != nil { + return fmt.Errorf("building request for %s: %w", path, err) + } + + req.Header.Set("Accept", "application/vnd.github+json") + + if token := githubToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + client := &http.Client{Timeout: proxyTimeout} + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("GET %s: %w", path, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s: %w: %s", path, errHTTPStatus, resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading %s: %w", path, err) + } + + if err := json.Unmarshal(body, out); err != nil { //nolint:noinlineerr + return fmt.Errorf("decoding %s: %w", path, err) + } + + return nil +} + +// latestRelease is the tag of a repository's newest published release. +func latestRelease(ctx context.Context, owner, name string) (string, error) { + var release struct { + TagName string `json:"tag_name"` + } + + err := githubJSON(ctx, fmt.Sprintf("/repos/%s/%s/releases/latest", owner, name), &release) + if err != nil { + return "", err + } + + if release.TagName == "" { + return "", fmt.Errorf("%w for %s/%s", errNoRelease, owner, name) + } + + return release.TagName, nil +} diff --git a/tools/bump/tools.go b/tools/bump/tools.go new file mode 100644 index 00000000..c343d81c --- /dev/null +++ b/tools/bump/tools.go @@ -0,0 +1,232 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + + "golang.org/x/mod/semver" +) + +const pypiURL = "https://pypi.org/pypi/%s/json" + +var ( + // preCommitRev matches the pinned revision of the upstream hook repository. + preCommitRev = regexp.MustCompile(`(?m)^(\s*- repo: https://github\.com/pre-commit/pre-commit-hooks\n\s*rev: )(\S+)`) + + // pyRequirement splits "mkdocs-materialx[imaging]~=10.1" into its parts. + pyRequirement = regexp.MustCompile(`^([A-Za-z0-9._-]+)(\[[^\]]*\])?~=(\d+)\.(\d+)$`) +) + +// applyOapiCodegen moves the generator pin in the Makefile. The generate area +// reads the pin back out of the Makefile, so the clients are regenerated with +// whatever this lands on. +func applyOapiCodegen(ctx context.Context, r *repo) (change, error) { + have, err := oapiVersion(r) + if err != nil { + return change{}, err + } + + want, err := latestVersion(ctx, "github.com/oapi-codegen/oapi-codegen/v2") + if err != nil { + return change{}, err + } + + if want == have { + return change{Empty: true}, nil + } + + content, err := r.readFile("Makefile") + if err != nil { + return change{}, err + } + + // Both invocations carry the pin; leaving one behind would generate the + // two clients with different tools. + updated := strings.ReplaceAll(content, + "oapi-codegen/v2/cmd/oapi-codegen@"+have, + "oapi-codegen/v2/cmd/oapi-codegen@"+want) + + err = r.writeFile("Makefile", updated) + if err != nil { + return change{}, err + } + + return change{ + Summary: fmt.Sprintf("oapi-codegen %s to %s", have, want), + Detail: []string{fmt.Sprintf("Makefile oapi-codegen %s -> %s", have, want)}, + }, nil +} + +// gateOapiCodegen asserts no invocation kept the old pin. +func gateOapiCodegen(_ context.Context, r *repo) error { + content, err := r.readFile("Makefile") + if err != nil { + return err + } + + version, err := oapiVersion(r) + if err != nil { + return err + } + + want := strings.Count(content, "oapi-codegen/v2/cmd/oapi-codegen@") + + got := strings.Count(content, "oapi-codegen/v2/cmd/oapi-codegen@"+version) + if got != want { + return fmt.Errorf("%w: %d of %d oapi-codegen invocations use %s", + errNoMatchingTag, got, want, version) + } + + return nil +} + +// applyPreCommit moves the only external hook revision. Every other hook in the +// config is language: system and follows the devShell. +func applyPreCommit(ctx context.Context, r *repo) (change, error) { + content, err := r.readFile(".pre-commit-config.yaml") + if err != nil { + return change{}, err + } + + m := preCommitRev.FindStringSubmatch(content) + if m == nil { + return change{}, fmt.Errorf("%w: pre-commit-hooks rev", errNoMatchingTag) + } + + want, err := latestRelease(ctx, "pre-commit", "pre-commit-hooks") + if err != nil { + return change{}, err + } + + if want == m[2] { + return change{Empty: true}, nil + } + + err = r.writeFile(".pre-commit-config.yaml", preCommitRev.ReplaceAllString(content, "${1}"+want)) + if err != nil { + return change{}, err + } + + return change{ + Summary: fmt.Sprintf("pre-commit-hooks %s to %s", m[2], want), + Detail: []string{fmt.Sprintf(".pre-commit-config.yaml rev %s -> %s", m[2], want)}, + }, nil +} + +// pypiVersion is the newest published version of a distribution. +func pypiVersion(ctx context.Context, name string) (string, error) { + body, err := fetch(ctx, fmt.Sprintf(pypiURL, name)) + if err != nil { + return "", err + } + + var meta struct { + Info struct { + Version string `json:"version"` + } `json:"info"` + } + + if err := json.Unmarshal(body, &meta); err != nil { //nolint:noinlineerr + return "", fmt.Errorf("decoding PyPI metadata for %s: %w", name, err) + } + + return meta.Info.Version, nil +} + +// applyDocsRequirements raises the compatible-release floors in the docs +// requirements. "~=X.Y" already admits newer patch and minor releases, so this +// is about keeping the recorded floor honest rather than unblocking an upgrade. +func applyDocsRequirements(ctx context.Context, r *repo) (change, error) { + const file = "docs/requirements.txt" + + content, err := r.readFile(file) + if err != nil { + return change{}, err + } + + var ( + out strings.Builder + details []string + ) + + for line := range strings.Lines(content) { + bumped, detail, err := bumpRequirement(ctx, line) + if err != nil { + return change{}, err + } + + out.WriteString(bumped) + + if detail != "" { + details = append(details, detail) + } + } + + if len(details) == 0 { + return change{Empty: true}, nil + } + + err = r.writeFile(file, out.String()) + if err != nil { + return change{}, err + } + + return change{ + Summary: fmt.Sprintf("%d docs requirement floors", len(details)), + Detail: details, + }, nil +} + +// bumpRequirement returns the line to write and, when it moved, a description. +func bumpRequirement(ctx context.Context, line string) (string, string, error) { + trimmed := strings.TrimRight(line, "\n") + + m := pyRequirement.FindStringSubmatch(trimmed) + if m == nil { + return line, "", nil + } + + name, extras, have := m[1], m[2], m[3]+"."+m[4] + + latest, err := pypiVersion(ctx, name) + if err != nil { + return "", "", err + } + + parts := strings.SplitN(latest, ".", 3) + if len(parts) < 2 { + return line, "", nil + } + + want := parts[0] + "." + parts[1] + if semver.Compare("v"+want, "v"+have) <= 0 { + return line, "", nil + } + + return fmt.Sprintf("%s%s~=%s\n", name, extras, want), + fmt.Sprintf("%s ~=%s -> ~=%s", name, have, want), nil +} + +func toolAreas() []area { + return []area{ + { + Name: "tools:oapi-codegen", + Apply: applyOapiCodegen, + Gate: gateOapiCodegen, + Message: func(c change) string { return "Makefile: bump " + c.Summary }, + }, + { + Name: "tools:pre-commit", + Apply: applyPreCommit, + Message: func(c change) string { return "prek: bump " + c.Summary }, + }, + { + Name: "tools:docs", + Apply: applyDocsRequirements, + Message: func(c change) string { return "docs: raise " + c.Summary }, + }, + } +} diff --git a/tools/bump/tools_test.go b/tools/bump/tools_test.go new file mode 100644 index 00000000..7d45f03a --- /dev/null +++ b/tools/bump/tools_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "strings" + "testing" +) + +func TestPyRequirementParse(t *testing.T) { + tests := []struct { + name string + line string + pkg, extras string + major, minor string + match bool + }{ + { + name: "plain", line: "mike~=2.1", + pkg: "mike", major: "2", minor: "1", match: true, + }, + { + name: "with extras", line: "mkdocs-materialx[imaging]~=10.1", + pkg: "mkdocs-materialx", extras: "[imaging]", major: "10", minor: "1", match: true, + }, + {name: "comment", line: "# a note"}, + {name: "blank", line: ""}, + {name: "exact pin is not ours to move", line: "mike==2.1.0"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + m := pyRequirement.FindStringSubmatch(test.line) + if (m != nil) != test.match { + t.Fatalf("match = %v, want %v", m != nil, test.match) + } + + if m == nil { + return + } + + for i, want := range map[int]string{1: test.pkg, 2: test.extras, 3: test.major, 4: test.minor} { + if m[i] != want { + t.Errorf("group %d = %q, want %q", i, m[i], want) + } + } + }) + } +} + +func TestPreCommitRevRewrite(t *testing.T) { + const config = `repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-json + - repo: local + hooks: + - id: prettier +` + + m := preCommitRev.FindStringSubmatch(config) + if m == nil { + t.Fatal("pattern did not match the config") + } + + if m[2] != "v6.0.0" { + t.Fatalf("captured %q, want v6.0.0", m[2]) + } + + got := preCommitRev.ReplaceAllString(config, "${1}v6.1.0") + if want := "rev: v6.1.0"; !strings.Contains(got, want) { + t.Errorf("rewrite did not contain %q:\n%s", want, got) + } + + // The local repo block has no rev; nothing else may be touched. + if !strings.Contains(got, " - repo: local") { + t.Error("rewrite disturbed the local hooks block") + } +}