From 024cf81a7b2a528fb7b205b1972de0c2b89a4936 Mon Sep 17 00:00:00 2001 From: Bear Date: Thu, 8 Oct 2026 11:53:36 -0600 Subject: [PATCH] feat: add self-update command to install the latest release in place Download the release archive for the current OS/arch, verify it against checksums.txt, and atomically replace the running binary. Includes --check to only report availability and points the update notice at the command. Also make the version ldflags override effective (the computed initializer made -X a no-op) and ignore +build metadata when comparing versions. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 3 +- README.md | 14 ++ cmd/root.go | 13 +- cmd/self_update.go | 296 ++++++++++++++++++++++++++++++++++++++++ cmd/self_update_test.go | 99 ++++++++++++++ cmd/update.go | 7 +- 6 files changed, 425 insertions(+), 7 deletions(-) create mode 100644 cmd/self_update.go create mode 100644 cmd/self_update_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 1b8e2b1..3958a6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,8 @@ Single-command Cobra CLI app with a `prune-archived` subcommand: - `cmd/config.go` — YAML config file loading (`~/.config/github-pokemon/config.yaml`) - `cmd/prune_archived.go` — subcommand to remove locally-cloned archived repos - `cmd/output.go` — progress bar and grouped result display -- `cmd/update.go` — background self-update check +- `cmd/update.go` — background new-version check +- `cmd/self_update.go` — `self-update` subcommand: downloads, verifies (sha256), and replaces the running binary The worker pool pattern: `runRootCommand()` creates a buffered channel of repos, spawns `--parallel` (default 5) goroutines via `worker()`, each calling `processRepository()` which either `git clone` or `git fetch --all`. diff --git a/README.md b/README.md index 5f0f21b..481f11c 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,20 @@ github-pokemon prune-archived github-pokemon prune-archived --confirm ``` +### Updating + +github-pokemon checks for a newer release on each run and prints a notice if one exists. To install it in place: + +```bash +# Check only +github-pokemon self-update --check + +# Download, verify against the release checksums.txt, and replace the running binary +github-pokemon self-update +``` + +If the binary lives somewhere you can't write to (e.g. `/usr/local/bin`), re-run with sufficient privileges. + ### Examples ```bash diff --git a/cmd/root.go b/cmd/root.go index 69c9a18..8ce4119 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,13 +19,19 @@ import ( ) // version is set via ldflags at build time (GoReleaser) or falls back to -// the module version embedded by go install. -var version = func() string { +// the module version embedded by go install. It must not have a computed +// initializer, or the linker's -X flag cannot override it. +var version string + +func resolveVersion() string { + if version != "" { + return version + } if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { return strings.TrimPrefix(info.Main.Version, "v") } return "0.0.0-dev" -}() +} var ( organization string @@ -99,6 +105,7 @@ func Execute() { } func init() { + version = resolveVersion() rootCmd.Version = version rootCmd.SetVersionTemplate("github-pokemon version {{.Version}}\n") diff --git a/cmd/self_update.go b/cmd/self_update.go new file mode 100644 index 0000000..d01db2d --- /dev/null +++ b/cmd/self_update.go @@ -0,0 +1,296 @@ +package cmd + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/google/go-github/v84/github" + "github.com/spf13/cobra" +) + +const ( + // maxDownloadSize bounds every downloaded or extracted file. + maxDownloadSize = 100 << 20 + checksumsAsset = "checksums.txt" +) + +var selfUpdateCheckOnly bool + +var selfUpdateCmd = &cobra.Command{ + Use: "self-update", + Short: "Update github-pokemon to the latest release", + Long: `Checks GitHub for the latest release and, if it is newer than the running +version, downloads the archive for this OS/architecture, verifies it against the +release's checksums.txt, and replaces the running binary in place. + +Use --check to only report whether an update is available.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runSelfUpdate(cmd.Context()) + }, + SilenceUsage: true, +} + +func init() { + selfUpdateCmd.Flags().BoolVar(&selfUpdateCheckOnly, "check", false, "Only check whether a newer version is available") + rootCmd.AddCommand(selfUpdateCmd) +} + +func runSelfUpdate(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + client := github.NewClient(nil) + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + client = client.WithAuthToken(token) + } + + release, _, err := client.Repositories.GetLatestRelease(ctx, repoOwner, repoName) + if err != nil { + return fmt.Errorf("fetching latest release: %w", err) + } + + latest := strings.TrimPrefix(release.GetTagName(), "v") + if !isNewer(latest, version) { + fmt.Printf("github-pokemon v%s is up to date\n", version) + return nil + } + + fmt.Printf("New version available: v%s (current: v%s)\n", latest, version) + if selfUpdateCheckOnly { + fmt.Println("Run 'github-pokemon self-update' to install it.") + return nil + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("locating current executable: %w", err) + } + if exe, err = filepath.EvalSymlinks(exe); err != nil { + return fmt.Errorf("resolving current executable: %w", err) + } + + archiveName := releaseArchiveName(latest, runtime.GOOS, runtime.GOARCH) + archiveAsset := findAsset(release, archiveName) + if archiveAsset == nil { + return fmt.Errorf("release v%s has no asset %q for %s/%s", latest, archiveName, runtime.GOOS, runtime.GOARCH) + } + sumsAsset := findAsset(release, checksumsAsset) + if sumsAsset == nil { + return fmt.Errorf("release v%s has no %s; refusing to install an unverified binary", latest, checksumsAsset) + } + + fmt.Printf("Downloading %s...\n", archiveName) + archive, err := downloadAsset(ctx, client, archiveAsset) + if err != nil { + return err + } + sums, err := downloadAsset(ctx, client, sumsAsset) + if err != nil { + return err + } + + if err := verifyChecksum(sums, archiveName, archive); err != nil { + return err + } + fmt.Println("Checksum verified.") + + binary, err := extractBinary(archive, archiveName, binaryName(runtime.GOOS)) + if err != nil { + return err + } + + if err := replaceExecutable(exe, binary, runtime.GOOS); err != nil { + if errors.Is(err, os.ErrPermission) { + return fmt.Errorf("%w\nYou may not have write access to %s; re-run with sufficient privileges (e.g. sudo) or download manually: https://github.com/%s/%s/releases/latest", err, filepath.Dir(exe), repoOwner, repoName) + } + return err + } + + fmt.Printf("Updated %s to v%s\n", exe, latest) + return nil +} + +// releaseArchiveName mirrors the GoReleaser archive name_template. +func releaseArchiveName(ver, goos, goarch string) string { + ext := "tar.gz" + if goos == "windows" { + ext = "zip" + } + return fmt.Sprintf("%s_%s_%s_%s.%s", repoName, ver, goos, goarch, ext) +} + +func binaryName(goos string) string { + if goos == "windows" { + return repoName + ".exe" + } + return repoName +} + +func findAsset(release *github.RepositoryRelease, name string) *github.ReleaseAsset { + for _, a := range release.Assets { + if a.GetName() == name { + return a + } + } + return nil +} + +func downloadAsset(ctx context.Context, client *github.Client, asset *github.ReleaseAsset) ([]byte, error) { + rc, redirectURL, err := client.Repositories.DownloadReleaseAsset(ctx, repoOwner, repoName, asset.GetID(), http.DefaultClient) + if err != nil { + return nil, fmt.Errorf("downloading %s: %w", asset.GetName(), err) + } + if rc == nil { + return nil, fmt.Errorf("downloading %s: unexpected redirect to %s", asset.GetName(), redirectURL) + } + defer func() { _ = rc.Close() }() + + data, err := io.ReadAll(io.LimitReader(rc, maxDownloadSize+1)) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", asset.GetName(), err) + } + if len(data) > maxDownloadSize { + return nil, fmt.Errorf("%s exceeds maximum size of %d bytes", asset.GetName(), maxDownloadSize) + } + return data, nil +} + +// verifyChecksum checks data against the sha256 entry for name in a +// checksums.txt body (" " per line). +func verifyChecksum(checksums []byte, name string, data []byte) error { + for _, line := range strings.Split(string(checksums), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != name { + continue + } + sum := sha256.Sum256(data) + if !strings.EqualFold(fields[0], hex.EncodeToString(sum[:])) { + return fmt.Errorf("checksum mismatch for %s", name) + } + return nil + } + return fmt.Errorf("no checksum listed for %s", name) +} + +// extractBinary returns the contents of the file named binName from a +// .tar.gz or .zip archive. +func extractBinary(archive []byte, archiveName, binName string) ([]byte, error) { + if strings.HasSuffix(archiveName, ".zip") { + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, fmt.Errorf("opening zip archive: %w", err) + } + for _, f := range zr.File { + if f.FileInfo().IsDir() || path.Base(f.Name) != binName { + continue + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("opening %s in archive: %w", f.Name, err) + } + defer func() { _ = rc.Close() }() + return readLimited(rc, binName) + } + return nil, fmt.Errorf("%s not found in archive", binName) + } + + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, fmt.Errorf("opening gzip archive: %w", err) + } + defer func() { _ = gz.Close() }() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("%s not found in archive", binName) + } + if err != nil { + return nil, fmt.Errorf("reading tar archive: %w", err) + } + if hdr.Typeflag == tar.TypeReg && path.Base(hdr.Name) == binName { + return readLimited(tr, binName) + } + } +} + +func readLimited(r io.Reader, name string) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, maxDownloadSize+1)) + if err != nil { + return nil, fmt.Errorf("extracting %s: %w", name, err) + } + if len(data) > maxDownloadSize { + return nil, fmt.Errorf("%s exceeds maximum size of %d bytes", name, maxDownloadSize) + } + return data, nil +} + +// replaceExecutable atomically swaps the file at exe with newBin. The new +// file is written next to exe so the final rename stays on one filesystem. +// On Windows a running executable can't be overwritten, but it can be +// renamed, so the old binary is moved aside first. +func replaceExecutable(exe string, newBin []byte, goos string) error { + info, err := os.Stat(exe) + if err != nil { + return fmt.Errorf("inspecting %s: %w", exe, err) + } + + tmp, err := os.CreateTemp(filepath.Dir(exe), "."+filepath.Base(exe)+".new-*") + if err != nil { + return fmt.Errorf("creating temporary file: %w", err) + } + tmpName := tmp.Name() + cleanup := func() { _ = os.Remove(tmpName) } + + if _, err := tmp.Write(newBin); err != nil { + _ = tmp.Close() + cleanup() + return fmt.Errorf("writing new binary: %w", err) + } + if err := tmp.Close(); err != nil { + cleanup() + return fmt.Errorf("writing new binary: %w", err) + } + if err := os.Chmod(tmpName, info.Mode().Perm()|0o111); err != nil { + cleanup() + return fmt.Errorf("setting permissions: %w", err) + } + + if goos == "windows" { + old := exe + ".old" + _ = os.Remove(old) + if err := os.Rename(exe, old); err != nil { + cleanup() + return fmt.Errorf("moving current binary aside: %w", err) + } + if err := os.Rename(tmpName, exe); err != nil { + _ = os.Rename(old, exe) + cleanup() + return fmt.Errorf("installing new binary: %w", err) + } + return nil + } + + if err := os.Rename(tmpName, exe); err != nil { + cleanup() + return fmt.Errorf("installing new binary: %w", err) + } + return nil +} diff --git a/cmd/self_update_test.go b/cmd/self_update_test.go new file mode 100644 index 0000000..5d786a3 --- /dev/null +++ b/cmd/self_update_test.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +func TestReleaseArchiveName(t *testing.T) { + if got, want := releaseArchiveName("1.6.0", "linux", "amd64"), "github-pokemon_1.6.0_linux_amd64.tar.gz"; got != want { + t.Errorf("got %q, want %q", got, want) + } + if got, want := releaseArchiveName("1.6.0", "windows", "arm64"), "github-pokemon_1.6.0_windows_arm64.zip"; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestVerifyChecksum(t *testing.T) { + data := []byte("archive-bytes") + sum := sha256.Sum256(data) + good := hex.EncodeToString(sum[:]) + " a.tar.gz\n" + "deadbeef b.tar.gz\n" + + if err := verifyChecksum([]byte(good), "a.tar.gz", data); err != nil { + t.Errorf("expected match, got %v", err) + } + if err := verifyChecksum([]byte(good), "b.tar.gz", data); err == nil { + t.Error("expected mismatch error") + } + if err := verifyChecksum([]byte(good), "c.tar.gz", data); err == nil { + t.Error("expected missing-entry error") + } +} + +func TestExtractBinaryTarGz(t *testing.T) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for name, body := range map[string]string{"README.md": "readme", "github-pokemon": "BINARY"} { + _ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}) + _, _ = tw.Write([]byte(body)) + } + _ = tw.Close() + _ = gz.Close() + + got, err := extractBinary(buf.Bytes(), "x.tar.gz", "github-pokemon") + if err != nil || string(got) != "BINARY" { + t.Fatalf("got %q, %v", got, err) + } + if _, err := extractBinary(buf.Bytes(), "x.tar.gz", "missing"); err == nil { + t.Error("expected not-found error") + } +} + +func TestExtractBinaryZip(t *testing.T) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, _ := zw.Create("github-pokemon.exe") + _, _ = w.Write([]byte("EXE")) + _ = zw.Close() + + got, err := extractBinary(buf.Bytes(), "x.zip", "github-pokemon.exe") + if err != nil || string(got) != "EXE" { + t.Fatalf("got %q, %v", got, err) + } +} + +func TestReplaceExecutable(t *testing.T) { + for _, goos := range []string{"linux", "windows"} { + dir := t.TempDir() + exe := filepath.Join(dir, "github-pokemon") + if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + + if err := replaceExecutable(exe, []byte("new"), goos); err != nil { + t.Fatalf("%s: %v", goos, err) + } + got, _ := os.ReadFile(exe) + if string(got) != "new" { + t.Errorf("%s: got %q", goos, got) + } + if info, _ := os.Stat(exe); info.Mode().Perm()&0o111 == 0 { + t.Errorf("%s: new binary is not executable", goos) + } + + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if e.Name() != "github-pokemon" && e.Name() != "github-pokemon.old" { + t.Errorf("%s: leftover file %s", goos, e.Name()) + } + } + } +} diff --git a/cmd/update.go b/cmd/update.go index c3ae158..45aa01c 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -73,8 +73,8 @@ func isNewer(latest, current string) bool { // parseSemver splits a "MAJOR.MINOR.PATCH" string (ignoring any pre-release // suffix after a hyphen) into three integers. Returns nil on failure. func parseSemver(v string) []int { - // Strip pre-release suffix (e.g. "1.0.0-dev" -> "1.0.0") - if idx := strings.IndexByte(v, '-'); idx != -1 { + // Strip pre-release and build metadata (e.g. "1.0.0-dev", "1.0.0+dirty" -> "1.0.0") + if idx := strings.IndexAny(v, "-+"); idx != -1 { v = v[:idx] } parts := strings.Split(v, ".") @@ -102,5 +102,6 @@ func printUpdateNotice(ch <-chan updateResult) { return } _, _ = fmt.Fprintf(os.Stderr, "\nA newer version of github-pokemon is available: v%s (current: v%s)\n", result.latest, version) - _, _ = fmt.Fprintf(os.Stderr, "Download: https://github.com/%s/%s/releases/latest\n", repoOwner, repoName) + _, _ = fmt.Fprintf(os.Stderr, "Update with: github-pokemon self-update\n") + _, _ = fmt.Fprintf(os.Stderr, "Or download: https://github.com/%s/%s/releases/latest\n", repoOwner, repoName) }