From e125c76756af7f5651ad7a4f6241dfd08afd697a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:00:54 +0000 Subject: [PATCH 1/3] Initial plan From 0fc5c1c4f72c2c167af0a76b91da2b6d9d886163 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:22:54 +0000 Subject: [PATCH 2/3] fix: fallback to git tags when no GitHub Releases found in gh aw upgrade When the GitHub Releases API returns an empty list for a repository, getLatestActionRelease now falls back to git ls-remote tag scanning (getLatestActionReleaseViaGitFn) instead of returning an error. This fixes the spurious 'github/gh-aw-actions/setup: no releases found' warning during 'gh aw upgrade' for repos that publish tags without GitHub Releases. - Add runGHReleasesAPIFn variable to make API call testable - Add getLatestActionReleaseViaGitFn variable for testability - Emit verbose info message when tag fallback is used - Only error when both sources find nothing - Add unit tests exercising the real fallback code path - Add FAQ entry documenting the Releases vs tags resolution logic Agent-Logs-Url: https://github.com/github/gh-aw/sessions/793935d9-1e6b-4fee-97bc-5ce82a95bb0d Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/faq.md | 11 +++++ pkg/cli/update_actions.go | 25 +++++++++- pkg/cli/update_actions_test.go | 65 ++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/reference/faq.md b/docs/src/content/docs/reference/faq.md index 0416ac186f2..3f7e09fc2d3 100644 --- a/docs/src/content/docs/reference/faq.md +++ b/docs/src/content/docs/reference/faq.md @@ -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/). diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index e54b6dc573c..1311f9a7692 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -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) @@ -191,7 +191,18 @@ 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) + } + return latestRelease, latestSHA, nil } // Parse current version @@ -429,6 +440,16 @@ 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") +} + // latestReleaseResult caches a resolved version/SHA pair. type latestReleaseResult struct { version string diff --git a/pkg/cli/update_actions_test.go b/pkg/cli/update_actions_test.go index 3254f5cc1b8..3ba2a944fad 100644 --- a/pkg/cli/update_actions_test.go +++ b/pkg/cli/update_actions_test.go @@ -3,6 +3,7 @@ package cli import ( + "errors" "os" "testing" @@ -510,3 +511,67 @@ 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") + } +} From 411fa0b37ab508d0a36e76d9b9014bf398e6d767 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:47:00 +0000 Subject: [PATCH 3/3] fix: filter out prerelease tags according to semver rules in version selection Both getLatestActionRelease (GitHub API path) and getLatestActionReleaseViaGit (git tag fallback) now skip prerelease versions (e.g. v1.1.0-beta.1) when building the valid-releases list. Per semver rules, v1.1.0-beta.1 > v1.0.0 (the base version is higher), so without this filter a prerelease tag could be incorrectly selected as the upgrade target. git ls-remote --tags returns every tag, making the fallback path particularly exposed to this. Also add getActionSHAForTagFn as a replaceable function variable (mirrors the existing pattern) to enable end-to-end testing without network calls. Adds TestGetLatestActionRelease_PrereleaseTagsSkipped to assert that v1.1.0-beta.1 is skipped in favour of the stable v1.0.0. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/02389988-6d87-487d-a93f-e4a06189744d Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/update_actions.go | 22 ++++++++++++++++------ pkg/cli/update_actions_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index 1311f9a7692..73bc26acf83 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -208,7 +208,9 @@ func getLatestActionRelease(repo, currentVersion string, allowMajor, verbose boo // 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 @@ -216,7 +218,7 @@ func getLatestActionRelease(repo, currentVersion string, allowMajor, verbose boo 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, @@ -236,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) } @@ -275,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) } @@ -330,7 +332,11 @@ 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 @@ -338,7 +344,7 @@ func getLatestActionReleaseViaGit(repo, currentVersion string, allowMajor, verbo 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, @@ -450,6 +456,10 @@ var runGHReleasesAPIFn = func(baseRepo string) ([]byte, error) { return workflow.RunGHCombined("Fetching releases...", "api", fmt.Sprintf("/repos/%s/releases", baseRepo), "--jq", ".[].tag_name") } +// 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 diff --git a/pkg/cli/update_actions_test.go b/pkg/cli/update_actions_test.go index 3ba2a944fad..9dacf29398b 100644 --- a/pkg/cli/update_actions_test.go +++ b/pkg/cli/update_actions_test.go @@ -575,3 +575,33 @@ func TestGetLatestActionRelease_FallbackReturnsErrorWhenBothFail(t *testing.T) { 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") + } +}