From 60c5f8277f5dceaaa2f6a214fa6a1612cf2e3415 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 4 Sep 2026 14:51:40 +0000 Subject: [PATCH] .github: fold the action version updater into tools/bump The standalone workflow has failed weekly since its token secret went missing. One bot, one credential, one pull request to review. --- .github/workflows/gh-actions-updater.yaml | 23 --- tools/bump/actions.go | 183 ++++++++++++++++++++++ tools/bump/actions_test.go | 93 +++++++++++ tools/bump/area.go | 3 +- tools/bump/github.go | 40 +++++ 5 files changed, 318 insertions(+), 24 deletions(-) delete mode 100644 .github/workflows/gh-actions-updater.yaml create mode 100644 tools/bump/actions.go create mode 100644 tools/bump/actions_test.go diff --git a/.github/workflows/gh-actions-updater.yaml b/.github/workflows/gh-actions-updater.yaml deleted file mode 100644 index 647e27dc..00000000 --- a/.github/workflows/gh-actions-updater.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: GitHub Actions Version Updater - -on: - schedule: - # Automatically run on every Sunday - - cron: "0 0 * * 0" - -jobs: - build: - if: github.repository == 'juanfont/headscale' - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - # [Required] Access token with `workflow` scope. - token: ${{ secrets.WORKFLOW_SECRET }} - - - name: Run GitHub Actions Version Updater - uses: saadmk11/github-actions-version-updater@d8781caf11d11168579c8e5e94f62b068038f442 # v0.9.0 - with: - # [Required] Access token with `workflow` scope. - token: ${{ secrets.WORKFLOW_SECRET }} diff --git a/tools/bump/actions.go b/tools/bump/actions.go new file mode 100644 index 00000000..262074de --- /dev/null +++ b/tools/bump/actions.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// actionPin matches a SHA-pinned action reference together with the trailing +// comment that says which version the SHA is. Both have to move, or the comment +// starts lying about what is running. +var actionPin = regexp.MustCompile( + `(?m)(uses:\s+)([\w.-]+)/([\w.-]+)((?:/[\w./-]+)?)@([0-9a-f]{40})(\s+#\s*)(\S+)`) + +// isVersionRef reports whether a pin comment names a release rather than a +// branch. A branch pin is deliberate and keeps following that branch. +func isVersionRef(ref string) bool { + return strings.HasPrefix(ref, "v") && len(ref) > 1 && ref[1] >= '0' && ref[1] <= '9' +} + +// workflowFiles lists the workflow definitions, which is the only place action +// pins live. +func workflowFiles(r *repo) ([]string, error) { + var files []string + + for _, ext := range []string{"*.yml", "*.yaml"} { + matches, err := filepath.Glob(r.path(".github", "workflows", ext)) + if err != nil { + return nil, fmt.Errorf("listing workflows: %w", err) + } + + for _, m := range matches { + files = append(files, filepath.Join(".github", "workflows", filepath.Base(m))) + } + } + + sort.Strings(files) + + return files, nil +} + +// actionTarget is where one action should end up. +type actionTarget struct { + SHA string + Ref string +} + +// resolveAction decides what a pin should point at. A release pin follows the +// newest release; a branch pin follows that branch's head. +func resolveAction(ctx context.Context, owner, name, ref string) (actionTarget, error) { + want := ref + + if isVersionRef(ref) { + latest, err := latestRelease(ctx, owner, name) + if err != nil { + return actionTarget{}, err + } + + want = latest + } + + sha, err := commitOfRef(ctx, owner, name, want) + if err != nil { + return actionTarget{}, err + } + + return actionTarget{SHA: sha, Ref: want}, nil +} + +// applyActions refreshes every SHA-pinned action reference. This replaces the +// separate actions-version workflow, so there is one bot, one token and one +// pull request to review. +func applyActions(ctx context.Context, r *repo) (change, error) { + files, err := workflowFiles(r) + if err != nil { + return change{}, err + } + + // One lookup per action, not per occurrence: checkout alone appears twenty + // times, and the API budget is not unlimited. + resolved := map[string]actionTarget{} + moved := map[string]string{} + + var touched []string + + for _, file := range files { + content, err := r.readFile(file) + if err != nil { + return change{}, err + } + + updated, err := rewriteActions(ctx, content, resolved, moved) + if err != nil { + return change{}, err + } + + if updated == content { + continue + } + + err = r.writeFile(file, updated) + if err != nil { + return change{}, err + } + + touched = append(touched, filepath.Base(file)) + } + + if len(moved) == 0 { + return change{Empty: true}, nil + } + + details := make([]string, 0, len(moved)) + for _, line := range moved { + details = append(details, line) + } + + sort.Strings(details) + + return change{ + Summary: fmt.Sprintf("%d action pins", len(moved)), + Detail: append(details, "files: "+strings.Join(touched, ", ")), + }, nil +} + +// rewriteActions rewrites every pin in one file, recording what moved. +func rewriteActions(ctx context.Context, content string, resolved map[string]actionTarget, moved map[string]string) (string, error) { + var failure error + + updated := actionPin.ReplaceAllStringFunc(content, func(match string) string { + m := actionPin.FindStringSubmatch(match) + owner, name, sub, sha, sep, ref := m[2], m[3], m[4], m[5], m[6], m[7] + + key := owner + "/" + name + "@" + ref + + target, ok := resolved[key] + if !ok { + var err error + + target, err = resolveAction(ctx, owner, name, ref) + if err != nil { + // One unreachable action must not abandon the rest; record it + // and leave that pin where it is. + failure = err + + return match + } + + resolved[key] = target + } + + if target.SHA == sha { + return match + } + + if ref == target.Ref { + // A branch pin keeps its name; only the commit under it moved. + moved[owner+"/"+name] = fmt.Sprintf("%s/%s %s %s -> %s", + owner, name, ref, sha[:7], target.SHA[:7]) + } else { + moved[owner+"/"+name] = fmt.Sprintf("%s/%s %s -> %s", owner, name, ref, target.Ref) + } + + return m[1] + owner + "/" + name + sub + "@" + target.SHA + sep + target.Ref + }) + + if failure != nil && len(moved) == 0 { + return "", failure + } + + return updated, nil +} + +func actionAreas() []area { + return []area{{ + Name: "actions", + Apply: applyActions, + Message: func(c change) string { return ".github: bump " + c.Summary }, + }} +} diff --git a/tools/bump/actions_test.go b/tools/bump/actions_test.go new file mode 100644 index 00000000..b97cfb69 --- /dev/null +++ b/tools/bump/actions_test.go @@ -0,0 +1,93 @@ +package main + +import "testing" + +func TestActionPinMatch(t *testing.T) { + tests := []struct { + name string + line string + owner, repo, sub string + sha, ref string + match bool + }{ + { + name: "sha pinned with version comment", + line: " - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n", + owner: "actions", repo: "checkout", + sha: "8e8c483db84b4bee98b60c0593521ed34d9990e8", + ref: "v6.0.1", + match: true, + }, + { + name: "branch comment", + line: " - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main\n", + owner: "NixOS", repo: "nix-installer-action", + sha: "6b8548fe06acfb0155a50ab5d561accb215764cc", + ref: "main", + match: true, + }, + { + // No SHA to replace and no comment to correct; leave it alone + // rather than silently changing how it is pinned. + name: "unpinned branch reference", + line: " uses: alexellis/setup-sshd-actor@master\n", + }, + { + name: "local reusable workflow", + line: " uses: ./.github/workflows/integration-test-template.yml\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + m := actionPin.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{2: test.owner, 3: test.repo, 4: test.sub, 5: test.sha, 7: test.ref} { + if m[i] != want { + t.Errorf("group %d = %q, want %q", i, m[i], want) + } + } + }) + } +} + +func TestActionPinRewrite(t *testing.T) { + const line = " - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n" + + m := actionPin.FindStringSubmatch(line) + got := m[1] + m[2] + "/" + m[3] + m[4] + "@" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + m[6] + "v7.0.0" + + const want = "uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v7.0.0" + if got != want { + t.Errorf("rewrite = %q, want %q", got, want) + } +} + +func TestIsVersionRef(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {in: "v6.0.1", want: true}, + {in: "v3.22", want: true}, + {in: "main"}, + {in: "master"}, + {in: "validate"}, + {in: "v"}, + } + + for _, test := range tests { + t.Run(test.in, func(t *testing.T) { + if got := isVersionRef(test.in); got != test.want { + t.Errorf("isVersionRef(%q) = %v, want %v", test.in, got, test.want) + } + }) + } +} diff --git a/tools/bump/area.go b/tools/bump/area.go index c2a1fd9e..b328680a 100644 --- a/tools/bump/area.go +++ b/tools/bump/area.go @@ -80,10 +80,11 @@ func allAreas() []area { }, ) areas = append(areas, imageAreas()...) + areas = append(areas, actionAreas()...) return append(areas, area{ Name: "generate", - Needs: []string{"gomod"}, + Needs: []string{"gomod", "tools:oapi-codegen"}, Apply: applyGenerate, Gate: gateGenerate, Message: func(change) string { return "all: regenerate generated files" }, diff --git a/tools/bump/github.go b/tools/bump/github.go index 98a76f33..41201c3a 100644 --- a/tools/bump/github.go +++ b/tools/bump/github.go @@ -89,3 +89,43 @@ func latestRelease(ctx context.Context, owner, name string) (string, error) { return release.TagName, nil } + +// commitOfRef resolves a tag or branch to the commit it points at, following +// annotated tags the way an action pin must. +func commitOfRef(ctx context.Context, owner, name, ref string) (string, error) { + var commit struct { + SHA string `json:"sha"` + } + + err := githubJSON(ctx, fmt.Sprintf("/repos/%s/%s/commits/%s", owner, name, ref), &commit) + if err != nil { + return "", err + } + + if commit.SHA == "" { + return "", fmt.Errorf("%w: %s/%s@%s", errNoRelease, owner, name, ref) + } + + return commit.SHA, nil +} + +// currentSlug is the repository the run is acting on. It defaults to whatever +// the job is running in rather than a constant: a hardcoded upstream slug let a +// fork push its branch and then try to open the pull request on someone else's +// repository, which fails at the very last step of a long run. +func currentSlug(ctx context.Context, r *repo, override string) (string, error) { + if override != "" { + return override, nil + } + + if env := os.Getenv("GITHUB_REPOSITORY"); env != "" { + return env, nil + } + + out, err := r.run(ctx, "gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner") + if err != nil { + return "", err + } + + return strings.TrimSpace(out), nil +}