Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 ./...
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Options:
```bash
actupdate --repo /path/to/repo
actupdate --yes
actupdate --cooldown-days 7
actupdate --github-token "$GITHUB_TOKEN"
actupdate version
```
Expand All @@ -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`,
Expand Down Expand Up @@ -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
Expand Down
42 changes: 35 additions & 7 deletions cmd/actupdate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
"flag"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"time"

"actupdate/internal/actionspec"
gh "actupdate/internal/github"
Expand All @@ -28,10 +30,13 @@ const (
exitVerificationFailure
)

const maxCooldownDays = int64(math.MaxInt64 / int64(24*time.Hour))

type cliOptions struct {
Repo string
Yes bool
GitHubToken string
Repo string
Yes bool
GitHubToken string
CooldownDays int
}

func main() {
Expand Down Expand Up @@ -79,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)
report, changes, hadVerificationFailure, err := buildReport(context.Background(), scans, client, cooldown)
if err != nil {
Comment thread
qartik marked this conversation as resolved.
fmt.Fprintf(errOut, "failed to build update plan: %v\n", err)
return exitOperationalError
Expand Down Expand Up @@ -135,15 +146,32 @@ 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")
}
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
Expand Down Expand Up @@ -179,7 +207,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{}
Expand Down Expand Up @@ -236,7 +264,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
}
Expand All @@ -251,7 +279,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
}
Expand Down
118 changes: 118 additions & 0 deletions cmd/actupdate/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,32 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)

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 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, "")
Expand Down Expand Up @@ -87,6 +111,100 @@ 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 {
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.Fprintf(w, `{"tagger":{"date":"%s"}}`, oldEnough)
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) {
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 {
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.Fprintf(w, `{"tagger":{"date":"%s"}}`, tooNewMoving)
case "/repos/actions/checkout/git/tags/tag-v621":
fmt.Fprintf(w, `{"tagger":{"date":"%s"}}`, tooNewExact)
default:
Comment thread
qartik marked this conversation as resolved.
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")
Expand Down
Loading