Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .execs/scripts/test-win.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@echo off
echo Running Windows batch file test...
echo OS: Windows
echo Batch file execution works correctly.
exit /b 0
5 changes: 5 additions & 0 deletions .execs/scripts/test-win.ps1
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions .execs/windows.flow
Original file line number Diff line number Diff line change
@@ -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
182 changes: 182 additions & 0 deletions .github/workflows/windows-ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion internal/fileparser/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions internal/services/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 3 additions & 1 deletion internal/utils/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion internal/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
47 changes: 32 additions & 15 deletions internal/utils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package utils_test
import (
"os"
"path/filepath"
"runtime"
"testing"

"github.com/flowexec/tuikit/io/mocks"
Expand All @@ -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")
Expand All @@ -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() {
Expand All @@ -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")))
})
})
})
Expand All @@ -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())
})
})
Expand Down Expand Up @@ -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)
}
3 changes: 3 additions & 0 deletions pkg/context/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
7 changes: 1 addition & 6 deletions pkg/filesystem/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading