Skip to content

Commit 7f1b822

Browse files
committed
Add self-update command and daily update check
- ghapp update: fetch latest release, verify SHA256, replace binaries - Daily background check with non-blocking notice on stderr - Platform-specific binary replacement (atomic rename Unix, rename+copy Windows) - Skip check for machine-consumed commands and dev builds - GHAPP_NO_UPDATE_CHECK=1 env var to opt out
1 parent e50337f commit 7f1b822

File tree

9 files changed

+1152
-2
lines changed

9 files changed

+1152
-2
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ gh pr list
8888
| `ghapp auth configure [--gh-auth MODE]` | Configure git credential helper, gh CLI, and git identity |
8989
| `ghapp auth status` | Show current auth configuration and diagnostics |
9090
| `ghapp auth reset [--remove-key]` | Remove all auth config and restore previous git identity |
91+
| `ghapp update` | Self-update to the latest release |
9192
| `ghapp version` | Print version info |
9293

9394
### `--gh-auth` modes
@@ -150,7 +151,7 @@ app_slug: myapp # cached after first auth configure
150151
bot_user_id: 149130343 # cached after first auth configure
151152
```
152153
153-
Environment overrides: `GHAPP_APP_ID`, `GHAPP_INSTALLATION_ID`, `GHAPP_PRIVATE_KEY_PATH`
154+
Environment overrides: `GHAPP_APP_ID`, `GHAPP_INSTALLATION_ID`, `GHAPP_PRIVATE_KEY_PATH`, `GHAPP_NO_UPDATE_CHECK=1` (disable daily update notice)
154155

155156
## Private Key Storage
156157

internal/cmd/root.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package cmd
22

33
import (
44
"fmt"
5+
"os"
56

67
"github.com/spf13/cobra"
78

89
"github.com/operator-kit/github-app-auth/internal/auth"
910
"github.com/operator-kit/github-app-auth/internal/config"
11+
"github.com/operator-kit/github-app-auth/internal/selfupdate"
1012
)
1113

1214
var (
@@ -18,6 +20,8 @@ var (
1820
versionStr string
1921
commitStr string
2022
dateStr string
23+
24+
updateResult chan string
2125
)
2226

2327
func SetVersionInfo(version, commit, date string) {
@@ -31,9 +35,11 @@ var rootCmd = &cobra.Command{
3135
Short: "GitHub App authentication for git and gh",
3236
Long: "Authenticate as a GitHub App, generate installation tokens, and configure git/gh to use them transparently.",
3337
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
38+
startUpdateCheck(cmd)
39+
3440
// Commands that don't need config
3541
name := cmd.Name()
36-
if name == "ghapp" || name == "version" || name == "setup" || name == "shell-init" {
42+
if name == "ghapp" || name == "version" || name == "setup" || name == "shell-init" || name == "update" {
3743
return nil
3844
}
3945

@@ -53,6 +59,40 @@ var rootCmd = &cobra.Command{
5359
}
5460
return nil
5561
},
62+
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
63+
if updateResult == nil {
64+
return nil
65+
}
66+
select {
67+
case latest := <-updateResult:
68+
if latest != "" {
69+
fmt.Fprintf(os.Stderr, "\nA new version of ghapp is available: v%s (current: v%s)\nRun 'ghapp update' to upgrade.\n", latest, versionStr)
70+
}
71+
default:
72+
// goroutine hasn't finished, skip silently
73+
}
74+
return nil
75+
},
76+
}
77+
78+
func startUpdateCheck(cmd *cobra.Command) {
79+
if os.Getenv("GHAPP_NO_UPDATE_CHECK") == "1" {
80+
return
81+
}
82+
if versionStr == "dev" {
83+
return
84+
}
85+
name := cmd.Name()
86+
if name == "credential-helper" || name == "shell-init" || name == "token" || name == "update" {
87+
return
88+
}
89+
if !selfupdate.ShouldCheck(versionStr) {
90+
return
91+
}
92+
updateResult = make(chan string, 1)
93+
go func() {
94+
updateResult <- selfupdate.CheckForUpdate(versionStr)
95+
}()
5696
}
5797

5898
func init() {
@@ -63,6 +103,7 @@ func init() {
63103
rootCmd.AddCommand(tokenCmd)
64104
rootCmd.AddCommand(authCmd)
65105
rootCmd.AddCommand(credentialHelperCmd)
106+
rootCmd.AddCommand(updateCmd)
66107
}
67108

68109
func Execute() error {

internal/cmd/update.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/operator-kit/github-app-auth/internal/selfupdate"
10+
)
11+
12+
var updateCmd = &cobra.Command{
13+
Use: "update",
14+
Short: "Update ghapp to the latest version",
15+
RunE: func(cmd *cobra.Command, args []string) error {
16+
if versionStr == "dev" {
17+
fmt.Fprintln(cmd.ErrOrStderr(), "Skipping update: running dev build")
18+
return nil
19+
}
20+
21+
release, err := selfupdate.FetchLatestRelease()
22+
if err != nil {
23+
return fmt.Errorf("check for update: %w", err)
24+
}
25+
if release == nil {
26+
fmt.Fprintln(cmd.OutOrStdout(), "No published release found.")
27+
return nil
28+
}
29+
30+
latest := strings.TrimPrefix(release.TagName, "v")
31+
if selfupdate.CompareVersions(versionStr, latest) >= 0 {
32+
fmt.Fprintf(cmd.OutOrStdout(), "Already up to date (v%s).\n", versionStr)
33+
return nil
34+
}
35+
36+
fmt.Fprintf(cmd.ErrOrStderr(), "Updating v%s → v%s\n", versionStr, latest)
37+
if err := selfupdate.Update(release, cmd.ErrOrStderr()); err != nil {
38+
return fmt.Errorf("update: %w", err)
39+
}
40+
41+
fmt.Fprintf(cmd.OutOrStdout(), "Successfully updated to v%s.\n", latest)
42+
return nil
43+
},
44+
}

internal/selfupdate/check.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package selfupdate
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"time"
11+
)
12+
13+
const checkFileName = "update-check.json"
14+
15+
// DirOverride allows tests to redirect the cache to a temp directory.
16+
var DirOverride string
17+
18+
// BaseURL is the GitHub API base URL (override in tests with httptest).
19+
var BaseURL = "https://api.github.com"
20+
21+
// CheckResult is persisted between runs to throttle update checks.
22+
type CheckResult struct {
23+
LatestVersion string `json:"latest_version"`
24+
CheckedAt time.Time `json:"checked_at"`
25+
}
26+
27+
// ReleaseResponse is the subset of GitHub's release JSON we need.
28+
type ReleaseResponse struct {
29+
TagName string `json:"tag_name"`
30+
Assets []Asset `json:"assets"`
31+
}
32+
33+
// Asset is a single file attached to a GitHub release.
34+
type Asset struct {
35+
Name string `json:"name"`
36+
BrowserDownloadURL string `json:"browser_download_url"`
37+
}
38+
39+
func checkFilePath() string {
40+
if DirOverride != "" {
41+
return filepath.Join(DirOverride, checkFileName)
42+
}
43+
configDir, err := os.UserConfigDir()
44+
if err != nil {
45+
return ""
46+
}
47+
return filepath.Join(configDir, "ghapp", checkFileName)
48+
}
49+
50+
func readCheckResult() *CheckResult {
51+
path := checkFilePath()
52+
if path == "" {
53+
return nil
54+
}
55+
data, err := os.ReadFile(path)
56+
if err != nil {
57+
return nil
58+
}
59+
var result CheckResult
60+
if err := json.Unmarshal(data, &result); err != nil {
61+
return nil
62+
}
63+
return &result
64+
}
65+
66+
func writeCheckResult(result *CheckResult) {
67+
path := checkFilePath()
68+
if path == "" {
69+
return
70+
}
71+
data, err := json.Marshal(result)
72+
if err != nil {
73+
return
74+
}
75+
_ = os.MkdirAll(filepath.Dir(path), 0o755)
76+
_ = os.WriteFile(path, data, 0o600)
77+
}
78+
79+
// ShouldCheck returns true if >24h since last check and version is not "dev".
80+
func ShouldCheck(currentVersion string) bool {
81+
if currentVersion == "dev" {
82+
return false
83+
}
84+
result := readCheckResult()
85+
if result == nil {
86+
return true
87+
}
88+
return time.Since(result.CheckedAt) > 24*time.Hour
89+
}
90+
91+
// FetchLatestRelease fetches the latest published release from GitHub.
92+
// Returns nil, nil if no published release exists (404).
93+
func FetchLatestRelease() (*ReleaseResponse, error) {
94+
url := BaseURL + "/repos/operator-kit/github-app-auth/releases/latest"
95+
client := &http.Client{Timeout: 5 * time.Second}
96+
resp, err := client.Get(url)
97+
if err != nil {
98+
return nil, fmt.Errorf("fetch release: %w", err)
99+
}
100+
defer resp.Body.Close()
101+
102+
if resp.StatusCode == http.StatusNotFound {
103+
return nil, nil
104+
}
105+
if resp.StatusCode != http.StatusOK {
106+
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
107+
}
108+
109+
var release ReleaseResponse
110+
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
111+
return nil, fmt.Errorf("decode release: %w", err)
112+
}
113+
return &release, nil
114+
}
115+
116+
// CheckForUpdate fetches the latest release and returns the version if newer.
117+
// Returns empty string if no update available or on any error.
118+
func CheckForUpdate(currentVersion string) string {
119+
release, err := FetchLatestRelease()
120+
if err != nil || release == nil {
121+
return ""
122+
}
123+
124+
latest := strings.TrimPrefix(release.TagName, "v")
125+
writeCheckResult(&CheckResult{
126+
LatestVersion: latest,
127+
CheckedAt: time.Now(),
128+
})
129+
130+
if CompareVersions(currentVersion, latest) < 0 {
131+
return latest
132+
}
133+
return ""
134+
}

0 commit comments

Comments
 (0)