Skip to content

Commit f7066f6

Browse files
Copilotpelikhan
andcommitted
Add daily update check to compile command
- Create update_check.go module with daily check logic - Add --no-check-update flag to compile command - Check disabled in CI/MCP server mode - Track last check time in temp directory - Use go-gh to query GitHub releases API - Non-blocking check with connectivity error handling - Display update message with upgrade instructions - Add comprehensive tests for update check functionality Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent 4df7bdd commit f7066f6

6 files changed

Lines changed: 560 additions & 15 deletions

File tree

‎.github/workflows/agent-performance-analyzer.lock.yml‎

Lines changed: 3 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎.github/workflows/campaign-manager.lock.yml‎

Lines changed: 3 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎.github/workflows/workflow-health-manager.lock.yml‎

Lines changed: 3 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎cmd/gh-aw/main.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,15 @@ Examples:
214214
jsonOutput, _ := cmd.Flags().GetBool("json")
215215
fix, _ := cmd.Flags().GetBool("fix")
216216
stats, _ := cmd.Flags().GetBool("stats")
217+
noCheckUpdate, _ := cmd.Flags().GetBool("no-check-update")
217218
verbose, _ := cmd.Flags().GetBool("verbose")
218219
if err := validateEngine(engineOverride); err != nil {
219220
return err
220221
}
221222

