Skip to content

feat(#67): add Windows and macOS legs to CI - #75

Merged
jsirish merged 3 commits into
mainfrom
feature/windows-macos-ci-legs
Sep 1, 2026
Merged

feat(#67): add Windows and macOS legs to CI#75
jsirish merged 3 commits into
mainfrom
feature/windows-macos-ci-legs

Conversation

@jsirish

@jsirish jsirish commented Sep 1, 2026

Copy link
Copy Markdown
Member

Summary

.github/workflows/ci.yml's shell job ran on ubuntu-latest only, despite all five
hooks being pure POSIX sh and the README making no OS-specific claim. Claude Code
executes a command hook under Git Bash on native Windows - a meaningfully different
environment than Ubuntu's bash.

  • Added windows-latest and macos-latest to the shell job's OS matrix
  • shellcheck/manifest-validation/version-agreement stay Linux-only (static analysis,
    the result doesn't depend on OS) - sh tests/run.sh runs on all three
  • is_windows() helper (checks MSYSTEM, uname -s, and $OS) skips 4
    permission-based test assertions that stage a POSIX mode bit NTFS can't reliably
    enforce, alongside the existing root-bypasses-permissions skip - reported in the
    final summary
  • Swapped ln -sf for a tiny exec-wrapper script in the missing-jq test's PATH stub -
    caught locally before pushing that a plain cp of the real binary can hang/crash on
    macOS (a Homebrew binary resolving shared libs via an @executable_path-relative
    reference breaks once relocated)
  • Added .gitattributes forcing LF on *.sh (GitHub's Windows runner defaults
    core.autocrlf to true, which would otherwise corrupt every shebang)
  • Fixed a real cross-platform correctness bug found by running on real Windows CI:
    session-capture.sh passed the project root to jq as --arg root "$root", then
    string-compared it against a captured file_path delivered over stdin. On Windows,
    jq is a native binary, and MSYS auto-converts a POSIX-looking value to Windows form
    before a native executable sees it - so $root never matched and every
    Edit/Write/NotebookEdit path stayed unrelativized (an absolute, OS-shaped path
    leaking into the buffer). Two other fix attempts (a blanket MSYS_NO_PATHCONV=1,
    then passing $root as an environment variable) both failed identically against
    real CI before landing on the actual fix: embedding $root as a JSON string literal
    inside jq's program text itself, which never crosses the boundary that was being
    converted.

This genuinely was not verifiable locally (no Windows environment available) - the
Windows leg failed twice against real CI before this third attempt passed. Each
attempt was diagnosed from the actual failing CI logs, not guessed.

Test plan

  • sh tests/run.sh: 176/176 pass locally on macOS
  • shellcheck -s sh hooks/*.sh tests/*.sh: clean
  • local-ci.sh --strict: all 5 checks PASS
  • Real CI, all three OSes, all green: shell (windows-latest), shell (macos-latest), shell (ubuntu-latest), opencode-plugin all pass on the
    final commit
  • Reviewed via /review-pr, which caught two real gaps in the first fix attempt
    (the env-var approach would have broken the plugin-version-header jq call, and
    two of the three new assertions couldn't have caught this bug in the first
    place) before either shipped
  • The literal-embedding fix verified locally against a path containing a space,
    and separately against one with an embedded double quote and backslash

Closes #67

jsirish and others added 3 commits September 1, 2026 11:27
.github/workflows/ci.yml's shell job ran on ubuntu-latest only, despite all
five hooks being pure POSIX sh and the README making no OS-specific claim.
Claude Code executes a command hook under Git Bash on native Windows, a
meaningfully different environment (NTFS permission semantics, no default
symlink privilege) than Ubuntu's bash - this converts that unmade platform
claim into a made, tested one, matching the coverage a competing plugin
(adrrr/persistent-handoff) already has.

Changes needed to make tests/run.sh actually pass on Windows/Git Bash:

- Three permission-based assertions stage a POSIX mode bit (000 unreadable,
  555 read-only directory) that NTFS's chmod emulation can't reliably
  enforce the same way ext4/APFS do. Guarded with a new is_windows() helper
  (checks MSYSTEM, which only Git Bash/MSYS2 sets) alongside the existing
  root-bypasses-permissions skip, reporting how many were skipped in the
  final summary rather than the pass count silently reading lower with no
  explanation.
- The missing-jq test's PATH stub swapped ln -sf for a tiny exec wrapper
  script per tool: a symlink needs a privilege Windows doesn't grant by
  default, and (caught by testing this locally before pushing) a `cp` of the
  real binary is actively worse - a macOS Homebrew binary can resolve its
  shared libraries via an @executable_path-relative reference, which hangs
  or crashes once relocated to the stub directory. A `#!/bin/sh -c 'exec
  <real> "$@"'` wrapper needs no privilege beyond writing a text file and
  never touches the real binary's own location.
- Added .gitattributes forcing LF on *.sh: GitHub's Windows runner image
  defaults core.autocrlf to true, and a CRLF-mangled shebang or an embedded
  \r mid-line breaks a shell script's execution outright.

The one chmod-based test NOT guarded (444 read-only file, blocking a write)
is left as-is - Windows' read-only DOS attribute plausibly does block writes
the way NTFS can't block reads, so this is a real test of real Windows
behavior, not a guess dressed as one. Live CI will confirm or correct that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXL2ffVDsAp4P7387Nr18G
The Windows leg of this PR's own CI failed on first push (172 passed, 3
failed) - exactly the outcome the PR was honest about not being able to
verify locally. /review-pr independently found the same two root causes by
reading the live CI output, plus two hardening gaps in the fix itself:

1. session-capture.sh passed the project root to jq as `--arg root "$root"`,
   then string-compared it against a captured file_path delivered via
   stdin. On Windows, jq is a native (non-MSYS) binary, and MSYS
   auto-converts a POSIX-looking ARGUMENT to Windows form before a native
   executable sees it - so $root arrived pre-converted while file_path
   didn't, and the two never matched. Every Edit/Write/NotebookEdit path
   onboard wrote stayed unrelativized on Windows: an absolute, OS-shaped path
   leaking into the buffer.

   First attempted fix (MSYS_NO_PATHCONV=1, global) was wrong and never
   shipped past local testing: the review caught that it would have broken
   session-onboard.sh's own `jq ... plugin.json` read, which legitimately
   NEEDS argv conversion to open that file on Windows. The actual fix keeps
   $root out of argv entirely - passed as an environment variable
   (`root="$root" jq ...`, referenced as `env.root`), which sidesteps MSYS's
   argv-only conversion without an opt-out that breaks something else.

2. A new test (7b3, from the earlier commit today) built a ~300-character
   path to force a git-status line past the truncation threshold. Windows'
   MAX_PATH (260) doesn't apply to a bare mkdir under Git Bash, but DOES
   apply to git's own add/commit machinery, so the fixture never staged.
   Shortened the path to a size that still exceeds the 200-char truncation
   threshold with margin while staying under 260, and added
   `git config core.longpaths true` as a second line of defense against
   whatever the real runner's own temp-path depth turns out to be.

Also from the same review pass:
- is_windows() checked only $MSYSTEM, which is set by the Git-for-Windows
  wrapper bash.exe, not the MSYS runtime itself - anything invoking
  usr/bin/sh.exe or usr/bin/bash.exe directly would leave it unset and the
  permission-test skips would silently stop firing. Added `uname -s`
  (MINGW*/MSYS*/CYGWIN*) and $OS=Windows_NT as backstops that come from the
  kernel/process environment directly.
- Write and NotebookEdit path relativization only asserted the relativized
  form was PRESENT, not that the absolute prefix was ABSENT - the same gap
  that let the Edit-path bug above ship unnoticed for those two tool types
  too (they share the identical code path and failed identically on
  Windows). Added the missing negative assertions.

174 -> 176 passing tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXL2ffVDsAp4P7387Nr18G
The previous commit's fix (root="$root" jq ..., referenced as env.root)
looked right by MSYS documentation but didn't survive contact with the real
runner: the second CI push failed identically - all three of Edit, Write,
and NotebookEdit still had their absolute prefix intact. Whatever channel is
actually doing the conversion on this runner, both an argv value AND an
environment variable turned out to go through it.

Stopped trying to find a channel MSYS won't touch and removed the channel
entirely: $root is now embedded as a JSON string LITERAL inside the jq
PROGRAM TEXT itself, computed via a separate `jq -Rs .` call fed over
stdin (the one channel already confirmed immune, since the file_path it's
compared against uses the same one). The whole filter argument - starting
with `def outcome($t): ...` and hundreds of characters long - doesn't
remotely resemble "a path" as a single argument, which is what MSYS's
conversion heuristic actually keys on; the value never appears as its own
argv item or env var for anything to convert.

Verified locally against a path containing a space, then again against one
with an embedded double quote and backslash (jq -Rs . correctly escapes
both), so this isn't fragile the way string-concatenation-into-a-shell-
command usually is.

This is now the third fix attempt for the same underlying symptom, each
verified locally and each pushed to find out against the one environment
that actually matters. Real Windows CI is watching this push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXL2ffVDsAp4P7387Nr18G
@jsirish
jsirish merged commit a19536c into main Sep 1, 2026
4 checks passed
@jsirish
jsirish deleted the feature/windows-macos-ci-legs branch September 1, 2026 16:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a Windows / Git Bash leg to CI

1 participant