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
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# actupdate

`actupdate` updates GitHub Action references in workflow YAML files to the latest
stable major version.
eligible stable version.

## What It Does

Expand All @@ -10,9 +10,9 @@ Run `actupdate` inside a git repo and it will:
1. Scan `.github/workflows/*.yml` and `.github/workflows/*.yaml`
2. Find remote `uses:` references such as `actions/checkout@v4`
3. Query GitHub for the action repository's tags
4. Prefer a moving major tag such as `v6` when it exists
4. Prefer moving tags such as `v6` or `v6.2` when the action publishes them
5. Fall back to the latest verified stable exact tag such as `v6.2.1` when the
repo does not publish moving major tags
repo does not publish an eligible moving tag
6. Show the planned updates with colorized terminal output when supported
7. Prompt once before rewriting files; pressing Enter accepts the default and
applies the changes
Expand Down Expand Up @@ -76,10 +76,15 @@ 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
- Updates move to the latest eligible stable version
- `--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
- Moving major tags such as `v6` are preferred first; moving minor tags such as
`v6.2` are preferred next; exact tags such as `v6.2.1` are the fallback
- Moving refs keep their precision within the same major, so `v3` is not
rewritten to `v3.4` and `v3.4` is not rewritten to `v3.4.1`
- Exact refs can upgrade within the same major, but representation-only changes
such as `v3.0` to `v3.0.0` are ignored
- If any candidate update cannot be verified, the tool prints the failures and
does not rewrite any files

Expand Down
53 changes: 38 additions & 15 deletions cmd/actupdate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ func main() {
func run(args []string, in io.Reader, out, errOut io.Writer, httpClient *http.Client, githubBaseURL string) int {
opts, err := parseArgs(args)
if err != nil {
if errors.Is(err, flag.ErrHelp) {
printUsage(out)
return exitOK
}
fmt.Fprintln(errOut, err)
return exitInvalidInput
}
Expand Down Expand Up @@ -140,13 +144,7 @@ func parseArgs(args []string) (*cliOptions, error) {
return nil, nil
}

fs := flag.NewFlagSet("actupdate", flag.ContinueOnError)
fs.SetOutput(io.Discard)
opts := &cliOptions{}
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")
fs, opts := newFlagSet(io.Discard)
if err := fs.Parse(args); err != nil {
return nil, err
}
Expand All @@ -162,6 +160,36 @@ func parseArgs(args []string) (*cliOptions, error) {
return opts, nil
}

func newFlagSet(out io.Writer) (*flag.FlagSet, *cliOptions) {
fs := flag.NewFlagSet("actupdate", flag.ContinueOnError)
fs.SetOutput(out)
Comment thread
qartik marked this conversation as resolved.
fs.Usage = func() {
fmt.Fprint(out, `Usage of actupdate:

Updates GitHub Action references in .github/workflows/*.yml and *.yaml files
to the latest eligible stable version.

Commands:
actupdate [flags]
actupdate version

Flags:
`)
fs.PrintDefaults()
}
opts := &cliOptions{}
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")
return fs, opts
}

func printUsage(out io.Writer) {
fs, _ := newFlagSet(out)
fs.Usage()
}

func cooldownDuration(days int) (time.Duration, error) {
if days < 0 {
return 0, fmt.Errorf("--cooldown-days must be non-negative")
Expand Down Expand Up @@ -210,7 +238,6 @@ func useColor(out io.Writer) bool {
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{}
hadVerificationFailure := false

for _, scan := range scans {
Expand Down Expand Up @@ -254,20 +281,16 @@ func buildReport(ctx context.Context, scans []workflows.FileScan, client *gh.Cli
continue
}

currentMajor, err := actionspec.ParseMajor(spec.Ref)
currentVersion, err := actionspec.ParseStableVersion(spec.Ref)
if err != nil {
entry.Status = plan.StatusSkipped
entry.Reason = "non-semver ref"
report.Add(entry)
continue
}

outcome, ok := repoResults[spec.Repo]
if !ok {
resolution, resolveErr := client.ResolveLatestMajor(ctx, spec.Repo, currentMajor, cooldown)
outcome = repoOutcome{Resolution: resolution, Err: resolveErr}
repoResults[spec.Repo] = outcome
}
resolution, resolveErr := client.ResolveLatestStable(ctx, spec.Repo, currentVersion, cooldown)
outcome := repoOutcome{Resolution: resolution, Err: resolveErr}

if outcome.Err != nil {
entry.Status = plan.StatusError
Expand Down
71 changes: 71 additions & 0 deletions cmd/actupdate/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,36 @@ func TestRunVersion(t *testing.T) {
}
}

func TestRunHelpWithOtherFlags(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := run([]string{"--help", "--repo", "."}, strings.NewReader(""), &stdout, &stderr, http.DefaultClient, "")
if exitCode != exitOK {
t.Fatalf("expected exit 0, got %d stderr=%s", exitCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
if got := stdout.String(); !strings.Contains(got, "Usage of actupdate:") {
t.Fatalf("expected usage output, got %q", got)
}
}

func TestRunInvalidFlagUsesStderrOnly(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := run([]string{"--unknown"}, strings.NewReader(""), &stdout, &stderr, http.DefaultClient, "")
if exitCode != exitInvalidInput {
t.Fatalf("expected invalid input exit, got %d", exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "flag provided but not defined") {
t.Fatalf("expected flag error on stderr, got %q", got)
}
}

func TestPromptConfirmDefaultsToYes(t *testing.T) {
var stdout bytes.Buffer
confirmed, err := promptConfirm(strings.NewReader("\n"), &stdout)
Expand Down Expand Up @@ -205,6 +235,47 @@ func TestRunCooldownDaysLeavesTooNewMajorUnchanged(t *testing.T) {
}
}

func TestRunUpdatesOlderExactTagEvenWhenSameRepoHasNewerRef(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, "build_wheels.yml")
original := "steps:\n - uses: pypa/cibuildwheel@v3.3\n - uses: pypa/cibuildwheel@v3.0\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) {
if r.URL.Path != "/repos/pypa/cibuildwheel/tags" {
http.NotFound(w, r)
return
}
fmt.Fprint(w, `[{"name":"v3.3"},{"name":"v3.0"},{"name":"v2.9"}]`)
}))
defer server.Close()

var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := run([]string{"--repo", repo, "--yes"}, 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)
}
got := string(updated)
if strings.Count(got, "pypa/cibuildwheel@v3.3") != 2 {
t.Fatalf("expected both refs to end at v3.3, got %q", got)
}
if !strings.Contains(stdout.String(), "pypa/cibuildwheel@v3.0 -> @v3.3") {
t.Fatalf("expected same-major upgrade in output, got %q", stdout.String())
}
}

func TestRunVerificationFailurePreventsWrites(t *testing.T) {
repo := t.TempDir()
workflowDir := filepath.Join(repo, ".github", "workflows")
Expand Down
Loading