From fab57dd93a50e064ce54be3d30efde004a0b64ce Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Thu, 30 Apr 2026 10:35:08 -0400 Subject: [PATCH 1/4] ci: add windows validation --- .execs/scripts/test-win.bat | 5 + .execs/scripts/test-win.ps1 | 5 + .execs/windows.flow | 27 +++++ .github/workflows/windows-ci.yml | 182 +++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 .execs/scripts/test-win.bat create mode 100644 .execs/scripts/test-win.ps1 create mode 100644 .execs/windows.flow create mode 100644 .github/workflows/windows-ci.yml diff --git a/.execs/scripts/test-win.bat b/.execs/scripts/test-win.bat new file mode 100644 index 00000000..451ff54d --- /dev/null +++ b/.execs/scripts/test-win.bat @@ -0,0 +1,5 @@ +@echo off +echo Running Windows batch file test... +echo OS: Windows +echo Batch file execution works correctly. +exit /b 0 diff --git a/.execs/scripts/test-win.ps1 b/.execs/scripts/test-win.ps1 new file mode 100644 index 00000000..757927ff --- /dev/null +++ b/.execs/scripts/test-win.ps1 @@ -0,0 +1,5 @@ +Write-Host "Running Windows PowerShell file test..." +Write-Host "OS: $([System.Environment]::OSVersion.Platform)" +Write-Host "PowerShell version: $($PSVersionTable.PSVersion)" +Write-Host "PowerShell file execution works correctly." +exit 0 diff --git a/.execs/windows.flow b/.execs/windows.flow new file mode 100644 index 00000000..82b386e0 --- /dev/null +++ b/.execs/windows.flow @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://flowexec.io/schemas/flowfile_schema.json +visibility: private +tags: [development, test, windows] +executables: + - verb: test + name: windows-scripts + description: Test Windows-native script file execution (.bat and .ps1) + serial: + execs: + - ref: test windows-bat + name: batch file execution + - ref: test windows-ps1 + name: powershell file execution + + - verb: test + name: windows-bat + description: Test .bat file execution via cmd.exe + exec: + dir: // + file: .execs/scripts/test-win.bat + + - verb: test + name: windows-ps1 + description: Test .ps1 file execution via pwsh/powershell + exec: + dir: // + file: .execs/scripts/test-win.ps1 diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml new file mode 100644 index 00000000..3d3ab7b4 --- /dev/null +++ b/.github/workflows/windows-ci.yml @@ -0,0 +1,182 @@ +name: Windows CI + +on: + pull_request: + types: [labeled, synchronize, reopened] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + name: "Test / unit tests (Windows)" + runs-on: windows-latest + if: > + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'test:windows') + steps: + - uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "^1.25" + cache: true + - name: Run unit tests + uses: flowexec/action@v1 + with: + executable: 'test unit' + params: 'CI=true' + timeout: '5m' + flow-version: 'v2.0.0-beta.3' + - name: Upload unit test coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: windows-unit-coverage + path: unit-coverage.out + + e2e-tests: + name: "Test / E2E tests (Windows)" + runs-on: windows-latest + if: > + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'test:windows') + steps: + - uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "^1.25" + cache: true + - name: Run E2E tests + uses: flowexec/action@v1 + with: + executable: 'test e2e' + params: 'CI=true' + timeout: '10m' + flow-version: 'v2.0.0-beta.3' + secrets: | + test-secret=test-value-from-action + another-secret=another-test-value + - name: Upload E2E test coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: windows-e2e-coverage + path: e2e-coverage.out + + build-binary: + name: "Build / Windows binary" + runs-on: windows-latest + if: > + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'test:windows') + steps: + - uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "^1.25" + cache: true + - name: Build flow binary + uses: flowexec/action@v1 + with: + executable: 'build binary' + timeout: '10m' + flow-version: 'v2.0.0-beta.3' + - name: Upload built binary + uses: actions/upload-artifact@v7 + with: + name: windows-flow-binary + path: .bin/flow.exe + retention-days: 1 + + binary-smoke: + name: "Test / binary smoke test (Windows)" + runs-on: windows-latest + needs: build-binary + steps: + - uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "^1.25" + cache: true + - name: Download built binary + uses: actions/download-artifact@v8 + with: + name: windows-flow-binary + path: .bin + - name: Run binary smoke test + uses: flowexec/action@v1 + with: + executable: 'test binary' + timeout: '5m' + flow-version: 'v2.0.0-beta.3' + + native-scripts: + name: "Test / native script execution (Windows)" + runs-on: windows-latest + if: > + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'test:windows') + steps: + - uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "^1.25" + cache: true + - name: Run .bat and .ps1 file execution tests + uses: flowexec/action@v1 + with: + executable: 'test windows-scripts' + timeout: '5m' + flow-version: 'v2.0.0-beta.3' + + windows-validation-complete: + name: "Windows validation complete" + runs-on: ubuntu-latest + needs: [unit-tests, e2e-tests, build-binary, binary-smoke, native-scripts] + if: always() + steps: + - name: Evaluate results and write summary + shell: bash + run: | + unit="${{ needs.unit-tests.result }}" + e2e="${{ needs.e2e-tests.result }}" + build="${{ needs.build-binary.result }}" + smoke="${{ needs.binary-smoke.result }}" + scripts="${{ needs.native-scripts.result }}" + + { + echo "## Windows CI Summary" + echo "" + echo "| Job | Result |" + echo "|-----|--------|" + echo "| Unit tests | $unit |" + echo "| E2E tests | $e2e |" + echo "| Build binary | $build |" + echo "| Binary smoke | $smoke |" + echo "| Native script execution | $scripts |" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + failed=0 + for result in "$unit" "$e2e" "$build" "$smoke" "$scripts"; do + if [[ "$result" != "success" && "$result" != "skipped" ]]; then + failed=1 + fi + done + + if [ "$failed" -eq 0 ]; then + echo "All Windows validation jobs passed." >> "$GITHUB_STEP_SUMMARY" + else + echo "One or more Windows validation jobs failed." >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi From 85c90e40ad066f74d3cedbbfcfa51cb3d9c02502 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Thu, 30 Apr 2026 10:56:10 -0400 Subject: [PATCH 2/4] bug fixes --- internal/fileparser/config.go | 4 +++- tests/utils/context.go | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/fileparser/config.go b/internal/fileparser/config.go index 619a2563..028a3449 100644 --- a/internal/fileparser/config.go +++ b/internal/fileparser/config.go @@ -52,8 +52,10 @@ func ExtractExecConfig(data, prefix string) (*ParseResult, error) { Params: make(executable.ParameterList, 0), Args: make(executable.ArgumentList, 0), } + data = strings.ReplaceAll(data, "\r\n", "\n") + data = strings.ReplaceAll(data, "\r", "\n") processingMultiLineDescription := false - for _, line := range strings.Split(data, "\n") { + for line := range strings.SplitSeq(data, "\n") { isComment := strings.HasPrefix(line, strings.TrimSpace(prefix)) if trimmedLine := strings.TrimSpace(line); !isComment && trimmedLine != "" { // If the line is not a comment or empty, break out of the loop. diff --git a/tests/utils/context.go b/tests/utils/context.go index c3014d33..40857335 100644 --- a/tests/utils/context.go +++ b/tests/utils/context.go @@ -232,6 +232,12 @@ func createTempIOFiles(tb testing.TB) (stdIn *os.File, stdOut *os.File) { if err != nil { tb.Fatalf("unable to create temp file: %v", err) } + // Registered after tb.TempDir() so it runs first (LIFO), ensuring handles + // are closed before the temp directories are removed on Windows. + tb.Cleanup(func() { + _ = stdOut.Close() + _ = stdIn.Close() + }) return } From 290a51954939fcf0d51edee63490e97d6a72c156 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Thu, 30 Apr 2026 11:29:36 -0400 Subject: [PATCH 3/4] improve support --- internal/services/git/git.go | 1 + internal/utils/utils.go | 3 ++- internal/utils/utils_test.go | 45 +++++++++++++++++++++++---------- pkg/filesystem/config.go | 7 +---- pkg/filesystem/logs_test.go | 3 ++- tests/git_workspace_e2e_test.go | 13 +++++++++- 6 files changed, 49 insertions(+), 23 deletions(-) diff --git a/internal/services/git/git.go b/internal/services/git/git.go index a60c767b..7a1e51bd 100644 --- a/internal/services/git/git.go +++ b/internal/services/git/git.go @@ -44,6 +44,7 @@ func ClonePath(gitURL string) (string, error) { } repoPath = strings.TrimSuffix(repoPath, ".git") repoPath = strings.TrimPrefix(repoPath, "/") + repoPath = strings.ReplaceAll(repoPath, ":", "_") // sanitize Windows drive-letter colons return filepath.Join(filesystem.CachedDataDirPath(), "git-workspaces", host, repoPath), nil } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index be072ca8..3224d17e 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -73,7 +73,8 @@ func ExpandPath(path, fallbackDir string, env map[string]string) string { func ExpandDirectory(dir, wsPath, execPath string, env map[string]string) string { expandedPath := dir if wsPath != "" && strings.HasPrefix(dir, "//") { - expandedPath = strings.Replace(expandedPath, "//", wsPath+string(filepath.Separator), 1) + rest := strings.TrimPrefix(dir, "//") + expandedPath = filepath.Join(wsPath, filepath.FromSlash(rest)) } expandedPath = ExpandPath(expandedPath, filepath.Dir(execPath), env) diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 366516ce..0546b1a0 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -3,6 +3,7 @@ package utils_test import ( "os" "path/filepath" + "runtime" "testing" "github.com/flowexec/tuikit/io/mocks" @@ -20,13 +21,11 @@ func TestUtils(t *testing.T) { } var _ = Describe("Utils", func() { - const ( - testHomeDir = "/Users/testuser" - wsDir = "/workspace" - execDir = "/execPath" - ) - var ( + testHomeDir = ap("/Users/testuser") + wsDir = ap("/workspace") + execDir = ap("/execPath") + mockLogger *mocks.MockLogger testWorkingDir, _ = os.UserConfigDir() execPath = filepath.Join(execDir, "exec.flow") @@ -39,6 +38,7 @@ var _ = Describe("Utils", func() { logger.Init(logger.InitOptions{Logger: mockLogger, TestingTB: GinkgoTB()}) Expect(os.Chdir(testWorkingDir)).To(Succeed()) Expect(os.Setenv("HOME", testHomeDir)).To(Succeed()) + Expect(os.Setenv("USERPROFILE", testHomeDir)).To(Succeed()) }) Describe("ExpandDirectory", func() { @@ -52,28 +52,28 @@ var _ = Describe("Utils", func() { Entry("dir is .", ".", testWorkingDir), Entry("dir starts with ./", "./dir", filepath.Join(testWorkingDir, "dir")), Entry("dir starts with ~/", "~/dir", filepath.Join(testHomeDir, "dir")), - Entry("dir starts with /", "/dir", "/dir"), + Entry("dir starts with /", ap("/dir"), ap("/dir")), Entry("default case", "dir", filepath.Join(execDir, "dir")), - Entry("hidden dir with extension-like name", "/path/.config", "/path/.config"), - Entry("file with extension returns parent dir", "/path/file.txt", "/path"), + Entry("hidden dir with extension-like name", ap("/path/.config"), ap("/path/.config")), + Entry("file with extension returns parent dir", ap("/path/file.txt"), ap("/path")), ) When("env vars are in the dir", func() { It("expands the env vars", func() { envMap := map[string]string{"VAR1": "one", "VAR2": "two"} - Expect(utils.ExpandDirectory("/${VAR1}/${VAR2}", wsDir, execPath, envMap)). - To(Equal("/one/two")) + Expect(utils.ExpandDirectory(ap("/${VAR1}/${VAR2}"), wsDir, execPath, envMap)). + To(Equal(ap("/one/two"))) }) It("expands the env vars with a ws prefix", func() { envMap := map[string]string{"VAR1": "one"} Expect(utils.ExpandDirectory("//dir/${VAR1}", wsDir, execPath, envMap)). - To(Equal("/workspace/dir/one")) + To(Equal(filepath.Join(wsDir, "dir", "one"))) }) It("logs a warning if the env var is not found", func() { envMap := map[string]string{"VAR1": "one"} mockLogger.EXPECT().Warn("unable to find env key in path expansion", "key", "VAR2") - Expect(utils.ExpandDirectory("/${VAR1}/${VAR2}", wsDir, execPath, envMap)). - To(Equal("/one")) + Expect(utils.ExpandDirectory(ap("/${VAR1}/${VAR2}"), wsDir, execPath, envMap)). + To(Equal(ap("/one"))) }) }) }) @@ -145,3 +145,20 @@ var _ = Describe("Utils", func() { ) }) }) + +// vol is the volume name of the working directory on Windows (e.g. "C:"). +// Empty on all other platforms. +var vol = func() string { + if runtime.GOOS == "windows" { + wd, _ := os.Getwd() + return filepath.VolumeName(wd) + } + return "" +}() + +// ap (absolute path) converts a POSIX-style absolute path such as "/foo/bar" +// into a native absolute path. On Windows it prepends the current volume so +// that filepath.IsAbs returns true; on other platforms it is a no-op. +func ap(p string) string { + return filepath.FromSlash(vol + p) +} diff --git a/pkg/filesystem/config.go b/pkg/filesystem/config.go index a751de3f..fa241cc1 100644 --- a/pkg/filesystem/config.go +++ b/pkg/filesystem/config.go @@ -50,12 +50,7 @@ func InitConfig() error { DefaultLogMode: "logfmt", } - _, err := os.Create(UserConfigFilePath()) - if err != nil { - return errors.Wrap(err, "unable to create config file") - } - err = WriteConfig(defaultCfg) - if err != nil { + if err := WriteConfig(defaultCfg); err != nil { return errors.Wrap(err, "unable to write default config") } return nil diff --git a/pkg/filesystem/logs_test.go b/pkg/filesystem/logs_test.go index b2cdac1b..64f3d1fb 100644 --- a/pkg/filesystem/logs_test.go +++ b/pkg/filesystem/logs_test.go @@ -2,6 +2,7 @@ package filesystem_test import ( "os" + "path/filepath" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -33,7 +34,7 @@ var _ = Describe("Logs", func() { Describe("LogsDir", func() { It("returns the correct logs directory path", func() { logsDir := filesystem.LogsDir() - Expect(logsDir).To(Equal(tmpDir + "/logs")) + Expect(logsDir).To(Equal(filepath.Join(tmpDir, "logs"))) }) }) diff --git a/tests/git_workspace_e2e_test.go b/tests/git_workspace_e2e_test.go index e0beafc1..1af6ed5e 100644 --- a/tests/git_workspace_e2e_test.go +++ b/tests/git_workspace_e2e_test.go @@ -34,7 +34,7 @@ var _ = Describe("git workspace e2e", Ordered, func() { // Create a local bare git repo with a flow.yaml as a test fixture. bareRepoDir = initBareRepo(GinkgoTB()) - bareRepoURL = "file://" + bareRepoDir + bareRepoURL = localFileURL(bareRepoDir) }) BeforeEach(func() { @@ -142,6 +142,17 @@ var _ = Describe("git workspace e2e", Ordered, func() { }) }) +// localFileURL converts an absolute filesystem path to a file:// URL that is +// valid on all platforms. On Windows, the drive-letter path (e.g. C:\foo) is +// converted to file:///C:/foo; on Unix the result is file:///path. +func localFileURL(absPath string) string { + slashed := filepath.ToSlash(absPath) + if slashed[0] != '/' { + slashed = "/" + slashed + } + return "file://" + slashed +} + // initBareRepo creates a local bare git repo with a flow.yaml file, // suitable for use as a test "remote" without any network calls. func initBareRepo(tb testing.TB) string { From 894a9dbb2ce6430d77d9357b3d9e64861286c443 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Thu, 30 Apr 2026 11:40:37 -0400 Subject: [PATCH 4/4] improve support --- internal/utils/env/env.go | 4 +++- internal/utils/utils_test.go | 2 +- pkg/context/context_test.go | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/utils/env/env.go b/internal/utils/env/env.go index 4e13652a..3a2023ad 100644 --- a/internal/utils/env/env.go +++ b/internal/utils/env/env.go @@ -277,7 +277,9 @@ func createEnvValueFile(destination, content, wsPath, flowFileDir string, envMap return "", fmt.Errorf("failed to create directory for temp file: %w", err) } - filename := filepath.Base(destination) + // Strip one leading slash before calling filepath.Base: on Windows, + // paths starting with "//" are parsed as UNC paths, causing Base("//x") → ".". + filename := filepath.Base(strings.TrimPrefix(destination, "/")) dest := filepath.Clean(filepath.Join(destDir, filename)) if err := os.WriteFile(dest, []byte(content), 0600); err != nil { return "", fmt.Errorf("failed to write temp file: %w", err) diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 0546b1a0..cf92f29a 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -96,7 +96,7 @@ var _ = Describe("Utils", func() { When("path is a sibling directory", func() { It("returns the relative path", func() { result, err := utils.PathFromWd(filepath.Join(filepath.Dir(testWorkingDir), "sibling")) - Expect(result).To(Equal("../sibling")) + Expect(result).To(Equal(filepath.Join("..", "sibling"))) Expect(err).ToNot(HaveOccurred()) }) }) diff --git a/pkg/context/context_test.go b/pkg/context/context_test.go index bfb54323..c9b73bb0 100644 --- a/pkg/context/context_test.go +++ b/pkg/context/context_test.go @@ -39,6 +39,9 @@ var _ = ginkgo.Describe("Context", func() { }) ginkgo.AfterEach(func() { + // On Windows the process cannot delete a directory it is cwd'd into, + // so navigate away first. + _ = os.Chdir(os.TempDir()) _ = os.RemoveAll(tmpDir) })