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
11 changes: 11 additions & 0 deletions docs/src/content/docs/reference/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,17 @@ updates:

See [Dependabot and gh-aw-actions](/gh-aw/reference/compilation-process/#dependabot-and-gh-aw-actions) for more details.

### How does `gh aw upgrade` resolve action versions when no GitHub Releases exist?

`gh aw upgrade` (and `gh aw update-actions`) resolves the latest version of each referenced action using a two-step process:

1. **GitHub Releases API** — queries `/repos/{owner}/{repo}/releases` via the `gh` CLI. If releases are found, the highest compatible semantic version is selected.
2. **Git tag fallback** — if the Releases API returns an empty list (which happens when a repository publishes tags without creating GitHub Releases), the command automatically falls back to scanning tags via `git ls-remote`. This fallback is **safe to ignore** — tags are a valid source for version pinning.

Only if *both* sources return no results does the upgrade produce a warning that cannot be resolved automatically.

> **Note:** `github/gh-aw-actions` intentionally publishes only tags (not GitHub Releases). The `gh aw upgrade` warning `github/gh-aw-actions/setup: no releases found` that appeared in earlier versions was caused by this two-step logic not falling back to tags. It has been fixed — the tag fallback now runs automatically.

### Why do I need a token or key?

When using **GitHub Copilot CLI**, a Personal Access Token (PAT) with "Copilot Requests" permission authenticates and associates automation work with your GitHub account. This ensures usage tracking against your subscription, appropriate AI permissions, and auditable actions. In the future, this may support organization-level association. See [Authentication](/gh-aw/reference/auth/).
Expand Down
47 changes: 39 additions & 8 deletions pkg/cli/update_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ func getLatestActionRelease(repo, currentVersion string, allowMajor, verbose boo
updateLog.Printf("Using base repository: %s for action: %s", baseRepo, repo)

// Use gh CLI to get releases
output, err := workflow.RunGHCombined("Fetching releases...", "api", fmt.Sprintf("/repos/%s/releases", baseRepo), "--jq", ".[].tag_name")
output, err := runGHReleasesAPIFn(baseRepo)
if err != nil {
// Check if this is an authentication error
outputStr := string(output)
Expand All @@ -191,21 +191,34 @@ func getLatestActionRelease(repo, currentVersion string, allowMajor, verbose boo

releases := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(releases) == 0 || releases[0] == "" {
return "", "", errors.New("no releases found")
// No GitHub Releases found; fall back to tag scanning via git ls-remote.
// Some repositories publish tags without creating GitHub Releases — this is safe
// to use and the warning below is informational only.
updateLog.Printf("No releases found via GitHub API for %s, falling back to git ls-remote tag scan", baseRepo)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(baseRepo+": no GitHub Releases found, falling back to tag scanning (safe to ignore)"))
}
latestRelease, latestSHA, gitErr := getLatestActionReleaseViaGitFn(repo, currentVersion, allowMajor, verbose)
if gitErr != nil {
return "", "", fmt.Errorf("no releases or tags found for %s: %w", baseRepo, gitErr)
}
Comment on lines +196 to +204
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The verbose info message says the tag-scan fallback is “safe to ignore” before the git fallback has actually succeeded. If the subsequent git ls-remote call fails, users will see a reassuring message immediately followed by an error, which is misleading. Consider moving the info message until after a successful git fallback, or reword it to something like “attempting tag scan fallback…” and only state it’s safe once a tag was found.

Suggested change
// to use and the warning below is informational only.
updateLog.Printf("No releases found via GitHub API for %s, falling back to git ls-remote tag scan", baseRepo)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(baseRepo+": no GitHub Releases found, falling back to tag scanning (safe to ignore)"))
}
latestRelease, latestSHA, gitErr := getLatestActionReleaseViaGitFn(repo, currentVersion, allowMajor, verbose)
if gitErr != nil {
return "", "", fmt.Errorf("no releases or tags found for %s: %w", baseRepo, gitErr)
}
// to use when the tag-scan fallback succeeds; the messages below are informational.
updateLog.Printf("No releases found via GitHub API for %s, falling back to git ls-remote tag scan", baseRepo)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(baseRepo+": no GitHub Releases found, attempting tag scan fallback via git ls-remote"))
}
latestRelease, latestSHA, gitErr := getLatestActionReleaseViaGitFn(repo, currentVersion, allowMajor, verbose)
if gitErr != nil {
return "", "", fmt.Errorf("no releases or tags found for %s: %w", baseRepo, gitErr)
}
if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(baseRepo+": tag scan fallback succeeded; using latest tag-based version (safe to ignore missing GitHub Releases)"))
}

