mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 13:11:30 -10:00
* fix(update): clearer error on unsupported release platform (android/termux) zero update/upgrade fails on Android/Termux because GOOS "android" has no published release archive. Replace the generic "unsupported release platform: android" error with a clear, actionable message explaining that no release builds are published for the platform, listing the supported platforms, and suggesting a rebuild from source. The command still fails gracefully (clean stderr message, exit 1, no panic/stack trace) rather than crashing. Fixes #598 * fix(update): address review feedback on unsupported-platform error - Condense the explanatory comment for the unsupported GOOS branch. - Replace the Unicode em dash with plain ASCII punctuation. - Align internal/release.ReleasePlatform's error message with the updater's so both flows give identical guidance. * fix(update): npm-based upgrade guidance for platforms without release assets Address review feedback: the previous message implied Android/Termux was an unsupported platform and told users to rebuild from source. Termux runs zero fine via the npm wrapper; it just has no self-updating release archive. Point users at 'npm update -g @gitlawb/zero', the documented Termux install/upgrade path, instead.
459 lines
14 KiB
Go
459 lines
14 KiB
Go
package update
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
DefaultRepository = "Gitlawb/zero"
|
|
DefaultTimeout = 5 * time.Second
|
|
)
|
|
|
|
type Release struct {
|
|
TagName string `json:"tag_name"`
|
|
HTMLURL string `json:"html_url"`
|
|
Assets []Asset `json:"assets"`
|
|
}
|
|
|
|
type Asset struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url,omitempty"`
|
|
}
|
|
|
|
type Result struct {
|
|
CurrentVersion string `json:"currentVersion"`
|
|
LatestVersion string `json:"latestVersion"`
|
|
ReleaseURL string `json:"releaseUrl"`
|
|
TagName string `json:"tagName"`
|
|
ReleaseAsset AssetCheck `json:"releaseAsset"`
|
|
UpdateAvailable bool `json:"updateAvailable"`
|
|
}
|
|
|
|
type AssetCheck struct {
|
|
Platform string `json:"platform"`
|
|
Arch string `json:"arch"`
|
|
ArchiveName string `json:"archiveName"`
|
|
ArchiveURL string `json:"archiveUrl,omitempty"`
|
|
ChecksumName string `json:"checksumName"`
|
|
ChecksumURL string `json:"checksumUrl,omitempty"`
|
|
ArchiveFound bool `json:"archiveFound"`
|
|
ChecksumFound bool `json:"checksumFound"`
|
|
Verified bool `json:"verified"`
|
|
}
|
|
|
|
// Target identifies a supported release archive target.
|
|
type Target struct {
|
|
Name string `json:"name"`
|
|
GOOS string `json:"goos"`
|
|
GOARCH string `json:"goarch"`
|
|
Platform string `json:"platform"`
|
|
Arch string `json:"arch"`
|
|
}
|
|
|
|
// Options configures a release update check.
|
|
type Options struct {
|
|
CurrentVersion string
|
|
// Endpoint accepts a full release API URL, an owner/repo slug, or a data:
|
|
// endpoint for deterministic tests.
|
|
Endpoint string
|
|
Repository string
|
|
Timeout time.Duration
|
|
GOOS string
|
|
GOARCH string
|
|
// Fetch overrides the release fetcher for tests and alternate transports.
|
|
Fetch func(context.Context, string) (Release, error)
|
|
}
|
|
|
|
type semverParts [3]int
|
|
|
|
var (
|
|
versionPattern = regexp.MustCompile(`^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:[-+].*)?$`)
|
|
repositoryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
|
|
)
|
|
|
|
// Endpoint returns the GitHub latest-release API endpoint for a repository.
|
|
func Endpoint(repository string) string {
|
|
return fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repository)
|
|
}
|
|
|
|
// ResolveEndpoint resolves a URL or owner/repo slug into a release API endpoint.
|
|
func ResolveEndpoint(endpointOrRepository string, repository string) (string, error) {
|
|
return resolveEndpoint(endpointOrRepository, repository)
|
|
}
|
|
|
|
// NormalizeVersionTag returns a comparable x.y.z version from a release tag.
|
|
func NormalizeVersionTag(version string) (string, error) {
|
|
return normalizeVersionTag(version)
|
|
}
|
|
|
|
// CompareSemver compares two semver-ish release tags.
|
|
func CompareSemver(left string, right string) (int, error) {
|
|
leftParts, err := parseSemver(left)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
rightParts, err := parseSemver(right)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return compareSemverParts(leftParts, rightParts), nil
|
|
}
|
|
|
|
// ResolveTarget maps a release target name like windows-x64 to Go build
|
|
// coordinates and release asset naming fields.
|
|
func ResolveTarget(target string) (Target, error) {
|
|
value := strings.TrimSpace(strings.ToLower(target))
|
|
switch value {
|
|
case "linux-x64":
|
|
return Target{Name: value, GOOS: "linux", GOARCH: "amd64", Platform: "linux", Arch: "x64"}, nil
|
|
case "linux-arm64":
|
|
return Target{Name: value, GOOS: "linux", GOARCH: "arm64", Platform: "linux", Arch: "arm64"}, nil
|
|
case "macos-x64":
|
|
return Target{Name: value, GOOS: "darwin", GOARCH: "amd64", Platform: "macos", Arch: "x64"}, nil
|
|
case "macos-arm64":
|
|
return Target{Name: value, GOOS: "darwin", GOARCH: "arm64", Platform: "macos", Arch: "arm64"}, nil
|
|
case "windows-x64":
|
|
return Target{Name: value, GOOS: "windows", GOARCH: "amd64", Platform: "windows", Arch: "x64"}, nil
|
|
case "windows-arm64":
|
|
return Target{Name: value, GOOS: "windows", GOARCH: "arm64", Platform: "windows", Arch: "arm64"}, nil
|
|
default:
|
|
return Target{}, fmt.Errorf("unsupported update target %q: expected one of linux-x64, linux-arm64, macos-x64, macos-arm64, windows-x64, windows-arm64", target)
|
|
}
|
|
}
|
|
|
|
func Check(ctx context.Context, options Options) (Result, error) {
|
|
rawVersion := strings.TrimSpace(firstNonEmpty(options.CurrentVersion, "0.0.0"))
|
|
currentVersion, err := normalizeVersionTag(rawVersion)
|
|
if err != nil {
|
|
// Source/dev builds carry a non-semver version ("dev"); the check is
|
|
// still useful there — compare as 0.0.0 so the latest release is always
|
|
// reported as available instead of failing before the network call.
|
|
currentVersion = "0.0.0"
|
|
}
|
|
repository := strings.TrimSpace(firstNonEmpty(options.Repository, DefaultRepository))
|
|
endpoint, err := resolveEndpoint(firstNonEmpty(options.Endpoint, os.Getenv("ZERO_UPDATE_RELEASE_URL")), repository)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
timeout := options.Timeout
|
|
if timeout < 0 {
|
|
return Result{}, fmt.Errorf("timeout must be non-negative")
|
|
}
|
|
if timeout == 0 {
|
|
timeout = DefaultTimeout
|
|
}
|
|
if timeout > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
}
|
|
fetch := options.Fetch
|
|
if fetch == nil {
|
|
fetch = fetchRelease
|
|
}
|
|
release, err := fetch(ctx, endpoint)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if strings.TrimSpace(release.TagName) == "" {
|
|
return Result{}, fmt.Errorf("github release response did not include a tag_name")
|
|
}
|
|
latestVersion, err := normalizeVersionTag(release.TagName)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
releaseURL := strings.TrimSpace(release.HTMLURL)
|
|
if releaseURL == "" {
|
|
releaseURL = fmt.Sprintf("https://github.com/%s/releases/tag/%s", repository, release.TagName)
|
|
}
|
|
assetCheck, err := verifyReleaseAssets(release, latestVersion, options)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
latestParts, err := parseSemverNormalized(latestVersion)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
currentParts, err := parseSemverNormalized(currentVersion)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return Result{
|
|
CurrentVersion: currentVersion,
|
|
LatestVersion: latestVersion,
|
|
ReleaseURL: releaseURL,
|
|
TagName: release.TagName,
|
|
ReleaseAsset: assetCheck,
|
|
UpdateAvailable: compareSemverParts(latestParts, currentParts) > 0,
|
|
}, nil
|
|
}
|
|
|
|
func Format(result Result) string {
|
|
if result.UpdateAvailable {
|
|
lines := []string{
|
|
fmt.Sprintf("[zero] Update available: %s -> %s", result.CurrentVersion, result.LatestVersion),
|
|
"Release: " + result.ReleaseURL,
|
|
}
|
|
lines = appendAssetLines(lines, result.ReleaseAsset)
|
|
if target := releaseAssetTarget(result.ReleaseAsset); target != "" {
|
|
lines = append(lines, "Download the verified "+target+" release asset, then replace the current zero binary.")
|
|
} else {
|
|
lines = append(lines, "Download the verified release asset, then replace the current zero binary.")
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
lines := []string{
|
|
fmt.Sprintf("[zero] up to date (%s)", result.CurrentVersion),
|
|
"Latest release: " + result.ReleaseURL,
|
|
}
|
|
lines = appendAssetLines(lines, result.ReleaseAsset)
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func appendAssetLines(lines []string, asset AssetCheck) []string {
|
|
if asset.ArchiveName == "" {
|
|
return lines
|
|
}
|
|
if target := releaseAssetTarget(asset); target != "" {
|
|
lines = append(lines, "Release target: "+target)
|
|
}
|
|
lines = append(lines, "Release asset: "+asset.ArchiveName)
|
|
if asset.ChecksumName != "" {
|
|
lines = append(lines, "Checksum asset: "+asset.ChecksumName)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func releaseAssetTarget(asset AssetCheck) string {
|
|
if asset.Platform == "" || asset.Arch == "" {
|
|
return ""
|
|
}
|
|
return asset.Platform + "-" + asset.Arch
|
|
}
|
|
|
|
func fetchRelease(ctx context.Context, endpoint string) (release Release, err error) {
|
|
if strings.HasPrefix(endpoint, "data:") {
|
|
return fetchDataRelease(endpoint)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return Release{}, err
|
|
}
|
|
request.Header.Set("Accept", "application/vnd.github+json")
|
|
request.Header.Set("User-Agent", "zero/update")
|
|
response, err := http.DefaultClient.Do(request)
|
|
if err != nil {
|
|
return Release{}, err
|
|
}
|
|
defer func() {
|
|
if closeErr := response.Body.Close(); closeErr != nil && err == nil {
|
|
err = fmt.Errorf("close update response: %w", closeErr)
|
|
}
|
|
}()
|
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
|
return Release{}, fmt.Errorf("github release check failed (%s)", response.Status)
|
|
}
|
|
if err := json.NewDecoder(response.Body).Decode(&release); err != nil {
|
|
return Release{}, err
|
|
}
|
|
return release, nil
|
|
}
|
|
|
|
func fetchDataRelease(endpoint string) (Release, error) {
|
|
comma := strings.Index(endpoint, ",")
|
|
if comma == -1 {
|
|
return Release{}, fmt.Errorf("invalid data update endpoint")
|
|
}
|
|
payload, err := url.QueryUnescape(endpoint[comma+1:])
|
|
if err != nil {
|
|
return Release{}, err
|
|
}
|
|
var release Release
|
|
if err := json.Unmarshal([]byte(payload), &release); err != nil {
|
|
return Release{}, err
|
|
}
|
|
return release, nil
|
|
}
|
|
|
|
func resolveEndpoint(endpointOrRepository string, repository string) (string, error) {
|
|
value := strings.TrimSpace(endpointOrRepository)
|
|
if value == "" {
|
|
return Endpoint(repository), nil
|
|
}
|
|
if repositoryPattern.MatchString(value) {
|
|
return Endpoint(value), nil
|
|
}
|
|
parsed, err := url.ParseRequestURI(value)
|
|
if err != nil || parsed.Scheme == "" {
|
|
return "", fmt.Errorf("invalid update endpoint %q: use a full URL or an owner/repo slug like %s", value, repository)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func verifyReleaseAssets(release Release, version string, options Options) (AssetCheck, error) {
|
|
assetCheck, err := expectedAssetCheck(version, options.GOOS, options.GOARCH)
|
|
if err != nil {
|
|
return AssetCheck{}, err
|
|
}
|
|
for _, asset := range release.Assets {
|
|
name := strings.TrimSpace(asset.Name)
|
|
switch name {
|
|
case assetCheck.ArchiveName:
|
|
assetCheck.ArchiveFound = true
|
|
assetCheck.ArchiveURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
|
case assetCheck.ChecksumName:
|
|
assetCheck.ChecksumFound = true
|
|
assetCheck.ChecksumURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
|
}
|
|
}
|
|
assetCheck.Verified = assetCheck.ArchiveFound && assetCheck.ChecksumFound
|
|
if assetCheck.Verified {
|
|
return assetCheck, nil
|
|
}
|
|
missing := []string{}
|
|
if !assetCheck.ArchiveFound {
|
|
missing = append(missing, assetCheck.ArchiveName)
|
|
}
|
|
if !assetCheck.ChecksumFound {
|
|
missing = append(missing, assetCheck.ChecksumName)
|
|
}
|
|
return AssetCheck{}, fmt.Errorf("release metadata missing expected asset(s) for %s-%s: %s", assetCheck.Platform, assetCheck.Arch, strings.Join(missing, ", "))
|
|
}
|
|
|
|
func expectedAssetCheck(version string, goos string, goarch string) (AssetCheck, error) {
|
|
platform, err := releasePlatform(firstNonEmpty(goos, runtime.GOOS))
|
|
if err != nil {
|
|
return AssetCheck{}, err
|
|
}
|
|
arch, err := releaseArch(firstNonEmpty(goarch, runtime.GOARCH))
|
|
if err != nil {
|
|
return AssetCheck{}, err
|
|
}
|
|
extension := "tar.gz"
|
|
if platform == "windows" {
|
|
extension = "zip"
|
|
}
|
|
archiveName := fmt.Sprintf("zero-v%s-%s-%s.%s", version, platform, arch, extension)
|
|
return AssetCheck{
|
|
Platform: platform,
|
|
Arch: arch,
|
|
ArchiveName: archiveName,
|
|
ChecksumName: archiveName + ".sha256",
|
|
}, nil
|
|
}
|
|
|
|
func releasePlatform(goos string) (string, error) {
|
|
switch strings.TrimSpace(goos) {
|
|
case "linux":
|
|
return "linux", nil
|
|
case "darwin":
|
|
return "macos", nil
|
|
case "windows":
|
|
return "windows", nil
|
|
default:
|
|
// No prebuilt release archive is published for this GOOS (e.g.
|
|
// Android/Termux). This does not mean the platform is unsupported --
|
|
// Termux runs zero fine via the npm wrapper -- it just has no
|
|
// self-updating release asset. Point users at `npm update` rather
|
|
// than a source rebuild, since that's the documented Termux
|
|
// install/upgrade path and doesn't require a Go toolchain.
|
|
return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g @gitlawb/zero` to get the latest.", goos)
|
|
}
|
|
}
|
|
|
|
func releaseArch(goarch string) (string, error) {
|
|
switch strings.TrimSpace(goarch) {
|
|
case "amd64":
|
|
return "x64", nil
|
|
case "arm64":
|
|
return "arm64", nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported release architecture: %s", goarch)
|
|
}
|
|
}
|
|
|
|
func normalizeVersionTag(version string) (string, error) {
|
|
match := versionPattern.FindStringSubmatch(strings.TrimSpace(version))
|
|
if match == nil {
|
|
return "", fmt.Errorf("invalid semantic version: %s", version)
|
|
}
|
|
major, err := parseVersionComponent(version, match[1])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
minor, err := parseVersionComponent(version, match[2])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
patch, err := parseVersionComponent(version, match[3])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("%d.%d.%d", major, minor, patch), nil
|
|
}
|
|
|
|
func parseSemver(version string) (semverParts, error) {
|
|
normalized, err := NormalizeVersionTag(version)
|
|
if err != nil {
|
|
return semverParts{}, err
|
|
}
|
|
return parseSemverNormalized(normalized)
|
|
}
|
|
|
|
func parseSemverNormalized(version string) (semverParts, error) {
|
|
parts := strings.Split(version, ".")
|
|
if len(parts) != 3 {
|
|
return semverParts{}, fmt.Errorf("invalid semantic version: %s", version)
|
|
}
|
|
major, err := parseVersionComponent(version, parts[0])
|
|
if err != nil {
|
|
return semverParts{}, err
|
|
}
|
|
minor, err := parseVersionComponent(version, parts[1])
|
|
if err != nil {
|
|
return semverParts{}, err
|
|
}
|
|
patch, err := parseVersionComponent(version, parts[2])
|
|
if err != nil {
|
|
return semverParts{}, err
|
|
}
|
|
return semverParts{major, minor, patch}, nil
|
|
}
|
|
|
|
func compareSemverParts(left semverParts, right semverParts) int {
|
|
for index := range left {
|
|
if left[index] != right[index] {
|
|
return left[index] - right[index]
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseVersionComponent(version string, component string) (int, error) {
|
|
parsed, err := strconv.ParseInt(component, 10, 31)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid semantic version: %s", version)
|
|
}
|
|
return int(parsed), nil
|
|
}
|