mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-03 05:13:36 +09:00
.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.
This commit is contained in:
@@ -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 }}
|
|
||||||
@@ -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 },
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -80,10 +80,11 @@ func allAreas() []area {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
areas = append(areas, imageAreas()...)
|
areas = append(areas, imageAreas()...)
|
||||||
|
areas = append(areas, actionAreas()...)
|
||||||
|
|
||||||
return append(areas, area{
|
return append(areas, area{
|
||||||
Name: "generate",
|
Name: "generate",
|
||||||
Needs: []string{"gomod"},
|
Needs: []string{"gomod", "tools:oapi-codegen"},
|
||||||
Apply: applyGenerate,
|
Apply: applyGenerate,
|
||||||
Gate: gateGenerate,
|
Gate: gateGenerate,
|
||||||
Message: func(change) string { return "all: regenerate generated files" },
|
Message: func(change) string { return "all: regenerate generated files" },
|
||||||
|
|||||||
@@ -89,3 +89,43 @@ func latestRelease(ctx context.Context, owner, name string) (string, error) {
|
|||||||
|
|
||||||
return release.TagName, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user