Browse Source

ci: share one Wine Windows gate script between CI and an optional local gate

scripts/wine-windows-gates.sh owns the whole lane — workspace snapshot,
concurrent Node/wineboot/install provisioning, entrypoint resolution, the
vue link, smoke, and the two concurrent blocking gates — so the ci.yml
windows job shrinks to runner provisioning plus one script call, and
pnpm run check:windows-wine runs the identical script locally. The local
path never mutates the working tree, persists the Wine prefix and the
checksum-verified Windows Node zip under .cache/wine-windows/, and falls
back to the cached zip offline. AGENTS.md ceiling 1680 -> 1695 for the
one-line command entry.
Tianyi Cui 2 tháng trước cách đây
mục cha
commit
f12ea986cb

+ 2 - 2
.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
-2026-07-27-wine-windows-gates-experiment.md: aab8aecdfca06c1f15641044a071015f543a84b6
-2026-07-27-wine-windows-gates-experiment.zh.md: 5239b185e1e0c63aa626ee3f20f3f298c0c8579d
+2026-07-27-wine-windows-gates-experiment.md: 918d4ab4a1974c6f8ea4bebeb48025aeecf0ef57
+2026-07-27-wine-windows-gates-experiment.zh.md: e1de3395ccd59307b520cb59210923f7fe1a21fd

+ 2 - 0
.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md

@@ -18,6 +18,8 @@ Dependencies install natively on Linux with `supportedArchitectures` extended to
 
 The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope.
 
+The gate logic lives in one script, [scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh): the ci.yml job provisions runner state (caches, apt Wine) and calls it, and the optional local gate `pnpm run check:windows-wine` runs the identical script on a developer machine that has Wine installed — one implementation, so local reproduction of a red CI lane needs no translation between environments. The script never mutates the working tree: it snapshots tracked plus untracked-unignored files into a scratch directory, applies the Wine-specific pnpm overrides to the snapshot only, and installs there against the shared store; the Wine prefix and the checksum-verified Windows Node zip persist under `.cache/wine-windows/` so local reruns skip provisioning, with an offline fallback to the newest cached zip when nodejs.org is unreachable.
+
 Four environment constraints shape the job, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the Actions runner's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate).
 
 ## Measured results

+ 2 - 0
.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md

@@ -18,6 +18,8 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表
 
 该通道靠四个杠杆保持 Linux CI 作业的墙钟:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。
 
+门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。
+
 四条环境约束塑造了该作业,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到 Actions runner 的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。
 
 ## 实测结果

+ 22 - 142
.github/workflows/ci.yml

@@ -286,20 +286,16 @@ jobs:
   # Windows Node under Wine on standard hosted Linux. The master
   # serial-windows job below keeps the complete native-kernel inventory —
   # including the observational portability gates this lane does not run —
-  # on real windows-2025. Direct tool entrypoints stand in for pnpm's cmd
-  # shims, which a Linux-side install does not create; layout, fidelity
-  # limits, and measured timings live in
+  # on real windows-2025. This job only provisions runner state (caches,
+  # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same
+  # script the optional local gate `pnpm run check:windows-wine` runs.
+  # Layout, fidelity limits, and measured timings live in
   # .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
   windows:
     if: github.event_name == 'pull_request'
     runs-on: ubuntu-latest
     name: windows node 24 / wine blocking
     timeout-minutes: 15
-    env:
-      WINEDEBUG: '-all'
-      WINEARCH: win64
-      # Skip Wine Mono / Gecko installers: Node needs neither.
-      WINEDLLOVERRIDES: 'mscoree,mshtml='
     steps:
       - uses: actions/checkout@v6
         with:
@@ -328,144 +324,28 @@ jobs:
           path: ~/wine-debs
           key: ${{ steps.wine-cache-key.outputs.key }}
 
