Skip to content

Commit e92466c

Browse files
fix: make rules lock cross-platform for Windows release builds (#252)
Split acquireRulesLock into OS-specific implementations with build tags. Unix keeps syscall.Flock, Windows uses O_CREATE|O_EXCL lockfile. Add CI cross-build check for Windows amd64/arm64 with enterprise tag.
1 parent 9545bbb commit e92466c

5 files changed

Lines changed: 106 additions & 23 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,8 @@ jobs:
121121
122122
- name: Verify enterprise binary runs
123123
run: ./pipelock-enterprise --version
124+
125+
- name: Cross-build Windows binaries (enterprise)
126+
run: |
127+
GOOS=windows GOARCH=amd64 go build -tags enterprise -trimpath -o /tmp/pipelock-windows-amd64.exe ./cmd/pipelock
128+
GOOS=windows GOARCH=arm64 go build -tags enterprise -trimpath -o /tmp/pipelock-windows-arm64.exe ./cmd/pipelock

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- Windows release builds: `pipelock rules` now uses an OS-specific lock implementation so the CLI cross-compiles cleanly for Windows targets. CI now cross-builds the enterprise CLI for Windows to catch release-only breakage before tagging.
12+
1013
## [1.4.0] - 2026-03-17
1114

1215
### Added

internal/cli/rules.go

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import (
1616
"os"
1717
"path/filepath"
1818
"strings"
19-
"syscall"
2019
"time"
2120

2221
"github.com/spf13/cobra"
@@ -79,28 +78,9 @@ func rulesCmd() *cobra.Command {
7978
return cmd
8079
}
8180

82-
// acquireRulesLock acquires an advisory file lock for mutating operations.
83-
// Returns a release function and an error. The caller must call the release
84-
// function when done.
85-
func acquireRulesLock(rulesDir string) (func(), error) {
86-
lockPath := filepath.Join(rulesDir, ".rules.lock")
87-
88-
f, err := os.OpenFile(filepath.Clean(lockPath), os.O_CREATE|os.O_RDWR, 0o600)
89-
if err != nil {
90-
return nil, fmt.Errorf("opening lock file: %w", err)
91-
}
92-
93-
fd := int(f.Fd()) //nolint:gosec // G115: file descriptors always fit in int
94-
if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
95-
_ = f.Close()
96-
return nil, fmt.Errorf("another rules command is running (lock: %s)", lockPath)
97-
}
98-
99-
return func() {
100-
_ = syscall.Flock(fd, syscall.LOCK_UN)
101-
_ = f.Close()
102-
}, nil
103-
}
81+
// acquireRulesLock is defined in rules_lock_unix.go and rules_lock_windows.go.
82+
// It acquires an advisory file lock for mutating operations. Returns a release
83+
// function and an error. The caller must call the release function when done.
10484

10585
// ensureDir creates a directory with 0o750 permissions if it does not exist.
10686
func ensureDir(path string) error {

internal/cli/rules_lock_unix.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright 2026 Josh Waldrep
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//go:build !windows
5+
6+
package cli
7+
8+
import (
9+
"errors"
10+
"fmt"
11+
"os"
12+
"path/filepath"
13+
"syscall"
14+
)
15+
16+
// acquireRulesLock acquires an advisory file lock using flock(2).
17+
// Returns a release function and an error.
18+
func acquireRulesLock(rulesDir string) (func(), error) {
19+
lockPath := filepath.Join(rulesDir, ".rules.lock")
20+
21+
f, err := os.OpenFile(filepath.Clean(lockPath), os.O_CREATE|os.O_RDWR, 0o600)
22+
if err != nil {
23+
return nil, fmt.Errorf("opening lock file: %w", err)
24+
}
25+
26+
fd := int(f.Fd()) //nolint:gosec // G115: file descriptors always fit in int
27+
if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
28+
_ = f.Close()
29+
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
30+
return nil, fmt.Errorf("another rules command is running (lock: %s)", lockPath)
31+
}
32+
return nil, fmt.Errorf("acquiring rules lock: %w", err)
33+
}
34+
35+
return func() {
36+
_ = syscall.Flock(fd, syscall.LOCK_UN)
37+
_ = f.Close()
38+
}, nil
39+
}

internal/cli/rules_lock_windows.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright 2026 Josh Waldrep
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//go:build windows
5+
6+
package cli
7+
8+
import (
9+
"fmt"
10+
"os"
11+
"path/filepath"
12+
"syscall"
13+
)
14+
15+
const (
16+
errSharingViolation = syscall.Errno(32)
17+
errLockViolation = syscall.Errno(33)
18+
)
19+
20+
// acquireRulesLock acquires an exclusive lock file handle for mutating
21+
// operations. On Windows, opening a file with share mode 0 prevents other
22+
// processes from opening the same path until the handle is closed.
23+
func acquireRulesLock(rulesDir string) (func(), error) {
24+
lockPath := filepath.Join(rulesDir, ".rules.lock")
25+
26+
pathp, err := syscall.UTF16PtrFromString(lockPath)
27+
if err != nil {
28+
return nil, fmt.Errorf("encoding lock file path: %w", err)
29+
}
30+
31+
handle, err := syscall.CreateFile(
32+
pathp,
33+
syscall.GENERIC_READ|syscall.GENERIC_WRITE,
34+
0,
35+
nil,
36+
syscall.OPEN_ALWAYS,
37+
syscall.FILE_ATTRIBUTE_NORMAL,
38+
0,
39+
)
40+
if err != nil {
41+
if err == errSharingViolation || err == errLockViolation {
42+
return nil, fmt.Errorf("another rules command is running (lock: %s)", lockPath)
43+
}
44+
return nil, fmt.Errorf("opening lock file: %w", err)
45+
}
46+
47+
f := os.NewFile(uintptr(handle), lockPath)
48+
if f == nil {
49+
_ = syscall.CloseHandle(handle)
50+
return nil, fmt.Errorf("creating lock file handle: %s", lockPath)
51+
}
52+
53+
return func() {
54+
_ = f.Close()
55+
}, nil
56+
}

0 commit comments

Comments
 (0)