From 6ec4ecc131a9417c775a45e56f9935b7b54d7b4e Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Fri, 20 Mar 2026 09:46:07 -0400 Subject: [PATCH 1/2] feat: add Windows binary to release pipeline and self-updater The release workflow, goreleaser config, and self-updater now support Windows amd64. Releases produce a .zip archive for Windows alongside .tar.gz for macOS/Linux. The updater detects the platform and extracts from the correct archive format. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 22 +++++++++--- .goreleaser.yaml | 7 ++++ internal/updater/updater.go | 64 ++++++++++++++++++++++++++++++++--- 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95fdc86..f48fd49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,10 @@ jobs: goos: linux goarch: amd64 name: linux_amd64 + - os: windows-latest + goos: windows + goarch: amd64 + name: windows_amd64 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 @@ -30,18 +34,27 @@ jobs: go-version-file: go.mod - name: Build + shell: bash env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} run: | VERSION=${GITHUB_REF_NAME#v} - go build -ldflags "-s -w -X main.Version=${VERSION}" -o mnemonic ./cmd/mnemonic + BINARY=mnemonic + if [ "${{ matrix.goos }}" = "windows" ]; then BINARY=mnemonic.exe; fi + go build -ldflags "-s -w -X main.Version=${VERSION}" -o "${BINARY}" ./cmd/mnemonic - name: Package + shell: bash run: | VERSION=${GITHUB_REF_NAME#v} - ARCHIVE="mnemonic_${VERSION}_${{ matrix.name }}.tar.gz" - tar czf "${ARCHIVE}" mnemonic README.md LICENSE config.example.yaml docs/ + if [ "${{ matrix.goos }}" = "windows" ]; then + ARCHIVE="mnemonic_${VERSION}_${{ matrix.name }}.zip" + 7z a "${ARCHIVE}" mnemonic.exe README.md LICENSE config.example.yaml docs/ + else + ARCHIVE="mnemonic_${VERSION}_${{ matrix.name }}.tar.gz" + tar czf "${ARCHIVE}" mnemonic README.md LICENSE config.example.yaml docs/ + fi echo "ARCHIVE=${ARCHIVE}" >> $GITHUB_ENV - name: Upload artifact @@ -64,13 +77,14 @@ jobs: - name: Generate checksums working-directory: dist - run: sha256sum *.tar.gz > checksums.txt + run: sha256sum *.tar.gz *.zip > checksums.txt - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: files: | dist/*.tar.gz + dist/*.zip dist/checksums.txt generate_release_notes: true draft: false diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9032d3d..f2223bc 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -14,17 +14,24 @@ builds: goos: - darwin - linux + - windows goarch: - amd64 - arm64 ignore: - goos: linux goarch: arm64 + - goos: windows + goarch: arm64 archives: - id: default formats: - tar.gz + format_overrides: + - goos: windows + formats: + - zip name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" files: - README.md diff --git a/internal/updater/updater.go b/internal/updater/updater.go index ad45814..0259f31 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -2,6 +2,7 @@ package updater import ( "archive/tar" + "archive/zip" "compress/gzip" "context" "crypto/sha256" @@ -85,7 +86,7 @@ func CheckForUpdate(ctx context.Context, currentVersion string) (*UpdateInfo, er latestVersion := strings.TrimPrefix(release.TagName, "v") // Find the asset for this platform - assetName := fmt.Sprintf("mnemonic_%s_%s_%s.tar.gz", latestVersion, runtime.GOOS, runtime.GOARCH) + assetName := fmt.Sprintf("mnemonic_%s_%s_%s%s", latestVersion, runtime.GOOS, runtime.GOARCH, archiveExt()) var assetURL, checksumsURL string for _, a := range release.Assets { switch a.Name { @@ -130,7 +131,7 @@ func PerformUpdate(ctx context.Context, info *UpdateInfo) (*UpdateResult, error) } execDir := filepath.Dir(execPath) - archivePath := filepath.Join(execDir, ".mnemonic.update.tar.gz") + archivePath := filepath.Join(execDir, ".mnemonic.update"+archiveExt()) newBinaryPath := filepath.Join(execDir, ".mnemonic.update.tmp") // Clean up temp files on failure @@ -146,7 +147,7 @@ func PerformUpdate(ctx context.Context, info *UpdateInfo) (*UpdateResult, error) // Verify checksum if available if info.ChecksumsURL != "" { - assetName := fmt.Sprintf("mnemonic_%s_%s_%s.tar.gz", info.LatestVersion, runtime.GOOS, runtime.GOARCH) + assetName := fmt.Sprintf("mnemonic_%s_%s_%s%s", info.LatestVersion, runtime.GOOS, runtime.GOARCH, archiveExt()) if err := verifyChecksum(ctx, archivePath, info.ChecksumsURL, assetName); err != nil { return nil, fmt.Errorf("checksum verification failed: %w", err) } @@ -296,8 +297,63 @@ func verifyChecksum(ctx context.Context, archivePath, checksumsURL, expectedName return nil } -// extractBinary extracts the "mnemonic" binary from a tar.gz archive. +// archiveExt returns the archive file extension for the current platform. +func archiveExt() string { + if runtime.GOOS == "windows" { + return ".zip" + } + return ".tar.gz" +} + +// extractBinary extracts the "mnemonic" binary from an archive. +// Supports tar.gz (macOS/Linux) and zip (Windows). func extractBinary(archivePath, destPath string) error { + if strings.HasSuffix(archivePath, ".zip") { + return extractBinaryFromZip(archivePath, destPath) + } + return extractBinaryFromTarGz(archivePath, destPath) +} + +// extractBinaryFromZip extracts the binary from a zip archive. +func extractBinaryFromZip(archivePath, destPath string) error { + r, err := zip.OpenReader(archivePath) + if err != nil { + return fmt.Errorf("opening zip archive: %w", err) + } + defer func() { _ = r.Close() }() + + binaryName := "mnemonic" + if runtime.GOOS == "windows" { + binaryName = "mnemonic.exe" + } + + for _, f := range r.File { + name := filepath.Base(f.Name) + if name == binaryName && !f.FileInfo().IsDir() { + rc, err := f.Open() + if err != nil { + return fmt.Errorf("opening zip entry: %w", err) + } + defer func() { _ = rc.Close() }() + + out, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("creating output file: %w", err) + } + // Limit copy to 500MB to prevent zip bomb attacks + if _, err := io.Copy(out, io.LimitReader(rc, 500*1024*1024)); err != nil { + _ = out.Close() + return fmt.Errorf("extracting binary: %w", err) + } + return out.Close() + } + } + + return fmt.Errorf("binary %q not found in archive", binaryName) +} + +// extractBinaryFromTarGz extracts the binary from a tar.gz archive. +func extractBinaryFromTarGz(archivePath, destPath string) error { f, err := os.Open(archivePath) if err != nil { return fmt.Errorf("opening archive: %w", err) From 112f3760ee74987bdc010e93207e224c6b871e9f Mon Sep 17 00:00:00 2001 From: jkbennitt Date: Fri, 20 Mar 2026 10:01:58 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20feedback=20=E2=80=94?= =?UTF-8?q?=20zip=20test=20coverage=20and=20nullglob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TestExtractBinaryFromZip and TestExtractBinaryFromZipNotFound to cover the new zip extraction path. Use shopt nullglob in the release checksum step so sha256sum doesn't error if a glob matches nothing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 4 +- internal/updater/updater_test.go | 90 ++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f48fd49..849cce5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,9 @@ jobs: - name: Generate checksums working-directory: dist - run: sha256sum *.tar.gz *.zip > checksums.txt + run: | + shopt -s nullglob + sha256sum *.tar.gz *.zip > checksums.txt - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 1d86696..ca4b9f6 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -2,6 +2,7 @@ package updater import ( "archive/tar" + "archive/zip" "compress/gzip" "context" "crypto/sha256" @@ -247,6 +248,95 @@ func TestExtractBinaryNotFound(t *testing.T) { } } +func TestExtractBinaryFromZip(t *testing.T) { + tmpDir := t.TempDir() + archivePath := filepath.Join(tmpDir, "test.zip") + destPath := filepath.Join(tmpDir, "mnemonic_extracted") + binaryContent := []byte("#!/bin/sh\necho hello\n") + + binaryName := "mnemonic" + if runtime.GOOS == "windows" { + binaryName = "mnemonic.exe" + } + + // Build the zip archive + f, err := os.Create(archivePath) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + + // Add a non-binary file first + w, err := zw.Create("README.md") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("hello")); err != nil { + t.Fatal(err) + } + + // Add the binary + w, err = zw.Create(binaryName) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(binaryContent); err != nil { + t.Fatal(err) + } + + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + // Extract + if err := extractBinary(archivePath, destPath); err != nil { + t.Fatalf("extractBinary (zip) failed: %v", err) + } + + // Verify content + got, err := os.ReadFile(destPath) + if err != nil { + t.Fatal(err) + } + if string(got) != string(binaryContent) { + t.Errorf("extracted content = %q, want %q", got, binaryContent) + } +} + +func TestExtractBinaryFromZipNotFound(t *testing.T) { + tmpDir := t.TempDir() + archivePath := filepath.Join(tmpDir, "test.zip") + + f, err := os.Create(archivePath) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + + w, err := zw.Create("README.md") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("hello")); err != nil { + t.Fatal(err) + } + + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + err = extractBinary(archivePath, filepath.Join(tmpDir, "out")) + if err == nil { + t.Fatal("expected error when binary not in zip archive") + } +} + func TestVerifyChecksum(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.tar.gz")