-      - name: Install dependencies and provision Wine concurrently
-        run: |
-          corepack enable
-
-          # Windows-lane install-time overrides. supportedArchitectures
-          # additionally materializes the win32-x64 platform packages
-          # (@esbuild/win32-x64, rolldown and rollup MSVC bindings) the
-          # Windows toolchain resolves at runtime; nodeLinker: hoisted lays
-          # node_modules out flat with real files because Windows Node under
-          # Wine does not realpath pnpm's isolated-layout symlinks. Neither
-          # override is recorded in the lockfile, so --frozen-lockfile stays
-          # valid. --ignore-scripts skips Linux lifecycle scripts no gate in
-          # this lane loads; the win32 binaries ship prebuilt.
-          cat >> pnpm-workspace.yaml <<'EOF'
-
-          nodeLinker: hoisted
-          supportedArchitectures:
-            os: [current, win32]
-            cpu: [current, x64]
-          EOF
-
-          pnpm install --frozen-lockfile --ignore-scripts &
-          install_pid=$!
-
-          provision_wine() {
-            set -euo pipefail
-            # Wine from the apt cache when present; else download the full
-            # dependency closure once and keep it for the next run. The
-            # `wine` dispatcher package (not bare `wine64`) is what puts a
-            # binary on PATH.
-            if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then
-              sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb
-            else
-              sudo apt-get update
-              sudo apt-get install -y --no-install-recommends --download-only wine
-              mkdir -p "$HOME/wine-debs"
-              cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true
-              sudo apt-get install -y --no-install-recommends wine
-            fi
-            WINE_BIN=''
-            for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do
-              if [ -n "$candidate" ] && [ -x "$candidate" ]; then WINE_BIN="$candidate"; break; fi
-            done
-            [ -n "$WINE_BIN" ] || { echo '::error::no wine binary found after install'; exit 1; }
-            echo "WINE_BIN=$WINE_BIN" >> "$GITHUB_ENV"
-
-            # Windows Node for the repo's primary line, checksum-verified
-            # against the same dist directory.
-            version=$(curl -fsSL https://nodejs.org/dist/index.json \
-              | jq -r --arg p "v${PRIMARY_NODE_VERSION}." '[.[] | select(.version | startswith($p))][0].version')
-            echo "Windows Node: $version"
-            curl -fsSL -o "$RUNNER_TEMP/node-win.zip" \
-              "https://nodejs.org/dist/${version}/node-${version}-win-x64.zip"
-            curl -fsSL "https://nodejs.org/dist/${version}/SHASUMS256.txt" \
-              | awk -v a="node-${version}-win-x64.zip" '$2 == a { print $1 "  '"$RUNNER_TEMP"'/node-win.zip" }' \
-              | sha256sum --check -
-            unzip -q "$RUNNER_TEMP/node-win.zip" -d "$RUNNER_TEMP/node-win"
-            echo "NODE_WIN=$RUNNER_TEMP/node-win/node-${version}-win-x64/node.exe" >> "$GITHUB_ENV"
-
-            "$WINE_BIN" wineboot --init || true
-            wineserver -w || true
-          }
-          provision_wine &
-          wine_pid=$!
-
-          install_status=0
-          wait "$install_pid" || install_status=$?
-          wine_status=0
-          wait "$wine_pid" || wine_status=$?
-          if (( install_status != 0 )); then exit "$install_status"; fi
-          exit "$wine_status"
-
-      - name: Resolve entrypoints, link vue, smoke Windows Node
+      # Runner provisioning only — a developer machine installs Wine through
+      # its own package manager; the gate script assumes a wine binary and
+      # fails loud without one. Wine from the apt cache when present; else
+      # download the full dependency closure once and keep it for the next
+      # run. The `wine` dispatcher package (not bare `wine64`) is what puts a
+      # binary on PATH.
+      - name: Install Wine
         run: |
