From 430d6062e59fb9389982293e685fa9fa68c23ed2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 15:07:01 +0000
Subject: [PATCH 1/4] Add stable/latest version alias support to
 install-gh-aw.sh

- Add resolve_stable_version() function that fetches all releases via the GitHub
  API and returns the highest release in the previous minor version band
  (e.g. v0.1.10 when the latest is v0.2.2)
- Change default version alias from 'latest' to 'stable'
- Stable resolution falls back to 'latest' when no previous minor band exists or
  contains no releases
- Supports jq for parsing and falls back to grep/sed for portability
- Update test script: rename Test 11, add Test 12 with inline algorithm tests
  covering standard, fallback, and highest-patch-selection cases"

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/770feefe-a50d-48b9-b307-3968176421e6

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 install-gh-aw.sh               |  91 +++++++++++++++++++--
 scripts/test-install-script.sh | 142 +++++++++++++++++++++++++++++++--
 2 files changed, 220 insertions(+), 13 deletions(-)

diff --git a/install-gh-aw.sh b/install-gh-aw.sh
index 8b3d322f685..b813c9b904c 100755
--- a/install-gh-aw.sh
+++ b/install-gh-aw.sh
@@ -3,11 +3,17 @@
 # Script to download and install gh-aw binary for the current OS and architecture
 # Supports: Linux, macOS (Darwin), FreeBSD, Windows (Git Bash/MSYS/Cygwin)
 # Usage: ./install-gh-aw.sh [version] [options]
-# If no version is specified, it will use "latest" (GitHub automatically resolves to the latest release)
+# If no version is specified, "stable" is used (highest release in the previous minor version band).
 # Note: Checksum validation is currently skipped by default (will be enabled in future releases)
-# 
+#
+# Version aliases:
+#   stable  - Highest release in the previous minor version band (e.g. v0.1.10 when latest is v0.2.2)
+#   latest  - The most recent release (GitHub automatically resolves to the latest release)
+#
 # Examples:
-#   ./install-gh-aw.sh                           # Install latest version
+#   ./install-gh-aw.sh                           # Install stable version (default)
+#   ./install-gh-aw.sh stable                    # Install stable version explicitly
+#   ./install-gh-aw.sh latest                    # Install latest version
 #   ./install-gh-aw.sh v1.0.0                    # Install specific version
 #   ./install-gh-aw.sh --skip-checksum           # Skip checksum validation
 #
@@ -224,17 +230,90 @@ fetch_release_data() {
     return 1
 }
 
