Skip to content
Draft
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
99 changes: 93 additions & 6 deletions actions/setup-cli/install.sh

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

99 changes: 93 additions & 6 deletions install-gh-aw.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down Expand Up @@ -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'..."
Expand Down
91 changes: 88 additions & 3 deletions pkg/cli/update_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ package cli

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/cli/go-gh/v2/pkg/api"
"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")
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading