143 lines
4.5 KiB
Go
143 lines
4.5 KiB
Go
// Package selfupdate provides version discovery and comparison for pigo's
|
|
// self-update feature (issue #465). It queries the GitHub Releases API for the
|
|
// latest published tag of the pigo repository and compares it against the
|
|
// build-time version injected into the main package, so both `pigo update` and
|
|
// the interactive startup banner can decide whether a newer release exists.
|
|
//
|
|
// The build version ("dev" for `go build`/`go run` from source, a real
|
|
// vX.Y.Z for goreleaser builds) is not owned by this package; callers pass it
|
|
// in. Non-release versions ("", "dev", "unknown") are treated as
|
|
// non-comparable so a source build never reports a spurious update.
|
|
package selfupdate
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Repo is the GitHub "owner/name" whose releases back pigo's self-update. It
|
|
// matches the release target in .goreleaser.yaml and install.sh.
|
|
const Repo = "smallnest/pigo"
|
|
|
|
// latestReleaseURL builds the GitHub API endpoint for a repo's latest release.
|
|
func latestReleaseURL(repo string) string {
|
|
return fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
|
}
|
|
|
|
// release is the subset of the GitHub release JSON we consume.
|
|
type release struct {
|
|
TagName string `json:"tag_name"`
|
|
}
|
|
|
|
// IsReleaseVersion reports whether v is a real release version that can be
|
|
// compared against a tag. The build defaults ("dev", "unknown") and the empty
|
|
// string are not release versions.
|
|
func IsReleaseVersion(v string) bool {
|
|
switch strings.TrimSpace(v) {
|
|
case "", "dev", "unknown":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// LatestTag queries the GitHub Releases API for repo's latest release tag
|
|
// (e.g. "v0.4.0"). If client is nil a client with a short timeout is used. When
|
|
// the GITHUB_TOKEN environment variable is set it is sent as a bearer token to
|
|
// raise the API rate limit, mirroring install.sh.
|
|
func LatestTag(ctx context.Context, client *http.Client, repo string) (string, error) {
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 10 * time.Second}
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL(repo), nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("selfupdate: build request: %w", err)
|
|
}
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
if tok := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); tok != "" {
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("selfupdate: query latest release: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("selfupdate: GitHub API returned %s", resp.Status)
|
|
}
|
|
|
|
var rel release
|
|
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
|
return "", fmt.Errorf("selfupdate: decode release JSON: %w", err)
|
|
}
|
|
tag := strings.TrimSpace(rel.TagName)
|
|
if tag == "" {
|
|
return "", fmt.Errorf("selfupdate: latest release has empty tag_name")
|
|
}
|
|
return tag, nil
|
|
}
|
|
|
|
// UpdateAvailable compares the current build version against a release tag and
|
|
// reports whether the tag is strictly newer. The comparable return is false
|
|
// when current is not a release version (source builds) or when either value
|
|
// cannot be parsed as a version — callers should treat non-comparable as "no
|
|
// update to offer" rather than an error.
|
|
func UpdateAvailable(current, latest string) (available, comparable bool) {
|
|
if !IsReleaseVersion(current) {
|
|
return false, false
|
|
}
|
|
cur, ok1 := parseVersion(current)
|
|
lat, ok2 := parseVersion(latest)
|
|
if !ok1 || !ok2 {
|
|
return false, false
|
|
}
|
|
return compare(lat, cur) > 0, true
|
|
}
|
|
|
|
// parseVersion parses a semantic-ish version ("v0.4.0", "0.4.0-next") into its
|
|
// numeric major/minor/patch, ignoring any leading "v" and any pre-release or
|
|
// build suffix after "-" or "+". It reports ok=false when no numeric component
|
|
// can be read.
|
|
func parseVersion(v string) ([3]int, bool) {
|
|
s := strings.TrimSpace(v)
|
|
s = strings.TrimPrefix(s, "v")
|
|
// Drop pre-release / build metadata: "0.4.0-next" -> "0.4.0".
|
|
if i := strings.IndexAny(s, "-+"); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
if s == "" {
|
|
return [3]int{}, false
|
|
}
|
|
parts := strings.Split(s, ".")
|
|
var out [3]int
|
|
for i := 0; i < 3 && i < len(parts); i++ {
|
|
n, err := strconv.Atoi(parts[i])
|
|
if err != nil {
|
|
return [3]int{}, false
|
|
}
|
|
out[i] = n
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// compare returns -1, 0, or 1 as a is less than, equal to, or greater than b.
|
|
func compare(a, b [3]int) int {
|
|
for i := 0; i < 3; i++ {
|
|
switch {
|
|
case a[i] < b[i]:
|
|
return -1
|
|
case a[i] > b[i]:
|
|
return 1
|
|
}
|
|
}
|
|
return 0
|
|
}
|