wine-windows-gates.sh 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. #!/usr/bin/env bash
  2. # Run the blocking Windows gates (workspace build, production site) with real
  3. # win-x64 Node.js under Wine — the same script the pull-request `windows` job
  4. # in ci.yml executes and the optional local gate `pnpm run check:windows-wine`
  5. # wraps. Owning rationale, fidelity limits, and measured timings:
  6. # .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
  7. #
  8. # The working tree is never mutated: tracked plus untracked-unignored files
  9. # are snapshotted into a scratch directory, the Wine-specific pnpm overrides
  10. # (hoisted layout, win32-x64 platform packages) are appended to the SNAPSHOT's
  11. # pnpm-workspace.yaml, and the install and gates run there against the shared
  12. # pnpm store. The Wine prefix and the checksum-verified Windows Node zip
  13. # persist in .cache/wine-windows/ so reruns skip provisioning.
  14. #
  15. # Environment: DSH_WINE_NODE_MAJOR (default $PRIMARY_NODE_VERSION, then 24)
  16. # picks the Windows Node line; DSH_WINE_GATE_CACHE_DIR relocates the cache;
  17. # DSH_WINE_GATE_KEEP=1 preserves the scratch tree for inspection.
  18. set -euo pipefail
  19. repo_root="$(git rev-parse --show-toplevel)"
  20. node_major="${DSH_WINE_NODE_MAJOR:-${PRIMARY_NODE_VERSION:-24}}"
  21. cache_dir="${DSH_WINE_GATE_CACHE_DIR:-$repo_root/.cache/wine-windows}"
  22. export WINEDEBUG='-all'
  23. export WINEARCH=win64
  24. # Skip Wine Mono / Gecko installers: Node needs neither.
  25. export WINEDLLOVERRIDES='mscoree,mshtml='
  26. export WINEPREFIX="$cache_dir/prefix"
  27. # ---- preflight: fail loud before any expensive work --------------------
  28. wine_bin=''
  29. for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do
  30. if [ -n "$candidate" ] && [ -x "$candidate" ]; then wine_bin="$candidate"; break; fi
  31. done
  32. # GNU coreutils sha256sum on Linux; perl shasum ships with macOS. Both
  33. # accept the same "<hash> <file>" --check input.
  34. checksum_tool=''
  35. if command -v sha256sum > /dev/null; then
  36. checksum_tool='sha256sum'
  37. elif command -v shasum > /dev/null; then
  38. checksum_tool='shasum'
  39. fi
  40. missing=()
  41. [ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)')
  42. command -v curl > /dev/null || missing+=('curl')
  43. command -v unzip > /dev/null || missing+=('unzip')
  44. [ -n "$checksum_tool" ] || missing+=('sha256sum or shasum (apt: coreutils | macOS ships shasum)')
  45. if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi
  46. command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)')
  47. if (( ${#missing[@]} > 0 )); then
  48. printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2
  49. exit 1
  50. fi
  51. # Verify file $2 against SHA-256 hex $1 with whichever tool preflight found.
  52. verify_sha256() {
  53. case "$checksum_tool" in
  54. sha256sum) printf '%s %s\n' "$1" "$2" | sha256sum --check - > /dev/null ;;
  55. shasum) printf '%s %s\n' "$1" "$2" | shasum -a 256 --check - > /dev/null ;;
  56. esac
  57. }
  58. scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")"
  59. cleanup() {
  60. wineserver -k > /dev/null 2>&1 || true
  61. if [ "${DSH_WINE_GATE_KEEP:-0}" = '1' ]; then
  62. echo "wine-windows-gates: scratch tree kept at $scratch"
  63. else
  64. rm -rf "$scratch"
  65. fi
  66. }
  67. trap cleanup EXIT
  68. mkdir -p "$cache_dir" "$scratch/logs"
  69. # ---- provision Windows Node, boot Wine, snapshot + install concurrently ----
  70. provision_node() {
  71. # Latest release of the primary line, checksum-verified against the same
  72. # dist directory. Offline runs fall back to the newest cached zip, loudly.
  73. local version zip
  74. version="$(curl -fsSL --max-time 30 https://nodejs.org/dist/index.json 2> /dev/null \
  75. | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const v=JSON.parse(d).find(r=>r.version.startsWith('v$node_major.'));if(v)console.log(v.version)})" \
  76. || true)"
  77. if [ -n "$version" ]; then
  78. zip="$cache_dir/node-$version-win-x64.zip"
  79. if [ ! -f "$zip" ]; then
  80. curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip"
  81. local expected
  82. expected="$(curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \
  83. | awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')"
  84. [ -n "$expected" ] || { echo "wine-windows-gates: no SHASUMS256 entry for node-$version-win-x64.zip" >&2; exit 1; }
  85. verify_sha256 "$expected" "$zip.tmp"
  86. mv "$zip.tmp" "$zip"
  87. fi
  88. else
  89. zip="$(ls -t "$cache_dir"/node-v"$node_major".*-win-x64.zip 2> /dev/null | head -1 || true)"
  90. [ -n "$zip" ] || { echo "wine-windows-gates: nodejs.org unreachable and no cached Windows Node v$node_major zip in $cache_dir" >&2; exit 1; }
  91. echo "wine-windows-gates: nodejs.org unreachable; using cached $(basename "$zip")" >&2
  92. fi
  93. unzip -q -o "$zip" -d "$scratch/node-win"
  94. echo "$scratch/node-win/$(basename "$zip" .zip)/node.exe" > "$scratch/node-win-path"
  95. }
  96. boot_wine() {
  97. "$wine_bin" wineboot --init > /dev/null 2>&1 || true
  98. wineserver -w || true
  99. }
  100. snapshot_and_install() {
  101. # Tracked + untracked-unignored files, minus agent-session litter; the
  102. # existence filter drops paths staged as deleted. Then the Wine-specific
  103. # install-time overrides go on the SNAPSHOT only: hoisted because Windows
  104. # Node under Wine does not realpath pnpm's isolated-layout symlinks, and
  105. # win32-x64 so the Windows esbuild/rolldown/rollup binaries materialize.
  106. # Neither is recorded in the lockfile, so --frozen-lockfile stays valid;
  107. # --ignore-scripts skips host lifecycle scripts no gate loads.
  108. git -C "$repo_root" ls-files -z --cached --others --exclude-standard -- . ':!:.claude' ':!:.codex' \
  109. | while IFS= read -r -d '' file; do [ -e "$repo_root/$file" ] && printf '%s\0' "$file"; done \
  110. | tar -C "$repo_root" --null --files-from=- -cf - \
  111. | tar -C "$scratch/tree" -xf -
  112. cat >> "$scratch/tree/pnpm-workspace.yaml" << 'EOF'
  113. nodeLinker: hoisted
  114. supportedArchitectures:
  115. os: [current, win32]
  116. cpu: [current, x64]
  117. EOF
  118. (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \
  119. || { tail -40 "$scratch/logs/install.log" >&2; return 1; }
  120. }
  121. mkdir "$scratch/tree"
  122. start=$SECONDS
  123. provision_node & node_pid=$!
  124. boot_wine & wine_pid=$!
  125. snapshot_and_install & install_pid=$!
  126. # Wait for EVERY child before judging any: a bare `wait` under set -e would
  127. # exit on the first failure and let the EXIT trap delete $scratch while the
  128. # other children still run inside it. Named statuses also make the report
  129. # point at the root cause instead of a downstream symptom.
  130. node_status=0; wait "$node_pid" || node_status=$?
  131. wine_status=0; wait "$wine_pid" || wine_status=$?
  132. install_status=0; wait "$install_pid" || install_status=$?
  133. provision_failed=0
  134. report_provision() {
  135. if (( $2 != 0 )); then
  136. echo "wine-windows-gates: FAILED $1 (exit $2)" >&2
  137. provision_failed=$2
  138. fi
  139. }
  140. report_provision 'Windows Node provisioning' "$node_status"
  141. report_provision 'wineboot' "$wine_status"
  142. report_provision 'workspace snapshot + pnpm install' "$install_status"
  143. if (( provision_failed != 0 )); then exit "$provision_failed"; fi
  144. node_win="$(cat "$scratch/node-win-path")"
  145. echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))"
  146. # ---- resolve entrypoints, lay the vue link, smoke ------------------------
  147. # Node under Wine cannot attach stdio to pipes the caller owns (Socket open
  148. # EBADF at bootstrap), so every invocation routes stdio through a file.
  149. wine_node() {
  150. local log="$1"
  151. shift
  152. local status=0
  153. "$wine_bin" "$node_win" "$@" < /dev/null > "$log" 2>&1 || status=$?
  154. return "$status"
  155. }
  156. cd "$scratch/tree"
  157. tsc_js='node_modules/typescript/bin/tsc'
  158. tsdown_js='node_modules/tsdown/dist/run.mjs'
  159. vitepress_js='node_modules/vitepress/bin/vitepress.js'
  160. [ -f "$vitepress_js" ] || vitepress_js='website/node_modules/vitepress/bin/vitepress.js'
  161. for entry in "$tsc_js" "$tsdown_js" "$vitepress_js"; do
  162. [ -f "$entry" ] || { echo "wine-windows-gates: expected entrypoint missing after hoisted install: $entry" >&2; exit 1; }
  163. done
  164. # VitePress links vue into the site's node_modules at build time; Wine cannot
  165. # CREATE Windows symlinks (ENOTSUP) but follows pre-existing Unix ones.
  166. if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then
  167. mkdir -p website/node_modules
  168. ln -s ../../node_modules/vue website/node_modules/vue
  169. fi
  170. wine_node "$scratch/logs/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version"
  171. cat "$scratch/logs/smoke.log"
  172. grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; }
  173. # ---- the two blocking surfaces, concurrently ------------------------------
  174. # The same shape run-gates gives ci-windows-blocking on native Windows:
  175. # `build` = tsc -b then tsdown, `production site` = the VitePress build. Both
  176. # statuses are captured so one failure cannot hide the other's result.
  177. build_gate() {
  178. wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $?
  179. wine_node "$scratch/logs/tsdown.log" "$tsdown_js"
  180. }
  181. site_gate() {
  182. cd website
  183. wine_node "$scratch/logs/site.log" "../$vitepress_js" build .
  184. }
  185. start=$SECONDS
  186. build_gate & build_pid=$!
  187. site_gate & site_pid=$!
  188. build_status=0
  189. wait "$build_pid" || build_status=$?
  190. site_status=0
  191. wait "$site_pid" || site_status=$?
  192. elapsed=$((SECONDS - start))
  193. report() {
  194. local label="$1" status="$2"
  195. shift 2
  196. if (( status == 0 )); then
  197. echo "wine-windows-gates: PASS $label (${elapsed}s window)"
  198. else
  199. echo "== FAILED $label (exit $status) ==" >&2
  200. for log in "$@"; do tail -n 200 "$log" >&2 || true; done
  201. fi
  202. }
  203. report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log"
  204. report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log"
  205. if (( build_status != 0 )); then exit "$build_status"; fi
  206. exit "$site_status"