Skip to content

Commit dc1a726

Browse files
committed
fix(release): daemon zip/tar archive contains canonical binary name
make release was packing "mxcli-daemon-windows-amd64.exe" inside the zip instead of "mxcli-daemon.exe", causing the launcher to error with "no file named mxcli-daemon.exe found in zip archive" on first run. The same bug existed for Linux/Darwin tar.zst archives (internal name was "mxcli-daemon-linux-amd64" instead of "mxcli-daemon"), though that path was untested. Fix: rename to the canonical name before packing, remove the temp copy after. Also refactor downloadDaemonVersionForPlatform to accept goos/ goarch so Linux CI can exercise the Windows extraction path, and add cross-platform integration tests to prevent regression. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent e851d5f commit dc1a726

4 files changed

Lines changed: 107 additions & 17 deletions

File tree

Makefile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,15 @@ release: clean sync-all
166166
@echo " -> Compressing daemon binaries (requires zstd)"
167167
@for f in $(BUILD_DIR)/$(DAEMON_NAME)-linux-* $(BUILD_DIR)/$(DAEMON_NAME)-darwin-*; do \
168168
echo " $$f -> $$f.tar.zst"; \
169-
tar -cf - -C $(BUILD_DIR) $$(basename $$f) | zstd -19 -f -o $$f.tar.zst; \
169+
cp "$$f" "$(BUILD_DIR)/$(DAEMON_NAME)"; \
170+
tar -cf - -C $(BUILD_DIR) $(DAEMON_NAME) | zstd -19 -f -o $$f.tar.zst; \
171+
rm -f "$(BUILD_DIR)/$(DAEMON_NAME)"; \
170172
done
171173
@for f in $(BUILD_DIR)/$(DAEMON_NAME)-windows-*.exe; do \
172174
echo " $$f -> $$f.zip"; \
173-
zip -j $$f.zip $$f; \
175+
cp "$$f" "$(BUILD_DIR)/$(DAEMON_NAME).exe"; \
176+
zip -j $$f.zip "$(BUILD_DIR)/$(DAEMON_NAME).exe"; \
177+
rm -f "$(BUILD_DIR)/$(DAEMON_NAME).exe"; \
174178
done
175179

176180
@echo ""

cmd/mxcli-launcher/daemon.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,10 @@ func (e *Env) downloadDaemon(destPath string) error {
118118
}
119119

120120
func (e *Env) downloadDaemonVersion(tag, destPath string) error {
121-
goos := runtime.GOOS
122-
goarch := runtime.GOARCH
121+
return e.downloadDaemonVersionForPlatform(tag, destPath, runtime.GOOS, runtime.GOARCH)
122+
}
123123

