fix(release): rt builds and runs on Windows (#1502) #1505

Merged
alex merged 4 commits from claude/1502-windows-build into main 2026-10-02 22:19:05 +02:00
Member

Refs #1502

rt now builds and runs natively on Windows. The cause was one Unix-only syscall in the cut lock. Once the build works, one Windows runtime bug (register-check) and one misleading refusal (post_bump_hooks) show up, and this PR fixes both. Linux behaviour is byte-identical.

Commits

Commit What
8e2cf58 fix(release) Cut lock behind tryLockExclusive/unlockFile: lock_unix.go (//go:build !windows, the identical flock(LOCK_EX|LOCK_NB)) + lock_windows.go (LockFileEx). Cross-process and killed-holder tests. Fragment.
6aef15b fix(register) scanOneFile converts the walked path once with filepath.ToSlash for the self-exclusion key, the allow-list subject and the printed hit.
0ac9790 fix(prep) On Windows, a configured post_bump_hook is refused by name with the real cause, before any hook runs. Docs line in integration.md.
ca66a11 test Test-harness portability only; no rt behaviour change. Details in the commit message.

Measured

Windows: Go 1.27.1 windows/amd64, Git 2.55.0.windows.3, run from Git Bash. Linux: golang:1.25 container on a real clone of the branch.

Base 55a12b9 This branch ca66a11
go build ./cmd/rt, Windows undefined: syscall.Flock (5 errors) builds; go run ./cmd/rt fragment-check changelog.d runs natively
rt register-check on this repo, Windows 71 false hits, exit 1 (lock backend only) OK: 0 register-drift hits, exit 0
go vet ./... && go test -count=1 ./..., Windows cmd/rt cannot compile 29/29 packages ok
the same, Linux 29/29 ok 29/29 ok; gofmt -l clean under Go 1.25

Linux unchanged. The review pass built rt before and after, at the change's first base 02fae6a, and compared stdout, stderr and exit code. All three were byte-identical for:

  • --help
  • register-check on the repo, on a fixture walk, and on explicit files including a ./ prefix
  • prep --dry-run with a non-executable hook, a hook that exits 3, and a succeeding hook
  • fragment-check

On the rebase to 55a12b9, only docs/integration.md met upstream changes, and that was context, not conflict.

Mutation evidence (AGENTS.md section 8 Axis B). Each mutant was applied, then restored:

Mutant Goes red
Lock contention treated as success TestAcquireLock_anotherProcessHoldsIt and TestNewCutter_concurrentCut, on Windows and Linux
LOCKFILE_FAIL_IMMEDIATELY / LOCK_NB dropped both tests block until the guard timeout, on Windows and Linux
Marker-file lock instead of an OS lock TestAcquireLock_killedHolderDoesNotStrandTheLock: stranded after 5 s
release not idempotent TestCutLock_releaseIsIdempotent
filescan.go reverted, tests kept (Windows) TestScanTree, TestScanTreeExplicitOSPath, TestRegisterFileScanDrift, TestRegisterFileScanExemptAndAllowlist
Dry run writes the real checkout, not the scratch copy (Windows) TestPrep_dryRunFailurePathLeavesTheCheckoutUntouched. Its hook arm is now Windows-aware; the unchanged-file arms still bite.

TestScanTreeExplicitOSPath can only discriminate on Windows; on Linux the two path forms are the same string. It was confirmed red there.

Decisions (AGENTS.md section 7)

How to call LockFileEx:

  • (A) bind the two kernel32 calls with syscall.NewLazyDLL + SyscallN. Chosen. Right when the module graph should stay at its two direct dependencies and only two calls are needed. The arguments mirror cmd/go's lockedfile: zeroed OVERLAPPED, whole range 0xFFFFFFFF:0xFFFFFFFF. kernel32 is a KnownDLL, so the search path does not matter. os.OpenFile(O_CREATE|O_RDWR) opens a synchronous handle in Go 1.24-1.27 (os/file_windows.go), so ERROR_IO_PENDING cannot occur.
  • (B) golang.org/x/sys/windows. Right if more Windows API is ever needed, or if generated bindings are preferred over hand-written SyscallN. It adds a module to go.sum and to the release supply chain.
  • (C) a marker file with a PID, no OS lock. Rejected: a dead holder strands it. The killed-holder test pins exactly this.

What rt prep does with post_bump_hooks on Windows:

  • (a) refuse by name, before running any hook. Chosen. Right while hooks are POSIX scripts shared with the Linux workflow. The old message, "not executable", pointed at a chmod +x that cannot help on Windows.
  • (b) skip the exec-bit check and exec the hook. Fails anyway: CreateProcess does not dispatch on a shebang.
  • (c) run hooks through Git for Windows' sh. Right only if a Windows-local rt prep with hooks becomes a real use case. It ties rt to where Git Bash is installed.

Skip or port a test: port wherever Windows has the feature. Skip only where it does not:

  • MSYS2 regexec cannot match a multibyte member of a multi-member bracket under a UTF-8 locale.
  • A symlink needs privilege. That skip already existed.

Re-pin (AGENTS.md section 2)

This changes code behind rt prep and rt release: the cut lock and the hook runner.

  • check-self-bootstrap will not flag it. gates.DefaultComposeScripts guards only workflow YAML and scripts/workflow-parse-check.sh (internal/gates/selfboot_check.go:234).
  • Linux output is byte-identical, so the pinned CI rt would compose the same rolling PR either way.
  • Re-pin now if the pinned CI binary should carry the Windows backend. Fold it into the next normal re-pin otherwise. That is enough because no Linux behaviour moves.

No new verb or flag (section 13 not triggered): --help output is identical.

Still not supported on Windows

  • post_bump_hooks: refused by name, see above.
  • The cut lock is per user, because it lives under %TEMP%. Since Go 1.23 filepath.EvalSymlinks does not resolve junctions, so a repo reached through a directory junction takes a different lock than its real path.
  • The suite must run from Git Bash. From PowerShell, sh, bash and locale resolve differently.
  • Three path-handling gaps found only by reading the code are out of scope here (listed in #1502): the version_files guard does not reject a ..\ prefix; resolveUnderRoot treats only a leading / as absolute; an atomic rename fails over a read-only destination.
  • Release assets stay linux/amd64 (.goreleaser.yaml, ADR-0009 section 9).

Verify at source (AGENTS.md section 6)

Please verify, not read:

  1. On Windows, go build ./cmd/rt builds here and fails on 55a12b9.
  2. rt register-check at the repo root on Windows: exit 0 here, 71 hits with only the lock commit applied.
  3. One lock mutant (for example, contention treated as success in lock_windows.go) turns TestAcquireLock_anotherProcessHoldsIt red.
  4. The Linux suite in golang:1.25.

🤖 Generated with Claude Code

Refs #1502 `rt` now builds and runs natively on Windows. The cause was one Unix-only syscall in the cut lock. Once the build works, one Windows runtime bug (`register-check`) and one misleading refusal (`post_bump_hooks`) show up, and this PR fixes both. Linux behaviour is byte-identical. ## Commits | Commit | What | |---|---| | `8e2cf58` fix(release) | Cut lock behind `tryLockExclusive`/`unlockFile`: `lock_unix.go` (`//go:build !windows`, the identical `flock(LOCK_EX\|LOCK_NB)`) + `lock_windows.go` (`LockFileEx`). Cross-process and killed-holder tests. Fragment. | | `6aef15b` fix(register) | `scanOneFile` converts the walked path once with `filepath.ToSlash` for the self-exclusion key, the allow-list subject and the printed hit. | | `0ac9790` fix(prep) | On Windows, a configured `post_bump_hook` is refused by name with the real cause, before any hook runs. Docs line in integration.md. | | `ca66a11` test | Test-harness portability only; no `rt` behaviour change. Details in the commit message. | ## Measured Windows: Go 1.27.1 windows/amd64, Git 2.55.0.windows.3, run from Git Bash. Linux: `golang:1.25` container on a real clone of the branch. | | Base `55a12b9` | This branch `ca66a11` | |---|---|---| | `go build ./cmd/rt`, Windows | `undefined: syscall.Flock` (5 errors) | builds; `go run ./cmd/rt fragment-check changelog.d` runs natively | | `rt register-check` on this repo, Windows | 71 false hits, exit 1 (lock backend only) | `OK: 0 register-drift hits`, exit 0 | | `go vet ./... && go test -count=1 ./...`, Windows | `cmd/rt` cannot compile | 29/29 packages ok | | the same, Linux | 29/29 ok | 29/29 ok; `gofmt -l` clean under Go 1.25 | **Linux unchanged.** The review pass built `rt` before and after, at the change's first base `02fae6a`, and compared stdout, stderr and exit code. All three were byte-identical for: - `--help` - `register-check` on the repo, on a fixture walk, and on explicit files including a `./` prefix - `prep --dry-run` with a non-executable hook, a hook that exits 3, and a succeeding hook - `fragment-check` On the rebase to `55a12b9`, only `docs/integration.md` met upstream changes, and that was context, not conflict. **Mutation evidence (AGENTS.md section 8 Axis B).** Each mutant was applied, then restored: | Mutant | Goes red | |---|---| | Lock contention treated as success | `TestAcquireLock_anotherProcessHoldsIt` and `TestNewCutter_concurrentCut`, on Windows and Linux | | `LOCKFILE_FAIL_IMMEDIATELY` / `LOCK_NB` dropped | both tests block until the guard timeout, on Windows and Linux | | Marker-file lock instead of an OS lock | `TestAcquireLock_killedHolderDoesNotStrandTheLock`: stranded after 5 s | | `release` not idempotent | `TestCutLock_releaseIsIdempotent` | | `filescan.go` reverted, tests kept (Windows) | `TestScanTree`, `TestScanTreeExplicitOSPath`, `TestRegisterFileScanDrift`, `TestRegisterFileScanExemptAndAllowlist` | | Dry run writes the real checkout, not the scratch copy (Windows) | `TestPrep_dryRunFailurePathLeavesTheCheckoutUntouched`. Its hook arm is now Windows-aware; the unchanged-file arms still bite. | `TestScanTreeExplicitOSPath` can only discriminate on Windows; on Linux the two path forms are the same string. It was confirmed red there. ## Decisions (AGENTS.md section 7) **How to call `LockFileEx`:** - **(A) bind the two kernel32 calls with `syscall.NewLazyDLL` + `SyscallN`.** Chosen. Right when the module graph should stay at its two direct dependencies and only two calls are needed. The arguments mirror cmd/go's lockedfile: zeroed `OVERLAPPED`, whole range `0xFFFFFFFF:0xFFFFFFFF`. kernel32 is a KnownDLL, so the search path does not matter. `os.OpenFile(O_CREATE|O_RDWR)` opens a synchronous handle in Go 1.24-1.27 (`os/file_windows.go`), so `ERROR_IO_PENDING` cannot occur. - **(B) `golang.org/x/sys/windows`.** Right if more Windows API is ever needed, or if generated bindings are preferred over hand-written `SyscallN`. It adds a module to `go.sum` and to the release supply chain. - **(C) a marker file with a PID, no OS lock.** Rejected: a dead holder strands it. The killed-holder test pins exactly this. **What `rt prep` does with `post_bump_hooks` on Windows:** - **(a) refuse by name, before running any hook.** Chosen. Right while hooks are POSIX scripts shared with the Linux workflow. The old message, "not executable", pointed at a `chmod +x` that cannot help on Windows. - **(b) skip the exec-bit check and exec the hook.** Fails anyway: `CreateProcess` does not dispatch on a shebang. - **(c) run hooks through Git for Windows' `sh`.** Right only if a Windows-local `rt prep` with hooks becomes a real use case. It ties `rt` to where Git Bash is installed. **Skip or port a test:** port wherever Windows has the feature. Skip only where it does not: - MSYS2 `regexec` cannot match a multibyte member of a multi-member bracket under a UTF-8 locale. - A symlink needs privilege. That skip already existed. ## Re-pin (AGENTS.md section 2) This changes code behind `rt prep` and `rt release`: the cut lock and the hook runner. - `check-self-bootstrap` will not flag it. `gates.DefaultComposeScripts` guards only workflow YAML and `scripts/workflow-parse-check.sh` (`internal/gates/selfboot_check.go:234`). - Linux output is byte-identical, so the pinned CI `rt` would compose the same rolling PR either way. - **Re-pin now** if the pinned CI binary should carry the Windows backend. **Fold it into the next normal re-pin** otherwise. That is enough because no Linux behaviour moves. No new verb or flag (section 13 not triggered): `--help` output is identical. ## Still not supported on Windows - `post_bump_hooks`: refused by name, see above. - The cut lock is per user, because it lives under `%TEMP%`. Since Go 1.23 `filepath.EvalSymlinks` does not resolve junctions, so a repo reached through a directory junction takes a different lock than its real path. - The suite must run from Git Bash. From PowerShell, `sh`, `bash` and `locale` resolve differently. - Three path-handling gaps found only by reading the code are out of scope here (listed in #1502): the `version_files` guard does not reject a `..\` prefix; `resolveUnderRoot` treats only a leading `/` as absolute; an atomic rename fails over a read-only destination. - Release assets stay linux/amd64 (`.goreleaser.yaml`, ADR-0009 section 9). ## Verify at source (AGENTS.md section 6) Please verify, not read: 1. On Windows, `go build ./cmd/rt` builds here and fails on `55a12b9`. 2. `rt register-check` at the repo root on Windows: exit 0 here, 71 hits with only the lock commit applied. 3. One lock mutant (for example, contention treated as success in `lock_windows.go`) turns `TestAcquireLock_anotherProcessHoldsIt` red. 4. The Linux suite in `golang:1.25`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
internal/release/lock.go called syscall.Flock, which package syscall
defines only on Unix, so rt did not compile on Windows at all.

The OS call now sits behind tryLockExclusive/unlockFile. lock_unix.go
(//go:build !windows) keeps the identical flock(LOCK_EX|LOCK_NB) call.
lock_windows.go binds kernel32's LockFileEx/UnlockFileEx with
LOCKFILE_EXCLUSIVE_LOCK|LOCKFILE_FAIL_IMMEDIATELY over the whole range,
the call cmd/go's lockedfile makes, without adding golang.org/x/sys.
ERROR_LOCK_VIOLATION maps to ErrConcurrentCut as EWOULDBLOCK does.

New tests take the lock from a second process (re-exec helper) and kill
a holder mid-hold, pinning the contract the cut relies on: fail fast
across processes, never stranded by a dead holder.

Refs #1502

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
filepath.WalkDir yields "docs\retro\x.md" on Windows. scanOneFile used
that path as the self-exclusion key, the allow-list subject and the
reported file, so every allow-listed directory was scanned anyway:
register-check on this repo reported 71 false hits on Windows and none
on Linux. Convert once with filepath.ToSlash, the identity on Linux.

Refs #1502

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hook is exec'd as a POSIX executable. On Windows os.Stat reports no
exec bit and CreateProcess does not dispatch on a shebang, so prep said
"post_bump_hook not executable", a fix the operator cannot apply. Refuse
a configured hook by name with the actual cause before running any. The
release workflow runs on Linux, where nothing changes.

Refs #1502

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test: make the suite pass natively on Windows (#1502)
All checks were successful
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 5s
ac-closure-check / ac-closure check (pull_request) Successful in 7s
ac-closure-check / check (pull_request) Successful in 0s
base-divergence-check / check (pull_request) Successful in 6s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 7s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 5s
fragment-check / coverage (pull_request) Successful in 7s
fragment-check / density (pull_request) Successful in 7s
fragment-check / check (pull_request) Successful in 0s
gitea-twin-check / check (pull_request) Successful in 6s
go-ci / lint + build + test (pull_request) Successful in 30s
go-ci / record reviewed vs landed commit (pull_request) Has been skipped
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
prep-order-check / check (pull_request) Successful in 6s
readme-pin-check / mirror (pull_request) Successful in 7s
readme-pin-check / digest (pull_request) Successful in 6s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 4s
tests / bats (pull_request) Successful in 32s
tests / dated-examples (pull_request) Successful in 5s
tests / contract-paths (pull_request) Successful in 4s
tests / shellcheck (pull_request) Successful in 4s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 7s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 5s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 4s
workflow-parse-check / check (pull_request) Successful in 0s
go-ci / page landing-tree failure (pull_request) Has been skipped
ca66a1119c
Test-harness assumptions only; rt's behaviour is unchanged:
- tests that build and exec rt name the binary rt.exe on Windows
- the staged-path lookup uses the slash form git prints
- fragment-check warning tests address the dir relatively, as pre-push
  and the default changelog.d do, so the ::warning file= line applies
- chmod-000 cases use a per-OS makeUnreadable; on Windows a handle that
  withholds FILE_SHARE_READ is what makes a present file unreadable
- the repin mode test expects 0666 on Windows, where Go models only the
  read-only attribute and a writable file always reads back 0666
- the bash locale control skips the MSYS2 case it cannot express
- the manifest-push tests get a 30s guard deadline instead of 2s, which
  a busy host missed mid-rebase

Refs #1502

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude requested review from alex 2026-10-02 11:39:26 +02:00
alex merged commit 96d2646769 into main 2026-10-02 22:19:05 +02:00
alex deleted branch claude/1502-windows-build 2026-10-02 22:19:05 +02:00

Landing identity record

  • PR: #1505
  • landed commit (server merge_commit_sha): 96d26467698f3184f8fb11dd202135e2c29dd3d8
  • effective official approval(s): none effective at record time
  • replay comparison: no official stamped SHA was returned

This is a post-merge identity record. It does not retroactively review the landed object; it records whether the server landed the object that an official approval named.

<!-- release-toolkit:landing-review-record-v1 landed=96d26467698f3184f8fb11dd202135e2c29dd3d8 --> ## Landing identity record - PR: #1505 - landed commit (server merge_commit_sha): `96d26467698f3184f8fb11dd202135e2c29dd3d8` - effective official approval(s): none effective at record time - replay comparison: no official stamped SHA was returned This is a post-merge identity record. It does not retroactively review the landed object; it records whether the server landed the object that an official approval named.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
frankenbit/release-toolkit!1505
No description provided.