fix(deploy): Hotfix #16 — split unixOwnerFromStat per-OS build tags (closes Windows CI matrix)

CI's cross-platform-build (windows-latest) job has been red for
several runs:

  internal/deploy/ownership.go:205 — undefined: syscall.Stat_t

Root cause:
  `syscall.Stat_t` is the Unix-specific POSIX stat-struct shape
  (linux / darwin / freebsd / openbsd / netbsd / dragonfly /
  solaris all expose it). On Windows GOOS, the syscall package
  defines `syscall.Win32FileAttributeData` instead, which carries
  no uid/gid fields. Any production tsx that names `syscall.Stat_t`
  unconditionally fails to compile on GOOS=windows.

  The function was added pre-cross-platform-matrix and never had
  to compile for Windows; CI's `cross-platform-build` job (added
  by Phase 3 TEST-H2) is what surfaced it. The ubuntu / macos
  matrix runs stayed green because both GOOSes expose the type.

Fix (standard Go per-platform build-tag split):
  Move `unixOwnerFromStat(fi os.FileInfo) (uid, gid int, ok bool)`
  out of ownership.go into per-OS sibling files:

    internal/deploy/ownership_unix.go    //go:build unix
    internal/deploy/ownership_windows.go //go:build windows

  ownership_unix.go: same impl as before. Uses `syscall.Stat_t`.
  Covers every Unix-y GOOS via Go 1.19+'s `unix` build constraint
  (linux + darwin + freebsd + openbsd + netbsd + dragonfly +
  solaris).

  ownership_windows.go: stub that returns (-1, -1, false). Windows
  has no native uid/gid; file ownership is expressed via SIDs +
  ACLs (`syscall.Win32FileAttributeData`), which the deploy
  package's call sites can't translate into uid/gid anyway. All
  four callers — applyOwnership (ownership.go:75),
  preserveSourceOwner (atomic.go:237), and two test sites — ALREADY
  handle ok=false by falling back to Plan.Defaults / runtime
  umask. Stub returning false is the correct platform contract.

  ownership.go: drop the `syscall` import (no longer needed there)
  + replace the function body with a doc comment pointing to the
  per-OS files so future readers know where the impl lives.

Note: the agent binary still compiles + runs on Windows; the
chown/chmod codepaths in the deploy package gate on
`runningAsRoot()` (os.Geteuid() == 0) which is also Unix-only in
practice — Windows agents run as a service under a SID that
doesn't translate to a uid anyway, so ownership operations on
Windows naturally no-op.

Verification (Go toolchain wired in sandbox, sub-platform builds
ran locally):
  • gofmt -l on all three touched files — clean
  • GOOS=linux GOARCH=amd64 go build ./internal/deploy/... — exit 0
  • GOOS=darwin GOARCH=amd64 go build ./internal/deploy/... — exit 0
  • GOOS=windows GOARCH=amd64 go build ./internal/deploy/... — exit 0
  • GOOS=windows GOARCH=amd64 go build ./cmd/{server,agent,cli,mcp-server}/...
    — exit 0 (all four CI matrix targets)
  • go vet ./internal/deploy/... — exit 0
  • staticcheck ./internal/deploy/... — zero findings
  • go test -short -count=1 ./internal/deploy/... — ok 0.216s (the
    four callers' tests all still pass on Linux)

Ground-truth: origin/master tip 622c19c (TEST-H3 just pushed)
verified via GitHub API BEFORE commit.

Falsifiable proof for the next CI run: the windows-latest leg of
cross-platform-build should turn green. The ubuntu-latest and
macos-latest legs were already green; this fix doesn't touch
their build path.
This commit is contained in:
shankar0123
2026-05-14 20:04:25 +00:00
parent 622c19cafe
commit 8e84527ba2
3 changed files with 78 additions and 10 deletions
+10 -10
View File
@@ -9,7 +9,6 @@ import (
"os" "os"
"os/user" "os/user"
"strconv" "strconv"
"syscall"
) )
// runningAsRoot reports whether the current process has uid 0. // runningAsRoot reports whether the current process has uid 0.
@@ -198,12 +197,13 @@ func lookupGID(groupname string) (int, error) {
// unixOwnerFromStat extracts (uid, gid) from a Unix-style FileInfo. // unixOwnerFromStat extracts (uid, gid) from a Unix-style FileInfo.
// On non-Unix platforms or when the underlying stat doesn't expose // On non-Unix platforms or when the underlying stat doesn't expose
// uid/gid, returns ok=false. // uid/gid, returns ok=false.
func unixOwnerFromStat(fi os.FileInfo) (uid int, gid int, ok bool) { //
if fi == nil { // Platform-specific implementations live in:
return -1, -1, false // - ownership_unix.go (//go:build unix — uses *syscall.Stat_t)
} // - ownership_windows.go (//go:build windows — stub returns false)
if sysStat, isUnix := fi.Sys().(*syscall.Stat_t); isUnix { //
return int(sysStat.Uid), int(sysStat.Gid), true // The split exists because syscall.Stat_t is Unix-only — Windows
} // has no equivalent shape, so any production tsx that names it
return -1, -1, false // fails to compile on GOOS=windows. The cross-platform-build CI
} // matrix caught this at Hotfix #16; the function was originally
// in this file pre-split.
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//go:build unix
// Unix-side implementation of unixOwnerFromStat. The `unix` build
// constraint (Go 1.19+) covers linux / darwin / freebsd / openbsd /
// netbsd / dragonfly / solaris — every GOOS where *syscall.Stat_t
// is a valid type assertion target for os.FileInfo.Sys().
//
// Hotfix #16 (2026-05-14): pre-split, this function lived inline in
// ownership.go with an unconditional `syscall.Stat_t` reference. That
// failed `GOOS=windows go build` because the type is undefined on
// that platform. The split is the standard Go pattern — the same
// function name + signature is satisfied by either build of the
// package, callers don't know or care which.
package deploy
import (
"os"
"syscall"
)
func unixOwnerFromStat(fi os.FileInfo) (uid int, gid int, ok bool) {
if fi == nil {
return -1, -1, false
}
if sysStat, isUnix := fi.Sys().(*syscall.Stat_t); isUnix {
return int(sysStat.Uid), int(sysStat.Gid), true
}
return -1, -1, false
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//go:build windows
// Windows stub for unixOwnerFromStat. Windows has no uid/gid concept
// the way Unix does — file ownership is expressed via SIDs (Security
// Identifiers) and ACLs (Access Control Lists), and os.FileInfo.Sys()
// returns *syscall.Win32FileAttributeData which carries no
// ownership data the deploy package's existing call sites can use.
//
// All four callers — applyOwnership at ownership.go:75,
// preserveSourceOwner at atomic.go:237, and two test sites — already
// handle the ok=false return path by falling back to Plan.Defaults
// or the runtime's umask. Returning false here is the correct
// platform contract: "no native ownership available on this
// platform; use the supplied defaults."
//
// Hotfix #16 (2026-05-14): created to unblock the
// cross-platform-build Windows matrix in CI, which had been
// red since the agent's deploy package gained ownership-
// preservation semantics. The agent binary still compiles for
// Windows; ownership operations on Windows are no-ops (which
// matches operator expectations — the certctl-agent's
// chown/chmod codepaths gate on `runningAsRoot()` and Windows
// runs the agent as a service under a SID that doesn't
// translate to a uid anyway).
package deploy
import "os"
func unixOwnerFromStat(_ os.FileInfo) (uid int, gid int, ok bool) {
return -1, -1, false
}