223+
// Check for updates (non-blocking, runs once per day)
224+
cli.CheckForUpdatesAsync(noCheckUpdate, verbose)
225+
222226
// If --fix is specified, run fix --write first
223227
if fix {
224228
fixConfig := cli.FixConfig{
@@ -468,6 +472,7 @@ Use "` + constants.CLIExtensionPrefix + ` help all" to show help for all command
468472
compileCmd.Flags().Bool("fix", false, "Apply automatic codemod fixes to workflows before compiling")
469473
compileCmd.Flags().Bool("json", false, "Output results in JSON format")
470474
compileCmd.Flags().Bool("stats", false, "Display statistics table sorted by file size (shows jobs, steps, scripts, and shells)")
475+
compileCmd.Flags().Bool("no-check-update", false, "Skip checking for gh-aw updates")
471476
compileCmd.MarkFlagsMutuallyExclusive("dir", "workflows-dir")
472477

473478
// Register completions for compile command

‎pkg/cli/update_check.go‎

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"time"
9+
10+
"github.com/cli/go-gh/v2/pkg/api"
11+
"github.com/githubnext/gh-aw/pkg/console"
12+
"github.com/githubnext/gh-aw/pkg/logger"
13+
)
14+
15+
var updateCheckLog = logger.New("cli:update_check")
16+
17+
const (
18+
// lastCheckFileName is the name of the file that tracks the last update check timestamp
19+
lastCheckFileName = "gh-aw-last-update-check"
20+
// checkInterval is how often we check for updates (24 hours)
21+
checkInterval = 24 * time.Hour
22+
)
23+
24+
// Release represents a GitHub release
25+
type Release struct {
26+
TagName string `json:"tag_name"`
27+
Name string `json:"name"`
28+
HTMLURL string `json:"html_url"`
29+
}
30+
31+
// shouldCheckForUpdate determines if we should check for updates based on:
32+
// - CI mode (disabled)
33+
// - MCP server mode (disabled via parent command detection)
34+
// - Time since last check (once per day)
35+
// - --no-check-update flag
36+
func shouldCheckForUpdate(noCheckUpdate bool) bool {
37+
// Skip if explicitly disabled
38+
if noCheckUpdate {
39+
updateCheckLog.Print("Update check disabled via --no-check-update flag")
40+
return false
41+
}
42+
43+
// Skip in CI environments
44+
if IsRunningInCI() {
45+
updateCheckLog.Print("Update check disabled in CI environment")
46+
return false
47+
}
48+
49+
// Skip if running as MCP server (detected by checking if parent command is "mcp-server")
50+
// When gh aw is invoked from MCP server, it's spawned as a subprocess
51+
if isRunningAsMCPServer() {
52+
updateCheckLog.Print("Update check disabled in MCP server mode")
53+
return false
54+
}
55+
56+
// Check if we've already checked recently
57+
lastCheckFile := getLastCheckFilePath()
58+
if lastCheckFile == "" {
59+
updateCheckLog.Print("Could not determine last check file path")
60+
return false
61+
}
62+
63+
// Read last check time
64+
data, err := os.ReadFile(lastCheckFile)
65+
if err != nil {
66+
if !os.IsNotExist(err) {
67+
updateCheckLog.Printf("Error reading last check file: %v", err)
68+
}
69+
// File doesn't exist or error reading - perform check
70+
return true
71+
}
72+
73+
lastCheck, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data)))
74+
if err != nil {
75+
updateCheckLog.Printf("Error parsing last check time: %v", err)
76+
// Invalid timestamp - perform check
77+
return true
78+
}
79+
80+
// Check if enough time has passed
81+
if time.Since(lastCheck) < checkInterval {
82+
updateCheckLog.Printf("Last check was %v ago, skipping", time.Since(lastCheck))
83+
return false
84+
}
85+
86+
updateCheckLog.Print("Last check was more than 24 hours ago, performing check")
87+
return true
88+
}
89+
90+
// isRunningAsMCPServer detects if we're running as a subprocess of mcp-server
91+
// This is a heuristic - we can't reliably detect this, so we're conservative
92+
func isRunningAsMCPServer() bool {
93+
// Check for MCP_SERVER environment variable that could be set by the MCP server
94+
if os.Getenv("GH_AW_MCP_SERVER") != "" {
95+
return true
96+
}
97+
// Additional heuristic: check if we're likely being invoked by MCP server
98+
// MCP server tools typically run with minimal environment
99+
return false
100+
}
101+
102+
var (
103+
// getLastCheckFilePathFunc allows overriding in tests
104+
getLastCheckFilePathFunc = getLastCheckFilePathImpl
105+
)
106+
107+
// getLastCheckFilePath returns the path to the last check timestamp file
108+
func getLastCheckFilePath() string {
109+
return getLastCheckFilePathFunc()
110+
}
111+
112+
// getLastCheckFilePathImpl is the actual implementation
113+
func getLastCheckFilePathImpl() string {
114+
// Use OS temp directory for cross-platform compatibility
115+
tmpDir := os.TempDir()
116+
if tmpDir == "" {
117+
updateCheckLog.Print("Could not determine temp directory")
118+
return ""
119+
}
120+
121+
// Create a gh-aw subdirectory in temp
122+
ghAwTmpDir := filepath.Join(tmpDir, "gh-aw")
123+
if err := os.MkdirAll(ghAwTmpDir, 0755); err != nil {
124+
updateCheckLog.Printf("Error creating gh-aw temp directory: %v", err)
125+
return ""
126+
}
127+
128+
return filepath.Join(ghAwTmpDir, lastCheckFileName)
129+
}
130+
131+
// updateLastCheckTime updates the timestamp of the last update check
132+
func updateLastCheckTime() {
133+
lastCheckFile := getLastCheckFilePath()
134+
if lastCheckFile == "" {
135+
return
136+
}
137+
138+
timestamp := time.Now().Format(time.RFC3339)
139+
if err := os.WriteFile(lastCheckFile, []byte(timestamp), 0644); err != nil {
140+
updateCheckLog.Printf("Error writing last check time: %v", err)
141+
}
142+
}
143+
144+
// checkForUpdates checks if a newer version of gh-aw is available
145+
// This function is non-blocking and ignores all errors (connectivity, API, etc.)
146+
func checkForUpdates(noCheckUpdate bool, verbose bool) {
147+
// Quick check if we should even attempt the update check
148+
if !shouldCheckForUpdate(noCheckUpdate) {
149+
return
150+
}
151+
152+
updateCheckLog.Print("Checking for gh-aw updates...")
153+
154+
// Update the last check time immediately to prevent concurrent checks
155+
updateLastCheckTime()
156+
157+
// Get current version
158+
currentVersion := GetVersion()
159+
if currentVersion == "" || currentVersion == "dev" {
160+
updateCheckLog.Print("Running development version, skipping update check")
161+
return
162+
}
163+
164+
// Query GitHub API for latest release
165+
latestVersion, err := getLatestRelease()
166+
if err != nil {
167+
// Silently ignore errors - update check should never fail the command
168+
updateCheckLog.Printf("Error checking for updates (ignoring): %v", err)
169+
return
170+
}
171+
172+
if latestVersion == "" {
173+
updateCheckLog.Print("Could not determine latest version")
174+
return
175+
}
176+
177+
// Compare versions
178+
if latestVersion == currentVersion {
179+
if verbose {
180+
updateCheckLog.Print("gh-aw is up to date")
181+
}
182+
return
183+
}
184+
185+
// Normalize versions for comparison (remove 'v' prefix)
186+
currentVersionNormalized := strings.TrimPrefix(currentVersion, "v")
187+
latestVersionNormalized := strings.TrimPrefix(latestVersion, "v")
188+
189+
if currentVersionNormalized == latestVersionNormalized {
190+
if verbose {
191+
updateCheckLog.Print("gh-aw is up to date (version format differs)")
192+
}
193+
return
194+
}
195+
196+
// Check if we're on a newer version (development/prerelease)
197+
// Simple heuristic: if current version sorts after latest, we might be on a dev version
198+
if currentVersionNormalized > latestVersionNormalized {
199+
updateCheckLog.Printf("Current version (%s) appears newer than latest release (%s), skipping notification", currentVersion, latestVersion)
200+
return
201+
}
202+
203+
// A newer version is available - display update message
204+
updateCheckLog.Printf("Newer version available: %s (current: %s)", latestVersion, currentVersion)
205+
fmt.Fprintln(os.Stderr, "")
206+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("A new version of gh-aw is available: %s (current: %s)", latestVersion, currentVersion)))
207+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Update with: gh extension upgrade githubnext/gh-aw"))
208+
fmt.Fprintln(os.Stderr, "")
209+
}
210+
211+
// getLatestRelease queries GitHub API for the latest release of gh-aw
212+
func getLatestRelease() (string, error) {
213+
updateCheckLog.Print("Querying GitHub API for latest release...")
214+
215+
// Create GitHub REST client using go-gh
216+
client, err := api.NewRESTClient(api.ClientOptions{})
217+
if err != nil {
218+
return "", fmt.Errorf("failed to create GitHub client: %w", err)
219+
}
220+
221+
// Query the latest release
222+
var release Release
223+
err = client.Get("repos/githubnext/gh-aw/releases/latest", &release)
224+
if err != nil {
225+
return "", fmt.Errorf("failed to query latest release: %w", err)
226+
}
227+
228+
updateCheckLog.Printf("Latest release: %s", release.TagName)
229+
return release.TagName, nil
230+
}
231+
232+
// CheckForUpdatesAsync performs update check in background (best effort)
233+
// This is called from compile command and should never block or fail the compilation
234+
func CheckForUpdatesAsync(noCheckUpdate bool, verbose bool) {
235+
// Run check in goroutine to avoid blocking compilation
236+
go func() {
237+
// Recover from any panics in the update check
238+
defer func() {
239+
if r := recover(); r != nil {
240+
updateCheckLog.Printf("Panic in update check (recovered): %v", r)
241+
}
242+
}()
243+
244+
checkForUpdates(noCheckUpdate, verbose)
245+
}()
246+
247+
// Give the goroutine a small window to complete quickly
248+
// This allows the message to appear before compilation starts
249+
// but doesn't block if the check takes longer
250+
time.Sleep(100 * time.Millisecond)
251+
}

0 commit comments

Comments
 (0)