Copilot uses AI. Check for mistakes.
return latestRelease, latestSHA, nil
}

// Parse current version
currentVer := parseVersion(currentVersion)

// Find all valid semantic version releases and sort by semver
// Find all valid stable semantic version releases (skip prereleases such as v1.0.0-beta.1).
// Per semver rules, v1.1.0-beta.1 > v1.0.0, so without this filter a prerelease of a
// higher base version could be incorrectly selected as the upgrade target.
type releaseWithVersion struct {
tag string
version *semanticVersion
}
var validReleases []releaseWithVersion
for _, release := range releases {
releaseVer := parseVersion(release)
if releaseVer != nil {
if releaseVer != nil && releaseVer.pre == "" {
validReleases = append(validReleases, releaseWithVersion{
tag: release,
version: releaseVer,
Expand All @@ -225,7 +238,7 @@ func getLatestActionRelease(repo, currentVersion string, allowMajor, verbose boo
// If current version is not valid, return the highest semver release
if currentVer == nil {
latestRelease := validReleases[0].tag
sha, err := getActionSHAForTag(baseRepo, latestRelease)
sha, err := getActionSHAForTagFn(baseRepo, latestRelease)
if err != nil {
return "", "", fmt.Errorf("failed to get SHA for %s: %w", latestRelease, err)
}
Expand Down Expand Up @@ -264,7 +277,7 @@ func getLatestActionRelease(repo, currentVersion string, allowMajor, verbose boo
}

// Get the SHA for the latest compatible release
sha, err := getActionSHAForTag(baseRepo, latestCompatible)
sha, err := getActionSHAForTagFn(baseRepo, latestCompatible)
if err != nil {
return "", "", fmt.Errorf("failed to get SHA for %s: %w", latestCompatible, err)
}
Expand Down Expand Up @@ -319,15 +332,19 @@ func getLatestActionReleaseViaGit(repo, currentVersion string, allowMajor, verbo
// Parse current version
currentVer := parseVersion(currentVersion)

// Find all valid semantic version releases and sort by semver
// Find all valid stable semantic version releases (skip prereleases such as v1.0.0-beta.1).
// Per semver rules, v1.1.0-beta.1 > v1.0.0, so without this filter a prerelease of a
// higher base version could be incorrectly selected as the upgrade target.
// git ls-remote --tags returns every tag, so the prerelease check is especially important
// for this fallback path.
type releaseWithVersion struct {
tag string
version *semanticVersion
}
var validReleases []releaseWithVersion
for _, release := range releases {
releaseVer := parseVersion(release)
if releaseVer != nil {
if releaseVer != nil && releaseVer.pre == "" {
validReleases = append(validReleases, releaseWithVersion{
tag: release,
version: releaseVer,
Expand Down Expand Up @@ -429,6 +446,20 @@ var actionRefPattern = regexp.MustCompile(`(uses:\s+)([a-zA-Z0-9][a-zA-Z0-9_-]*/
// tests to avoid network calls.
var getLatestActionReleaseFn = getLatestActionRelease

// getLatestActionReleaseViaGitFn is the function used to fetch the latest release via git
// ls-remote as a fallback. It can be replaced in tests to avoid network calls.
var getLatestActionReleaseViaGitFn = getLatestActionReleaseViaGit

// runGHReleasesAPIFn calls the GitHub Releases API for the given base repository and
// returns the raw output. It can be replaced in tests to avoid network calls.
var runGHReleasesAPIFn = func(baseRepo string) ([]byte, error) {
return workflow.RunGHCombined("Fetching releases...", "api", fmt.Sprintf("/repos/%s/releases", baseRepo), "--jq", ".[].tag_name")
}
Comment on lines +449 to +457
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getLatestActionReleaseViaGitFn is introduced as an injectable wrapper for the git ls-remote fallback, but getLatestActionRelease still calls getLatestActionReleaseViaGit directly in the auth-error fallback path. This makes the injection incomplete and prevents tests from stubbing the git fallback consistently. Consider routing all git fallback call sites through getLatestActionReleaseViaGitFn.

Copilot uses AI. Check for mistakes.

// getActionSHAForTagFn resolves the commit SHA for a given tag. It can be replaced in
// tests to avoid network calls.
var getActionSHAForTagFn = getActionSHAForTag

// latestReleaseResult caches a resolved version/SHA pair.
type latestReleaseResult struct {
version string
Expand Down
95 changes: 95 additions & 0 deletions pkg/cli/update_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package cli

import (
"errors"
"os"
"testing"

Expand Down Expand Up @@ -510,3 +511,97 @@ func TestUpdateActionRefsInContent_AllOrgsUpdatedWhenAllowMajor(t *testing.T) {
t.Errorf("updateActionRefsInContent() output mismatch\nGot:\n%s\nWant:\n%s", got, want)
}
}

// TestGetLatestActionRelease_FallsBackToGitWhenNoReleases verifies that when the GitHub
// Releases API returns an empty list, getLatestActionRelease falls back to the git
// ls-remote tag scan (getLatestActionReleaseViaGitFn) rather than returning an error.
func TestGetLatestActionRelease_FallsBackToGitWhenNoReleases(t *testing.T) {
origAPIfn := runGHReleasesAPIFn
origGitFn := getLatestActionReleaseViaGitFn
defer func() {
runGHReleasesAPIFn = origAPIfn
getLatestActionReleaseViaGitFn = origGitFn
}()

// Simulate the GitHub Releases API returning an empty list (no releases published).
runGHReleasesAPIFn = func(baseRepo string) ([]byte, error) {
return []byte(""), nil
}

gitFnCalled := false
getLatestActionReleaseViaGitFn = func(repo, currentVersion string, allowMajor, verbose bool) (string, string, error) {
gitFnCalled = true
return "v1.2.3", "abc1234567890123456789012345678901234567", nil
}

version, sha, err := getLatestActionRelease("github/gh-aw-actions/setup", "v1", false, false)
if err != nil {
t.Fatalf("expected no error when git fallback succeeds, got: %v", err)
}
if version != "v1.2.3" {
t.Errorf("version = %q, want %q", version, "v1.2.3")
}
if sha != "abc1234567890123456789012345678901234567" {
t.Errorf("sha = %q, want %q", sha, "abc1234567890123456789012345678901234567")
}
if !gitFnCalled {
t.Error("expected getLatestActionReleaseViaGitFn to be called as fallback, but it was not")
}
}

// TestGetLatestActionRelease_FallbackReturnsErrorWhenBothFail verifies that when the
// GitHub Releases API returns an empty list and the git fallback also fails, the
// function returns an error rather than silently succeeding.
func TestGetLatestActionRelease_FallbackReturnsErrorWhenBothFail(t *testing.T) {
origAPIfn := runGHReleasesAPIFn
origGitFn := getLatestActionReleaseViaGitFn
defer func() {
runGHReleasesAPIFn = origAPIfn
getLatestActionReleaseViaGitFn = origGitFn
}()

// Simulate the GitHub Releases API returning an empty list.
runGHReleasesAPIFn = func(baseRepo string) ([]byte, error) {
return []byte(""), nil
}

// Simulate the git fallback also finding nothing.
getLatestActionReleaseViaGitFn = func(repo, currentVersion string, allowMajor, verbose bool) (string, string, error) {
return "", "", errors.New("no releases found")
}

_, _, err := getLatestActionRelease("github/gh-aw-actions/setup", "v1", false, false)
if err == nil {
t.Fatal("expected error when both releases API and git fallback fail, got nil")
}
}

// TestGetLatestActionRelease_PrereleaseTagsSkipped verifies that prerelease tags are
// not selected as the upgrade target even when they have a higher base version than
// the latest stable release. Per semver rules, v1.1.0-beta.1 > v1.0.0 (base version
// comparison), so without explicit filtering a prerelease could be picked incorrectly.
func TestGetLatestActionRelease_PrereleaseTagsSkipped(t *testing.T) {
origAPIfn := runGHReleasesAPIFn
origSHAfn := getActionSHAForTagFn
defer func() {
runGHReleasesAPIFn = origAPIfn
getActionSHAForTagFn = origSHAfn
}()

// Return a stable release alongside a higher-versioned prerelease.
runGHReleasesAPIFn = func(baseRepo string) ([]byte, error) {
return []byte("v1.0.0\nv1.1.0-beta.1"), nil
}

getActionSHAForTagFn = func(repo, tag string) (string, error) {
return "stablesha1234567890123456789012345678901", nil
}

version, _, err := getLatestActionRelease("actions/checkout", "v1.0.0", true, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if version != "v1.0.0" {
t.Errorf("version = %q, want %q (prerelease v1.1.0-beta.1 should be skipped)", version, "v1.0.0")
}
}
Loading