mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 00:06:58 +00:00
isNewerVersion split versions on "." and ran each component through
strconv.Atoi, discarding the error and substituting 0. For a git-describe
build the last component is "4-18-g8f70cce", which does not parse, so it
became 0 and every published release compared as newer. Running
v0.1.4-18-g8f70cce printed "Update available: v0.1.4" on every single
invocation, naming a version the binary was already 18 commits past.
Versions are now parsed properly: the "-<commits>-g<sha>" tail is recognised
and kept as a count of commits past the tag, and a version that cannot be
read in full returns false rather than being silently treated as 0. That
second part is the actual root cause — the comparison could not distinguish
"this component is zero" from "I could not read this component".
Ordering for a git-describe build is deliberately not semver, where a
prerelease sorts below its release. Such a build is commits AHEAD of its tag,
so v0.1.4-18-g8f70cce is newer than v0.1.4 and older than v0.1.4-20-gabc1234.
The function had no tests. Both failing cases are pinned along with the
ordinary release comparisons, so the git-describe handling cannot regress the
normal upgrade path.
Pre-existing at efabb677 rather than introduced by this range.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
278 lines
7.4 KiB
Go
278 lines
7.4 KiB
Go
package credhelper
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func newUpdateCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "update",
|
|
Short: "Update to the latest version",
|
|
RunE: runUpdate,
|
|
}
|
|
cmd.Flags().Bool("check", false, "Only check for updates, don't install")
|
|
return cmd
|
|
}
|
|
|
|
func runUpdate(cmd *cobra.Command, args []string) error {
|
|
checkOnly, _ := cmd.Flags().GetBool("check")
|
|
|
|
latest, err := fetchLatestVersion()
|
|
if err != nil {
|
|
return fmt.Errorf("checking for updates: %w", err)
|
|
}
|
|
|
|
if !isNewerVersion(latest, cfg.Version) {
|
|
fmt.Printf("You're already running the latest version (%s)\n", cfg.Version)
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("New version available: %s (current: %s)\n", latest, cfg.Version)
|
|
|
|
if checkOnly {
|
|
return nil
|
|
}
|
|
|
|
if err := performUpdate(latest); err != nil {
|
|
return fmt.Errorf("update failed: %w", err)
|
|
}
|
|
|
|
fmt.Println("Update completed successfully!")
|
|
return nil
|
|
}
|
|
|
|
// fetchLatestVersion resolves the latest released version by following the
|
|
// {ReleasesBaseURL}/tags/latest redirect chain. Tangled redirects
|
|
// DID→handle first, then handle/tags/latest→handle/tags/vX.Y.Z, so we follow
|
|
// the chain and read the tag from the final effective URL.
|
|
func fetchLatestVersion() (string, error) {
|
|
client := httpClientWithTimeout(10*time.Second, nil)
|
|
|
|
resp, err := client.Get(cfg.ReleasesBaseURL + "/tags/latest")
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetching latest tag: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
finalURL := resp.Request.URL
|
|
tag := path.Base(finalURL.Path)
|
|
if !strings.HasPrefix(tag, "v") {
|
|
return "", fmt.Errorf("unexpected tag in final URL %q", finalURL.String())
|
|
}
|
|
|
|
return tag, nil
|
|
}
|
|
|
|
// isNewerVersion compares two version strings (simple semver comparison)
|
|
// describeSuffix matches the "-<commits>-g<sha>" tail `git describe` appends to
|
|
// the most recent tag, as in v0.1.4-18-g8f70cce.
|
|
var describeSuffix = regexp.MustCompile(`^(.*)-(\d+)-g[0-9a-f]+$`)
|
|
|
|
// parsedVersion is a version split into its numeric components plus, for a
|
|
// git-describe build, how many commits it sits past its tag.
|
|
type parsedVersion struct {
|
|
nums []int
|
|
ahead int
|
|
}
|
|
|
|
// parseVersion reads "v0.1.4" and "v0.1.4-18-g8f70cce". It reports ok=false for
|
|
// anything it cannot read in full, so an unparseable version is never mistaken
|
|
// for an upgrade — the previous code swallowed strconv errors and substituted
|
|
// 0, which made every release look newer than any git-describe build.
|
|
func parseVersion(s string) (parsedVersion, bool) {
|
|
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
|
if s == "" {
|
|
return parsedVersion{}, false
|
|
}
|
|
|
|
var ahead int
|
|
if m := describeSuffix.FindStringSubmatch(s); m != nil {
|
|
n, err := strconv.Atoi(m[2])
|
|
if err != nil {
|
|
return parsedVersion{}, false
|
|
}
|
|
s, ahead = m[1], n
|
|
}
|
|
|
|
parts := strings.Split(s, ".")
|
|
nums := make([]int, 0, len(parts))
|
|
for _, p := range parts {
|
|
n, err := strconv.Atoi(p)
|
|
if err != nil {
|
|
return parsedVersion{}, false
|
|
}
|
|
nums = append(nums, n)
|
|
}
|
|
return parsedVersion{nums: nums, ahead: ahead}, true
|
|
}
|
|
|
|
// versionComponent reads the i'th numeric component, treating absent trailing
|
|
// components as 0 so "0.1" and "0.1.0" compare equal.
|
|
func versionComponent(nums []int, i int) int {
|
|
if i < len(nums) {
|
|
return nums[i]
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func isNewerVersion(newVersion, currentVersion string) bool {
|
|
if currentVersion == "dev" {
|
|
return true
|
|
}
|
|
|
|
newV, okNew := parseVersion(newVersion)
|
|
curV, okCur := parseVersion(currentVersion)
|
|
if !okNew || !okCur {
|
|
return false
|
|
}
|
|
|
|
for i := range max(len(newV.nums), len(curV.nums)) {
|
|
a, b := versionComponent(newV.nums, i), versionComponent(curV.nums, i)
|
|
if a != b {
|
|
return a > b
|
|
}
|
|
}
|
|
|
|
// Same tag: whichever build sits further past it is the newer one. This is
|
|
// deliberately not semver ordering, where a prerelease sorts below its
|
|
// release — a git-describe build is commits AHEAD of its tag, not behind.
|
|
return newV.ahead > curV.ahead
|
|
}
|
|
|
|
// goreleaserArchiveName returns the archive filename goreleaser publishes for
|
|
// the given version and the current platform. The naming template lives in
|
|
// .goreleaser.yaml: <UpdateAssetName>_{Version}_{Title(OS)}_{Arch} with
|
|
// amd64→x86_64 and 386→i386.
|
|
func goreleaserArchiveName(version string) string {
|
|
versionNoV := strings.TrimPrefix(version, "v")
|
|
|
|
os := strings.ToUpper(runtime.GOOS[:1]) + runtime.GOOS[1:]
|
|
|
|
arch := runtime.GOARCH
|
|
switch arch {
|
|
case "amd64":
|
|
arch = "x86_64"
|
|
case "386":
|
|
arch = "i386"
|
|
}
|
|
|
|
return fmt.Sprintf("%s_%s_%s_%s.tar.gz", cfg.UpdateAssetName, versionNoV, os, arch)
|
|
}
|
|
|
|
// performUpdate downloads and installs the new version
|
|
func performUpdate(latest string) error {
|
|
filename := goreleaserArchiveName(latest)
|
|
downloadURL := fmt.Sprintf("%s/tags/%s/download/%s", cfg.ReleasesBaseURL, latest, filename)
|
|
|
|
fmt.Printf("Downloading update from %s...\n", downloadURL)
|
|
|
|
tmpDir, err := os.MkdirTemp("", cfg.BinaryName+"-update-")
|
|
if err != nil {
|
|
return fmt.Errorf("creating temp directory: %w", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
archivePath := filepath.Join(tmpDir, "archive.tar.gz")
|
|
if err := downloadFile(downloadURL, archivePath); err != nil {
|
|
return fmt.Errorf("downloading: %w", err)
|
|
}
|
|
|
|
binaryPath := filepath.Join(tmpDir, cfg.BinaryName)
|
|
if runtime.GOOS == "windows" {
|
|
binaryPath += ".exe"
|
|
}
|
|
|
|
if err := extractTarGz(archivePath, tmpDir); err != nil {
|
|
return fmt.Errorf("extracting archive: %w", err)
|
|
}
|
|
|
|
currentPath, err := os.Executable()
|
|
if err != nil {
|
|
return fmt.Errorf("getting current executable path: %w", err)
|
|
}
|
|
currentPath, err = filepath.EvalSymlinks(currentPath)
|
|
if err != nil {
|
|
return fmt.Errorf("resolving symlinks: %w", err)
|
|
}
|
|
|
|
fmt.Println("Verifying new binary...")
|
|
verifyCmd := exec.Command(binaryPath, "--version")
|
|
if output, err := verifyCmd.Output(); err != nil {
|
|
return fmt.Errorf("new binary verification failed: %w", err)
|
|
} else {
|
|
fmt.Printf("New binary version: %s", string(output))
|
|
}
|
|
|
|
backupPath := currentPath + ".bak"
|
|
if err := os.Rename(currentPath, backupPath); err != nil {
|
|
return fmt.Errorf("backing up current binary: %w", err)
|
|
}
|
|
|
|
if err := copyFile(binaryPath, currentPath); err != nil {
|
|
os.Rename(backupPath, currentPath) //nolint:errcheck
|
|
return fmt.Errorf("installing new binary: %w", err)
|
|
}
|
|
|
|
if err := os.Chmod(currentPath, 0755); err != nil {
|
|
os.Remove(currentPath) //nolint:errcheck
|
|
os.Rename(backupPath, currentPath) //nolint:errcheck
|
|
return fmt.Errorf("setting permissions: %w", err)
|
|
}
|
|
|
|
os.Remove(backupPath) //nolint:errcheck
|
|
return nil
|
|
}
|
|
|
|
// downloadFile downloads a file from a URL to a local path
|
|
func downloadFile(url, destPath string) error {
|
|
resp, err := httpClient().Get(url) //nolint:gosec
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("download returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
out, err := os.Create(destPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
|
|
_, err = io.Copy(out, resp.Body)
|
|
return err
|
|
}
|
|
|
|
// extractTarGz extracts a .tar.gz archive
|
|
func extractTarGz(archivePath, destDir string) error {
|
|
cmd := exec.Command("tar", "-xzf", archivePath, "-C", destDir)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("tar failed: %s: %w", string(output), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// copyFile copies a file from src to dst
|
|
func copyFile(src, dst string) error {
|
|
input, err := os.ReadFile(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(dst, input, 0755)
|
|
}
|