-# Get version (use provided version or default to "latest")
+# Resolve the "stable" version alias.
+# "stable" is the highest release in the previous minor version band relative to the latest release.
+# For example, if the latest is v0.2.2 and releases include v0.1.10, stable resolves to v0.1.10.
+# Falls back to the latest release if no previous minor band exists or contains no releases.
+resolve_stable_version() {
+    local api_url="https://api.github.com/repos/$REPO/releases?per_page=100"
+
+    print_info "Resolving 'stable' version..." >&2
+
+    # Fetch all releases from GitHub API
+    local releases_json
+    if ! releases_json=$(fetch_release_data "$api_url"); then
+        print_error "Failed to fetch releases list from GitHub API" >&2
+        return 1
+    fi
+
+    # Extract non-pre-release, non-draft version tags
+    local versions
+    if [ "$HAS_JQ" = true ]; then
+        versions=$(echo "$releases_json" | jq -r '.[] | select(.prerelease == false and .draft == false) | .tag_name')
+    else
+        # Without jq: extract tag_name values and keep only plain semver tags (vMAJOR.MINOR.PATCH)
+        versions=$(echo "$releases_json" | grep '"tag_name"' | sed 's/.*"tag_name": *"//;s/".*//' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$')
+    fi
+
+    if [ -z "$versions" ]; then
+        print_error "No stable releases found" >&2
+        return 1
+    fi
+
+    # Find the numerically highest (latest) version
+    local latest_clean
+    latest_clean=$(echo "$versions" | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
+
+    # Extract major and minor components
+    local latest_major latest_minor
+    latest_major=$(echo "$latest_clean" | cut -d. -f1)
+    latest_minor=$(echo "$latest_clean" | cut -d. -f2)
+
+    # Determine the previous minor version band
+    local prev_minor=$((latest_minor - 1))
+
+    if [ "$prev_minor" -lt 0 ]; then
+        print_warning "No previous minor release band exists (latest is v${latest_major}.0.x). Using latest instead." >&2
+        echo "v${latest_clean}"
+        return 0
+    fi
+
+    # Find the highest version in the previous minor band
+    local stable_clean
+    stable_clean=$(echo "$versions" | sed 's/^v//' | grep -E "^${latest_major}\.${prev_minor}\." | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
+
+    if [ -z "$stable_clean" ]; then
+        print_warning "No releases found in v${latest_major}.${prev_minor}.x band. Using latest instead." >&2
+        echo "v${latest_clean}"
+        return 0
+    fi
+
+    echo "v${stable_clean}"
+    return 0
+}
+
+# Get version (use provided version or default to "stable")
 # VERSION is already set from argument parsing
 REPO="github/gh-aw"
 
 if [ -z "$VERSION" ]; then
-    print_info "No version specified, using 'latest'..."
-    VERSION="latest"
+    print_info "No version specified, using 'stable'..."
+    VERSION="stable"
 else
     print_info "Using specified version: $VERSION"
 fi
 
+# Resolve "stable" alias to a concrete version tag via the GitHub API
+if [ "$VERSION" = "stable" ]; then
+    RESOLVED_VERSION=$(resolve_stable_version)
+    if [ $? -ne 0 ] || [ -z "$RESOLVED_VERSION" ]; then
+        print_error "Failed to resolve stable version. Please specify a version explicitly (e.g. v1.0.0) or use 'latest'."
+        exit 1
+    fi
+    print_info "Resolved 'stable' to: $RESOLVED_VERSION"
+    VERSION="$RESOLVED_VERSION"
+fi
+
 # Try gh extension install if requested (and gh is available)
 if [ "$TRY_GH_INSTALL" = true ] && command -v gh &> /dev/null; then
     print_info "Attempting to install gh-aw using 'gh extension install'..."
diff --git a/scripts/test-install-script.sh b/scripts/test-install-script.sh
index 90c62f5c337..3167755057e 100755
--- a/scripts/test-install-script.sh
+++ b/scripts/test-install-script.sh
@@ -351,19 +351,19 @@ else
     exit 1
 fi
 
-# Test 11: Verify "latest" version functionality
+# Test 11: Verify default version and version alias functionality
 echo ""
-echo "Test 11: Verify 'latest' version functionality"
+echo "Test 11: Verify default and version alias functionality"
 
-# Check for "latest" as default version
-if grep -q "using 'latest'" "$PROJECT_ROOT/install-gh-aw.sh"; then
-    echo "  ✓ PASS: Script uses 'latest' as default version"
+# Check that "stable" is the default version
+if grep -q "using 'stable'" "$PROJECT_ROOT/install-gh-aw.sh"; then
+    echo "  ✓ PASS: Script uses 'stable' as default version"
 else
-    echo "  ✗ FAIL: Script does not use 'latest' as default version"
+    echo "  ✗ FAIL: Script does not use 'stable' as default version"
     exit 1
 fi
 
-# Check for latest URL construction
+# Check for latest URL construction (used when "latest" alias is explicitly specified)
 if grep -q 'releases/latest/download' "$PROJECT_ROOT/install-gh-aw.sh"; then
     echo "  ✓ PASS: Latest release URL pattern is correct"
 else
@@ -387,5 +387,133 @@ else
     echo "  ✓ PASS: Version validation logic removed"
 fi
 
+# Test 12: Verify "stable" version resolution functionality
+echo ""
+echo "Test 12: Verify 'stable' version resolution functionality"
+
+# Check for resolve_stable_version function
+if grep -q "resolve_stable_version()" "$PROJECT_ROOT/install-gh-aw.sh"; then
+    echo "  ✓ PASS: resolve_stable_version function exists"
+else
+    echo "  ✗ FAIL: resolve_stable_version function not found"
+    exit 1
+fi
+
+# Check that stable is resolved via API before being used
+if grep -q 'VERSION.*=.*"stable"' "$PROJECT_ROOT/install-gh-aw.sh" || grep -q "VERSION.*stable" "$PROJECT_ROOT/install-gh-aw.sh"; then
+    echo "  ✓ PASS: Stable version alias is handled"
+else
+    echo "  ✗ FAIL: Stable version alias not handled"
+    exit 1
+fi
+
+# Check that resolve_stable_version uses the GitHub releases API
+if grep -q 'api.github.com/repos.*releases' "$PROJECT_ROOT/install-gh-aw.sh"; then
+    echo "  ✓ PASS: resolve_stable_version uses GitHub releases API"
+else
+    echo "  ✗ FAIL: resolve_stable_version does not use GitHub releases API"
+    exit 1
+fi
+
+# Check that stable resolution falls back to latest when no previous minor band exists
+if grep -q "No previous minor release band" "$PROJECT_ROOT/install-gh-aw.sh"; then
+    echo "  ✓ PASS: Stable resolution handles missing previous minor band"
+else
+    echo "  ✗ FAIL: Stable resolution does not handle missing previous minor band"
+    exit 1
+fi
+
+# Check that stable resolution falls back when band is empty
+if grep -q "No releases found in v" "$PROJECT_ROOT/install-gh-aw.sh"; then
+    echo "  ✓ PASS: Stable resolution handles empty minor band"
+else
+    echo "  ✗ FAIL: Stable resolution does not handle empty minor band"
+    exit 1
+fi
+
+# Test the stable version resolution logic inline
+echo ""
+echo "Test 12b: Verify stable version resolution algorithm"
+
+# Simulate the algorithm with known input
+simulate_stable_resolution() {
+    local versions="$1"
+    # Replicate the logic from resolve_stable_version
+
+    local latest_clean
+    latest_clean=$(echo "$versions" | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
+
+    local latest_major latest_minor
+    latest_major=$(echo "$latest_clean" | cut -d. -f1)
+    latest_minor=$(echo "$latest_clean" | cut -d. -f2)
+
+    local prev_minor=$((latest_minor - 1))
+
+    if [ "$prev_minor" -lt 0 ]; then
+        echo "v${latest_clean}"
+        return 0
+    fi
+
+    local stable_clean
+    stable_clean=$(echo "$versions" | sed 's/^v//' | grep -E "^${latest_major}\.${prev_minor}\." | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
+
+    if [ -z "$stable_clean" ]; then
+        echo "v${latest_clean}"
+        return 0
+    fi
+
+    echo "v${stable_clean}"
+}
+
+# Test: standard case - v0.2.2 latest, v0.1.10 stable
+VERSIONS="v0.2.2
+v0.1.10
+v0.1.9
+v0.2.0"
+RESULT=$(simulate_stable_resolution "$VERSIONS")
+if [ "$RESULT" = "v0.1.10" ]; then
+    echo "  ✓ PASS: Standard case - stable resolved to v0.1.10 (latest v0.2.2)"
+else
+    echo "  ✗ FAIL: Standard case - expected v0.1.10 but got $RESULT"
+    exit 1
+fi
+
+# Test: no previous minor band (latest is v1.0.3, no v0.x) - falls back to latest
+VERSIONS="v1.0.3
+v1.0.2
+v1.0.0"
+RESULT=$(simulate_stable_resolution "$VERSIONS")
+if [ "$RESULT" = "v1.0.3" ]; then
+    echo "  ✓ PASS: No previous minor band - falls back to latest v1.0.3"
+else
+    echo "  ✗ FAIL: No previous minor band - expected v1.0.3 but got $RESULT"
+    exit 1
+fi
+
+# Test: previous minor band is empty - falls back to latest
+VERSIONS="v0.3.1
+v0.3.0
+v0.1.5"
+RESULT=$(simulate_stable_resolution "$VERSIONS")
+if [ "$RESULT" = "v0.3.1" ]; then
+    echo "  ✓ PASS: Empty previous minor band - falls back to latest v0.3.1"
+else
+    echo "  ✗ FAIL: Empty previous minor band - expected v0.3.1 but got $RESULT"
+    exit 1
+fi
+
+# Test: picks highest patch in previous minor band
+VERSIONS="v2.1.0
+v2.0.5
+v2.0.1
+v2.0.3"
+RESULT=$(simulate_stable_resolution "$VERSIONS")
+if [ "$RESULT" = "v2.0.5" ]; then
+    echo "  ✓ PASS: Highest patch selection - stable resolved to v2.0.5"
+else
+    echo "  ✗ FAIL: Highest patch selection - expected v2.0.5 but got $RESULT"
+    exit 1
+fi
+
 echo ""
 echo "=== All tests passed ==="

From a38b6805d7c62d6d08e1bdc51f62b6178d4d1ddd Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 15:08:21 +0000
Subject: [PATCH 2/4] Address code review: add comments for per_page limit,
 fallback filtering, and test duplication rationale

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/770feefe-a50d-48b9-b307-3968176421e6

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 install-gh-aw.sh               | 10 +++++++++-
 scripts/test-install-script.sh |  3 +++
 2 files changed, 12 insertions(+), 1 deletion(-)

diff --git a/install-gh-aw.sh b/install-gh-aw.sh
index b813c9b904c..168d345fce1 100755
--- a/install-gh-aw.sh
+++ b/install-gh-aw.sh
@@ -235,6 +235,10 @@ fetch_release_data() {
 # For example, if the latest is v0.2.2 and releases include v0.1.10, stable resolves to v0.1.10.
 # Falls back to the latest release if no previous minor band exists or contains no releases.
 resolve_stable_version() {
+    # GitHub API returns at most 100 items per page. For the vast majority of projects
+    # this covers all releases; if a repo ever exceeds 100 releases the resolution will
+    # still be correct as long as both the latest and its previous minor band are within
+    # the first 100 entries (releases are returned newest-first by default).
     local api_url="https://api.github.com/repos/$REPO/releases?per_page=100"
 
     print_info "Resolving 'stable' version..." >&2
@@ -251,7 +255,11 @@ resolve_stable_version() {
     if [ "$HAS_JQ" = true ]; then
         versions=$(echo "$releases_json" | jq -r '.[] | select(.prerelease == false and .draft == false) | .tag_name')
     else
-        # Without jq: extract tag_name values and keep only plain semver tags (vMAJOR.MINOR.PATCH)
+        # Without jq: extract tag_name values and keep only plain semver tags (vMAJOR.MINOR.PATCH).
+        # The strict regex (anchored with $) already excludes pre-release tags such as v1.0.0-rc.1
+        # or v1.0.0-beta because those contain extra characters after the patch number.
+        # Draft releases cannot be reliably detected without jq, but GitHub does not include
+        # drafts in the public releases API response for unauthenticated requests.
         versions=$(echo "$releases_json" | grep '"tag_name"' | sed 's/.*"tag_name": *"//;s/".*//' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$')
     fi
 
diff --git a/scripts/test-install-script.sh b/scripts/test-install-script.sh
index 3167755057e..624b8a5d37f 100755
--- a/scripts/test-install-script.sh
+++ b/scripts/test-install-script.sh
@@ -432,6 +432,9 @@ else
 fi
 
 # Test the stable version resolution logic inline
+# simulate_stable_resolution intentionally duplicates the core algorithm from
+# resolve_stable_version so that tests remain self-contained without a live GitHub
+# API call. If the algorithm in install-gh-aw.sh is updated, keep this in sync.
 echo ""
 echo "Test 12b: Verify stable version resolution algorithm"
 

From 540b9713e6d4bf2ccdf349da97d3fea26a9370ed Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 16:26:53 +0000
Subject: [PATCH 3/4] Plan: add --channel flag to upgrade command with
 stable/latest support

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/bbaf44e4-a31d-4b73-a6e6-9dae9c5ce205

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 actions/setup-cli/install.sh | 99 +++++++++++++++++++++++++++++++++---
 1 file changed, 93 insertions(+), 6 deletions(-)

diff --git a/actions/setup-cli/install.sh b/actions/setup-cli/install.sh
index 8b3d322f685..168d345fce1 100755
--- a/actions/setup-cli/install.sh
+++ b/actions/setup-cli/install.sh
@@ -3,11 +3,17 @@
 # Script to download and install gh-aw binary for the current OS and architecture
 # Supports: Linux, macOS (Darwin), FreeBSD, Windows (Git Bash/MSYS/Cygwin)
 # Usage: ./install-gh-aw.sh [version] [options]
-# If no version is specified, it will use "latest" (GitHub automatically resolves to the latest release)
+# If no version is specified, "stable" is used (highest release in the previous minor version band).
 # Note: Checksum validation is currently skipped by default (will be enabled in future releases)
-# 
+#
+# Version aliases:
+#   stable  - Highest release in the previous minor version band (e.g. v0.1.10 when latest is v0.2.2)
+#   latest  - The most recent release (GitHub automatically resolves to the latest release)
+#
 # Examples:
-#   ./install-gh-aw.sh                           # Install latest version
+#   ./install-gh-aw.sh                           # Install stable version (default)
+#   ./install-gh-aw.sh stable                    # Install stable version explicitly
+#   ./install-gh-aw.sh latest                    # Install latest version
 #   ./install-gh-aw.sh v1.0.0                    # Install specific version
 #   ./install-gh-aw.sh --skip-checksum           # Skip checksum validation
 #
@@ -224,17 +230,98 @@ fetch_release_data() {
     return 1
 }
 
-# Get version (use provided version or default to "latest")
+# Resolve the "stable" version alias.
+# "stable" is the highest release in the previous minor version band relative to the latest release.
+# For example, if the latest is v0.2.2 and releases include v0.1.10, stable resolves to v0.1.10.
+# Falls back to the latest release if no previous minor band exists or contains no releases.
+resolve_stable_version() {
+    # GitHub API returns at most 100 items per page. For the vast majority of projects
+    # this covers all releases; if a repo ever exceeds 100 releases the resolution will
+    # still be correct as long as both the latest and its previous minor band are within
+    # the first 100 entries (releases are returned newest-first by default).
+    local api_url="https://api.github.com/repos/$REPO/releases?per_page=100"
+
+    print_info "Resolving 'stable' version..." >&2
+
+    # Fetch all releases from GitHub API
+    local releases_json
+    if ! releases_json=$(fetch_release_data "$api_url"); then
+        print_error "Failed to fetch releases list from GitHub API" >&2
+        return 1
+    fi
+
+    # Extract non-pre-release, non-draft version tags
+    local versions
+    if [ "$HAS_JQ" = true ]; then
+        versions=$(echo "$releases_json" | jq -r '.[] | select(.prerelease == false and .draft == false) | .tag_name')
+    else
+        # Without jq: extract tag_name values and keep only plain semver tags (vMAJOR.MINOR.PATCH).
+        # The strict regex (anchored with $) already excludes pre-release tags such as v1.0.0-rc.1
+        # or v1.0.0-beta because those contain extra characters after the patch number.
+        # Draft releases cannot be reliably detected without jq, but GitHub does not include
+        # drafts in the public releases API response for unauthenticated requests.
+        versions=$(echo "$releases_json" | grep '"tag_name"' | sed 's/.*"tag_name": *"//;s/".*//' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$')
+    fi
+
+    if [ -z "$versions" ]; then
+        print_error "No stable releases found" >&2
+        return 1
+    fi
+
+    # Find the numerically highest (latest) version
+    local latest_clean
+    latest_clean=$(echo "$versions" | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
+
+    # Extract major and minor components
+    local latest_major latest_minor
+    latest_major=$(echo "$latest_clean" | cut -d. -f1)
+    latest_minor=$(echo "$latest_clean" | cut -d. -f2)
+
+    # Determine the previous minor version band
+    local prev_minor=$((latest_minor - 1))
+
+    if [ "$prev_minor" -lt 0 ]; then
+        print_warning "No previous minor release band exists (latest is v${latest_major}.0.x). Using latest instead." >&2
+        echo "v${latest_clean}"
+        return 0
+    fi
+
+    # Find the highest version in the previous minor band
+    local stable_clean
+    stable_clean=$(echo "$versions" | sed 's/^v//' | grep -E "^${latest_major}\.${prev_minor}\." | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
+
+    if [ -z "$stable_clean" ]; then
+        print_warning "No releases found in v${latest_major}.${prev_minor}.x band. Using latest instead." >&2
+        echo "v${latest_clean}"
+        return 0
+    fi
+
+    echo "v${stable_clean}"
+    return 0
+}
+
+# Get version (use provided version or default to "stable")
 # VERSION is already set from argument parsing
 REPO="github/gh-aw"
 
 if [ -z "$VERSION" ]; then
-    print_info "No version specified, using 'latest'..."
-    VERSION="latest"
+    print_info "No version specified, using 'stable'..."
+    VERSION="stable"
 else
     print_info "Using specified version: $VERSION"
 fi
 
+# Resolve "stable" alias to a concrete version tag via the GitHub API
+if [ "$VERSION" = "stable" ]; then
+    RESOLVED_VERSION=$(resolve_stable_version)
+    if [ $? -ne 0 ] || [ -z "$RESOLVED_VERSION" ]; then
+        print_error "Failed to resolve stable version. Please specify a version explicitly (e.g. v1.0.0) or use 'latest'."
+        exit 1
+    fi
+    print_info "Resolved 'stable' to: $RESOLVED_VERSION"
+    VERSION="$RESOLVED_VERSION"
+fi
+
 # Try gh extension install if requested (and gh is available)
 if [ "$TRY_GH_INSTALL" = true ] && command -v gh &> /dev/null; then
     print_info "Attempting to install gh-aw using 'gh extension install'..."

From 5214d57a3ad3800b63bacb0a6b61ba0287abd254 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 16:36:42 +0000
Subject: [PATCH 4/4] Add --channel flag to upgrade command with stable/latest
 support

- Add getStableRelease() to update_check.go that resolves the highest release
  in the previous minor version band (e.g. v0.1.10 when latest is v0.2.2)
- Add Prerelease and Draft fields to Release struct for proper filtering
- Update upgradeExtensionIfOutdated to accept a channel parameter ('stable'
  or 'latest'), default 'stable'
- Add buildExtensionInstallCommand helper that uses 'gh extension upgrade'
  for latest and 'gh extension install --force --pin VERSION' for stable
- Add --channel flag (default 'stable') to upgrade command with validation
- Thread channel through runUpgradeCommand call chain
- Update help text with RELEASE CHANNEL section and --channel examples
- Update tests to pass channel parameter; add test for latest channel

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/bbaf44e4-a31d-4b73-a6e6-9dae9c5ce205

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 pkg/cli/update_check.go                | 91 +++++++++++++++++++++++++-
 pkg/cli/update_extension_check.go      | 62 ++++++++++++------
 pkg/cli/update_extension_check_test.go | 17 ++++-
 pkg/cli/upgrade_command.go             | 40 +++++++----
 4 files changed, 174 insertions(+), 36 deletions(-)

diff --git a/pkg/cli/update_check.go b/pkg/cli/update_check.go
index 4870dd573c2..e02557f7691 100644
--- a/pkg/cli/update_check.go
+++ b/pkg/cli/update_check.go
@@ -2,9 +2,11 @@ package cli
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"time"
 
@@ -12,6 +14,7 @@ import (
 	"github.com/github/gh-aw/pkg/console"
 	"github.com/github/gh-aw/pkg/logger"
 	"github.com/github/gh-aw/pkg/workflow"
+	"golang.org/x/mod/semver"
 )
 
 var updateCheckLog = logger.New("cli:update_check")
@@ -25,9 +28,11 @@ const (
 
 // Release represents a GitHub release
 type Release struct {
-	TagName string `json:"tag_name"`
-	Name    string `json:"name"`
-	HTMLURL string `json:"html_url"`
+	TagName    string `json:"tag_name"`
+	Name       string `json:"name"`
+	HTMLURL    string `json:"html_url"`
+	Prerelease bool   `json:"prerelease"`
+	Draft      bool   `json:"draft"`
 }
 
 // shouldCheckForUpdate determines if we should check for updates based on:
@@ -226,6 +231,86 @@ func getLatestRelease() (string, error) {
 	return release.TagName, nil
 }
 
+// getStableRelease resolves the "stable" version alias.
+// Stable is the highest release in the previous minor version band relative to
+// the latest release. For example, if the latest is v0.2.2 and releases include
+// v0.1.10, stable resolves to v0.1.10.
+// Falls back to the latest release if no previous minor band exists or is empty.
+func getStableRelease() (string, error) {
+	updateCheckLog.Print("Querying GitHub API for stable release...")
+
+	client, err := api.NewRESTClient(api.ClientOptions{})
+	if err != nil {
+		return "", fmt.Errorf("failed to create GitHub client: %w", err)
+	}
+
+	// Fetch up to 100 releases. The GitHub API returns them newest-first by default.
+	// For the vast majority of projects this is sufficient; if a repo exceeds 100 releases
+	// the resolution is still correct as long as both the latest and its previous minor
+	// band are within the first 100 entries.
+	var releases []Release
+	if err = client.Get("repos/github/gh-aw/releases?per_page=100", &releases); err != nil {
+		return "", fmt.Errorf("failed to query releases: %w", err)
+	}
+
+	// Collect valid stable (non-draft, non-prerelease) semver versions.
+	var versions []string
+	for _, r := range releases {
+		if r.Draft || r.Prerelease {
+			continue
+		}
+		sv := "v" + strings.TrimPrefix(r.TagName, "v")
+		if semver.IsValid(sv) {
+			versions = append(versions, sv)
+		}
+	}
+
+	if len(versions) == 0 {
+		return "", errors.New("no stable releases found")
+	}
+
+	// Sort ascending so we can take the last element as the highest.
+	semver.Sort(versions)
+	latestSV := versions[len(versions)-1]
+
+	// Parse vMAJOR.MINOR.PATCH into components.
+	rawParts := strings.Split(strings.TrimPrefix(semver.Canonical(latestSV), "v"), ".")
+	if len(rawParts) < 3 {
+		updateCheckLog.Printf("Unexpected semver format %s; falling back to latest", latestSV)
+		return latestSV, nil
+	}
+
+	latestMinor, parseErr := strconv.Atoi(rawParts[1])
+	if parseErr != nil {
+		updateCheckLog.Printf("Could not parse minor component of %s; falling back to latest", latestSV)
+		return latestSV, nil
+	}
+
+	// If latest is a vMAJOR.0.x release there is no previous minor band.
+	if latestMinor == 0 {
+		updateCheckLog.Printf("%s is already in minor band 0; no previous band, using latest", latestSV)
+		return latestSV, nil
+	}
+
+	prevBandPrefix := fmt.Sprintf("v%s.%d.", rawParts[0], latestMinor-1)
+
+	// versions is sorted ascending, so the last match is the highest patch in the band.
+	stableSV := ""
+	for _, v := range versions {
+		if strings.HasPrefix(v, prevBandPrefix) {
+			stableSV = v
+		}
+	}
+
+	if stableSV == "" {
+		updateCheckLog.Printf("No releases found in %s* band; falling back to latest (%s)", prevBandPrefix, latestSV)
+		return latestSV, nil
+	}
+
+	updateCheckLog.Printf("Stable release: %s (latest: %s)", stableSV, latestSV)
+	return stableSV, nil
+}
+
 // CheckForUpdatesAsync performs update check in background (best effort)
 // This is called from compile command and should never block or fail the compilation
 // The context can be used to cancel the update check if the program is shutting down
diff --git a/pkg/cli/update_extension_check.go b/pkg/cli/update_extension_check.go
index 25e903631be..70394a31fad 100644
--- a/pkg/cli/update_extension_check.go
+++ b/pkg/cli/update_extension_check.go
@@ -22,6 +22,10 @@ var updateExtensionCheckLog = logger.New("cli:update_extension_check")
 // upgradeExtensionIfOutdated checks if a newer version of the gh-aw extension is available
 // and, if so, upgrades it automatically.
 //
+// channel selects which release to target: "stable" (default, highest release in the
+// previous minor band) or "latest" (most recent release). An empty string defaults to
+// "stable".
+//
 // Returns:
 //   - upgraded: true if an upgrade was performed.
 //   - installPath: on Linux, the resolved path where the new binary was installed
@@ -35,9 +39,12 @@ var updateExtensionCheckLog = logger.New("cli:update_extension_check")
 // baked in. The caller should re-launch the freshly-installed binary (at
 // installPath) so that subsequent work (e.g. lock-file compilation) uses the
 // correct new version string.
-func upgradeExtensionIfOutdated(verbose bool) (bool, string, error) {
+func upgradeExtensionIfOutdated(channel string, verbose bool) (bool, string, error) {
+	if channel == "" {
+		channel = "stable"
+	}
 	currentVersion := GetVersion()
-	updateExtensionCheckLog.Printf("Checking if extension needs upgrade (current: %s)", currentVersion)
+	updateExtensionCheckLog.Printf("Checking if extension needs upgrade (current: %s, channel: %s)", currentVersion, channel)
 
 	// Skip for non-release versions (dev builds)
 	if !workflow.IsReleasedVersion(currentVersion) {
@@ -48,32 +55,38 @@ func upgradeExtensionIfOutdated(verbose bool) (bool, string, error) {
 		return false, "", nil
 	}
 
-	// Query GitHub API for latest release
-	latestVersion, err := getLatestRelease()
+	// Query GitHub API for the target release based on channel
+	var targetVersion string
+	var err error
+	if channel == "latest" {
+		targetVersion, err = getLatestRelease()
+	} else {
+		targetVersion, err = getStableRelease()
+	}
 	if err != nil {
 		// Fail silently - don't block the upgrade command if we can't reach GitHub
-		updateExtensionCheckLog.Printf("Failed to check for latest release (silently ignoring): %v", err)
+		updateExtensionCheckLog.Printf("Failed to check for %s release (silently ignoring): %v", channel, err)
 		if verbose {
 			fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not check for extension updates: %v", err)))
 		}
 		return false, "", nil
 	}
 
-	if latestVersion == "" {
-		updateExtensionCheckLog.Print("Could not determine latest version, skipping upgrade")
+	if targetVersion == "" {
+		updateExtensionCheckLog.Printf("Could not determine %s version, skipping upgrade", channel)
 		return false, "", nil
 	}
 
-	updateExtensionCheckLog.Printf("Latest version: %s", latestVersion)
+	updateExtensionCheckLog.Printf("Target version (%s channel): %s", channel, targetVersion)
 
 	// Ensure both versions have the 'v' prefix required by the semver package.
 	currentSV := "v" + strings.TrimPrefix(currentVersion, "v")
-	latestSV := "v" + strings.TrimPrefix(latestVersion, "v")
+	targetSV := "v" + strings.TrimPrefix(targetVersion, "v")
 
-	// Already on the latest (or newer) version – use proper semver comparison so
+	// Already on the target (or newer) version – use proper semver comparison so
 	// that e.g. "0.10.0" is correctly treated as newer than "0.9.0".
-	if semver.IsValid(currentSV) && semver.IsValid(latestSV) {
-		if semver.Compare(currentSV, latestSV) >= 0 {
+	if semver.IsValid(currentSV) && semver.IsValid(targetSV) {
+		if semver.Compare(currentSV, targetSV) >= 0 {
 			updateExtensionCheckLog.Print("Extension is already up to date")
 			if verbose {
 				fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ gh-aw extension is up to date"))
@@ -84,12 +97,12 @@ func upgradeExtensionIfOutdated(verbose bool) (bool, string, error) {
 		// Versions are not valid semver; skip unreliable string comparison and
 		// proceed with the upgrade to avoid incorrectly treating an outdated
 		// version as up to date (lexicographic comparison breaks for e.g. "0.9.0" vs "0.10.0").
-		updateExtensionCheckLog.Printf("Non-semver versions detected (current=%q, latest=%q); proceeding with upgrade", currentVersion, latestVersion)
+		updateExtensionCheckLog.Printf("Non-semver versions detected (current=%q, target=%q); proceeding with upgrade", currentVersion, targetVersion)
 	}
 
 	// A newer version is available – upgrade automatically
-	updateExtensionCheckLog.Printf("Upgrading extension from %s to %s", currentVersion, latestVersion)
-	fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Upgrading gh-aw extension from %s to %s...", currentVersion, latestVersion)))
+	updateExtensionCheckLog.Printf("Upgrading extension from %s to %s", currentVersion, targetVersion)
+	fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Upgrading gh-aw extension from %s to %s...", currentVersion, targetVersion)))
 
 	// First attempt: run the upgrade without touching the filesystem.
 	// On most systems (and on Linux when there is no in-use binary conflict)
@@ -102,7 +115,7 @@ func upgradeExtensionIfOutdated(verbose bool) (bool, string, error) {
 	// rename+retry path succeeds and the user is not shown a confusing failure.
 	var firstAttemptBuf bytes.Buffer
 	firstAttemptOut := firstAttemptWriter(os.Stderr, &firstAttemptBuf)
-	firstCmd := exec.Command("gh", "extension", "upgrade", "github/gh-aw")
+	firstCmd := buildExtensionInstallCommand(channel, targetVersion)
 	firstCmd.Stdout = firstAttemptOut
 	firstCmd.Stderr = firstAttemptOut
 	firstErr := firstCmd.Run()
@@ -112,7 +125,7 @@ func upgradeExtensionIfOutdated(verbose bool) (bool, string, error) {
 			// Replay the buffered output that was not shown during the attempt.
 			_, _ = io.Copy(os.Stderr, &firstAttemptBuf)
 		}
-		fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ gh-aw extension upgraded to "+latestVersion))
+		fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ gh-aw extension upgraded to "+targetVersion))
 		return true, "", nil
 	}
 
@@ -141,7 +154,7 @@ func upgradeExtensionIfOutdated(verbose bool) (bool, string, error) {
 		}
 	}
 
-	retryCmd := exec.Command("gh", "extension", "upgrade", "github/gh-aw")
+	retryCmd := buildExtensionInstallCommand(channel, targetVersion)
 	retryCmd.Stdout = os.Stderr
 	retryCmd.Stderr = os.Stderr
 	if retryErr := retryCmd.Run(); retryErr != nil {
@@ -157,10 +170,21 @@ func upgradeExtensionIfOutdated(verbose bool) (bool, string, error) {
 		cleanupExecutableBackup(installPath)
 	}
 
-	fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ gh-aw extension upgraded to "+latestVersion))
+	fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ gh-aw extension upgraded to "+targetVersion))
 	return true, installPath, nil
 }
 
+// buildExtensionInstallCommand builds the gh CLI command to install the gh-aw extension.
+// For the "latest" channel it uses `gh extension upgrade` (always installs the latest).
+// For any other channel (e.g. "stable") it uses `gh extension install --force --pin VERSION`
+// to target a specific release.
+func buildExtensionInstallCommand(channel string, targetVersion string) *exec.Cmd {
+	if channel == "latest" {
+		return exec.Command("gh", "extension", "upgrade", "github/gh-aw")
+	}
+	return exec.Command("gh", "extension", "install", "--force", "--pin", targetVersion, "github/gh-aw")
+}
+
 // firstAttemptWriter returns a writer that buffers output on Linux (so that
 // ETXTBSY error messages from a failed first upgrade attempt can be suppressed
 // when the rename+retry workaround succeeds) and writes directly to dst on
diff --git a/pkg/cli/update_extension_check_test.go b/pkg/cli/update_extension_check_test.go
index 1f0ac7c4612..e8d2476bf48 100644
--- a/pkg/cli/update_extension_check_test.go
+++ b/pkg/cli/update_extension_check_test.go
@@ -25,7 +25,7 @@ func TestUpgradeExtensionIfOutdated_DevBuild(t *testing.T) {
 	// Verify the function exits before making any API calls.
 	// If it did make API calls we'd see a network error in test environments,
 	// but the function must return (false, "", nil) immediately.
-	upgraded, installPath, err := upgradeExtensionIfOutdated(false)
+	upgraded, installPath, err := upgradeExtensionIfOutdated("stable", false)
 	require.NoError(t, err, "Should not return error for dev builds")
 	assert.False(t, upgraded, "Should not report upgrade for dev builds")
 	assert.Empty(t, installPath, "installPath should be empty for dev builds")
@@ -42,12 +42,25 @@ func TestUpgradeExtensionIfOutdated_SilentFailureOnAPIError(t *testing.T) {
 	// Use a release version so the API call is attempted
 	SetVersionInfo("v0.1.0")
 
-	upgraded, installPath, err := upgradeExtensionIfOutdated(false)
+	upgraded, installPath, err := upgradeExtensionIfOutdated("stable", false)
 	require.NoError(t, err, "Should fail silently on API errors")
 	assert.False(t, upgraded, "Should not report upgrade when API is unreachable")
 	assert.Empty(t, installPath, "installPath should be empty when API is unreachable")
 }
 
+func TestUpgradeExtensionIfOutdated_SilentFailureOnAPIError_LatestChannel(t *testing.T) {
+	// Same silent-failure behaviour should apply for the "latest" channel.
+	originalVersion := GetVersion()
+	defer SetVersionInfo(originalVersion)
+
+	SetVersionInfo("v0.1.0")
+
+	upgraded, installPath, err := upgradeExtensionIfOutdated("latest", false)
+	require.NoError(t, err, "Should fail silently on API errors (latest channel)")
+	assert.False(t, upgraded, "Should not report upgrade when API is unreachable (latest channel)")
+	assert.Empty(t, installPath, "installPath should be empty when API is unreachable (latest channel)")
+}
+
 func TestFirstAttemptWriter_Linux(t *testing.T) {
 	if runtime.GOOS != "linux" {
 		t.Skip("Linux-only behavior")
diff --git a/pkg/cli/upgrade_command.go b/pkg/cli/upgrade_command.go
index 186b0ad13b9..5c0ee707762 100644
--- a/pkg/cli/upgrade_command.go
+++ b/pkg/cli/upgrade_command.go
@@ -26,6 +26,7 @@ type UpgradeConfig struct {
 	NoActions   bool
 	Audit       bool
 	JSON        bool
+	Channel     string // Release channel: "stable" (default) or "latest"
 }
 
 // NewUpgradeCommand creates the upgrade command
@@ -41,6 +42,12 @@ This command:
   3. Updates GitHub Actions versions in .github/aw/actions-lock.json (unless --no-actions is set)
   4. Compiles all workflows to generate lock files (like 'compile' command)
 
+RELEASE CHANNEL:
+Use --channel to control which version of the gh-aw extension to upgrade to.
+  stable  (default) Highest release in the previous minor version band.
+                    Example: v0.1.10 when the latest release is v0.2.2.
+  latest            The most recent release.
+
 DEPENDENCY HEALTH AUDIT:
 Use --audit to check dependency health without performing upgrades. This includes:
 - Outdated Go dependencies with available updates
@@ -60,14 +67,16 @@ The upgrade process ensures:
 This command always upgrades all Markdown files in .github/workflows.
 
 Examples:
-  ` + string(constants.CLIExtensionPrefix) + ` upgrade                    # Upgrade all workflows
-  ` + string(constants.CLIExtensionPrefix) + ` upgrade --no-fix          # Update agent files only (skip codemods, actions, and compilation)
-  ` + string(constants.CLIExtensionPrefix) + ` upgrade --no-actions      # Skip updating GitHub Actions versions
-  ` + string(constants.CLIExtensionPrefix) + ` upgrade --no-compile      # Skip recompiling workflows (do not modify lock files)
+  ` + string(constants.CLIExtensionPrefix) + ` upgrade                         # Upgrade to stable (default)
+  ` + string(constants.CLIExtensionPrefix) + ` upgrade --channel stable        # Upgrade to stable explicitly
+  ` + string(constants.CLIExtensionPrefix) + ` upgrade --channel latest        # Upgrade to latest release
+  ` + string(constants.CLIExtensionPrefix) + ` upgrade --no-fix               # Update agent files only (skip codemods, actions, and compilation)
+  ` + string(constants.CLIExtensionPrefix) + ` upgrade --no-actions           # Skip updating GitHub Actions versions
+  ` + string(constants.CLIExtensionPrefix) + ` upgrade --no-compile           # Skip recompiling workflows (do not modify lock files)
   ` + string(constants.CLIExtensionPrefix) + ` upgrade --create-pull-request  # Upgrade and open a pull request
   ` + string(constants.CLIExtensionPrefix) + ` upgrade --dir custom/workflows  # Upgrade workflows in custom directory
-  ` + string(constants.CLIExtensionPrefix) + ` upgrade --audit           # Check dependency health without upgrading
-  ` + string(constants.CLIExtensionPrefix) + ` upgrade --audit --json    # Output audit results in JSON format`,
+  ` + string(constants.CLIExtensionPrefix) + ` upgrade --audit                # Check dependency health without upgrading
+  ` + string(constants.CLIExtensionPrefix) + ` upgrade --audit --json         # Output audit results in JSON format`,
 		Args: cobra.NoArgs,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			verbose, _ := cmd.Flags().GetBool("verbose")
@@ -81,6 +90,12 @@ Examples:
 			auditFlag, _ := cmd.Flags().GetBool("audit")
 			jsonOutput, _ := cmd.Flags().GetBool("json")
 			skipExtensionUpgrade, _ := cmd.Flags().GetBool("skip-extension-upgrade")
+			channel, _ := cmd.Flags().GetString("channel")
+
+			// Validate channel value
+			if channel != "stable" && channel != "latest" {
+				return fmt.Errorf("invalid channel %q: must be \"stable\" or \"latest\"", channel)
+			}
 
 			// Handle audit mode
 			if auditFlag {
@@ -93,7 +108,7 @@ Examples:
 				}
 			}
 
-			if err := runUpgradeCommand(verbose, dir, noFix, noCompile, noActions, skipExtensionUpgrade); err != nil {
+			if err := runUpgradeCommand(verbose, dir, noFix, noCompile, noActions, skipExtensionUpgrade, channel); err != nil {
 				return err
 			}
 
@@ -118,6 +133,7 @@ Examples:
 	cmd.Flags().Bool("audit", false, "Check dependency health without performing upgrades")
 	cmd.Flags().Bool("skip-extension-upgrade", false, "Skip automatic extension upgrade (used internally to prevent recursion after upgrade)")
 	_ = cmd.Flags().MarkHidden("skip-extension-upgrade")
+	cmd.Flags().String("channel", "stable", "Release channel for the gh-aw extension upgrade: stable or latest")
 	addJSONFlag(cmd)
 
 	// Register completions
@@ -146,18 +162,18 @@ func runDependencyAudit(verbose bool, jsonOutput bool) error {
 }
 
 // runUpgradeCommand executes the upgrade process
-func runUpgradeCommand(verbose bool, workflowDir string, noFix bool, noCompile bool, noActions bool, skipExtensionUpgrade bool) error {
-	upgradeLog.Printf("Running upgrade command: verbose=%v, workflowDir=%s, noFix=%v, noCompile=%v, noActions=%v, skipExtensionUpgrade=%v",
-		verbose, workflowDir, noFix, noCompile, noActions, skipExtensionUpgrade)
+func runUpgradeCommand(verbose bool, workflowDir string, noFix bool, noCompile bool, noActions bool, skipExtensionUpgrade bool, channel string) error {
+	upgradeLog.Printf("Running upgrade command: verbose=%v, workflowDir=%s, noFix=%v, noCompile=%v, noActions=%v, skipExtensionUpgrade=%v, channel=%s",
+		verbose, workflowDir, noFix, noCompile, noActions, skipExtensionUpgrade, channel)
 
-	// Step 0b: Ensure gh-aw extension is on the latest version.
+	// Step 0b: Ensure gh-aw extension is on the target channel version.
 	// If the extension was just upgraded, re-launch the freshly-installed binary
 	// with the same flags so that all subsequent steps (e.g. lock-file compilation)
 	// use the correct new version string.  The hidden --skip-extension-upgrade flag
 	// prevents the re-launched process from entering this branch again.
 	if !skipExtensionUpgrade {
 		fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Checking gh-aw extension version..."))
-		upgraded, installPath, err := upgradeExtensionIfOutdated(verbose)
+		upgraded, installPath, err := upgradeExtensionIfOutdated(channel, verbose)
 		if err != nil {
 			upgradeLog.Printf("Extension upgrade failed: %v", err)
 			return err
