mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-26 10:14:52 +09:00
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.
This commit is contained in:
@@ -62,6 +62,7 @@ func allAreas() []area {
|
||||
},
|
||||
}
|
||||
|
||||
areas = append(areas, toolAreas()...)
|
||||
areas = append(areas,
|
||||
area{
|
||||
Name: "gomod",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user