From 34c7d40c96a6972a2ae2e4b02c1a977a02fc7da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Fri, 12 Jun 2026 22:12:24 +0800 Subject: [PATCH 1/4] feat(release): publish tar.gz/zip archives instead of raw binaries (#729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compress release assets with gzip (~31% ratio, 71MB→22MB). Updater and install scripts now download and extract from archives. - CI: pack tar.gz (unix) / zip (windows) after build, upload archives - updater: assetName() returns archive name, Download() extracts binary - install.sh: download tar.gz → verify → extract → install - install.ps1: download zip → verify → Expand-Archive → install - Security: tar/zip extraction filters for regular files only --- .github/workflows/release.yml | 18 ++- internal/updater/updater.go | 127 ++++++++++++++++++--- internal/updater/updater_test.go | 187 ++++++++++++++++++++++--------- scripts/install.ps1 | 29 +++-- scripts/install.sh | 29 ++--- 5 files changed, 292 insertions(+), 98 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73d41851e..af8373fa1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: | @@ -103,10 +107,19 @@ 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 }} @@ -131,7 +144,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 "" @@ -145,7 +158,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 }} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 085129b63..1d40ca2c8 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -2,6 +2,9 @@ package updater import ( + "archive/tar" + "archive/zip" + "compress/gzip" "context" "crypto/sha256" "encoding/hex" @@ -64,8 +67,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" @@ -115,7 +127,7 @@ func (u *Updater) Check(ctx context.Context) (*CheckResult, error) { } } 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{ @@ -128,10 +140,10 @@ func (u *Updater) Check(ctx context.Context) (*CheckResult, error) { }, nil } -// Download fetches the binary to a temp file and returns its path. +// Download fetches the archive, extracts the binary 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) } @@ -143,7 +155,7 @@ func (u *Updater) Download(ctx context.Context, url string) (string, error) { resp, err := u.Client.Do(req) if err != nil { - return "", fmt.Errorf("download binary: %w", err) + return "", fmt.Errorf("download archive: %w", err) } defer func() { _ = resp.Body.Close() }() @@ -151,23 +163,108 @@ func (u *Updater) Download(ctx context.Context, url string) (string, error) { return "", fmt.Errorf("download failed with HTTP %d", resp.StatusCode) } - tmp, err := os.CreateTemp("", "hotplex-update-*") + // Save archive to temp, then extract binary. + 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(archiveFile, io.LimitReader(resp.Body, 200<<20)); err != nil { // 200MB max + _ = archiveFile.Close() + _ = os.Remove(archivePath) + return "", fmt.Errorf("write archive: %w", err) + } + _ = archiveFile.Close() + + binaryPath, err := u.extractBinary(archivePath) + _ = os.Remove(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) +} + +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() }() - 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) + gz, err := gzip.NewReader(f) + if err != nil { + return "", fmt.Errorf("decompress gzip: %w", err) } - if err := tmp.Close(); err != nil { - _ = os.Remove(tmpPath) - return "", fmt.Errorf("close temp file: %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 { + 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() }() - return tmpPath, nil + 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. diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 60eb84d74..81c71610a 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -1,6 +1,9 @@ package updater import ( + "archive/tar" + "archive/zip" + "compress/gzip" "context" "crypto/sha256" "encoding/json" @@ -41,23 +44,44 @@ func releaseJSON(tag string, assets []Asset) string { } func TestAssetName(t *testing.T) { + t.Parallel() + tests := []struct { + goos string + goarch string + want string + }{ + {"darwin", "arm64", "hotplex-darwin-arm64.tar.gz"}, + {"darwin", "amd64", "hotplex-darwin-amd64.tar.gz"}, + {"linux", "amd64", "hotplex-linux-amd64.tar.gz"}, + {"linux", "arm64", "hotplex-linux-arm64.tar.gz"}, + {"windows", "amd64", "hotplex-windows-amd64.zip"}, + {"windows", "arm64", "hotplex-windows-arm64.zip"}, + } + for _, tt := range tests { + t.Run(tt.goos+"/"+tt.goarch, func(t *testing.T) { + t.Parallel() + u := &Updater{GOOS: tt.goos, GOARCH: tt.goarch} + require.Equal(t, tt.want, u.assetName()) + }) + } +} + +func TestBinaryName(t *testing.T) { + t.Parallel() tests := []struct { goos string goarch string want string }{ {"darwin", "arm64", "hotplex-darwin-arm64"}, - {"darwin", "amd64", "hotplex-darwin-amd64"}, {"linux", "amd64", "hotplex-linux-amd64"}, - {"linux", "arm64", "hotplex-linux-arm64"}, {"windows", "amd64", "hotplex-windows-amd64.exe"}, - {"windows", "arm64", "hotplex-windows-arm64.exe"}, } for _, tt := range tests { t.Run(tt.goos+"/"+tt.goarch, func(t *testing.T) { t.Parallel() u := &Updater{GOOS: tt.goos, GOARCH: tt.goarch} - require.Equal(t, tt.want, u.assetName()) + require.Equal(t, tt.want, u.binaryName()) }) } } @@ -66,7 +90,7 @@ func TestCheck_UpdateAvailable(t *testing.T) { t.Parallel() u, _ := testUpdater(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, releaseJSON("v1.4.0", []Asset{ - {Name: "hotplex-darwin-arm64", BrowserDownloadURL: "http://example.com/binary"}, + {Name: "hotplex-darwin-arm64.tar.gz", BrowserDownloadURL: "http://example.com/archive"}, {Name: "checksums.txt", BrowserDownloadURL: "http://example.com/checksums"}, })) })) @@ -74,7 +98,7 @@ func TestCheck_UpdateAvailable(t *testing.T) { require.NoError(t, err) require.True(t, result.UpdateAvailable) require.Equal(t, "v1.4.0", result.LatestVersion) - require.Equal(t, "http://example.com/binary", result.DownloadURL) + require.Equal(t, "http://example.com/archive", result.DownloadURL) require.Equal(t, "http://example.com/checksums", result.ChecksumURL) } @@ -82,7 +106,7 @@ func TestCheck_AlreadyUpToDate(t *testing.T) { t.Parallel() u, _ := testUpdater(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, releaseJSON("v1.3.0", []Asset{ - {Name: "hotplex-darwin-arm64", BrowserDownloadURL: "http://example.com/binary"}, + {Name: "hotplex-darwin-arm64.tar.gz", BrowserDownloadURL: "http://example.com/archive"}, {Name: "checksums.txt", BrowserDownloadURL: "http://example.com/checksums"}, })) })) @@ -122,12 +146,12 @@ func TestCheck_AssetNotFound(t *testing.T) { t.Parallel() u, _ := testUpdater(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, releaseJSON("v1.4.0", []Asset{ - {Name: "hotplex-linux-amd64", BrowserDownloadURL: "http://example.com/binary"}, + {Name: "hotplex-linux-amd64.tar.gz", BrowserDownloadURL: "http://example.com/archive"}, })) })) _, err := u.Check(context.Background()) require.Error(t, err) - require.Contains(t, err.Error(), "no binary found") + require.Contains(t, err.Error(), "no archive found") } func TestCheck_NonOKStatus(t *testing.T) { @@ -140,15 +164,66 @@ func TestCheck_NonOKStatus(t *testing.T) { require.Contains(t, err.Error(), "HTTP 500") } -func TestDownload_Success(t *testing.T) { +func makeTarGz(t *testing.T, binaryName string, content []byte) []byte { + t.Helper() + var buf strings.Builder + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: binaryName, + Mode: 0o755, + Size: int64(len(content)), + })) + _, err := tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + return []byte(buf.String()) +} + +func makeZip(t *testing.T, binaryName string, content []byte) []byte { + t.Helper() + var buf strings.Builder + zw := zip.NewWriter(&buf) + w, err := zw.Create(binaryName) + require.NoError(t, err) + _, err = w.Write(content) + require.NoError(t, err) + require.NoError(t, zw.Close()) + return []byte(buf.String()) +} + +func TestDownload_TarGz(t *testing.T) { t.Parallel() content := []byte("fake-binary-content") + archive := makeTarGz(t, "hotplex-darwin-arm64", content) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(content) + _, _ = w.Write(archive) })) t.Cleanup(server.Close) - u := &Updater{Client: server.Client()} + u := &Updater{Client: server.Client(), GOOS: "darwin", GOARCH: "arm64"} + path, err := u.Download(context.Background(), server.URL) + require.NoError(t, err) + defer os.Remove(path) + + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, content, data) +} + +func TestDownload_Zip(t *testing.T) { + t.Parallel() + content := []byte("fake-windows-binary") + archive := makeZip(t, "hotplex-windows-amd64.exe", content) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(archive) + })) + t.Cleanup(server.Close) + + u := &Updater{Client: server.Client(), GOOS: "windows", GOARCH: "amd64"} path, err := u.Download(context.Background(), server.URL) require.NoError(t, err) defer os.Remove(path) @@ -158,16 +233,43 @@ func TestDownload_Success(t *testing.T) { require.Equal(t, content, data) } +func TestDownload_BinaryNotFoundInArchive(t *testing.T) { + t.Parallel() + archive := makeTarGz(t, "wrong-binary-name", []byte("x")) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(archive) + })) + t.Cleanup(server.Close) + + u := &Updater{Client: server.Client(), GOOS: "darwin", GOARCH: "arm64"} + _, err := u.Download(context.Background(), server.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "not found in archive") +} + +func TestDownload_NonOKStatus(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + + u := &Updater{Client: server.Client()} + _, err := u.Download(context.Background(), server.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "HTTP 404") +} + func TestVerifyChecksum_Success(t *testing.T) { t.Parallel() - // Create a temp file with known content tmp := t.TempDir() - filePath := filepath.Join(tmp, "hotplex-darwin-arm64") - content := []byte("fake-binary") + filePath := filepath.Join(tmp, "hotplex-darwin-arm64.tar.gz") + content := []byte("fake-archive") require.NoError(t, os.WriteFile(filePath, content, 0o644)) hash := sha256.Sum256(content) - checksumLine := fmt.Sprintf("%s hotplex-darwin-arm64", fmt.Sprintf("%x", hash[:])) + checksumLine := fmt.Sprintf("%s hotplex-darwin-arm64.tar.gz", fmt.Sprintf("%x", hash[:])) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(checksumLine)) @@ -175,23 +277,23 @@ func TestVerifyChecksum_Success(t *testing.T) { t.Cleanup(server.Close) u := &Updater{Client: server.Client()} - err := u.VerifyChecksum(context.Background(), server.URL, "hotplex-darwin-arm64", filePath) + err := u.VerifyChecksum(context.Background(), server.URL, "hotplex-darwin-arm64.tar.gz", filePath) require.NoError(t, err) } func TestVerifyChecksum_Mismatch(t *testing.T) { t.Parallel() tmp := t.TempDir() - filePath := filepath.Join(tmp, "hotplex-darwin-arm64") + filePath := filepath.Join(tmp, "hotplex-darwin-arm64.tar.gz") require.NoError(t, os.WriteFile(filePath, []byte("content"), 0o644)) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("0000000000000000 hotplex-darwin-arm64")) + _, _ = w.Write([]byte("0000000000000000 hotplex-darwin-arm64.tar.gz")) })) t.Cleanup(server.Close) u := &Updater{Client: server.Client()} - err := u.VerifyChecksum(context.Background(), server.URL, "hotplex-darwin-arm64", filePath) + err := u.VerifyChecksum(context.Background(), server.URL, "hotplex-darwin-arm64.tar.gz", filePath) require.Error(t, err) require.Contains(t, err.Error(), "checksum mismatch") } @@ -199,16 +301,16 @@ func TestVerifyChecksum_Mismatch(t *testing.T) { func TestVerifyChecksum_MissingEntry(t *testing.T) { t.Parallel() tmp := t.TempDir() - filePath := filepath.Join(tmp, "hotplex-darwin-arm64") + filePath := filepath.Join(tmp, "hotplex-darwin-arm64.tar.gz") require.NoError(t, os.WriteFile(filePath, []byte("x"), 0o644)) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("abc123 hotplex-linux-amd64")) + _, _ = w.Write([]byte("abc123 hotplex-linux-amd64.tar.gz")) })) t.Cleanup(server.Close) u := &Updater{Client: server.Client()} - err := u.VerifyChecksum(context.Background(), server.URL, "hotplex-darwin-arm64", filePath) + err := u.VerifyChecksum(context.Background(), server.URL, "hotplex-darwin-arm64.tar.gz", filePath) require.Error(t, err) require.Contains(t, err.Error(), "not found in checksums.txt") } @@ -216,7 +318,7 @@ func TestVerifyChecksum_MissingEntry(t *testing.T) { func TestVerifyChecksum_NoURL(t *testing.T) { t.Parallel() u := &Updater{Client: http.DefaultClient} - err := u.VerifyChecksum(context.Background(), "", "hotplex-darwin-arm64", "/dev/null") + err := u.VerifyChecksum(context.Background(), "", "hotplex-darwin-arm64.tar.gz", "/dev/null") require.Error(t, err) require.Contains(t, err.Error(), "skipping verification") } @@ -225,15 +327,12 @@ func TestReplace_Success(t *testing.T) { t.Parallel() tmp := t.TempDir() - // Create fake "current binary" currentBin := filepath.Join(tmp, "hotplex") require.NoError(t, os.WriteFile(currentBin, []byte("old"), 0o755)) - // Create fake "new binary" newBin := filepath.Join(tmp, "hotplex-new") require.NoError(t, os.WriteFile(newBin, []byte("new"), 0o755)) - // Test the rename pattern directly backupPath := currentBin + ".old" require.NoError(t, os.Rename(currentBin, backupPath)) require.NoError(t, os.Rename(newBin, currentBin)) @@ -243,7 +342,6 @@ func TestReplace_Success(t *testing.T) { require.NoError(t, err) require.Equal(t, []byte("new"), data) - // New binary file should no longer exist at old path _, err = os.Stat(newBin) require.True(t, os.IsNotExist(err)) } @@ -266,19 +364,18 @@ func TestVersionEqual(t *testing.T) { func TestFindChecksum(t *testing.T) { t.Parallel() - checksums := "abc123 hotplex-linux-amd64\ndef456 hotplex-darwin-arm64\n" - hash, err := findChecksum(checksums, "hotplex-darwin-arm64") + checksums := "abc123 hotplex-linux-amd64.tar.gz\ndef456 hotplex-darwin-arm64.tar.gz\n" + hash, err := findChecksum(checksums, "hotplex-darwin-arm64.tar.gz") require.NoError(t, err) require.Equal(t, "def456", hash) - _, err = findChecksum(checksums, "hotplex-windows-amd64.exe") + _, err = findChecksum(checksums, "hotplex-windows-amd64.zip") require.Error(t, err) require.Contains(t, err.Error(), "not found") } func TestIsWritable(t *testing.T) { t.Parallel() - // Test with a temp file instead of the running binary to avoid "text file busy" on Linux CI. tmp := t.TempDir() f := filepath.Join(tmp, "test-binary") require.NoError(t, os.WriteFile(f, []byte("x"), 0o755)) @@ -287,7 +384,6 @@ func TestIsWritable(t *testing.T) { require.NoError(t, err) require.Equal(t, f, path) - // Read-only file should fail. readOnly := filepath.Join(tmp, "readonly") require.NoError(t, os.WriteFile(readOnly, []byte("x"), 0o444)) _, err = testIsWritablePath(readOnly) @@ -305,14 +401,13 @@ func testIsWritablePath(path string) (string, error) { func TestIsDocker(t *testing.T) { t.Parallel() - // In a test environment, IsDocker should return false (no /.dockerenv) require.False(t, IsDocker()) } func TestCheck_ContextCancelled(t *testing.T) { t.Parallel() u, _ := testUpdater(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - <-r.Context().Done() // block until client cancels + <-r.Context().Done() })) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) defer cancel() @@ -320,19 +415,6 @@ func TestCheck_ContextCancelled(t *testing.T) { require.Error(t, err) } -func TestDownload_NonOKStatus(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - t.Cleanup(server.Close) - - u := &Updater{Client: server.Client()} - _, err := u.Download(context.Background(), server.URL) - require.Error(t, err) - require.Contains(t, err.Error(), "HTTP 404") -} - func TestVerifyChecksum_HTTPError(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -341,14 +423,7 @@ func TestVerifyChecksum_HTTPError(t *testing.T) { t.Cleanup(server.Close) u := &Updater{Client: server.Client()} - err := u.VerifyChecksum(context.Background(), server.URL, "hotplex-darwin-arm64", "/dev/null") + err := u.VerifyChecksum(context.Background(), server.URL, "hotplex-darwin-arm64.tar.gz", "/dev/null") require.Error(t, err) require.Contains(t, err.Error(), "HTTP 500") } - -func TestAssetName_NonWindows(t *testing.T) { - t.Parallel() - u := &Updater{GOOS: "linux", GOARCH: "amd64"} - require.Equal(t, "hotplex-linux-amd64", u.assetName()) - require.False(t, strings.HasSuffix(u.assetName(), ".exe")) -} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ad7387fd3..91a0efb0c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -163,9 +163,10 @@ if ($Release -notmatch '^v\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$') { # ── Download ───────────────────────────────────────────────────────────────── +$ArchiveName = "hotplex-windows-${Arch}.zip" $BinaryName = "hotplex-windows-${Arch}.exe" $BaseUrl = "https://github.com/$Repo/releases/download/$Release" -$DownloadUrl = "$BaseUrl/$BinaryName" +$DownloadUrl = "$BaseUrl/$ArchiveName" $ChecksumUrl = "$BaseUrl/checksums.txt" $TmpDir = Join-Path $env:TEMP "hotplex-install-$(Get-Random)" @@ -174,26 +175,26 @@ New-Item -ItemType Directory -Path $TmpDir -Force | Out-Null $Success = $false try { - # Download binary + # Download archive Write-Info "Downloading hotplex $Release for windows/$Arch..." - $BinaryPath = Join-Path $TmpDir $BinaryName + $ArchivePath = Join-Path $TmpDir $ArchiveName $PrevProgress = $ProgressPreference $ProgressPreference = "SilentlyContinue" try { - Invoke-WebRequest -Uri $DownloadUrl -OutFile $BinaryPath -UseBasicParsing + Invoke-WebRequest -Uri $DownloadUrl -OutFile $ArchivePath -UseBasicParsing } catch { Write-Err "Download failed: $($_.Exception.Message)" - Write-Host "The release may not include Windows binaries." + Write-Host "The release may not include Windows archives." Write-Host "Check available releases at: https://github.com/$Repo/releases" return } $ProgressPreference = $PrevProgress # Verify file is not empty - $FileSize = (Get-Item $BinaryPath).Length + $FileSize = (Get-Item $ArchivePath).Length if ($FileSize -eq 0) { - Write-Err "Downloaded file is empty — release binary may not exist for this platform." + Write-Err "Downloaded file is empty — release archive may not exist for this platform." return } @@ -205,10 +206,10 @@ try { Invoke-WebRequest -Uri $ChecksumUrl -OutFile $ChecksumPath -UseBasicParsing $ProgressPreference = $PrevProgress - $ExpectedLine = Get-Content $ChecksumPath | Where-Object { $_ -like "*$BinaryName*" } | Select-Object -First 1 + $ExpectedLine = Get-Content $ChecksumPath | Where-Object { $_ -like "*$ArchiveName*" } | Select-Object -First 1 if ($ExpectedLine) { $Expected = ($ExpectedLine -split "\s+")[0] - $Actual = (Get-FileHash -Path $BinaryPath -Algorithm SHA256).Hash.ToLower() + $Actual = (Get-FileHash -Path $ArchivePath -Algorithm SHA256).Hash.ToLower() if ($Expected -ne $Actual) { Write-Err "Checksum mismatch!" Write-Host " Expected: $Expected" @@ -217,16 +218,20 @@ try { } Write-Info "Checksum verified." } else { - Write-Warn "Binary not found in checksums file — skipping verification." + Write-Warn "Archive not found in checksums file — skipping verification." } } catch { Write-Warn "Checksums file unavailable — skipping verification." } - # ── Install ──────────────────────────────────────────────────────────────── + # ── Extract and install ──────────────────────────────────────────────────── + + $ExtractDir = Join-Path $TmpDir "extracted" + Expand-Archive -Path $ArchivePath -DestinationPath $ExtractDir -Force + $ExtractedBinary = Join-Path $ExtractDir $BinaryName New-Item -ItemType Directory -Path $Prefix -Force | Out-Null - Copy-Item $BinaryPath $TargetPath -Force + Copy-Item $ExtractedBinary $TargetPath -Force Write-Info "Installed: $TargetPath" diff --git a/scripts/install.sh b/scripts/install.sh index 50cf28432..c5213cdd9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -133,11 +133,11 @@ TARGET="$PREFIX/bin/$BIN_NAME" WORK_DIR=$(mktemp -d) trap 'rm -rf "$WORK_DIR"' EXIT -BINARY_NAME="hotplex-${OS}-${ARCH}" +ARCHIVE_NAME="hotplex-${OS}-${ARCH}.tar.gz" CHECKSUM_NAME="checksums.txt" BASE_URL="https://github.com/${REPO}/releases/download/${RELEASE}" -BINARY_PATH="${WORK_DIR}/${BINARY_NAME}" +ARCHIVE_PATH="${WORK_DIR}/${ARCHIVE_NAME}" CHECKSUM_PATH="${WORK_DIR}/${CHECKSUM_NAME}" info "Downloading hotplex ${RELEASE} for ${OS}/${ARCH}..." @@ -145,16 +145,16 @@ mkdir -p "$PREFIX/bin" DL_OK=false if [[ "$DL_CMD" == "curl" ]]; then - curl -fSL --progress-bar "${BASE_URL}/${BINARY_NAME}" -o "$BINARY_PATH" && DL_OK=true + curl -fSL --progress-bar "${BASE_URL}/${ARCHIVE_NAME}" -o "$ARCHIVE_PATH" && DL_OK=true else - wget -q --show-progress "${BASE_URL}/${BINARY_NAME}" -O "$BINARY_PATH" && DL_OK=true + wget -q --show-progress "${BASE_URL}/${ARCHIVE_NAME}" -O "$ARCHIVE_PATH" && DL_OK=true fi -$DL_OK || die "Download failed. Release ${RELEASE} may not include a binary for ${OS}/${ARCH}. +$DL_OK || die "Download failed. Release ${RELEASE} may not include an archive for ${OS}/${ARCH}. Check available releases: https://github.com/${REPO}/releases" -[[ $(stat -f%z "$BINARY_PATH" 2>/dev/null || stat -c%s "$BINARY_PATH") -eq 0 ]] \ - && die "Downloaded file is empty — release binary may not exist for this platform." +[[ $(stat -f%z "$ARCHIVE_PATH" 2>/dev/null || stat -c%s "$ARCHIVE_PATH") -eq 0 ]] \ + && die "Downloaded file is empty — release archive may not exist for this platform." # ── Verify checksum ────────────────────────────────────────────────────────── @@ -168,28 +168,31 @@ if [[ -n "$HASH_CMD" ]]; then fi if $DL_OK && [[ -f "$CHECKSUM_PATH" ]]; then - EXPECTED=$(grep "$BINARY_NAME" "$CHECKSUM_PATH" | awk '{print $1}') + EXPECTED=$(grep "$ARCHIVE_NAME" "$CHECKSUM_PATH" | awk '{print $1}') if [[ -n "$EXPECTED" ]]; then if [[ "$HASH_CMD" == "sha256sum" ]]; then - ACTUAL=$(sha256sum "$BINARY_PATH" | awk '{print $1}') + ACTUAL=$(sha256sum "$ARCHIVE_PATH" | awk '{print $1}') else - ACTUAL=$(shasum -a 256 "$BINARY_PATH" | awk '{print $1}') + ACTUAL=$(shasum -a 256 "$ARCHIVE_PATH" | awk '{print $1}') fi if [[ "$EXPECTED" != "$ACTUAL" ]]; then die "Checksum mismatch! Expected: $EXPECTED Actual: $ACTUAL" fi info "Checksum verified." else - warn "Binary not found in checksums file — skipping verification." + warn "Archive not found in checksums file — skipping verification." fi else warn "Checksums file unavailable — skipping verification." fi fi -# ── Install ────────────────────────────────────────────────────────────────── +# ── Extract and install ────────────────────────────────────────────────────── -mv "$BINARY_PATH" "$TARGET" +tar -xzf "$ARCHIVE_PATH" -C "$WORK_DIR" "hotplex-${OS}-${ARCH}" +EXTRACTED="${WORK_DIR}/hotplex-${OS}-${ARCH}" +[[ -f "$EXTRACTED" ]] || die "Binary hotplex-${OS}-${ARCH} not found in archive." +mv "$EXTRACTED" "$TARGET" chmod +x "$TARGET" # ── Verify installation ───────────────────────────────────────────────────── From 8ecb9955201fba55419dbfc96d4e79341c2f57e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Fri, 12 Jun 2026 22:42:16 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(release):=20address=20PR=20#730=20revie?= =?UTF-8?q?w=20findings=20=E2=80=94=20P0+P1+P2=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: Checksum verification target mismatch — split Download and Extract so VerifyChecksum runs on the archive before extracting the binary. P1: http.Client.Timeout(30s) cutting short the 3min download context — Download now uses a client without Timeout, relying solely on context. P1: Backward compatibility — Check falls back to legacy raw binary asset when archive not found; update.go skips Extract for IsLegacyBinary. P2: Tar path traversal defense — reject entries with ".." or absolute paths. P2: Release includes both archives and raw binaries for transition period. P2: E2E coverage — added TestExtract_*, TestCheck_LegacyBinaryFallback. --- .github/workflows/release.yml | 3 ++ cmd/hotplex/update.go | 24 ++++++++--- internal/updater/updater.go | 32 ++++++++++++--- internal/updater/updater_test.go | 69 ++++++++++++++++++++++++++++---- 4 files changed, 108 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af8373fa1..ea7a78ac9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,6 +160,9 @@ jobs: files: | dist/hotplex-*.tar.gz dist/hotplex-*.zip + dist/hotplex-darwin-* + dist/hotplex-linux-* + dist/hotplex-windows-*.exe dist/checksums.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/cmd/hotplex/update.go b/cmd/hotplex/update.go index c9c2619be..d84a8d919 100644 --- a/cmd/hotplex/update.go +++ b/cmd/hotplex/update.go @@ -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.`, @@ -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() diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 1d40ca2c8..79cc44acc 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -43,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. @@ -117,11 +118,19 @@ func (u *Updater) Check(ctx context.Context) (*CheckResult, error) { } want := u.assetName() + legacy := u.binaryName() 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 + isLegacy = true + } case "checksums.txt": checksumURL = a.BrowserDownloadURL } @@ -137,10 +146,11 @@ func (u *Updater) Check(ctx context.Context) (*CheckResult, error) { AssetName: want, DownloadURL: downloadURL, ChecksumURL: checksumURL, + IsLegacyBinary: isLegacy, }, nil } -// Download fetches the archive, extracts 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.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) @@ -148,12 +158,17 @@ func (u *Updater) Download(ctx context.Context, url string) (string, error) { return "", fmt.Errorf("create request: %w", err) } - // Use a longer timeout for binary download via context. + // Use a dedicated client without Timeout so context controls the deadline. dlCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) defer cancel() req = req.WithContext(dlCtx) - resp, err := u.Client.Do(req) + client := &http.Client{} // no Timeout; relies on context + if u.Client != nil { + client = u.Client + } + + resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("download archive: %w", err) } @@ -163,7 +178,6 @@ func (u *Updater) Download(ctx context.Context, url string) (string, error) { return "", fmt.Errorf("download failed with HTTP %d", resp.StatusCode) } - // Save archive to temp, then extract binary. archiveFile, err := os.CreateTemp("", "hotplex-update-archive-*") if err != nil { return "", fmt.Errorf("create temp file: %w", err) @@ -177,8 +191,13 @@ func (u *Updater) Download(ctx context.Context, url string) (string, error) { } _ = 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) - _ = os.Remove(archivePath) if err != nil { return "", err } @@ -216,7 +235,8 @@ func (u *Updater) extractFromTarGz(archivePath, want string) (string, error) { if err != nil { return "", fmt.Errorf("read tar: %w", err) } - if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == want { + 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) diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 81c71610a..ecbc32952 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -208,9 +208,10 @@ func TestDownload_TarGz(t *testing.T) { require.NoError(t, err) defer os.Remove(path) + // Download returns archive bytes, not extracted binary data, err := os.ReadFile(path) require.NoError(t, err) - require.Equal(t, content, data) + require.Equal(t, archive, data) } func TestDownload_Zip(t *testing.T) { @@ -228,26 +229,78 @@ func TestDownload_Zip(t *testing.T) { require.NoError(t, err) defer os.Remove(path) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, archive, data) +} + +func TestExtract_TarGz(t *testing.T) { + t.Parallel() + content := []byte("fake-binary-content") + archive := makeTarGz(t, "hotplex-darwin-arm64", content) + + tmp := t.TempDir() + archivePath := filepath.Join(tmp, "archive.tar.gz") + require.NoError(t, os.WriteFile(archivePath, archive, 0o644)) + + u := &Updater{GOOS: "darwin", GOARCH: "arm64"} + path, err := u.Extract(archivePath) + require.NoError(t, err) + defer os.Remove(path) + + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, content, data) +} + +func TestExtract_Zip(t *testing.T) { + t.Parallel() + content := []byte("fake-windows-binary") + archive := makeZip(t, "hotplex-windows-amd64.exe", content) + + tmp := t.TempDir() + archivePath := filepath.Join(tmp, "archive.zip") + require.NoError(t, os.WriteFile(archivePath, archive, 0o644)) + + u := &Updater{GOOS: "windows", GOARCH: "amd64"} + path, err := u.Extract(archivePath) + require.NoError(t, err) + defer os.Remove(path) + data, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, content, data) } -func TestDownload_BinaryNotFoundInArchive(t *testing.T) { +func TestExtract_BinaryNotFoundInArchive(t *testing.T) { t.Parallel() archive := makeTarGz(t, "wrong-binary-name", []byte("x")) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(archive) - })) - t.Cleanup(server.Close) + tmp := t.TempDir() + archivePath := filepath.Join(tmp, "archive.tar.gz") + require.NoError(t, os.WriteFile(archivePath, archive, 0o644)) - u := &Updater{Client: server.Client(), GOOS: "darwin", GOARCH: "arm64"} - _, err := u.Download(context.Background(), server.URL) + u := &Updater{GOOS: "darwin", GOARCH: "arm64"} + _, err := u.Extract(archivePath) require.Error(t, err) require.Contains(t, err.Error(), "not found in archive") } +func TestCheck_LegacyBinaryFallback(t *testing.T) { + t.Parallel() + u, _ := testUpdater(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, releaseJSON("v1.4.0", []Asset{ + {Name: "hotplex-darwin-arm64", BrowserDownloadURL: "http://example.com/binary"}, + {Name: "checksums.txt", BrowserDownloadURL: "http://example.com/checksums"}, + })) + })) + result, err := u.Check(context.Background()) + require.NoError(t, err) + require.True(t, result.UpdateAvailable) + require.True(t, result.IsLegacyBinary) + require.Equal(t, "http://example.com/binary", result.DownloadURL) +} + func TestDownload_NonOKStatus(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From bffe54e7c643b5cf53d76907e2840f71a8c856f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Fri, 12 Jun 2026 22:56:50 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(release):=20address=20PR=20#730=20round?= =?UTF-8?q?=202=20review=20=E2=80=94=20P0+P1+P2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: Remove raw binary upload from release — archives only. P1: Legacy fallback sets AssetName to binaryName() so VerifyChecksum finds the correct entry in pre-archive checksums.txt. P2: Download uses a fresh http.Client inheriting Transport/Jar but without Timeout, so context deadline controls the download. --- .github/workflows/release.yml | 3 --- internal/updater/updater.go | 19 ++++++++++--------- internal/updater/updater_test.go | 1 + 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea7a78ac9..af8373fa1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,9 +160,6 @@ jobs: files: | dist/hotplex-*.tar.gz dist/hotplex-*.zip - dist/hotplex-darwin-* - dist/hotplex-linux-* - dist/hotplex-windows-*.exe dist/checksums.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 79cc44acc..a4bb83147 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -119,6 +119,7 @@ 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 { @@ -129,6 +130,7 @@ func (u *Updater) Check(ctx context.Context) (*CheckResult, error) { // Fallback: pre-archive releases publish raw binaries. if downloadURL == "" { downloadURL = a.BrowserDownloadURL + assetName = legacy isLegacy = true } case "checksums.txt": @@ -143,7 +145,7 @@ func (u *Updater) Check(ctx context.Context) (*CheckResult, error) { CurrentVersion: u.CurrentVersion, LatestVersion: release.TagName, UpdateAvailable: !versionEqual(u.CurrentVersion, release.TagName), - AssetName: want, + AssetName: assetName, DownloadURL: downloadURL, ChecksumURL: checksumURL, IsLegacyBinary: isLegacy, @@ -158,17 +160,16 @@ func (u *Updater) Download(ctx context.Context, url string) (string, error) { return "", fmt.Errorf("create request: %w", err) } - // Use a dedicated client without Timeout so context controls the deadline. - dlCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) - defer cancel() - req = req.WithContext(dlCtx) - - client := &http.Client{} // no Timeout; relies on context + // Use a client without Timeout so context controls the deadline. + dlClient := &http.Client{} if u.Client != nil { - client = u.Client + dlClient = &http.Client{ + Transport: u.Client.Transport, + Jar: u.Client.Jar, + } } - resp, err := client.Do(req) + resp, err := dlClient.Do(req) if err != nil { return "", fmt.Errorf("download archive: %w", err) } diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index ecbc32952..eb0265b65 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -298,6 +298,7 @@ func TestCheck_LegacyBinaryFallback(t *testing.T) { require.NoError(t, err) require.True(t, result.UpdateAvailable) require.True(t, result.IsLegacyBinary) + require.Equal(t, "hotplex-darwin-arm64", result.AssetName) require.Equal(t, "http://example.com/binary", result.DownloadURL) } From 05678d771ee33013d3d84e2e9b23e6909c67dd6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Fri, 12 Jun 2026 23:09:13 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(release):=20address=20round=203=20P2=20?= =?UTF-8?q?review=20=E2=80=94=20artifact=20filter=20+=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filter upload-artifact to only include archives (no raw binaries) - Update install script headers from "Binary Installer" to "Installer" - Clarify description: "archive, verifies checksum, extracts and installs" --- .github/workflows/release.yml | 4 +++- scripts/install.ps1 | 4 ++-- scripts/install.sh | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af8373fa1..bded4cfab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,7 +123,9 @@ jobs: - uses: actions/upload-artifact@v7 with: name: dist-${{ matrix.os }}-${{ matrix.arch }} - path: dist/ + path: | + dist/*.tar.gz + dist/*.zip retention-days: 7 # ── Release ───────────────────────────────────────────────────────────── diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 91a0efb0c..c8ef33990 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,10 +1,10 @@ #requires -Version 5.1 <# .SYNOPSIS - HotPlex Worker Gateway — Binary Installer (Windows) + HotPlex Worker Gateway — Installer (Windows) .DESCRIPTION - Downloads a GitHub release binary and installs it. + Downloads a GitHub release archive, verifies checksum, extracts and installs. For config, secrets, and service setup, run: hotplex onboard .PARAMETER Prefix diff --git a/scripts/install.sh b/scripts/install.sh index c5213cdd9..649c1c187 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # -# HotPlex Worker Gateway — Binary Installer (macOS / Linux) +# HotPlex Worker Gateway — Installer (macOS / Linux) # -# Downloads a GitHub release binary and installs it. +# Downloads a GitHub release archive, verifies checksum, extracts and installs. # For config, secrets, and service setup, run: hotplex onboard # # Usage: