Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ jobs:

- run: go mod download

- name: Install zip utility
if: matrix.os == 'windows'
run: sudo apt-get update -qq && sudo apt-get install -y -qq zip

- name: Compute version
id: version
run: |
Expand All @@ -103,14 +107,25 @@ jobs:
EXT=""
[ "${{ matrix.os }}" = "windows" ] && EXT=".exe"
NAME="hotplex-${{ matrix.os }}-${{ matrix.arch }}${EXT}"
ARCHIVE="hotplex-${{ matrix.os }}-${{ matrix.arch }}"
mkdir -p dist
go build -ldflags="${LDFLAGS} -X main.version=${VERSION} -X main.commit=${COMMIT}" \
-o "dist/${NAME}" ./cmd/hotplex

# Pack archive: tar.gz for unix, zip for windows
cd dist
if [ "${{ matrix.os }}" = "windows" ]; then
zip -9 "${ARCHIVE}.zip" "${NAME}"
else
tar -czf "${ARCHIVE}.tar.gz" "${NAME}"
fi

- uses: actions/upload-artifact@v7
with:
name: dist-${{ matrix.os }}-${{ matrix.arch }}
path: dist/
path: |
dist/*.tar.gz
dist/*.zip
retention-days: 7

# ── Release ─────────────────────────────────────────────────────────────
Expand All @@ -131,7 +146,7 @@ jobs:
- name: Generate checksums
run: |
cd dist
sha256sum hotplex-* > checksums.txt
sha256sum hotplex-*.tar.gz hotplex-*.zip > checksums.txt
echo "=== Checksums ==="
cat checksums.txt
echo ""
Expand All @@ -145,7 +160,8 @@ jobs:
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
generate_release_notes: true
files: |
dist/hotplex-*
dist/hotplex-*.tar.gz
dist/hotplex-*.zip
dist/checksums.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
24 changes: 18 additions & 6 deletions cmd/hotplex/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ func newUpdateCmd() *cobra.Command {
Short: "Update hotplex to the latest version",
Long: `Check for updates and install the latest version of hotplex.

Downloads the binary from GitHub Releases, verifies the sha256 checksum,
and atomically replaces the running binary.
Downloads the archive from GitHub Releases, verifies the sha256 checksum,
extracts the binary, and atomically replaces the running binary.

Supports all platforms: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64,
windows/amd64, windows/arm64.`,
Expand Down Expand Up @@ -78,20 +78,32 @@ windows/amd64, windows/arm64.`,
return err
}

// Download.
// Download archive (or legacy raw binary).
fmt.Fprintf(os.Stderr, " Downloading %s ...\n", result.AssetName)
tmpPath, err := u.Download(ctx, result.DownloadURL)
archivePath, err := u.Download(ctx, result.DownloadURL)
if err != nil {
return err
}
defer func() { _ = os.Remove(tmpPath) }()
defer func() { _ = os.Remove(archivePath) }()

// Verify checksum.
fmt.Fprintf(os.Stderr, " Verifying checksum...\n")
if err := u.VerifyChecksum(ctx, result.ChecksumURL, result.AssetName, tmpPath); err != nil {
if err := u.VerifyChecksum(ctx, result.ChecksumURL, result.AssetName, archivePath); err != nil {
return fmt.Errorf("checksum verification failed: %w", err)
}

// Extract binary from archive (skip for legacy raw binary releases).
var tmpPath string
if result.IsLegacyBinary {
tmpPath = archivePath
} else {
fmt.Fprintf(os.Stderr, " Extracting...\n")
tmpPath, err = u.Extract(archivePath)
if err != nil {
return err
}
defer func() { _ = os.Remove(tmpPath) }()
}
// Detect running gateway before replacing.
gatewayInst, gatewayErr := findRunningGateway()

Expand Down
160 changes: 139 additions & 21 deletions internal/updater/updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
package updater

import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
Expand Down Expand Up @@ -40,6 +43,7 @@ type CheckResult struct {
AssetName string
DownloadURL string
ChecksumURL string
IsLegacyBinary bool // true when downloading a pre-archive raw binary
}

// Updater holds configuration for update operations.
Expand All @@ -64,8 +68,17 @@ func New(currentVersion string) *Updater {
}
}

// assetName returns the expected binary name for the current platform.
// assetName returns the expected archive name for the current platform.
func (u *Updater) assetName() string {
base := fmt.Sprintf("hotplex-%s-%s", u.GOOS, u.GOARCH)
if u.GOOS == "windows" {
return base + ".zip"
}
return base + ".tar.gz"
}

// binaryName returns the expected binary file name inside the archive.
func (u *Updater) binaryName() string {
name := fmt.Sprintf("hotplex-%s-%s", u.GOOS, u.GOARCH)
if u.GOOS == "windows" {
name += ".exe"
Expand Down Expand Up @@ -105,69 +118,174 @@ func (u *Updater) Check(ctx context.Context) (*CheckResult, error) {
}

want := u.assetName()
legacy := u.binaryName()
assetName := want // archive name by default
var downloadURL, checksumURL string
var isLegacy bool
for _, a := range release.Assets {
switch a.Name {
case want:
downloadURL = a.BrowserDownloadURL
case legacy:
// Fallback: pre-archive releases publish raw binaries.
if downloadURL == "" {
downloadURL = a.BrowserDownloadURL
assetName = legacy
isLegacy = true
}
case "checksums.txt":
checksumURL = a.BrowserDownloadURL
}
}
if downloadURL == "" {
return nil, fmt.Errorf("no binary found for %s in release %s", want, release.TagName)
return nil, fmt.Errorf("no archive found for %s in release %s", want, release.TagName)
}

return &CheckResult{
CurrentVersion: u.CurrentVersion,
LatestVersion: release.TagName,
UpdateAvailable: !versionEqual(u.CurrentVersion, release.TagName),
AssetName: want,
AssetName: assetName,
DownloadURL: downloadURL,
ChecksumURL: checksumURL,
IsLegacyBinary: isLegacy,
}, nil
}

// Download fetches the binary to a temp file and returns its path.
// Download fetches the archive to a temp file and returns its path.
// Caller is responsible for cleaning up the temp file.
func (u *Updater) Download(ctx context.Context, url string) (string, error) {
req, err := http.NewRequest(http.MethodGet, url, http.NoBody)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}

// Use a longer timeout for binary download via context.
dlCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
req = req.WithContext(dlCtx)
// Use a client without Timeout so context controls the deadline.
dlClient := &http.Client{}
if u.Client != nil {
dlClient = &http.Client{
Transport: u.Client.Transport,
Jar: u.Client.Jar,
}
}

resp, err := u.Client.Do(req)
resp, err := dlClient.Do(req)
if err != nil {
return "", fmt.Errorf("download binary: %w", err)
return "", fmt.Errorf("download archive: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed with HTTP %d", resp.StatusCode)
}

tmp, err := os.CreateTemp("", "hotplex-update-*")
archiveFile, err := os.CreateTemp("", "hotplex-update-archive-*")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmp.Name()
archivePath := archiveFile.Name()

if _, err := io.Copy(tmp, io.LimitReader(resp.Body, 200<<20)); err != nil { // 200MB max
_ = tmp.Close()
_ = os.Remove(tmpPath)
return "", fmt.Errorf("write download: %w", err)
if _, err := io.Copy(archiveFile, io.LimitReader(resp.Body, 200<<20)); err != nil { // 200MB max
_ = archiveFile.Close()
_ = os.Remove(archivePath)
return "", fmt.Errorf("write archive: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("close temp file: %w", err)
_ = archiveFile.Close()

return archivePath, nil
}

// Extract reads the archive at archivePath and extracts the platform binary to a temp file.
// Caller is responsible for cleaning up the returned path.
func (u *Updater) Extract(archivePath string) (string, error) {
binaryPath, err := u.extractBinary(archivePath)
if err != nil {
return "", err
}
return binaryPath, nil
}

// extractBinary extracts the platform binary from a tar.gz or zip archive.
func (u *Updater) extractBinary(archivePath string) (string, error) {
want := u.binaryName()
if u.GOOS == "windows" {
return u.extractFromZip(archivePath, want)
}
return u.extractFromTarGz(archivePath, want)
}

return tmpPath, nil
func (u *Updater) extractFromTarGz(archivePath, want string) (string, error) {
f, err := os.Open(archivePath)
if err != nil {
return "", fmt.Errorf("open archive: %w", err)
}
defer func() { _ = f.Close() }()

gz, err := gzip.NewReader(f)
if err != nil {
return "", fmt.Errorf("decompress gzip: %w", err)
}
defer func() { _ = gz.Close() }()

tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("read tar: %w", err)
}
if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == want &&
!strings.Contains(hdr.Name, "..") && !filepath.IsAbs(hdr.Name) {
out, err := os.CreateTemp("", "hotplex-update-*")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
if _, err := io.Copy(out, io.LimitReader(tr, 200<<20)); err != nil {
_ = out.Close()
_ = os.Remove(out.Name())
return "", fmt.Errorf("extract binary: %w", err)
}
_ = out.Close()
return out.Name(), nil
}
}
return "", fmt.Errorf("binary %s not found in archive", want)
}

func (u *Updater) extractFromZip(archivePath, want string) (string, error) {
zr, err := zip.OpenReader(archivePath)
if err != nil {
return "", fmt.Errorf("open zip: %w", err)
}
defer func() { _ = zr.Close() }()

for _, f := range zr.File {
if !f.Mode().IsRegular() || filepath.Base(f.Name) != want {
continue
}
rc, err := f.Open()
if err != nil {
return "", fmt.Errorf("open entry: %w", err)
}

out, err := os.CreateTemp("", "hotplex-update-*")
if err != nil {
_ = rc.Close()
return "", fmt.Errorf("create temp file: %w", err)
}
_, err = io.Copy(out, io.LimitReader(rc, 200<<20))
_ = rc.Close()
if err != nil {
_ = out.Close()
_ = os.Remove(out.Name())
return "", fmt.Errorf("extract binary: %w", err)
}
_ = out.Close()
return out.Name(), nil
}
return "", fmt.Errorf("binary %s not found in archive", want)
}

// VerifyChecksum downloads checksums.txt and compares sha256 of the file at path.
Expand Down
Loading