first commit
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
// This file caches the latest-release check so pigo's startup banner can show
|
||||
// "update available" without a network call on every launch (US-004, FR-10).
|
||||
// The cache lives at $PIGO_HOME/update-check.json (or ~/.pigo/update-check.json)
|
||||
// and records the last check time plus the latest tag seen. CachedLatest reads
|
||||
// it synchronously (fast, local); StartBackgroundCheck refreshes it off the hot
|
||||
// path when older than the TTL, so a fresh result shows on the next launch. All
|
||||
// failures are silent: a missing or corrupt cache, an unresolvable home, or a
|
||||
// network error never surfaces an error or blocks startup.
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// checkTTL is the minimum interval between networked latest-release checks.
|
||||
const checkTTL = 24 * time.Hour
|
||||
|
||||
// cacheFileName is the on-disk cache under the pigo home directory.
|
||||
const cacheFileName = "update-check.json"
|
||||
|
||||
// updateCache is the on-disk shape of the latest-release check cache.
|
||||
type updateCache struct {
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
Latest string `json:"latest"`
|
||||
}
|
||||
|
||||
// cachePath returns the cache file path, or "" when the home dir is unavailable.
|
||||
func cachePath() string {
|
||||
if dir := os.Getenv("PIGO_HOME"); dir != "" {
|
||||
return filepath.Join(dir, cacheFileName)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".pigo", cacheFileName)
|
||||
}
|
||||
|
||||
// CachedLatest returns the latest tag recorded in the cache and whether the cache
|
||||
// is still fresh (younger than checkTTL). A missing, corrupt, or unreadable cache
|
||||
// yields ("", false). It never returns an error — the banner must not break on a
|
||||
// bad cache.
|
||||
func CachedLatest() (latest string, fresh bool) {
|
||||
p := cachePath()
|
||||
if p == "" {
|
||||
return "", false
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var c updateCache
|
||||
if err := json.Unmarshal(data, &c); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return c.Latest, time.Since(c.CheckedAt) < checkTTL
|
||||
}
|
||||
|
||||
// StartBackgroundCheck refreshes the cache off the hot path when it is stale
|
||||
// (older than checkTTL). It returns immediately; the actual network check runs in
|
||||
// a goroutine so it never blocks banner rendering or first input. When current is
|
||||
// not a release version (dev/unknown) it does nothing — there is nothing to
|
||||
// compare against. All errors are swallowed: a failed check simply leaves the
|
||||
// cache untouched for next time.
|
||||
func StartBackgroundCheck(current string) {
|
||||
if !IsReleaseVersion(current) {
|
||||
return
|
||||
}
|
||||
if _, fresh := CachedLatest(); fresh {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
tag, err := LatestTag(ctx, &http.Client{Timeout: 10 * time.Second}, Repo)
|
||||
if err != nil || tag == "" {
|
||||
return
|
||||
}
|
||||
writeCache(tag)
|
||||
}()
|
||||
}
|
||||
|
||||
// writeCache persists the latest tag with the current time. Failures are silent.
|
||||
func writeCache(latest string) {
|
||||
p := cachePath()
|
||||
if p == "" {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(updateCache{CheckedAt: time.Now(), Latest: latest})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(p, data, 0o644)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCachedLatest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", dir)
|
||||
|
||||
// No cache file yet → not fresh, empty latest.
|
||||
if latest, fresh := CachedLatest(); latest != "" || fresh {
|
||||
t.Errorf("empty cache = (%q,%v), want (\"\",false)", latest, fresh)
|
||||
}
|
||||
|
||||
// Fresh cache → returns latest and fresh=true.
|
||||
writeCache("v0.4.0")
|
||||
if latest, fresh := CachedLatest(); latest != "v0.4.0" || !fresh {
|
||||
t.Errorf("fresh cache = (%q,%v), want (v0.4.0,true)", latest, fresh)
|
||||
}
|
||||
|
||||
// Stale cache (older than TTL) → latest kept, fresh=false.
|
||||
stale, _ := json.Marshal(updateCache{CheckedAt: time.Now().Add(-25 * time.Hour), Latest: "v0.3.0"})
|
||||
if err := os.WriteFile(filepath.Join(dir, cacheFileName), stale, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest, fresh := CachedLatest(); latest != "v0.3.0" || fresh {
|
||||
t.Errorf("stale cache = (%q,%v), want (v0.3.0,false)", latest, fresh)
|
||||
}
|
||||
|
||||
// Corrupt cache → silent ("", false), never an error.
|
||||
if err := os.WriteFile(filepath.Join(dir, cacheFileName), []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest, fresh := CachedLatest(); latest != "" || fresh {
|
||||
t.Errorf("corrupt cache = (%q,%v), want (\"\",false)", latest, fresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartBackgroundCheckDevNoWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", dir)
|
||||
// dev is not a release version → must not touch the network or write a cache.
|
||||
StartBackgroundCheck("dev")
|
||||
if _, err := os.Stat(filepath.Join(dir, cacheFileName)); !os.IsNotExist(err) {
|
||||
t.Errorf("dev build wrote a cache file; want none")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// This file implements pigo's binary self-replacement for `pigo update` (issue
|
||||
// #466). Given the current build version it discovers the latest release (via
|
||||
// version.go), downloads the matching goreleaser archive for the running
|
||||
// GOOS/GOARCH, verifies its SHA256 against the release's checksums.txt, and
|
||||
// atomically replaces the running executable.
|
||||
//
|
||||
// The archive naming mirrors .goreleaser.yaml and install.sh exactly, so this
|
||||
// stays a single source of truth with the release tooling. Replacement is
|
||||
// atomic: the new binary is written to a temp file in the target's directory
|
||||
// and os.Rename'd over the current executable, so a failure mid-download never
|
||||
// leaves a truncated binary in place.
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// checksumsFile is the goreleaser checksums artifact name (see .goreleaser.yaml).
|
||||
const checksumsFile = "checksums.txt"
|
||||
|
||||
// Updater performs a self-replacement. Its fields are seams for testing;
|
||||
// NewUpdater fills them with production defaults.
|
||||
type Updater struct {
|
||||
HTTPClient *http.Client
|
||||
// Repo is "owner/name"; ReleaseBaseURL overrides the download host in tests.
|
||||
Repo string
|
||||
ReleaseBaseURL string // e.g. https://github.com/smallnest/pigo/releases/download
|
||||
GOOS, GOARCH string
|
||||
// ExecPath is the executable to replace; defaults to os.Executable().
|
||||
ExecPath string
|
||||
}
|
||||
|
||||
// Run performs `pigo update` (self-update pigo). It discovers the latest
|
||||
// release, compares it to current, and replaces the running binary when a
|
||||
// newer release exists. When current is a source build ("dev"), it cannot
|
||||
// compare and proceeds to install the latest. Returns a process exit code.
|
||||
func Run(ctx context.Context, current string, out, errOut io.Writer) int {
|
||||
tag, err := LatestTag(ctx, nil, Repo)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "pigo: failed to check for updates: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if avail, comparable := UpdateAvailable(current, tag); comparable && !avail {
|
||||
fmt.Fprintf(out, "already up to date at %s\n", current)
|
||||
return 0
|
||||
}
|
||||
u, err := NewUpdater()
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "pigo: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := u.Apply(ctx, tag, out); err != nil {
|
||||
fmt.Fprintf(errOut, "pigo: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(out, "updated to %s\n", tag)
|
||||
return 0
|
||||
}
|
||||
|
||||
// NewUpdater returns an Updater configured for the running process.
|
||||
func NewUpdater() (*Updater, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfupdate: locate executable: %w", err)
|
||||
}
|
||||
// Resolve symlinks so we replace the real file, not a symlink.
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
return &Updater{
|
||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||
Repo: Repo,
|
||||
ReleaseBaseURL: "https://github.com/" + Repo + "/releases/download",
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
ExecPath: exe,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// archiveName builds the goreleaser archive filename for a release version
|
||||
// (without leading "v") on the updater's platform. It mirrors the
|
||||
// name_template and format_overrides in .goreleaser.yaml.
|
||||
func (u *Updater) archiveName(versionNoV string) string {
|
||||
osName := map[string]string{"darwin": "Darwin", "linux": "Linux", "windows": "Windows"}[u.GOOS]
|
||||
if osName == "" {
|
||||
osName = u.GOOS
|
||||
}
|
||||
arch := map[string]string{"amd64": "x86_64", "386": "i386"}[u.GOARCH]
|
||||
if arch == "" {
|
||||
arch = u.GOARCH // arm64 and others pass through
|
||||
}
|
||||
ext := "tar.gz"
|
||||
if u.GOOS == "windows" {
|
||||
ext = "zip"
|
||||
}
|
||||
return fmt.Sprintf("pigo_%s_%s_%s.%s", versionNoV, osName, arch, ext)
|
||||
}
|
||||
|
||||
// binaryName is the executable name inside the archive.
|
||||
func (u *Updater) binaryName() string {
|
||||
if u.GOOS == "windows" {
|
||||
return "pigo.exe"
|
||||
}
|
||||
return "pigo"
|
||||
}
|
||||
|
||||
// Apply downloads the release identified by tag, verifies its checksum, and
|
||||
// atomically replaces the target executable. tag is like "v0.4.0".
|
||||
func (u *Updater) Apply(ctx context.Context, tag string, out io.Writer) error {
|
||||
versionNoV := strings.TrimPrefix(strings.TrimSpace(tag), "v")
|
||||
archive := u.archiveName(versionNoV)
|
||||
base := fmt.Sprintf("%s/%s", strings.TrimRight(u.ReleaseBaseURL, "/"), tag)
|
||||
|
||||
fmt.Fprintf(out, "downloading %s ...\n", archive)
|
||||
archiveBytes, err := u.download(ctx, base+"/"+archive)
|
||||
if err != nil {
|
||||
return fmt.Errorf("selfupdate: download archive: %w", err)
|
||||
}
|
||||
|
||||
sums, err := u.download(ctx, base+"/"+checksumsFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("selfupdate: download checksums: %w", err)
|
||||
}
|
||||
want, err := checksumFor(sums, archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
got := sha256.Sum256(archiveBytes)
|
||||
if hex.EncodeToString(got[:]) != want {
|
||||
return fmt.Errorf("selfupdate: checksum mismatch for %s (archive corrupt or tampered)", archive)
|
||||
}
|
||||
|
||||
binary, err := extractBinary(archiveBytes, u.binaryName(), u.GOOS == "windows")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.replace(binary); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// download fetches url and returns the full body. A non-200 status is an error.
|
||||
func (u *Updater) download(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := u.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// replace atomically swaps the target executable with newBin: it writes a temp
|
||||
// file in the target's directory, sets it executable, and renames it over the
|
||||
// target. Writing to the same directory keeps the rename atomic (same
|
||||
// filesystem). A permission error on the directory yields an actionable message.
|
||||
func (u *Updater) replace(newBin []byte) error {
|
||||
dir := filepath.Dir(u.ExecPath)
|
||||
tmp, err := os.CreateTemp(dir, ".pigo-update-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("selfupdate: cannot write to %s: %w (try running with sudo, or install pigo to a writable directory)", dir, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op after successful rename
|
||||
|
||||
if _, err := tmp.Write(newBin); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("selfupdate: write new binary: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("selfupdate: close new binary: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpName, 0o755); err != nil {
|
||||
return fmt.Errorf("selfupdate: chmod new binary: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, u.ExecPath); err != nil {
|
||||
return fmt.Errorf("selfupdate: replace %s: %w", u.ExecPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checksumFor finds the hex SHA256 for archive in a goreleaser checksums.txt
|
||||
// body (lines of "<hex> <filename>").
|
||||
func checksumFor(sums []byte, archive string) (string, error) {
|
||||
sc := bufio.NewScanner(bytes.NewReader(sums))
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) == 2 && fields[1] == archive {
|
||||
return fields[0], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("selfupdate: %s not found in checksums.txt", archive)
|
||||
}
|
||||
|
||||
// extractBinary pulls the named binary out of an archive (tar.gz, or zip when
|
||||
// isZip). It returns the binary bytes.
|
||||
func extractBinary(archive []byte, name string, isZip bool) ([]byte, error) {
|
||||
if isZip {
|
||||
return extractFromZip(archive, name)
|
||||
}
|
||||
return extractFromTarGz(archive, name)
|
||||
}
|
||||
|
||||
func extractFromTarGz(archive []byte, name string) ([]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfupdate: gzip reader: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfupdate: read tar: %w", err)
|
||||
}
|
||||
if filepath.Base(hdr.Name) == name && hdr.Typeflag == tar.TypeReg {
|
||||
return io.ReadAll(tr)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("selfupdate: %s not found in archive", name)
|
||||
}
|
||||
|
||||
func extractFromZip(archive []byte, name string) ([]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfupdate: zip reader: %w", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if filepath.Base(f.Name) == name {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfupdate: open %s in zip: %w", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("selfupdate: %s not found in archive", name)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArchiveName(t *testing.T) {
|
||||
tests := []struct {
|
||||
goos, goarch, want string
|
||||
}{
|
||||
{"darwin", "arm64", "pigo_0.4.0_Darwin_arm64.tar.gz"},
|
||||
{"darwin", "amd64", "pigo_0.4.0_Darwin_x86_64.tar.gz"},
|
||||
{"linux", "amd64", "pigo_0.4.0_Linux_x86_64.tar.gz"},
|
||||
{"linux", "386", "pigo_0.4.0_Linux_i386.tar.gz"},
|
||||
{"windows", "amd64", "pigo_0.4.0_Windows_x86_64.zip"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
u := &Updater{GOOS: tt.goos, GOARCH: tt.goarch}
|
||||
if got := u.archiveName("0.4.0"); got != tt.want {
|
||||
t.Errorf("archiveName(%s/%s) = %q, want %q", tt.goos, tt.goarch, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksumFor(t *testing.T) {
|
||||
sums := []byte("abc123 pigo_0.4.0_Linux_x86_64.tar.gz\ndef456 pigo_0.4.0_Darwin_arm64.tar.gz\n")
|
||||
got, err := checksumFor(sums, "pigo_0.4.0_Darwin_arm64.tar.gz")
|
||||
if err != nil || got != "def456" {
|
||||
t.Errorf("checksumFor = (%q,%v), want (def456,nil)", got, err)
|
||||
}
|
||||
if _, err := checksumFor(sums, "missing.tar.gz"); err == nil {
|
||||
t.Error("expected error for missing archive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBinaryTarGz(t *testing.T) {
|
||||
want := []byte("#!fake pigo binary")
|
||||
archive := makeTarGz(t, "pigo", want)
|
||||
got, err := extractBinary(archive, "pigo", false)
|
||||
if err != nil {
|
||||
t.Fatalf("extractBinary: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("extracted = %q, want %q", got, want)
|
||||
}
|
||||
if _, err := extractBinary(archive, "nope", false); err == nil {
|
||||
t.Error("expected error for missing binary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceAtomic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "pigo")
|
||||
if err := os.WriteFile(target, []byte("old"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := &Updater{ExecPath: target}
|
||||
newBin := []byte("new binary content")
|
||||
if err := u.replace(newBin); err != nil {
|
||||
t.Fatalf("replace: %v", err)
|
||||
}
|
||||
got, _ := os.ReadFile(target)
|
||||
if !bytes.Equal(got, newBin) {
|
||||
t.Errorf("after replace = %q, want %q", got, newBin)
|
||||
}
|
||||
// No leftover temp files in the directory.
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("expected 1 file after replace, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEndToEnd(t *testing.T) {
|
||||
binary := []byte("brand new pigo v0.4.0")
|
||||
archive := makeTarGz(t, "pigo", binary)
|
||||
sum := sha256.Sum256(archive)
|
||||
archiveName := "pigo_0.4.0_Linux_x86_64.tar.gz"
|
||||
sums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), archiveName)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch filepath.Base(r.URL.Path) {
|
||||
case archiveName:
|
||||
_, _ = w.Write(archive)
|
||||
case checksumsFile:
|
||||
_, _ = w.Write([]byte(sums))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "pigo")
|
||||
_ = os.WriteFile(target, []byte("old"), 0o755)
|
||||
|
||||
u := &Updater{
|
||||
HTTPClient: srv.Client(),
|
||||
Repo: "smallnest/pigo",
|
||||
ReleaseBaseURL: srv.URL,
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
ExecPath: target,
|
||||
}
|
||||
if err := u.Apply(context.Background(), "v0.4.0", &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
got, _ := os.ReadFile(target)
|
||||
if !bytes.Equal(got, binary) {
|
||||
t.Errorf("target after Apply = %q, want %q", got, binary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyChecksumMismatch(t *testing.T) {
|
||||
archive := makeTarGz(t, "pigo", []byte("real content"))
|
||||
archiveName := "pigo_0.4.0_Linux_x86_64.tar.gz"
|
||||
// Wrong checksum on purpose.
|
||||
sums := "0000000000000000000000000000000000000000000000000000000000000000 " + archiveName + "\n"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch filepath.Base(r.URL.Path) {
|
||||
case archiveName:
|
||||
_, _ = w.Write(archive)
|
||||
case checksumsFile:
|
||||
_, _ = w.Write([]byte(sums))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "pigo")
|
||||
_ = os.WriteFile(target, []byte("old"), 0o755)
|
||||
|
||||
u := &Updater{
|
||||
HTTPClient: srv.Client(),
|
||||
ReleaseBaseURL: srv.URL,
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
ExecPath: target,
|
||||
}
|
||||
if err := u.Apply(context.Background(), "v0.4.0", &bytes.Buffer{}); err == nil {
|
||||
t.Fatal("expected checksum mismatch error")
|
||||
}
|
||||
// Target must be untouched on checksum failure.
|
||||
if got, _ := os.ReadFile(target); string(got) != "old" {
|
||||
t.Errorf("target modified despite checksum failure: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func makeTarGz(t *testing.T, name string, content []byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
hdr := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(content)), Typeflag: tar.TypeReg}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsReleaseVersion(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"": false,
|
||||
"dev": false,
|
||||
"unknown": false,
|
||||
" dev ": false,
|
||||
"v0.4.0": true,
|
||||
"0.4.0": true,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := IsReleaseVersion(in); got != want {
|
||||
t.Errorf("IsReleaseVersion(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAvailable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current, latest string
|
||||
wantAvail, wantOK bool
|
||||
}{
|
||||
{"update available", "v0.3.1", "v0.4.0", true, true},
|
||||
{"patch update", "0.4.0", "0.4.1", true, true},
|
||||
{"already latest", "v0.4.0", "v0.4.0", false, true},
|
||||
{"current newer", "v0.5.0", "v0.4.0", false, true},
|
||||
{"prerelease latest", "v0.4.0", "v0.4.1-next", true, true},
|
||||
{"dev current not comparable", "dev", "v0.4.0", false, false},
|
||||
{"unknown current not comparable", "unknown", "v0.4.0", false, false},
|
||||
{"unparseable latest", "v0.4.0", "not-a-version", false, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
avail, ok := UpdateAvailable(tt.current, tt.latest)
|
||||
if avail != tt.wantAvail || ok != tt.wantOK {
|
||||
t.Errorf("UpdateAvailable(%q,%q) = (%v,%v), want (%v,%v)",
|
||||
tt.current, tt.latest, avail, ok, tt.wantAvail, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want [3]int
|
||||
ok bool
|
||||
}{
|
||||
{"v1.2.3", [3]int{1, 2, 3}, true},
|
||||
{"1.2.3", [3]int{1, 2, 3}, true},
|
||||
{"0.4.0-next", [3]int{0, 4, 0}, true},
|
||||
{"1.2", [3]int{1, 2, 0}, true},
|
||||
{"", [3]int{}, false},
|
||||
{"vabc", [3]int{}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, ok := parseVersion(tt.in)
|
||||
if got != tt.want || ok != tt.ok {
|
||||
t.Errorf("parseVersion(%q) = (%v,%v), want (%v,%v)", tt.in, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTag(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Accept") != "application/vnd.github+json" {
|
||||
t.Errorf("missing Accept header")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"tag_name":"v0.4.0","name":"pigo 0.4.0"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// LatestTag builds the URL from repo; use a transport that redirects to the
|
||||
// test server regardless of host.
|
||||
client := srv.Client()
|
||||
client.Transport = rewriteHost{base: srv.URL, rt: client.Transport}
|
||||
|
||||
tag, err := LatestTag(context.Background(), client, "smallnest/pigo")
|
||||
if err != nil {
|
||||
t.Fatalf("LatestTag: %v", err)
|
||||
}
|
||||
if tag != "v0.4.0" {
|
||||
t.Errorf("tag = %q, want v0.4.0", tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTagErrors(t *testing.T) {
|
||||
t.Run("non-200", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
client := srv.Client()
|
||||
client.Transport = rewriteHost{base: srv.URL, rt: client.Transport}
|
||||
if _, err := LatestTag(context.Background(), client, "smallnest/pigo"); err == nil {
|
||||
t.Fatal("expected error on 403")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty tag", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"tag_name":""}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
client := srv.Client()
|
||||
client.Transport = rewriteHost{base: srv.URL, rt: client.Transport}
|
||||
if _, err := LatestTag(context.Background(), client, "smallnest/pigo"); err == nil {
|
||||
t.Fatal("expected error on empty tag_name")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// rewriteHost redirects every request to base, so tests can point the fixed
|
||||
// GitHub API URL at an httptest server.
|
||||
type rewriteHost struct {
|
||||
base string
|
||||
rt http.RoundTripper
|
||||
}
|
||||
|
||||
func (rw rewriteHost) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
u, err := req.URL.Parse(rw.base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.URL.Scheme = u.Scheme
|
||||
req.URL.Host = u.Host
|
||||
rt := rw.rt
|
||||
if rt == nil {
|
||||
rt = http.DefaultTransport
|
||||
}
|
||||
return rt.RoundTrip(req)
|
||||
}
|
||||
Reference in New Issue
Block a user