first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+264
View File
@@ -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)
}