From 41d98ee51a9900259ba21813446a31b9b6fba169 Mon Sep 17 00:00:00 2001 From: Kartik Singhal Date: Thu, 14 May 2026 08:47:08 -0500 Subject: [PATCH 1/4] feat: add action update cooldown --- README.md | 8 + cmd/actupdate/main.go | 20 ++- cmd/actupdate/main_test.go | 107 +++++++++++++ internal/github/client.go | 217 +++++++++++++++++++++++---- internal/github/client_test.go | 267 ++++++++++++++++++++++++++++++++- 5 files changed, 577 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index c589afc..9a9752b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Options: ```bash actupdate --repo /path/to/repo actupdate --yes +actupdate --cooldown-days 7 actupdate --github-token "$GITHUB_TOKEN" actupdate version ``` @@ -39,9 +40,14 @@ Flags: - `--repo`: operate on a different repo root instead of the current directory - `--yes`: apply immediately after printing the plan +- `--cooldown-days`: ignore candidate tags newer than the given number of days - `--github-token`: override token lookup; otherwise the tool uses `GITHUB_TOKEN`, then `GH_TOKEN` +Use `--cooldown-days` when you want to avoid immediately adopting freshly +published action tags. For example, `actupdate --cooldown-days 7` only upgrades +to tags that are at least seven days old. + ## GitHub Auth If you see `verification failed: ... GitHub API rate limited or forbidden`, @@ -71,6 +77,8 @@ Each release asset is named like `actupdate_linux_amd64.tar.gz` and contains the - Only stable semver tags are considered - Pre-release tags such as `-rc`, `-beta`, and `-alpha` are ignored - Updates only move to the latest stable major +- `--cooldown-days` can exclude newer tags until they have aged past the + configured threshold - Same-major patch or minor bumps are not applied in v1 - If any candidate update cannot be verified, the tool prints the failures and does not rewrite any files diff --git a/cmd/actupdate/main.go b/cmd/actupdate/main.go index 1859e62..a3492d6 100644 --- a/cmd/actupdate/main.go +++ b/cmd/actupdate/main.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "time" "actupdate/internal/actionspec" gh "actupdate/internal/github" @@ -29,9 +30,10 @@ const ( ) type cliOptions struct { - Repo string - Yes bool - GitHubToken string + Repo string + Yes bool + GitHubToken string + CooldownDays int } func main() { @@ -80,7 +82,7 @@ func run(args []string, in io.Reader, out, errOut io.Writer, httpClient *http.Cl } client := gh.NewClient(httpClient, githubBaseURL, resolveToken(opts.GitHubToken)) - report, changes, hadVerificationFailure, err := buildReport(context.Background(), scans, client) + report, changes, hadVerificationFailure, err := buildReport(context.Background(), scans, client, time.Duration(opts.CooldownDays)*24*time.Hour) if err != nil { fmt.Fprintf(errOut, "failed to build update plan: %v\n", err) return exitOperationalError @@ -135,12 +137,16 @@ func parseArgs(args []string) (*cliOptions, error) { fs.StringVar(&opts.Repo, "repo", "", "path to repository root") fs.BoolVar(&opts.Yes, "yes", false, "apply without prompting") fs.StringVar(&opts.GitHubToken, "github-token", "", "GitHub token override") + fs.IntVar(&opts.CooldownDays, "cooldown-days", 0, "minimum tag age in days before upgrading") if err := fs.Parse(args); err != nil { return nil, err } if fs.NArg() != 0 { return nil, fmt.Errorf("unexpected positional arguments: %s", strings.Join(fs.Args(), " ")) } + if opts.CooldownDays < 0 { + return nil, fmt.Errorf("--cooldown-days must be non-negative") + } return opts, nil } @@ -179,7 +185,7 @@ func useColor(out io.Writer) bool { return term.IsTerminal(int(file.Fd())) } -func buildReport(ctx context.Context, scans []workflows.FileScan, client *gh.Client) (plan.Report, []workflows.Change, bool, error) { +func buildReport(ctx context.Context, scans []workflows.FileScan, client *gh.Client, cooldown time.Duration) (plan.Report, []workflows.Change, bool, error) { report := plan.Report{} var changes []workflows.Change repoResults := map[string]repoOutcome{} @@ -236,7 +242,7 @@ func buildReport(ctx context.Context, scans []workflows.FileScan, client *gh.Cli outcome, ok := repoResults[spec.Repo] if !ok { - resolution, resolveErr := client.ResolveLatestMajor(ctx, spec.Repo, currentMajor) + resolution, resolveErr := client.ResolveLatestMajor(ctx, spec.Repo, currentMajor, cooldown) outcome = repoOutcome{Resolution: resolution, Err: resolveErr} repoResults[spec.Repo] = outcome } @@ -251,7 +257,7 @@ func buildReport(ctx context.Context, scans []workflows.FileScan, client *gh.Cli if !outcome.Resolution.HasUpgrade { entry.Status = plan.StatusUnchanged - entry.Reason = "already on latest stable major" + entry.Reason = outcome.Resolution.Reason report.Add(entry) continue } diff --git a/cmd/actupdate/main_test.go b/cmd/actupdate/main_test.go index 482717e..8e04fe2 100644 --- a/cmd/actupdate/main_test.go +++ b/cmd/actupdate/main_test.go @@ -11,6 +11,22 @@ import ( "testing" ) +func TestParseArgsCooldownDays(t *testing.T) { + opts, err := parseArgs([]string{"--cooldown-days", "7"}) + if err != nil { + t.Fatalf("parse args: %v", err) + } + if opts.CooldownDays != 7 { + t.Fatalf("expected cooldown 7, got %d", opts.CooldownDays) + } +} + +func TestParseArgsRejectsNegativeCooldownDays(t *testing.T) { + if _, err := parseArgs([]string{"--cooldown-days", "-1"}); err == nil { + t.Fatal("expected error") + } +} + func TestRunVersion(t *testing.T) { var stdout bytes.Buffer exitCode := run([]string{"version"}, strings.NewReader(""), &stdout, &bytes.Buffer{}, http.DefaultClient, "") @@ -87,6 +103,97 @@ func TestRunApplyYes(t *testing.T) { } } +func TestRunApplyYesWithCooldownDays(t *testing.T) { + repo := t.TempDir() + workflowDir := filepath.Join(repo, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + workflowPath := filepath.Join(workflowDir, "release.yml") + original := "steps:\n - uses: actions/checkout@v4\n" + if err := os.WriteFile(workflowPath, []byte(original), 0o644); err != nil { + t.Fatalf("write workflow: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/actions/checkout/tags": + fmt.Fprint(w, `[{"name":"v6"},{"name":"v4"}]`) + case "/repos/actions/checkout/git/ref/tags/v6": + fmt.Fprint(w, `{"object":{"type":"tag","sha":"tag-v6"}}`) + case "/repos/actions/checkout/git/tags/tag-v6": + fmt.Fprint(w, `{"tagger":{"date":"2026-05-01T12:00:00Z"}}`) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := run([]string{"--repo", repo, "--yes", "--cooldown-days", "7"}, strings.NewReader(""), &stdout, &stderr, server.Client(), server.URL) + if exitCode != exitOK { + t.Fatalf("expected exit 0, got %d stderr=%s", exitCode, stderr.String()) + } + + updated, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatalf("read workflow: %v", err) + } + if got := string(updated); !strings.Contains(got, "actions/checkout@v6") { + t.Fatalf("expected updated workflow, got %q", got) + } +} + +func TestRunCooldownDaysLeavesTooNewMajorUnchanged(t *testing.T) { + repo := t.TempDir() + workflowDir := filepath.Join(repo, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + workflowPath := filepath.Join(workflowDir, "release.yml") + original := "steps:\n - uses: actions/checkout@v4\n" + if err := os.WriteFile(workflowPath, []byte(original), 0o644); err != nil { + t.Fatalf("write workflow: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/actions/checkout/tags": + fmt.Fprint(w, `[{"name":"v6"},{"name":"v6.2.1"},{"name":"v4"}]`) + case "/repos/actions/checkout/git/ref/tags/v6": + fmt.Fprint(w, `{"object":{"type":"tag","sha":"tag-v6"}}`) + case "/repos/actions/checkout/git/ref/tags/v6.2.1": + fmt.Fprint(w, `{"object":{"type":"tag","sha":"tag-v621"}}`) + case "/repos/actions/checkout/git/tags/tag-v6": + fmt.Fprint(w, `{"tagger":{"date":"2026-05-10T12:00:00Z"}}`) + case "/repos/actions/checkout/git/tags/tag-v621": + fmt.Fprint(w, `{"tagger":{"date":"2026-05-11T12:00:00Z"}}`) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := run([]string{"--repo", repo, "--yes", "--cooldown-days", "7"}, strings.NewReader(""), &stdout, &stderr, server.Client(), server.URL) + if exitCode != exitOK { + t.Fatalf("expected exit 0, got %d stderr=%s", exitCode, stderr.String()) + } + + updated, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatalf("read workflow: %v", err) + } + if string(updated) != original { + t.Fatalf("workflow should remain unchanged, got %q", string(updated)) + } + if !strings.Contains(stdout.String(), "unchanged: newer major tags are still within cooldown") { + t.Fatalf("expected cooldown reason in output, got %q", stdout.String()) + } +} + func TestRunVerificationFailurePreventsWrites(t *testing.T) { repo := t.TempDir() workflowDir := filepath.Join(repo, ".github", "workflows") diff --git a/internal/github/client.go b/internal/github/client.go index 66876ba..f36c419 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -8,6 +8,7 @@ import ( "net/url" "slices" "strings" + "time" "actupdate/internal/actionspec" ) @@ -19,6 +20,8 @@ type Client struct { baseURL string token string cache map[string][]actionspec.StableVersion + tagTimes map[string]time.Time + now func() time.Time } type Resolution struct { @@ -32,6 +35,28 @@ type tagResponse struct { Name string `json:"name"` } +type gitRefResponse struct { + Object gitObject `json:"object"` +} + +type gitObject struct { + Type string `json:"type"` + SHA string `json:"sha"` +} + +type gitTagResponse struct { + Tagger gitSignature `json:"tagger"` +} + +type gitCommitResponse struct { + Author gitSignature `json:"author"` + Committer gitSignature `json:"committer"` +} + +type gitSignature struct { + Date string `json:"date"` +} + func NewClient(httpClient *http.Client, baseURL, token string) *Client { if httpClient == nil { httpClient = http.DefaultClient @@ -44,10 +69,12 @@ func NewClient(httpClient *http.Client, baseURL, token string) *Client { baseURL: strings.TrimRight(baseURL, "/"), token: token, cache: map[string][]actionspec.StableVersion{}, + tagTimes: map[string]time.Time{}, + now: time.Now, } } -func (c *Client) ResolveLatestMajor(ctx context.Context, repo string, currentMajor int) (Resolution, error) { +func (c *Client) ResolveLatestMajor(ctx context.Context, repo string, currentMajor int, cooldown time.Duration) (Resolution, error) { tags, err := c.stableTags(ctx, repo) if err != nil { return Resolution{}, err @@ -63,27 +90,64 @@ func (c *Client) ResolveLatestMajor(ctx context.Context, repo string, currentMaj } } if latestMajor <= currentMajor { - return Resolution{HasUpgrade: false, LatestMajor: latestMajor}, nil + return Resolution{ + HasUpgrade: false, + LatestMajor: latestMajor, + Reason: "already on latest stable major", + }, nil + } + + eligibleTags := tags + if cooldown > 0 { + cutoff := c.now().Add(-cooldown) + eligibleTags = make([]actionspec.StableVersion, 0, len(tags)) + for _, tag := range tags { + if tag.Major <= currentMajor { + eligibleTags = append(eligibleTags, tag) + continue + } + publishedAt, err := c.tagPublishedAt(ctx, repo, tag.Original) + if err != nil { + return Resolution{}, err + } + if !publishedAt.After(cutoff) { + eligibleTags = append(eligibleTags, tag) + } + } + } + + latestEligibleMajor := currentMajor + for _, tag := range eligibleTags { + if tag.Major > latestEligibleMajor { + latestEligibleMajor = tag.Major + } + } + if latestEligibleMajor <= currentMajor { + return Resolution{ + HasUpgrade: false, + LatestMajor: latestMajor, + Reason: "newer major tags are still within cooldown", + }, nil } - if moving, ok := findMovingMajor(tags, latestMajor); ok { + if moving, ok := findMovingMajor(eligibleTags, latestEligibleMajor); ok { return Resolution{ TargetRef: moving.Original, HasUpgrade: true, Reason: "moving major tag", - LatestMajor: latestMajor, + LatestMajor: latestEligibleMajor, }, nil } - best, ok := findHighestForMajor(tags, latestMajor) + best, ok := findHighestForMajor(eligibleTags, latestEligibleMajor) if !ok { - return Resolution{}, fmt.Errorf("%s: no stable tag found for latest major v%d", repo, latestMajor) + return Resolution{}, fmt.Errorf("%s: no stable tag found for latest eligible major v%d", repo, latestEligibleMajor) } return Resolution{ TargetRef: best.Original, HasUpgrade: true, Reason: "exact tag fallback", - LatestMajor: latestMajor, + LatestMajor: latestEligibleMajor, }, nil } @@ -107,31 +171,9 @@ func (c *Client) stableTags(ctx context.Context, repo string) ([]actionspec.Stab if err != nil { return nil, err } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "actupdate") - if c.token != "" { - req.Header.Set("Authorization", "Bearer "+c.token) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("%s: request failed: %w", repo, err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("%s: repository not found", repo) - } - if resp.StatusCode == http.StatusForbidden { - return nil, fmt.Errorf("%s: GitHub API rate limited or forbidden", repo) - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%s: unexpected GitHub API status %d", repo, resp.StatusCode) - } - var tags []tagResponse - if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil { - return nil, fmt.Errorf("%s: failed to decode tags: %w", repo, err) + if err := c.getJSON(req, repo, "repository not found", &tags); err != nil { + return nil, err } for _, tag := range tags { @@ -151,6 +193,119 @@ func (c *Client) stableTags(ctx context.Context, repo string) ([]actionspec.Stab return versions, nil } +func (c *Client) tagPublishedAt(ctx context.Context, repo, tag string) (time.Time, error) { + cacheKey := repo + "@" + tag + if cached, ok := c.tagTimes[cacheKey]; ok { + return cached, nil + } + + refEndpoint, err := url.Parse(fmt.Sprintf("%s/repos/%s/git/ref/tags/%s", c.baseURL, repo, tag)) + if err != nil { + return time.Time{}, err + } + refReq, err := http.NewRequestWithContext(ctx, http.MethodGet, refEndpoint.String(), nil) + if err != nil { + return time.Time{}, err + } + + var ref gitRefResponse + if err := c.getJSON(refReq, repo, fmt.Sprintf("tag ref %s not found", tag), &ref); err != nil { + return time.Time{}, err + } + + var publishedAt time.Time + switch ref.Object.Type { + case "tag": + publishedAt, err = c.annotatedTagTime(ctx, repo, ref.Object.SHA) + case "commit": + publishedAt, err = c.commitTime(ctx, repo, ref.Object.SHA) + default: + err = fmt.Errorf("%s: unsupported git ref object type %q for tag %s", repo, ref.Object.Type, tag) + } + if err != nil { + return time.Time{}, err + } + + c.tagTimes[cacheKey] = publishedAt + return publishedAt, nil +} + +func (c *Client) annotatedTagTime(ctx context.Context, repo, sha string) (time.Time, error) { + endpoint, err := url.Parse(fmt.Sprintf("%s/repos/%s/git/tags/%s", c.baseURL, repo, sha)) + if err != nil { + return time.Time{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return time.Time{}, err + } + + var tag gitTagResponse + if err := c.getJSON(req, repo, fmt.Sprintf("annotated tag %s not found", sha), &tag); err != nil { + return time.Time{}, err + } + return parseGitHubTime(repo, tag.Tagger.Date, "tagger") +} + +func (c *Client) commitTime(ctx context.Context, repo, sha string) (time.Time, error) { + endpoint, err := url.Parse(fmt.Sprintf("%s/repos/%s/git/commits/%s", c.baseURL, repo, sha)) + if err != nil { + return time.Time{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return time.Time{}, err + } + + var commit gitCommitResponse + if err := c.getJSON(req, repo, fmt.Sprintf("commit %s not found", sha), &commit); err != nil { + return time.Time{}, err + } + if commit.Committer.Date != "" { + return parseGitHubTime(repo, commit.Committer.Date, "committer") + } + return parseGitHubTime(repo, commit.Author.Date, "author") +} + +func (c *Client) getJSON(req *http.Request, repo, notFoundMessage string, out any) error { + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "actupdate") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("%s: request failed: %w", repo, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("%s: %s", repo, notFoundMessage) + } + if resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("%s: GitHub API rate limited or forbidden", repo) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s: unexpected GitHub API status %d", repo, resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("%s: failed to decode GitHub response: %w", repo, err) + } + return nil +} + +func parseGitHubTime(repo, value, field string) (time.Time, error) { + if value == "" { + return time.Time{}, fmt.Errorf("%s: missing %s timestamp in GitHub response", repo, field) + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, fmt.Errorf("%s: invalid %s timestamp %q: %w", repo, field, value, err) + } + return parsed, nil +} + func compareVersionDesc(a, b actionspec.StableVersion) int { if a.Major != b.Major { return b.Major - a.Major diff --git a/internal/github/client_test.go b/internal/github/client_test.go index e93f186..441f838 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -2,10 +2,13 @@ package github import ( "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" + "strings" "testing" + "time" ) func TestResolveLatestMajorPrefersMovingTag(t *testing.T) { @@ -15,7 +18,7 @@ func TestResolveLatestMajorPrefersMovingTag(t *testing.T) { defer server.Close() client := NewClient(server.Client(), server.URL, "") - result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 4) + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 4, 0) if err != nil { t.Fatalf("resolve: %v", err) } @@ -31,7 +34,7 @@ func TestResolveLatestMajorFallsBackToExactTag(t *testing.T) { defer server.Close() client := NewClient(server.Client(), server.URL, "") - result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 4) + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 4, 0) if err != nil { t.Fatalf("resolve: %v", err) } @@ -47,7 +50,7 @@ func TestResolveLatestMajorIgnoresPrerelease(t *testing.T) { defer server.Close() client := NewClient(server.Client(), server.URL, "") - result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 5) + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 5, 0) if err != nil { t.Fatalf("resolve: %v", err) } @@ -63,7 +66,263 @@ func TestResolveLatestMajorHandlesNotFound(t *testing.T) { defer server.Close() client := NewClient(server.Client(), server.URL, "") - if _, err := client.ResolveLatestMajor(context.Background(), "missing/repo", 4); err == nil { + if _, err := client.ResolveLatestMajor(context.Background(), "missing/repo", 4, 0); err == nil { t.Fatal("expected error") } } + +func TestResolveLatestMajorWithCooldownUsesOlderMovingTag(t *testing.T) { + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + server := newGitHubTestServer(t, githubTestData{ + tags: []string{"v6", "v6.2.1", "v4"}, + refs: map[string]gitRef{ + "v6": {Type: "tag", SHA: "tag-v6"}, + "v6.2.1": {Type: "tag", SHA: "tag-v621"}, + }, + tagObjects: map[string]string{ + "tag-v6": now.Add(-10 * 24 * time.Hour).Format(time.RFC3339), + "tag-v621": now.Add(-9 * 24 * time.Hour).Format(time.RFC3339), + }, + }) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + client.now = func() time.Time { return now } + + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 4, 7*24*time.Hour) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !result.HasUpgrade || result.TargetRef != "v6" || result.Reason != "moving major tag" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestResolveLatestMajorWithCooldownFallsBackToOlderExactTag(t *testing.T) { + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + server := newGitHubTestServer(t, githubTestData{ + tags: []string{"v6", "v6.2.1", "v4"}, + refs: map[string]gitRef{ + "v6": {Type: "tag", SHA: "tag-v6"}, + "v6.2.1": {Type: "tag", SHA: "tag-v621"}, + }, + tagObjects: map[string]string{ + "tag-v6": now.Add(-2 * 24 * time.Hour).Format(time.RFC3339), + "tag-v621": now.Add(-9 * 24 * time.Hour).Format(time.RFC3339), + }, + }) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + client.now = func() time.Time { return now } + + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 4, 7*24*time.Hour) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !result.HasUpgrade || result.TargetRef != "v6.2.1" || result.Reason != "exact tag fallback" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestResolveLatestMajorWithCooldownSkipsTooNewMajor(t *testing.T) { + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + server := newGitHubTestServer(t, githubTestData{ + tags: []string{"v6", "v6.2.1", "v4"}, + refs: map[string]gitRef{ + "v6": {Type: "tag", SHA: "tag-v6"}, + "v6.2.1": {Type: "tag", SHA: "tag-v621"}, + }, + tagObjects: map[string]string{ + "tag-v6": now.Add(-2 * 24 * time.Hour).Format(time.RFC3339), + "tag-v621": now.Add(-1 * 24 * time.Hour).Format(time.RFC3339), + }, + }) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + client.now = func() time.Time { return now } + + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 4, 7*24*time.Hour) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if result.HasUpgrade { + t.Fatalf("expected no upgrade, got %+v", result) + } + if result.Reason != "newer major tags are still within cooldown" { + t.Fatalf("unexpected reason: %q", result.Reason) + } +} + +func TestResolveLatestMajorCooldownUsesAnnotatedTagTimestamp(t *testing.T) { + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + server := newGitHubTestServer(t, githubTestData{ + tags: []string{"v6"}, + refs: map[string]gitRef{ + "v6": {Type: "tag", SHA: "tag-v6"}, + }, + tagObjects: map[string]string{ + "tag-v6": now.Add(-10 * 24 * time.Hour).Format(time.RFC3339), + }, + }) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + client.now = func() time.Time { return now } + + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 5, 7*24*time.Hour) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !result.HasUpgrade || result.TargetRef != "v6" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestResolveLatestMajorCooldownUsesLightweightCommitTimestamp(t *testing.T) { + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + server := newGitHubTestServer(t, githubTestData{ + tags: []string{"v6"}, + refs: map[string]gitRef{ + "v6": {Type: "commit", SHA: "commit-v6"}, + }, + commits: map[string]string{ + "commit-v6": now.Add(-10 * 24 * time.Hour).Format(time.RFC3339), + }, + }) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + client.now = func() time.Time { return now } + + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 5, 7*24*time.Hour) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !result.HasUpgrade || result.TargetRef != "v6" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestResolveLatestMajorCachesTagTimestamps(t *testing.T) { + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + hits := map[string]int{} + server := newGitHubTestServer(t, githubTestData{ + tags: []string{"v6"}, + refs: map[string]gitRef{ + "v6": {Type: "tag", SHA: "tag-v6"}, + }, + tagObjects: map[string]string{ + "tag-v6": now.Add(-10 * 24 * time.Hour).Format(time.RFC3339), + }, + hits: hits, + }) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + client.now = func() time.Time { return now } + + for range 2 { + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 5, 7*24*time.Hour) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !result.HasUpgrade || result.TargetRef != "v6" { + t.Fatalf("unexpected result: %+v", result) + } + } + + if hits["/repos/actions/checkout/git/ref/tags/v6"] != 1 { + t.Fatalf("expected one ref lookup, got %d", hits["/repos/actions/checkout/git/ref/tags/v6"]) + } + if hits["/repos/actions/checkout/git/tags/tag-v6"] != 1 { + t.Fatalf("expected one tag-object lookup, got %d", hits["/repos/actions/checkout/git/tags/tag-v6"]) + } +} + +type githubTestData struct { + tags []string + refs map[string]gitRef + tagObjects map[string]string + commits map[string]string + hits map[string]int +} + +type gitRef struct { + Type string + SHA string +} + +func newGitHubTestServer(t *testing.T, data githubTestData) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if data.hits != nil { + data.hits[r.URL.Path]++ + } + + switch { + case r.URL.Path == "/repos/actions/checkout/tags": + type tagItem struct { + Name string `json:"name"` + } + items := make([]tagItem, 0, len(data.tags)) + for _, tag := range data.tags { + items = append(items, tagItem{Name: tag}) + } + writeJSON(t, w, items) + return + case strings.HasPrefix(r.URL.Path, "/repos/actions/checkout/git/ref/tags/"): + tag := strings.TrimPrefix(r.URL.Path, "/repos/actions/checkout/git/ref/tags/") + ref, ok := data.refs[tag] + if !ok { + http.NotFound(w, r) + return + } + writeJSON(t, w, map[string]any{ + "object": map[string]any{ + "type": ref.Type, + "sha": ref.SHA, + }, + }) + return + case strings.HasPrefix(r.URL.Path, "/repos/actions/checkout/git/tags/"): + sha := strings.TrimPrefix(r.URL.Path, "/repos/actions/checkout/git/tags/") + date, ok := data.tagObjects[sha] + if !ok { + http.NotFound(w, r) + return + } + writeJSON(t, w, map[string]any{ + "tagger": map[string]any{ + "date": date, + }, + }) + return + case strings.HasPrefix(r.URL.Path, "/repos/actions/checkout/git/commits/"): + sha := strings.TrimPrefix(r.URL.Path, "/repos/actions/checkout/git/commits/") + date, ok := data.commits[sha] + if !ok { + http.NotFound(w, r) + return + } + writeJSON(t, w, map[string]any{ + "committer": map[string]any{ + "date": date, + }, + }) + return + default: + http.NotFound(w, r) + } + })) +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("encode json: %v", err) + } +} From 457423ceaa55354685aa0be39ea16c5a55ae7a86 Mon Sep 17 00:00:00 2001 From: Kartik Singhal Date: Thu, 14 May 2026 08:57:46 -0500 Subject: [PATCH 2/4] fix: address PR feedback on cooldown lookups --- cmd/actupdate/main_test.go | 10 ++- internal/github/client.go | 125 +++++++++++++++++++++------------ internal/github/client_test.go | 55 +++++++++++++-- 3 files changed, 137 insertions(+), 53 deletions(-) diff --git a/cmd/actupdate/main_test.go b/cmd/actupdate/main_test.go index 8e04fe2..ab8d762 100644 --- a/cmd/actupdate/main_test.go +++ b/cmd/actupdate/main_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestParseArgsCooldownDays(t *testing.T) { @@ -104,6 +105,7 @@ func TestRunApplyYes(t *testing.T) { } func TestRunApplyYesWithCooldownDays(t *testing.T) { + oldEnough := time.Now().Add(-10 * 24 * time.Hour).UTC().Format(time.RFC3339) repo := t.TempDir() workflowDir := filepath.Join(repo, ".github", "workflows") if err := os.MkdirAll(workflowDir, 0o755); err != nil { @@ -122,7 +124,7 @@ func TestRunApplyYesWithCooldownDays(t *testing.T) { case "/repos/actions/checkout/git/ref/tags/v6": fmt.Fprint(w, `{"object":{"type":"tag","sha":"tag-v6"}}`) case "/repos/actions/checkout/git/tags/tag-v6": - fmt.Fprint(w, `{"tagger":{"date":"2026-05-01T12:00:00Z"}}`) + fmt.Fprintf(w, `{"tagger":{"date":"%s"}}`, oldEnough) default: http.NotFound(w, r) } @@ -146,6 +148,8 @@ func TestRunApplyYesWithCooldownDays(t *testing.T) { } func TestRunCooldownDaysLeavesTooNewMajorUnchanged(t *testing.T) { + tooNewMoving := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339) + tooNewExact := time.Now().Add(-2 * 24 * time.Hour).UTC().Format(time.RFC3339) repo := t.TempDir() workflowDir := filepath.Join(repo, ".github", "workflows") if err := os.MkdirAll(workflowDir, 0o755); err != nil { @@ -166,9 +170,9 @@ func TestRunCooldownDaysLeavesTooNewMajorUnchanged(t *testing.T) { case "/repos/actions/checkout/git/ref/tags/v6.2.1": fmt.Fprint(w, `{"object":{"type":"tag","sha":"tag-v621"}}`) case "/repos/actions/checkout/git/tags/tag-v6": - fmt.Fprint(w, `{"tagger":{"date":"2026-05-10T12:00:00Z"}}`) + fmt.Fprintf(w, `{"tagger":{"date":"%s"}}`, tooNewMoving) case "/repos/actions/checkout/git/tags/tag-v621": - fmt.Fprint(w, `{"tagger":{"date":"2026-05-11T12:00:00Z"}}`) + fmt.Fprintf(w, `{"tagger":{"date":"%s"}}`, tooNewExact) default: http.NotFound(w, r) } diff --git a/internal/github/client.go b/internal/github/client.go index f36c419..a3df8e4 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -97,57 +97,49 @@ func (c *Client) ResolveLatestMajor(ctx context.Context, repo string, currentMaj }, nil } - eligibleTags := tags + cutoff := time.Time{} if cooldown > 0 { - cutoff := c.now().Add(-cooldown) - eligibleTags = make([]actionspec.StableVersion, 0, len(tags)) - for _, tag := range tags { - if tag.Major <= currentMajor { - eligibleTags = append(eligibleTags, tag) - continue - } - publishedAt, err := c.tagPublishedAt(ctx, repo, tag.Original) + cutoff = c.now().Add(-cooldown) + } + + for _, major := range newerMajors(tags, currentMajor) { + if moving, ok := findMovingMajor(tags, major); ok { + eligible, err := c.tagEligible(ctx, repo, moving.Original, cutoff) if err != nil { return Resolution{}, err } - if !publishedAt.After(cutoff) { - eligibleTags = append(eligibleTags, tag) + if eligible { + return Resolution{ + TargetRef: moving.Original, + HasUpgrade: true, + Reason: "moving major tag", + LatestMajor: major, + }, nil } } - } - latestEligibleMajor := currentMajor - for _, tag := range eligibleTags { - if tag.Major > latestEligibleMajor { - latestEligibleMajor = tag.Major + best, ok := findHighestForMajor(tags, major) + if !ok { + return Resolution{}, fmt.Errorf("%s: no stable tag found for major v%d", repo, major) + } + eligible, err := c.tagEligible(ctx, repo, best.Original, cutoff) + if err != nil { + return Resolution{}, err + } + if eligible { + return Resolution{ + TargetRef: best.Original, + HasUpgrade: true, + Reason: "exact tag fallback", + LatestMajor: major, + }, nil } - } - if latestEligibleMajor <= currentMajor { - return Resolution{ - HasUpgrade: false, - LatestMajor: latestMajor, - Reason: "newer major tags are still within cooldown", - }, nil - } - - if moving, ok := findMovingMajor(eligibleTags, latestEligibleMajor); ok { - return Resolution{ - TargetRef: moving.Original, - HasUpgrade: true, - Reason: "moving major tag", - LatestMajor: latestEligibleMajor, - }, nil } - best, ok := findHighestForMajor(eligibleTags, latestEligibleMajor) - if !ok { - return Resolution{}, fmt.Errorf("%s: no stable tag found for latest eligible major v%d", repo, latestEligibleMajor) - } return Resolution{ - TargetRef: best.Original, - HasUpgrade: true, - Reason: "exact tag fallback", - LatestMajor: latestEligibleMajor, + HasUpgrade: false, + LatestMajor: latestMajor, + Reason: "newer major tags are still within cooldown", }, nil } @@ -199,7 +191,7 @@ func (c *Client) tagPublishedAt(ctx context.Context, repo, tag string) (time.Tim return cached, nil } - refEndpoint, err := url.Parse(fmt.Sprintf("%s/repos/%s/git/ref/tags/%s", c.baseURL, repo, tag)) + refEndpoint, err := c.endpointURL(repo, "git", "ref", "tags", tag) if err != nil { return time.Time{}, err } @@ -231,7 +223,7 @@ func (c *Client) tagPublishedAt(ctx context.Context, repo, tag string) (time.Tim } func (c *Client) annotatedTagTime(ctx context.Context, repo, sha string) (time.Time, error) { - endpoint, err := url.Parse(fmt.Sprintf("%s/repos/%s/git/tags/%s", c.baseURL, repo, sha)) + endpoint, err := c.endpointURL(repo, "git", "tags", sha) if err != nil { return time.Time{}, err } @@ -248,7 +240,7 @@ func (c *Client) annotatedTagTime(ctx context.Context, repo, sha string) (time.T } func (c *Client) commitTime(ctx context.Context, repo, sha string) (time.Time, error) { - endpoint, err := url.Parse(fmt.Sprintf("%s/repos/%s/git/commits/%s", c.baseURL, repo, sha)) + endpoint, err := c.endpointURL(repo, "git", "commits", sha) if err != nil { return time.Time{}, err } @@ -267,6 +259,53 @@ func (c *Client) commitTime(ctx context.Context, repo, sha string) (time.Time, e return parseGitHubTime(repo, commit.Author.Date, "author") } +func (c *Client) tagEligible(ctx context.Context, repo, tag string, cutoff time.Time) (bool, error) { + if cutoff.IsZero() { + return true, nil + } + publishedAt, err := c.tagPublishedAt(ctx, repo, tag) + if err != nil { + return false, err + } + return !publishedAt.After(cutoff), nil +} + +func (c *Client) endpointURL(repo string, segments ...string) (*url.URL, error) { + base := strings.TrimRight(c.baseURL, "/") + path := strings.Builder{} + path.WriteString(base) + path.WriteString("/repos/") + + repoParts := strings.Split(repo, "/") + for i, part := range repoParts { + if i > 0 { + path.WriteByte('/') + } + path.WriteString(url.PathEscape(part)) + } + for _, segment := range segments { + path.WriteByte('/') + path.WriteString(url.PathEscape(segment)) + } + return url.Parse(path.String()) +} + +func newerMajors(tags []actionspec.StableVersion, currentMajor int) []int { + seen := map[int]struct{}{} + majors := make([]int, 0) + for _, tag := range tags { + if tag.Major <= currentMajor { + continue + } + if _, ok := seen[tag.Major]; ok { + continue + } + seen[tag.Major] = struct{}{} + majors = append(majors, tag.Major) + } + return majors +} + func (c *Client) getJSON(req *http.Request, repo, notFoundMessage string, out any) error { req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "actupdate") diff --git a/internal/github/client_test.go b/internal/github/client_test.go index 441f838..5bc47c9 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -241,12 +242,45 @@ func TestResolveLatestMajorCachesTagTimestamps(t *testing.T) { } } +func TestResolveLatestMajorEscapesTagPathSegments(t *testing.T) { + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + hits := map[string]int{} + escapedHits := map[string]int{} + server := newGitHubTestServer(t, githubTestData{ + tags: []string{"release/6", "v4"}, + refs: map[string]gitRef{ + "release/6": {Type: "tag", SHA: "tag-release-6"}, + }, + tagObjects: map[string]string{ + "tag-release-6": now.Add(-10 * 24 * time.Hour).Format(time.RFC3339), + }, + hits: hits, + escapedHits: escapedHits, + }) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + client.now = func() time.Time { return now } + + publishedAt, err := client.tagPublishedAt(context.Background(), "actions/checkout", "release/6") + if err != nil { + t.Fatalf("tagPublishedAt: %v", err) + } + if publishedAt.IsZero() { + t.Fatal("expected publish time") + } + if escapedHits["/repos/actions/checkout/git/ref/tags/release%2F6"] != 1 { + t.Fatalf("expected escaped ref lookup, got %#v", escapedHits) + } +} + type githubTestData struct { - tags []string - refs map[string]gitRef - tagObjects map[string]string - commits map[string]string - hits map[string]int + tags []string + refs map[string]gitRef + tagObjects map[string]string + commits map[string]string + hits map[string]int + escapedHits map[string]int } type gitRef struct { @@ -261,6 +295,9 @@ func newGitHubTestServer(t *testing.T, data githubTestData) *httptest.Server { if data.hits != nil { data.hits[r.URL.Path]++ } + if data.escapedHits != nil { + data.escapedHits[r.URL.EscapedPath()]++ + } switch { case r.URL.Path == "/repos/actions/checkout/tags": @@ -274,8 +311,12 @@ func newGitHubTestServer(t *testing.T, data githubTestData) *httptest.Server { writeJSON(t, w, items) return case strings.HasPrefix(r.URL.Path, "/repos/actions/checkout/git/ref/tags/"): - tag := strings.TrimPrefix(r.URL.Path, "/repos/actions/checkout/git/ref/tags/") - ref, ok := data.refs[tag] + tag := strings.TrimPrefix(r.URL.EscapedPath(), "/repos/actions/checkout/git/ref/tags/") + unescapedTag, err := url.PathUnescape(tag) + if err != nil { + t.Fatalf("unescape tag: %v", err) + } + ref, ok := data.refs[unescapedTag] if !ok { http.NotFound(w, r) return From bc199f4893e98d1e22951bb381646ae8c6267705 Mon Sep 17 00:00:00 2001 From: Kartik Singhal Date: Thu, 14 May 2026 09:00:46 -0500 Subject: [PATCH 3/4] ci: add pull request test workflow --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5a35197 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-24.04 + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Run tests + run: go test ./... From dfe70a6b077efafe24fad660f51730ecd701e84e Mon Sep 17 00:00:00 2001 From: Kartik Singhal Date: Thu, 14 May 2026 09:11:12 -0500 Subject: [PATCH 4/4] fix: guard cooldown duration overflow --- cmd/actupdate/main.go | 24 +++++++++++++++++++++++- cmd/actupdate/main_test.go | 7 +++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/cmd/actupdate/main.go b/cmd/actupdate/main.go index a3492d6..2683c0e 100644 --- a/cmd/actupdate/main.go +++ b/cmd/actupdate/main.go @@ -7,6 +7,7 @@ import ( "flag" "fmt" "io" + "math" "net/http" "os" "path/filepath" @@ -29,6 +30,8 @@ const ( exitVerificationFailure ) +const maxCooldownDays = int64(math.MaxInt64 / int64(24*time.Hour)) + type cliOptions struct { Repo string Yes bool @@ -81,8 +84,14 @@ func run(args []string, in io.Reader, out, errOut io.Writer, httpClient *http.Cl return exitInvalidInput } + cooldown, err := cooldownDuration(opts.CooldownDays) + if err != nil { + fmt.Fprintln(errOut, err) + return exitInvalidInput + } + client := gh.NewClient(httpClient, githubBaseURL, resolveToken(opts.GitHubToken)) - report, changes, hadVerificationFailure, err := buildReport(context.Background(), scans, client, time.Duration(opts.CooldownDays)*24*time.Hour) + report, changes, hadVerificationFailure, err := buildReport(context.Background(), scans, client, cooldown) if err != nil { fmt.Fprintf(errOut, "failed to build update plan: %v\n", err) return exitOperationalError @@ -147,9 +156,22 @@ func parseArgs(args []string) (*cliOptions, error) { if opts.CooldownDays < 0 { return nil, fmt.Errorf("--cooldown-days must be non-negative") } + if int64(opts.CooldownDays) > maxCooldownDays { + return nil, fmt.Errorf("--cooldown-days must be at most %d", maxCooldownDays) + } return opts, nil } +func cooldownDuration(days int) (time.Duration, error) { + if days < 0 { + return 0, fmt.Errorf("--cooldown-days must be non-negative") + } + if int64(days) > maxCooldownDays { + return 0, fmt.Errorf("--cooldown-days must be at most %d", maxCooldownDays) + } + return time.Duration(days) * 24 * time.Hour, nil +} + func resolveToken(explicit string) string { if explicit != "" { return explicit diff --git a/cmd/actupdate/main_test.go b/cmd/actupdate/main_test.go index ab8d762..9ed53cb 100644 --- a/cmd/actupdate/main_test.go +++ b/cmd/actupdate/main_test.go @@ -28,6 +28,13 @@ func TestParseArgsRejectsNegativeCooldownDays(t *testing.T) { } } +func TestParseArgsRejectsOverflowingCooldownDays(t *testing.T) { + tooLarge := fmt.Sprintf("%d", maxCooldownDays+1) + if _, err := parseArgs([]string{"--cooldown-days", tooLarge}); err == nil { + t.Fatal("expected error") + } +} + func TestRunVersion(t *testing.T) { var stdout bytes.Buffer exitCode := run([]string{"version"}, strings.NewReader(""), &stdout, &bytes.Buffer{}, http.DefaultClient, "")