-          # Node under Wine cannot attach stdio to the Actions runner's pipes
-          # (Socket open EBADF at bootstrap), so every invocation runs through
-          # this wrapper: stdio to a regular file, replayed after exit.
-          cat > "$RUNNER_TEMP/wine-node.sh" <<'SH'
-          #!/usr/bin/env bash
-          set -u
-          log="$1"; shift
-          "$WINE_BIN" "$NODE_WIN" "$@" < /dev/null > "$log" 2>&1
-          status=$?
-          tail -n 300 "$log"
-          exit "$status"
-          SH
-          chmod +x "$RUNNER_TEMP/wine-node.sh"
-
-          resolve() {
-            local name="$1"; shift
-            for p in "$@"; do
-              if [ -f "$p" ]; then echo "$name=$PWD/$p" >> "$GITHUB_ENV"; return 0; fi
-            done
-            echo "::error::$name not found at any of: $*"; return 1
-          }
-          resolve TSC_JS node_modules/typescript/bin/tsc
-          resolve TSDOWN_JS node_modules/tsdown/dist/run.mjs
-          resolve VITEPRESS_JS website/node_modules/vitepress/bin/vitepress.js node_modules/vitepress/bin/vitepress.js
-
-          # VitePress links vue into the site's node_modules at build time;
-          # Wine cannot CREATE Windows symlinks (ENOTSUP) but follows
-          # pre-existing Unix ones, so lay the link down host-side.
-          if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then
-            mkdir -p website/node_modules
-            ln -s ../../node_modules/vue website/node_modules/vue
+          if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then
+            sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb
+          else
+            sudo apt-get update
+            sudo apt-get install -y --no-install-recommends --download-only wine
+            mkdir -p "$HOME/wine-debs"
+            cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true
+            sudo apt-get install -y --no-install-recommends wine
           fi
 
-          "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version"
-
-      # The two blocking surfaces run concurrently, the same shape run-gates
-      # gives ci-windows-blocking on native Windows: `build` = tsc -b then
-      # tsdown, `production site` = the VitePress build. Both statuses are
-      # captured so one failure cannot hide the other's result.
-      - name: Run blocking Windows gates concurrently under Wine
+      - name: Run the Wine Windows gates
         run: |
-          build_gate() {
-            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" "$TSC_JS" -b --pretty false || return $?
-            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" "$TSDOWN_JS"
-          }
-          site_gate() {
-            cd website
-            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" "$VITEPRESS_JS" build .
-          }
-          start=$SECONDS
-          build_gate > "$RUNNER_TEMP/build-gate.out" 2>&1 &
-          build_pid=$!
-          site_gate > "$RUNNER_TEMP/site-gate.out" 2>&1 &
-          site_pid=$!
-          build_status=0
-          wait "$build_pid" || build_status=$?
-          site_status=0
-          wait "$site_pid" || site_status=$?
-          echo "== build gate (exit $build_status, $((SECONDS - start))s elapsed) =="
-          tail -n 120 "$RUNNER_TEMP/build-gate.out"
-          echo "== production site gate (exit $site_status, $((SECONDS - start))s elapsed) =="
-          tail -n 120 "$RUNNER_TEMP/site-gate.out"
-          if (( build_status != 0 )); then exit "$build_status"; fi
-          exit "$site_status"
+          corepack enable
+          bash scripts/wine-windows-gates.sh
 
       - name: Shut down wineserver
         if: always()

+ 1 - 0
AGENTS.md

@@ -59,6 +59,7 @@ pnpm run typecheck
 pnpm run lint
 pnpm run duplication    # cross-file TypeScript clone detection
 pnpm run build          # tsc emits lib/types, tsdown bundles runtime
+pnpm run check:windows-wine  # optional: CI's blocking Windows gates locally (needs wine)
 pnpm run hygiene        # knip + publint + workspace constraints + NodeNext consumer check
 pnpm run doc-sync       # all documentation gates; leaf list in scripts/run-gates.ts
 pnpm run website:build  # VitePress build (doubles as dead-link check)

+ 1 - 0
package.json

@@ -40,6 +40,7 @@
     "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking",
     "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete",
     "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational",
+    "check:windows-wine": "bash scripts/wine-windows-gates.sh",
     "check:node-compat": "tsx scripts/run-gates.ts node-compat",
     "knip": "knip --treat-config-hints-as-errors",
     "publint": "tsx scripts/publint-all.ts",

+ 1 - 1
scripts/doc-budgets.manifest.json

