Files
warmbly/internal/cli/update/update.go
T

306 lines
8.9 KiB
Go

// Package update keeps an installed CLI current.
//
// Two jobs: telling someone a newer release exists without getting in their
// way, and replacing the binary when they ask for it.
//
// The version lookup deliberately does not use the GitHub API. The
// unauthenticated API is rate limited per IP, which on a shared CI runner or
// behind a corporate NAT means the check fails for everyone at once; the
// releases/latest redirect is a plain HTTP redirect with no such limit.
package update
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
const (
repo = "warmbly/warmbly"
// LatestURL redirects to the newest release's tag page.
LatestURL = "https://github.com/" + repo + "/releases/latest"
// DownloadBase is where the release assets live. Names carry no version,
// so "latest" resolves without knowing the tag first.
DownloadBase = "https://github.com/" + repo + "/releases/latest/download"
)
// CheckInterval is how often the background nudge looks for a new release.
// Once a day: often enough to matter, rare enough that nobody notices it.
const CheckInterval = 24 * time.Hour
// LatestVersion resolves the newest published release tag by following the
// latest-release redirect and reading the tag out of the final URL.
func LatestVersion(ctx context.Context, timeout time.Duration) (string, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
client := &http.Client{
// Stop at the redirect: the tag is in the Location header, and
// following it would download an HTML page for nothing.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodHead, LatestURL, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
location := resp.Header.Get("Location")
if location == "" {
return "", errors.New("no release redirect")
}
tag := location[strings.LastIndex(location, "/")+1:]
if !strings.HasPrefix(tag, "v") {
return "", fmt.Errorf("unexpected release tag %q", tag)
}
return tag, nil
}
// IsNewer reports whether candidate is a later release than current. Both are
// vX.Y.Z. A current version that is not a clean release tag (a dev build, a
// git describe string) returns false: someone running their own build does not
// want to be told to download ours.
func IsNewer(current, candidate string) bool {
cur, ok := parseVersion(current)
if !ok {
return false
}
next, ok := parseVersion(candidate)
if !ok {
return false
}
for i := 0; i < 3; i++ {
if next[i] != cur[i] {
return next[i] > cur[i]
}
}
return false
}
func parseVersion(v string) ([3]int, bool) {
var out [3]int
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
// A git describe string (1.2.3-4-gabc1234) or a prerelease is not a plain
// release, so it never compares.
if v == "" || strings.ContainsAny(v, "-+ ") {
return out, false
}
parts := strings.Split(v, ".")
if len(parts) != 3 {
return out, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return out, false
}
out[i] = n
}
return out, true
}
// Method is how this binary got here, which decides how it should be replaced.
type Method int
const (
// MethodBinary is a plain binary we can overwrite ourselves.
MethodBinary Method = iota
MethodHomebrew
MethodScoop
MethodGoInstall
MethodPackage
)
// UpgradeCommand is what to tell the user to run when we must not replace the
// binary ourselves. Empty when a self-replace is the right answer.
func (m Method) UpgradeCommand() string {
switch m {
case MethodHomebrew:
return "brew upgrade warmbly"
case MethodScoop:
return "scoop update warmbly"
case MethodGoInstall:
return "go install github.com/" + repo + "/cmd/cli@latest"
case MethodPackage:
return "your package manager"
default:
return ""
}
}
// DetectMethod works out how this binary was installed from where it sits.
// Fighting a package manager by overwriting the file it owns produces a
// version that reverts on the next upgrade, so this is what stops that.
func DetectMethod(executable string) Method {
path, err := filepath.EvalSymlinks(executable)
if err != nil {
path = executable
}
// Backslashes are normalised explicitly rather than with filepath.ToSlash,
// which is a no-op off Windows: the detection then behaves the same
// wherever it runs, including in a test.
lower := strings.ToLower(strings.ReplaceAll(path, `\`, "/"))
switch {
case strings.Contains(lower, "/cellar/"), strings.Contains(lower, "/homebrew/"),
strings.Contains(lower, "/linuxbrew/"):
return MethodHomebrew
case strings.Contains(lower, "/scoop/"):
return MethodScoop
case strings.Contains(lower, "/go/bin/"), strings.HasSuffix(lower, "/gopath/bin/warmbly"):
return MethodGoInstall
case strings.HasPrefix(lower, "/usr/bin/"), strings.HasPrefix(lower, "/opt/"),
strings.HasPrefix(lower, "/snap/"), strings.HasPrefix(lower, "/nix/"):
return MethodPackage
default:
return MethodBinary
}
}
// AssetName is the archive published for the running platform.
func AssetName() string {
if runtime.GOOS == "windows" {
return fmt.Sprintf("warmbly_%s_%s.zip", runtime.GOOS, runtime.GOARCH)
}
return fmt.Sprintf("warmbly_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH)
}
// Replace downloads the newest build for this platform, verifies it against
// the published checksums, and swaps it in for the running binary.
//
// The swap is a rename, which is atomic: an interrupted upgrade leaves either
// the old binary or the new one, never half of either.
func Replace(ctx context.Context, executable string, progress func(string)) error {
if runtime.GOOS == "windows" {
return errors.New("self-upgrade is not supported on Windows because a running .exe cannot be replaced.\nRun the installer again instead:\n irm https://warmbly.com/cli.ps1 | iex")
}
asset := AssetName()
progress("downloading " + asset)
archive, err := download(ctx, DownloadBase+"/"+asset)
if err != nil {
return err
}
progress("verifying checksum")
sums, err := download(ctx, DownloadBase+"/checksums.txt")
if err != nil {
return fmt.Errorf("could not fetch checksums.txt, so the download was not verified: %w", err)
}
want := checksumFor(string(sums), asset)
if want == "" {
return fmt.Errorf("checksums.txt has no entry for %s", asset)
}
sum := sha256.Sum256(archive)
if got := hex.EncodeToString(sum[:]); got != want {
return fmt.Errorf("checksum mismatch for %s.\n expected %s\n got %s\nNothing was changed", asset, want, got)
}
progress("unpacking")
binary, err := extractBinary(archive)
if err != nil {
return err
}
target, err := filepath.EvalSymlinks(executable)
if err != nil {
target = executable
}
dir := filepath.Dir(target)
tmp, err := os.CreateTemp(dir, ".warmbly-upgrade-*")
if err != nil {
return fmt.Errorf("cannot write to %s: %w\nIf it is system-owned, re-run the installer instead:\n curl -fsSL https://warmbly.com/cli.sh | sh", dir, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(binary); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpName, 0o755); err != nil {
return err
}
if err := os.Rename(tmpName, target); err != nil {
return fmt.Errorf("could not replace %s: %w", target, err)
}
return nil
}
func download(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("could not download %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("could not download %s: HTTP %d", url, resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 200<<20))
}
func checksumFor(sums, asset string) string {
for _, line := range strings.Split(sums, "\n") {
fields := strings.Fields(line)
if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == asset {
return fields[0]
}
}
return ""
}
// extractBinary pulls just the warmbly executable out of the release archive.
func extractBinary(archive []byte) ([]byte, error) {
gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
return nil, fmt.Errorf("the archive is not readable: %w", err)
}
defer gz.Close()
reader := tar.NewReader(gz)
for {
header, err := reader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
if header.Typeflag != tar.TypeReg {
continue
}
if filepath.Base(header.Name) != "warmbly" {
continue
}
return io.ReadAll(io.LimitReader(reader, 200<<20))
}
return nil, errors.New("the archive did not contain a warmbly binary")
}