From 43a219139220e074bbf83e75c7267de031e8b304 Mon Sep 17 00:00:00 2001 From: javi11 Date: Sat, 11 Apr 2026 18:46:55 +0200 Subject: [PATCH 1/5] fix: place folder-mode NZB and PAR2 in a per-folder output subdirectory When Postie processes a watched folder (SingleNzbPerFolder or explicit folder upload), generated NZB and PAR2 files are now placed inside a dedicated // subdirectory instead of directly in the output root. Before this fix, postFolder() used: nzbPath = filepath.Join(outputDir, folderName+".nzb") par2OutputDir = outputDir Both paths now use folderOutputDir = filepath.Join(outputDir, folderName), so the layout becomes: /Movie_A/Movie_A.nzb /Movie_A/*.par2 (when maintain_par2_files=true) This fixes the cross-volume case (watch folder and output folder on different mount points) where files were previously scattered in the output root. The fix covers both the sequential (WaitForPar2=true) and parallel (WaitForPar2=false) code paths. Also bumps Node.js to v22 in CI workflows and adds tests covering: - Sequential and parallel paths with maintain_par2_files=false - Sequential and parallel paths with maintain_par2_files=true (peer files) - Explicit cross-volume path separation scenario --- .github/workflows/dev-build.yml | 4 +- .github/workflows/release.yml | 4 +- pkg/postie/postfolder_test.go | 304 ++++++++++++++++++++++++++++++++ pkg/postie/postie.go | 17 +- 4 files changed, 318 insertions(+), 11 deletions(-) create mode 100644 pkg/postie/postfolder_test.go diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 04ee71c..9b67b17 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -74,7 +74,7 @@ jobs: go-version: 1.26 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Checkout uses: actions/checkout@v4 @@ -159,7 +159,7 @@ jobs: go-version: 1.26 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b0a42e..e752a67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -384,7 +384,7 @@ jobs: go-version: 1.26 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' # checkout - name: Checkout @@ -424,7 +424,7 @@ jobs: go-version: 1.26 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' # checkout - name: Checkout diff --git a/pkg/postie/postfolder_test.go b/pkg/postie/postfolder_test.go new file mode 100644 index 0000000..72b2912 --- /dev/null +++ b/pkg/postie/postfolder_test.go @@ -0,0 +1,304 @@ +package postie + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/javi11/postie/internal/article" + "github.com/javi11/postie/internal/config" + "github.com/javi11/postie/internal/nzb" + "github.com/javi11/postie/internal/poster" + "github.com/javi11/postie/pkg/fileinfo" +) + +// ─── mock poster ──────────────────────────────────────────────────────────── + +type mockPoster struct{} + +func (m *mockPoster) Post(_ context.Context, files []string, _ string, nzbGen nzb.NZBGenerator) error { + addFakeArticles(nzbGen, files) + return nil +} + +func (m *mockPoster) PostWithRelativePaths(_ context.Context, files []string, _ string, nzbGen nzb.NZBGenerator, _ map[string]string) error { + addFakeArticles(nzbGen, files) + return nil +} + +func (m *mockPoster) Stats() poster.Stats { return poster.Stats{} } +func (m *mockPoster) Close() {} + +// addFakeArticles injects one minimal article per file so nzbGen.Generate succeeds. +func addFakeArticles(nzbGen nzb.NZBGenerator, files []string) { + for i, f := range files { + a := &article.Article{ + MessageID: "fake-id@test", + OriginalSubject: "test subject", + OriginalName: filepath.Base(f), + FileName: filepath.Base(f), + From: "poster@test", + Groups: []string{"alt.binaries.test"}, + PartNumber: 1, + TotalParts: 1, + FileNumber: i + 1, + Size: 100, + } + nzbGen.AddArticle(a) + } +} + +// ─── mock PAR2 executor ────────────────────────────────────────────────────── + +type mockPar2Executor struct { + // recordedOutputDir is set on each CreateInDirectory call. + recordedOutputDir string + // par2FileNames are created in outputDir when it is non-empty. + par2FileNames []string +} + +func (m *mockPar2Executor) Create(_ context.Context, _ []fileinfo.FileInfo) ([]string, error) { + return nil, nil +} + +func (m *mockPar2Executor) CreateInDirectory(_ context.Context, _ []fileinfo.FileInfo, outputDir string) ([]string, error) { + m.recordedOutputDir = outputDir + if outputDir == "" || len(m.par2FileNames) == 0 { + return nil, nil + } + if err := os.MkdirAll(outputDir, 0755); err != nil { + return nil, err + } + var created []string + for _, name := range m.par2FileNames { + p := filepath.Join(outputDir, name) + if err := os.WriteFile(p, []byte("dummy"), 0644); err != nil { + return nil, err + } + created = append(created, p) + } + return created, nil +} + +// ─── helpers ──────────────────────────────────────────────────────────────── + +func boolPtr(b bool) *bool { return &b } + +// newTestPostie builds a minimal Postie with the given PAR2 and poster mocks. +func newTestPostie(par2exec *mockPar2Executor, waitForPar2 bool, maintainPar2 bool) *Postie { + return &Postie{ + par2Cfg: &config.Par2Config{ + Enabled: boolPtr(true), + MaintainPar2Files: boolPtr(maintainPar2), + }, + postingCfg: config.PostingConfig{ + WaitForPar2: boolPtr(waitForPar2), + ArticleSizeInBytes: 750_000, + }, + compressionCfg: config.NzbCompressionConfig{Enabled: false}, + par2runner: par2exec, + poster: &mockPoster{}, + } +} + +// makeSourceFiles creates a temporary source folder with a dummy file and returns +// the folder path, the file list, and a cleanup function. +func makeSourceFiles(t *testing.T, watchRoot, folderName, fileName string) ([]fileinfo.FileInfo, func()) { + t.Helper() + folderPath := filepath.Join(watchRoot, folderName) + if err := os.MkdirAll(folderPath, 0755); err != nil { + t.Fatalf("mkdir source folder: %v", err) + } + filePath := filepath.Join(folderPath, fileName) + if err := os.WriteFile(filePath, []byte("content"), 0644); err != nil { + t.Fatalf("write source file: %v", err) + } + files := []fileinfo.FileInfo{{ + Path: filePath, + Size: 7, + RelativePath: folderName + "/" + fileName, + }} + return files, func() { os.RemoveAll(watchRoot) } +} + +// ─── tests ─────────────────────────────────────────────────────────────────── + +// TestPostFolderOutputSubdirectory verifies that postFolder always places the +// NZB inside // regardless of whether the watch folder +// and output folder are on the same or different volume paths. +func TestPostFolderOutputSubdirectory(t *testing.T) { + tests := []struct { + name string + watchRoot string // simulated watch folder root + folderName string + waitForPar2 bool + }{ + { + name: "same-volume paths, sequential (WaitForPar2=true)", + folderName: "Movie_A", + waitForPar2: true, + }, + { + name: "same-volume paths, parallel (WaitForPar2=false)", + folderName: "Movie_A", + waitForPar2: false, + }, + { + name: "cross-volume paths, sequential (WaitForPar2=true)", + folderName: "Movie_A", + waitForPar2: true, + }, + { + name: "cross-volume paths, parallel (WaitForPar2=false)", + folderName: "Movie_A", + waitForPar2: false, + }, + { + name: "folder with nested content", + folderName: "TV.Show.S01", + waitForPar2: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + watchRoot := t.TempDir() + outputDir := t.TempDir() + + files, cleanup := makeSourceFiles(t, watchRoot, tt.folderName, "movie.mkv") + defer cleanup() + + par2mock := &mockPar2Executor{} // maintain_par2_files = false + p := newTestPostie(par2mock, tt.waitForPar2, false) + + // rootDir is the parent of the folder being processed + rootDir := watchRoot + _, err := p.postFolder(context.Background(), files, rootDir, outputDir) + if err != nil { + t.Fatalf("postFolder returned error: %v", err) + } + + wantNZB := filepath.Join(outputDir, tt.folderName, tt.folderName+".nzb") + if _, err := os.Stat(wantNZB); os.IsNotExist(err) { + t.Errorf("NZB not found at expected path %q", wantNZB) + } + + // NZB must NOT be in the output root (old broken behaviour) + wrongNZB := filepath.Join(outputDir, tt.folderName+".nzb") + if _, err := os.Stat(wrongNZB); err == nil { + t.Errorf("NZB found at old (incorrect) path %q — should be in subfolder", wrongNZB) + } + }) + } +} + +// TestPostFolderMaintainPar2FilesSubdirectory verifies that when maintain_par2_files +// is enabled ("nzb peer file" mode), PAR2 files are also placed in the same +// // subdirectory as the NZB — not in the output root. +func TestPostFolderMaintainPar2FilesSubdirectory(t *testing.T) { + tests := []struct { + name string + folderName string + waitForPar2 bool + par2Names []string + }{ + { + name: "maintain_par2_files enabled, sequential (WaitForPar2=true)", + folderName: "Movie_A", + waitForPar2: true, + par2Names: []string{"movie.mkv.par2", "movie.mkv.vol0+1.par2"}, + }, + { + name: "maintain_par2_files enabled, parallel (WaitForPar2=false)", + folderName: "Movie_A", + waitForPar2: false, + par2Names: []string{"movie.mkv.par2", "movie.mkv.vol0+1.par2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + watchRoot := t.TempDir() + outputDir := t.TempDir() + + files, cleanup := makeSourceFiles(t, watchRoot, tt.folderName, "movie.mkv") + defer cleanup() + + par2mock := &mockPar2Executor{par2FileNames: tt.par2Names} + p := newTestPostie(par2mock, tt.waitForPar2, true) // maintainPar2=true + + rootDir := watchRoot + _, err := p.postFolder(context.Background(), files, rootDir, outputDir) + if err != nil { + t.Fatalf("postFolder returned error: %v", err) + } + + wantSubdir := filepath.Join(outputDir, tt.folderName) + + // NZB must be in the subfolder + wantNZB := filepath.Join(wantSubdir, tt.folderName+".nzb") + if _, err := os.Stat(wantNZB); os.IsNotExist(err) { + t.Errorf("NZB not found at expected subfolder path %q", wantNZB) + } + + // PAR2 executor must have received the subfolder as outputDir + if par2mock.recordedOutputDir != wantSubdir { + t.Errorf("par2 executor received outputDir=%q, want %q", + par2mock.recordedOutputDir, wantSubdir) + } + + // Each PAR2 file must be inside the subfolder, not in the output root + for _, name := range tt.par2Names { + wantPar2 := filepath.Join(wantSubdir, name) + if _, err := os.Stat(wantPar2); os.IsNotExist(err) { + t.Errorf("PAR2 file %q not found in expected subfolder", wantPar2) + } + wrongPar2 := filepath.Join(outputDir, name) + if _, err := os.Stat(wrongPar2); err == nil { + t.Errorf("PAR2 file %q found in output root (should be in subfolder)", wrongPar2) + } + } + }) + } +} + +// TestPostFolderCrossVolumePathSeparation verifies the specific cross-volume +// scenario: watch folder on one "volume" path and output on another. +// The key invariant is that rootDir and files[0].Path share a prefix that is +// NOT a prefix of outputDir — simulating different disk volumes. +func TestPostFolderCrossVolumePathSeparation(t *testing.T) { + // Simulate cross-volume by using two completely independent temp dirs + // (on the same real host FS, but with no shared path prefix after the + // OS temp root, mimicking the cross-volume case at the path-string level). + vol3Watch := t.TempDir() // simulates /volume3/Watch + vol2Output := t.TempDir() // simulates /volume2/output + + const folderName = "Movie_A" + files, cleanup := makeSourceFiles(t, vol3Watch, folderName, "movie.mkv") + defer cleanup() + + par2mock := &mockPar2Executor{} + p := newTestPostie(par2mock, false, false) + + _, err := p.postFolder(context.Background(), files, vol3Watch, vol2Output) + if err != nil { + t.Fatalf("postFolder returned error: %v", err) + } + + wantNZB := filepath.Join(vol2Output, folderName, folderName+".nzb") + if _, err := os.Stat(wantNZB); os.IsNotExist(err) { + t.Errorf("cross-volume: NZB not found at %q", wantNZB) + } + + // Confirm nothing leaked into the output root + entries, _ := os.ReadDir(vol2Output) + for _, e := range entries { + if !e.IsDir() { + t.Errorf("unexpected file in output root: %q (all files should be in subfolder)", e.Name()) + } + if e.IsDir() && e.Name() != folderName { + t.Errorf("unexpected directory in output root: %q", e.Name()) + } + } +} diff --git a/pkg/postie/postie.go b/pkg/postie/postie.go index 332043e..5be20f9 100644 --- a/pkg/postie/postie.go +++ b/pkg/postie/postie.go @@ -440,6 +440,9 @@ func (p *Postie) postFolder(ctx context.Context, files []fileinfo.FileInfo, root startTime := time.Now() folderName := deriveFolderName(rootDir, files) + // All generated files (NZB and PAR2) go into a dedicated subfolder named after + // the source folder, regardless of whether watch and output are on the same volume. + folderOutputDir := filepath.Join(outputDir, folderName) slog.InfoContext(ctx, "Posting folder as single NZB", "folder", folderName, "files", len(files)) @@ -493,8 +496,8 @@ func (p *Postie) postFolder(ctx context.Context, files []fileinfo.FileInfo, root // Determine PAR2 output directory based on maintain_par2_files setting var par2OutputDir string if p.par2Cfg.MaintainPar2Files != nil && *p.par2Cfg.MaintainPar2Files { - // For folder posting, PAR2 files go directly in outputDir - par2OutputDir = outputDir + // For folder posting, PAR2 files go into the folder-specific output subdirectory + par2OutputDir = folderOutputDir slog.DebugContext(ctx, "Generating PAR2 files directly in output directory", "folder", folderName, "outputDir", par2OutputDir) @@ -530,7 +533,7 @@ func (p *Postie) postFolder(ctx context.Context, files []fileinfo.FileInfo, root } // Generate NZB and return with deferred error if present - nzbPath := filepath.Join(outputDir, folderName+".nzb") + nzbPath := filepath.Join(folderOutputDir, folderName+".nzb") finalPath, nzbErr := nzbGen.Generate(nzbPath) if nzbErr != nil { return "", fmt.Errorf("error generating NZB file for folder: %w", nzbErr) @@ -564,8 +567,8 @@ func (p *Postie) postFolder(ctx context.Context, files []fileinfo.FileInfo, root // Determine PAR2 output directory based on maintain_par2_files setting var par2OutputDir string if p.par2Cfg.MaintainPar2Files != nil && *p.par2Cfg.MaintainPar2Files { - // For folder posting, PAR2 files go directly in outputDir - par2OutputDir = outputDir + // For folder posting, PAR2 files go into the folder-specific output subdirectory + par2OutputDir = folderOutputDir slog.DebugContext(ctx, "Generating PAR2 files directly in output directory", "folder", folderName, "outputDir", par2OutputDir) @@ -612,8 +615,8 @@ func (p *Postie) postFolder(ctx context.Context, files []fileinfo.FileInfo, root } // Generate single NZB file for the entire folder - // Use folder name as the base for NZB filename - nzbPath := filepath.Join(outputDir, folderName+".nzb") + // Use folder name as the base for NZB filename, placed inside the folder-specific output subdir + nzbPath := filepath.Join(folderOutputDir, folderName+".nzb") finalPath, err := nzbGen.Generate(nzbPath) if err != nil { return "", fmt.Errorf("error generating NZB file for folder: %w", err) From a6ee8d84a786fcda872585be0f93d228800583ed Mon Sep 17 00:00:00 2001 From: javi11 Date: Sat, 11 Apr 2026 19:04:32 +0200 Subject: [PATCH 2/5] ci: update Docker actions to latest major versions and Zig to 0.15.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump docker/setup-buildx-action v3→v4, docker/login-action v3→v4, docker/build-push-action v5→v6, docker/metadata-action v5→v6, and Zig compiler 0.14.1→0.15.1 across dev-build.yml and release.yml. --- .github/workflows/dev-build.yml | 14 +++++++------- .github/workflows/release.yml | 20 ++++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 9b67b17..fe6c588 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -203,17 +203,17 @@ jobs: path: frontend/build/ - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Docker login - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push AMD64 image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile.ci @@ -244,17 +244,17 @@ jobs: path: frontend/build/ - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Docker login - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push ARM64 image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile.ci @@ -274,7 +274,7 @@ jobs: packages: write steps: - name: Docker login - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e752a67..df72752 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -137,7 +137,7 @@ jobs: if: matrix.goos == 'linux' && matrix.goarch == 'arm64' uses: mlugg/setup-zig@v2 with: - version: 0.14.1 + version: 0.15.1 # Extract version info - name: Extract version info @@ -474,11 +474,11 @@ jobs: path: frontend/build/ - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # docker login - name: Docker login - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -487,7 +487,7 @@ jobs: # Extract metadata for tags and labels - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -497,7 +497,7 @@ jobs: # build and push amd64 image - name: Build and push AMD64 image id: build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile.ci @@ -531,11 +531,11 @@ jobs: path: frontend/build/ - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # docker login - name: Docker login - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -544,7 +544,7 @@ jobs: # Extract metadata for tags and labels - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -554,7 +554,7 @@ jobs: # build and push arm64 image - name: Build and push ARM64 image id: build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile.ci @@ -582,7 +582,7 @@ jobs: # docker login - name: Docker login - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} From 597a0c8aed0ecee4bb39b5e4b0d59921ec35c3d8 Mon Sep 17 00:00:00 2001 From: javi11 Date: Sat, 11 Apr 2026 19:08:40 +0200 Subject: [PATCH 3/5] ci: remove snap-based chromium-browser install in e2e workflow ubuntu-latest (Ubuntu 24.04) installs chromium-browser as a snap wrapper which fails in CI due to snap store timeouts. The GitHub Actions runner already ships google-chrome-stable, which the e2e helpers already prefer as the first candidate. --- .github/workflows/e2e.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b408086..053dfab 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -25,8 +25,5 @@ jobs: - name: Build web binary run: go build -o ./bin/postie-web ./cmd/web - - name: Install Chromium - run: sudo apt-get install -y chromium-browser - - name: Run E2E tests run: go test -v -tags e2e -timeout 120s ./tests/e2e/... From 8f1455e30a7b3d954af92ca6ebf4b38d6f78a6c7 Mon Sep 17 00:00:00 2001 From: javi11 Date: Sat, 11 Apr 2026 19:16:04 +0200 Subject: [PATCH 4/5] fix(e2e): wait for tab visibility before clicking to avoid config-hydration race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openSettingsTab was using WaitReady(body)+Sleep(300ms) before clicking the target tab. In CI, the Svelte config fetch + {#if enabled} render cycle can exceed 300ms, leaving the tab-panel content un-rendered when WaitVisible polls for elements inside it. Replace the fixed sleep with WaitVisible(tabSelector) which blocks until the page has fully hydrated and the tab input itself is in the DOM — a reliable signal that all tab-panel content is rendered. --- tests/e2e/helpers_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/helpers_test.go b/tests/e2e/helpers_test.go index 7ce356b..e629730 100644 --- a/tests/e2e/helpers_test.go +++ b/tests/e2e/helpers_test.go @@ -129,10 +129,12 @@ func newChromedpCtx(t *testing.T) (context.Context, context.CancelFunc) { // tabLabel must match the aria-label of the DaisyUI radio tab input. func openSettingsTab(_ context.Context, tabLabel string) chromedp.Tasks { tabSelector := `input[role="tab"][aria-label="` + tabLabel + `"]` + // Wait for the tab radio itself to appear — this confirms the settings page + // has fully hydrated and the Svelte config fetch has completed, so any + // {#if enabled} blocks inside the tab panel are already rendered. return chromedp.Tasks{ chromedp.Navigate(baseURL + "/settings"), - chromedp.WaitReady("body"), - chromedp.Sleep(300 * time.Millisecond), + chromedp.WaitVisible(tabSelector, chromedp.ByQuery), chromedp.Click(tabSelector, chromedp.ByQuery), chromedp.Sleep(200 * time.Millisecond), } From b931e0b9bbd1caf8c88ac5956e0ce5569143d603 Mon Sep 17 00:00:00 2001 From: javi11 Date: Sat, 11 Apr 2026 19:35:59 +0200 Subject: [PATCH 5/5] ci: statically link MinGW runtimes in Windows GUI builds The Windows GUI (Wails) build was missing MSYS2 setup and CGO_LDFLAGS, causing the executable to depend on libgcc_s_seh-1.dll, libstdc++-6.dll, and libwinpthread-1.dll at runtime. Add the same static linking flags already used by the CLI build to both release.yml and dev-build.yml. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/dev-build.yml | 10 ++++++++++ .github/workflows/release.yml | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index fe6c588..33808ae 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -81,6 +81,14 @@ jobs: with: fetch-depth: 0 + # Set up MSYS2 (needed for static linking of MinGW runtimes) + - name: Set up MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + install: mingw-w64-x86_64-gcc + update: false + - name: Install Wails run: go install github.com/wailsapp/wails/v2/cmd/wails@latest @@ -91,6 +99,8 @@ jobs: run: wails build -platform windows/amd64 -ldflags="-extldflags=-static" env: CGO_ENABLED: 1 + CC: gcc + CGO_LDFLAGS: '-static-libgcc -static-libstdc++ -Wl,-Bstatic -lpthread -Wl,-Bdynamic' - name: Upload Windows GUI artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df72752..4ba2ac5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -392,6 +392,14 @@ jobs: with: fetch-depth: 0 + # Set up MSYS2 on Windows (needed for static linking of MinGW runtimes) + - name: Set up MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + install: mingw-w64-x86_64-gcc + update: false + # Install Wails (version from wails.json) - name: Install Wails run: go install github.com/wailsapp/wails/v2/cmd/wails@latest @@ -405,6 +413,8 @@ jobs: run: wails build -platform windows/amd64 -ldflags="-extldflags=-static" env: CGO_ENABLED: 1 + CC: gcc + CGO_LDFLAGS: '-static-libgcc -static-libstdc++ -Wl,-Bstatic -lpthread -Wl,-Bdynamic' # Upload Windows artifact - name: Upload Windows GUI artifact