124+
func (e *Env) downloadDaemonVersionForPlatform(tag, destPath, goos, goarch string) error {
124125
var archiveExt string
125126
if goos == "windows" {
126127
archiveExt = ".exe.zip"

cmd/mxcli-launcher/install_update_test.go

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package main
44

55
import (
66
"os"
7+
"runtime"
78
"strconv"
89
"strings"
910
"sync"
@@ -18,9 +19,16 @@ import (
1819
// Returns both Env (for launcher calls) and FakeGitHub (for RequestLog assertions).
1920
func newInstallEnv(t *testing.T, cfg *testfixtures.FakeGitHub, daemonContent []byte) (*Env, *testfixtures.FakeGitHub) {
2021
t.Helper()
21-
payload, err := testfixtures.BuildDaemonPayload(daemonContent)
22+
return newInstallEnvForPlatform(t, cfg, daemonContent, runtime.GOOS, runtime.GOARCH)
23+
}
24+
25+
// newInstallEnvForPlatform is like newInstallEnv but targets an explicit platform,
26+
// enabling Linux CI to exercise Windows download/extraction paths.
27+
func newInstallEnvForPlatform(t *testing.T, cfg *testfixtures.FakeGitHub, daemonContent []byte, goos, goarch string) (*Env, *testfixtures.FakeGitHub) {
28+
t.Helper()
29+
payload, err := testfixtures.BuildDaemonPayloadForPlatform(goos, goarch, daemonContent)
2230
if err != nil {
23-
t.Fatalf("BuildDaemonPayload: %v", err)
31+
t.Fatalf("BuildDaemonPayloadForPlatform: %v", err)
2432
}
2533
cfg.Payload = payload
2634
gh := testfixtures.NewFakeGitHub(t, cfg)
@@ -386,3 +394,49 @@ func TestRunUpgrade_ConcurrentOnlyOneWins(t *testing.T) {
386394
t.Errorf("expected exactly 1 lock acquisition, got %d", successes)
387395
}
388396
}
397+
398+
// — Cross-platform download path tests —
399+
// These run on any host OS so Linux CI can exercise Windows packaging.
400+
401+
func TestDownloadDaemonVersion_WindowsZipOnLinuxCI(t *testing.T) {
402+
t.Parallel()
403+
// Regression test: make release was packing "mxcli-daemon-windows-amd64.exe" inside
404+
// the zip instead of "mxcli-daemon.exe", causing "no file named found in zip archive".
405+
// BuildDaemonPayloadForPlatform("windows",...) creates a zip with "mxcli-daemon.exe"
406+
// inside (the correct post-fix layout), so this test fails if the extraction logic
407+
// or the fixture naming drifts apart again.
408+
e, _ := newInstallEnvForPlatform(t, &testfixtures.FakeGitHub{LatestTag: "v0.15.0"}, []byte("win-binary"), "windows", "amd64")
409+
410+
dest := e.daemonBinaryPath()
411+
if err := e.downloadDaemonVersionForPlatform("v0.15.0", dest, "windows", "amd64"); err != nil {
412+
t.Fatalf("Windows download failed: %v", err)
413+
}
414+
415+
got, err := os.ReadFile(dest)
416+
if err != nil {
417+
t.Fatalf("daemon binary not written: %v", err)
418+
}
419+
if string(got) != "win-binary" {
420+
t.Errorf("binary content = %q, want win-binary", got)
421+
}
422+
}
423+
424+
func TestDownloadDaemonVersion_LinuxTarZst(t *testing.T) {
425+
t.Parallel()
426+
// Mirrors TestDownloadDaemon_FreshInstall but explicitly targets Linux/amd64
427+
// so both Unix and Windows hosts exercise the tar.zst path.
428+
e, _ := newInstallEnvForPlatform(t, &testfixtures.FakeGitHub{LatestTag: "v0.15.0"}, []byte("linux-binary"), "linux", "amd64")
429+
430+
dest := e.daemonBinaryPath()
431+
if err := e.downloadDaemonVersionForPlatform("v0.15.0", dest, "linux", "amd64"); err != nil {
432+
t.Fatalf("Linux download failed: %v", err)
433+
}
434+
435+
got, err := os.ReadFile(dest)
436+
if err != nil {
437+
t.Fatalf("daemon binary not written: %v", err)
438+
}
439+
if string(got) != "linux-binary" {
440+
t.Errorf("binary content = %q, want linux-binary", got)
441+
}
442+
}

cmd/mxcli-launcher/testfixtures/fake_daemon.go

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package testfixtures
55

66
import (
77
"archive/tar"
8+
"archive/zip"
89
"bytes"
910
"crypto/sha256"
1011
"encoding/hex"
@@ -18,22 +19,54 @@ import (
1819
type DaemonPayload struct {
1920
// AssetName is the filename the fake server uses, e.g. "mxcli-daemon-linux-amd64.tar.zst"
2021
AssetName string
21-
// Archive is the raw bytes of the tar.zst archive.
22+
// Archive is the raw bytes of the tar.zst or .exe.zip archive.
2223
Archive []byte
2324
// Checksum is the correct SHA256 hex digest of Archive.
2425
Checksum string
2526
}
2627

27-
// BuildDaemonPayload creates a minimal tar.zst containing a fake daemon binary
28-
// for the current platform. The binary content is the provided content bytes.
28+
// BuildDaemonPayload creates a fake daemon archive for the current platform.
29+
// Windows → .exe.zip containing "mxcli-daemon.exe".
30+
// Linux/Darwin → .tar.zst containing "mxcli-daemon".
2931
func BuildDaemonPayload(content []byte) (*DaemonPayload, error) {
30-
goos := runtime.GOOS
31-
goarch := runtime.GOARCH
32+
return BuildDaemonPayloadForPlatform(runtime.GOOS, runtime.GOARCH, content)
33+
}
3234

33-
binaryName := "mxcli-daemon"
35+
// BuildDaemonPayloadForPlatform creates a fake daemon archive for an arbitrary platform.
36+
// This allows Linux CI to test the Windows download path without a real Windows machine.
37+
func BuildDaemonPayloadForPlatform(goos, goarch string, content []byte) (*DaemonPayload, error) {
3438
if goos == "windows" {
35-
binaryName = "mxcli-daemon.exe"
39+
return buildWindowsPayload(goos, goarch, content)
40+
}
41+
return buildUnixPayload(goos, goarch, content)
42+
}
43+
44+
func buildWindowsPayload(goos, goarch string, content []byte) (*DaemonPayload, error) {
45+
assetName := fmt.Sprintf("mxcli-daemon-%s-%s.exe.zip", goos, goarch)
46+
47+
var buf bytes.Buffer
48+
w := zip.NewWriter(&buf)
49+
f, err := w.Create("mxcli-daemon.exe")
50+
if err != nil {
51+
return nil, fmt.Errorf("zip create: %w", err)
52+
}
53+
if _, err := f.Write(content); err != nil {
54+
return nil, fmt.Errorf("zip write: %w", err)
3655
}
56+
if err := w.Close(); err != nil {
57+
return nil, fmt.Errorf("zip close: %w", err)
58+
}
59+
60+
archiveBytes := buf.Bytes()
61+
h := sha256.Sum256(archiveBytes)
62+
return &DaemonPayload{
63+
AssetName: assetName,
64+
Archive: archiveBytes,
65+
Checksum: hex.EncodeToString(h[:]),
66+
}, nil
67+
}
68+
69+
func buildUnixPayload(goos, goarch string, content []byte) (*DaemonPayload, error) {
3770
assetName := fmt.Sprintf("mxcli-daemon-%s-%s.tar.zst", goos, goarch)
3871

3972
var buf bytes.Buffer
@@ -43,7 +76,7 @@ func BuildDaemonPayload(content []byte) (*DaemonPayload, error) {
4376
}
4477
tw := tar.NewWriter(zw)
4578
hdr := &tar.Header{
46-
Name: binaryName,
79+
Name: "mxcli-daemon",
4780
Typeflag: tar.TypeReg,
4881
Size: int64(len(content)),
4982
Mode: 0755,
@@ -59,12 +92,10 @@ func BuildDaemonPayload(content []byte) (*DaemonPayload, error) {
5992

6093
archiveBytes := buf.Bytes()
6194
h := sha256.Sum256(archiveBytes)
62-
checksum := hex.EncodeToString(h[:])
63-
6495
return &DaemonPayload{
6596
AssetName: assetName,
6697
Archive: archiveBytes,
67-
Checksum: checksum,
98+
Checksum: hex.EncodeToString(h[:]),
6899
}, nil
69100
}
70101

0 commit comments

Comments
 (0)