@@ -1,5 +1,5 @@
 {
-  "AGENTS.md": 1680,
+  "AGENTS.md": 1695,
   "docs/AGENTS.md": 1150,
   "docs/architecture.md": 1800,
   "docs/cordis-primer.md": 600,

+ 187 - 0
scripts/wine-windows-gates.sh

@@ -0,0 +1,187 @@
+#!/usr/bin/env bash
+# Run the blocking Windows gates (workspace build, production site) with real
+# win-x64 Node.js under Wine — the same script the pull-request `windows` job
+# in ci.yml executes and the optional local gate `pnpm run check:windows-wine`
+# wraps. Owning rationale, fidelity limits, and measured timings:
+# .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
+#
+# The working tree is never mutated: tracked plus untracked-unignored files
+# are snapshotted into a scratch directory, the Wine-specific pnpm overrides
+# (hoisted layout, win32-x64 platform packages) are appended to the SNAPSHOT's
+# pnpm-workspace.yaml, and the install and gates run there against the shared
+# pnpm store. The Wine prefix and the checksum-verified Windows Node zip
+# persist in .cache/wine-windows/ so reruns skip provisioning.
+#
+# Environment: DSH_WINE_NODE_MAJOR (default $PRIMARY_NODE_VERSION, then 24)
+# picks the Windows Node line; DSH_WINE_GATE_CACHE_DIR relocates the cache;
+# DSH_WINE_GATE_KEEP=1 preserves the scratch tree for inspection.
+
+set -euo pipefail
+
+repo_root="$(git rev-parse --show-toplevel)"
+node_major="${DSH_WINE_NODE_MAJOR:-${PRIMARY_NODE_VERSION:-24}}"
+cache_dir="${DSH_WINE_GATE_CACHE_DIR:-$repo_root/.cache/wine-windows}"
+
+export WINEDEBUG='-all'
+export WINEARCH=win64
+# Skip Wine Mono / Gecko installers: Node needs neither.
+export WINEDLLOVERRIDES='mscoree,mshtml='
+export WINEPREFIX="$cache_dir/prefix"
+
+# ---- preflight: fail loud before any expensive work --------------------
+wine_bin=''
+for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do
+  if [ -n "$candidate" ] && [ -x "$candidate" ]; then wine_bin="$candidate"; break; fi
+done
+missing=()
+[ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)')
+command -v curl > /dev/null || missing+=('curl')
+command -v unzip > /dev/null || missing+=('unzip')
+if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi
+command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)')
+if (( ${#missing[@]} > 0 )); then
+  printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2
+  exit 1
+fi
+
+scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")"
+cleanup() {
+  wineserver -k > /dev/null 2>&1 || true
+  if [ "${DSH_WINE_GATE_KEEP:-0}" = '1' ]; then
+    echo "wine-windows-gates: scratch tree kept at $scratch"
+  else
+    rm -rf "$scratch"
+  fi
+}
+trap cleanup EXIT
+mkdir -p "$cache_dir" "$scratch/logs"
+
+# ---- provision Windows Node, boot Wine, snapshot + install concurrently ----
+provision_node() {
+  # Latest release of the primary line, checksum-verified against the same
+  # dist directory. Offline runs fall back to the newest cached zip, loudly.
+  local version zip
+  version="$(curl -fsSL --max-time 30 https://nodejs.org/dist/index.json 2> /dev/null \
+    | 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)})" \
+    || true)"
+  if [ -n "$version" ]; then
+    zip="$cache_dir/node-$version-win-x64.zip"
+    if [ ! -f "$zip" ]; then
+      curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip"
+      curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \
+        | awk -v a="node-$version-win-x64.zip" -v f="$zip.tmp" '$2 == a { print $1 "  " f }' \
+        | sha256sum --check - > /dev/null
+      mv "$zip.tmp" "$zip"
+    fi
+  else
+    zip="$(ls -t "$cache_dir"/node-v"$node_major".*-win-x64.zip 2> /dev/null | head -1 || true)"
+    [ -n "$zip" ] || { echo "wine-windows-gates: nodejs.org unreachable and no cached Windows Node v$node_major zip in $cache_dir" >&2; exit 1; }
+    echo "wine-windows-gates: nodejs.org unreachable; using cached $(basename "$zip")" >&2
+  fi
+  unzip -q -o "$zip" -d "$scratch/node-win"
+  echo "$scratch/node-win/$(basename "$zip" .zip)/node.exe" > "$scratch/node-win-path"
+}
+
+boot_wine() {
+  "$wine_bin" wineboot --init > /dev/null 2>&1 || true
+  wineserver -w || true
+}
+
+snapshot_and_install() {
+  # Tracked + untracked-unignored files, minus agent-session litter; the
+  # existence filter drops paths staged as deleted. Then the Wine-specific
+  # install-time overrides go on the SNAPSHOT only: hoisted because Windows
+  # Node under Wine does not realpath pnpm's isolated-layout symlinks, and
+  # win32-x64 so the Windows esbuild/rolldown/rollup binaries materialize.
+  # Neither is recorded in the lockfile, so --frozen-lockfile stays valid;
+  # --ignore-scripts skips host lifecycle scripts no gate loads.
+  git -C "$repo_root" ls-files -z --cached --others --exclude-standard -- . ':!:.claude' ':!:.codex' \
+    | while IFS= read -r -d '' file; do [ -e "$repo_root/$file" ] && printf '%s\0' "$file"; done \
+    | tar -C "$repo_root" --null --files-from=- -cf - \
+    | tar -C "$scratch/tree" -xf -
+  cat >> "$scratch/tree/pnpm-workspace.yaml" << 'EOF'
+
+nodeLinker: hoisted
+supportedArchitectures:
+  os: [current, win32]
+  cpu: [current, x64]
+EOF
+  (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \
+    || { tail -40 "$scratch/logs/install.log" >&2; return 1; }
+}
+
+mkdir "$scratch/tree"
+start=$SECONDS
+provision_node & node_pid=$!
+boot_wine & wine_pid=$!
+snapshot_and_install & install_pid=$!
+for task_pid in "$node_pid" "$wine_pid" "$install_pid"; do wait "$task_pid"; done
+node_win="$(cat "$scratch/node-win-path")"
+echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))"
+
+# ---- resolve entrypoints, lay the vue link, smoke ------------------------
+# Node under Wine cannot attach stdio to pipes the caller owns (Socket open
+# EBADF at bootstrap), so every invocation routes stdio through a file.
+wine_node() {
+  local log="$1"
+  shift
+  local status=0
+  "$wine_bin" "$node_win" "$@" < /dev/null > "$log" 2>&1 || status=$?
+  return "$status"
+}
+
+cd "$scratch/tree"
+tsc_js='node_modules/typescript/bin/tsc'
+tsdown_js='node_modules/tsdown/dist/run.mjs'
+vitepress_js='node_modules/vitepress/bin/vitepress.js'
+[ -f "$vitepress_js" ] || vitepress_js='website/node_modules/vitepress/bin/vitepress.js'
+for entry in "$tsc_js" "$tsdown_js" "$vitepress_js"; do
+  [ -f "$entry" ] || { echo "wine-windows-gates: expected entrypoint missing after hoisted install: $entry" >&2; exit 1; }
+done
+# VitePress links vue into the site's node_modules at build time; Wine cannot
+# CREATE Windows symlinks (ENOTSUP) but follows pre-existing Unix ones.
+if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then
+  mkdir -p website/node_modules
+  ln -s ../../node_modules/vue website/node_modules/vue
+fi
+
+wine_node "$scratch/logs/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version"
+cat "$scratch/logs/smoke.log"
+grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; }
+
+# ---- the two blocking surfaces, concurrently ------------------------------
+# The same shape run-gates gives ci-windows-blocking on native Windows:
+# `build` = tsc -b then tsdown, `production site` = the VitePress build. Both
+# statuses are captured so one failure cannot hide the other's result.
+build_gate() {
+  wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $?
+  wine_node "$scratch/logs/tsdown.log" "$tsdown_js"
+}
+site_gate() {
+  cd website
+  wine_node "$scratch/logs/site.log" "../$vitepress_js" build .
+}
+
+start=$SECONDS
+build_gate & build_pid=$!
+site_gate & site_pid=$!
+build_status=0
+wait "$build_pid" || build_status=$?
+site_status=0
+wait "$site_pid" || site_status=$?
+elapsed=$((SECONDS - start))
+
+report() {
+  local label="$1" status="$2"
+  shift 2
+  if (( status == 0 )); then
+    echo "wine-windows-gates: PASS $label (${elapsed}s window)"
+  else
+    echo "== FAILED $label (exit $status) ==" >&2
+    for log in "$@"; do tail -n 200 "$log" >&2 || true; done
+  fi
+}
+report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log"
+report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log"
+if (( build_status != 0 )); then exit "$build_status"; fi
+exit "$site_status"