name: Claude Mini NL Test Suite (Unity live) on: workflow_dispatch: {} permissions: contents: read checks: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: UNITY_VERSION: 2021.3.45f1 UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3 UNITY_CACHE_ROOT: /home/runner/work/_temp/_github_home jobs: nl-suite: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 60 env: JUNIT_OUT: reports/junit-nl-suite.xml MD_OUT: reports/junit-nl-suite.md steps: # ---------- Detect secrets ---------- - name: Detect secrets (outputs) id: detect env: UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | set -e if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; } || [ -n "$UNITY_SERIAL" ]; then echo "unity_ok=true" >> "$GITHUB_OUTPUT" else echo "unity_ok=false" >> "$GITHUB_OUTPUT" fi - uses: actions/checkout@v4 with: fetch-depth: 0 # ---------- Python env for MCP server (uv) ---------- - uses: astral-sh/setup-uv@v4 with: python-version: '3.11' - name: Install MCP server run: | set -eux uv venv echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" if [ -f UnityMcpBridge/UnityMcpServer~/src/pyproject.toml ]; then uv pip install -e UnityMcpBridge/UnityMcpServer~/src elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then uv pip install -e UnityMcpBridge/UnityMcpServer~/ elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt else echo "No MCP Python deps found (skipping)" fi # ---------- License prime on host (handles ULF or EBL) ---------- - name: Prime Unity license on host (GameCI) if: steps.detect.outputs.unity_ok == 'true' uses: game-ci/unity-test-runner@v4 env: UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} with: projectPath: TestProjects/UnityMCPTests testMode: EditMode customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics unityVersion: ${{ env.UNITY_VERSION }} # (Optional) Show where the license actually got written - name: Inspect GameCI license caches (host) if: steps.detect.outputs.unity_ok == 'true' run: | set -eux find "${{ env.UNITY_CACHE_ROOT }}" -maxdepth 4 \( -path "*/.cache" -prune -o -type f \( -name '*.ulf' -o -name 'user.json' \) -print \) 2>/dev/null || true # ---------- Clean any stale MCP status from previous runs ---------- - name: Clean old MCP status run: | set -eux mkdir -p "$HOME/.unity-mcp" rm -f "$HOME/.unity-mcp"/unity-mcp-status-*.json || true # ---------- Start headless Unity that stays up (bridge enabled) ---------- - name: Start Unity (persistent bridge) if: steps.detect.outputs.unity_ok == 'true' env: UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} run: | set -eu if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then echo "Unity project not found; failing fast." exit 1 fi mkdir -p "$HOME/.unity-mcp" MANUAL_ARG=() if [ -f "${UNITY_CACHE_ROOT}/.local/share/unity3d/Unity_lic.ulf" ]; then MANUAL_ARG=(-manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf) fi EBL_ARGS=() [ -n "${UNITY_SERIAL:-}" ] && EBL_ARGS+=(-serial "$UNITY_SERIAL") [ -n "${UNITY_EMAIL:-}" ] && EBL_ARGS+=(-username "$UNITY_EMAIL") [ -n "${UNITY_PASSWORD:-}" ] && EBL_ARGS+=(-password "$UNITY_PASSWORD") docker rm -f unity-mcp >/dev/null 2>&1 || true docker run -d --name unity-mcp --network host \ -e HOME=/root \ -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ -e UNITY_MCP_BIND_HOST=127.0.0.1 \ -v "${{ github.workspace }}:/workspace" -w /workspace \ -v "${{ env.UNITY_CACHE_ROOT }}:/root" \ -v "$HOME/.unity-mcp:/root/.unity-mcp" \ ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -stackTraceLogType Full \ -projectPath /workspace/TestProjects/UnityMCPTests \ "${MANUAL_ARG[@]}" \ "${EBL_ARGS[@]}" \ -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect # ---------- Wait for Unity bridge (fail fast if not running/ready) ---------- - name: Wait for Unity bridge (robust) if: steps.detect.outputs.unity_ok == 'true' run: | set -euo pipefail if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then echo "Unity container failed to start"; docker ps -a || true; exit 1 fi docker logs -f unity-mcp 2>&1 | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' & LOGPID=$! deadline=$((SECONDS+420)); READY=0 try_connect_host() { P="$1" timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$P; head -c 8 <&3 >/dev/null" && return 0 || true if command -v nc >/dev/null 2>&1; then nc -6 -z ::1 "$P" && return 0 || true; fi return 1 } # in-container probe will try IPv4 then IPv6 via nc or /dev/tcp while [ $SECONDS -lt $deadline ]; do if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then READY=1; echo "Bridge ready (log markers)"; break fi PORT=$(python -c "import os,glob,json,sys,time; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(next((json.load(open(f,'r',encoding='utf-8')).get('unity_port') for f in fs if time.time()-os.path.getmtime(f)<=300 and json.load(open(f,'r',encoding='utf-8')).get('unity_port')), '' ))" 2>/dev/null || true) if [ -n "${PORT:-}" ] && { try_connect_host "$PORT" || docker exec unity-mcp bash -lc "timeout 1 bash -lc 'exec 3<>/dev/tcp/127.0.0.1/$PORT' || (command -v nc >/dev/null 2>&1 && nc -6 -z ::1 $PORT)"; }; then READY=1; echo "Bridge ready on port $PORT"; break fi if docker logs unity-mcp 2>&1 | grep -qE "No valid Unity Editor license|Token not found in cache|com\.unity\.editor\.headless"; then echo "Licensing error detected"; break fi sleep 2 done kill $LOGPID || true if [ "$READY" != "1" ]; then echo "Bridge not ready; diagnostics:" echo "== status files =="; ls -la "$HOME/.unity-mcp" || true echo "== status contents =="; for f in "$HOME"/.unity-mcp/unity-mcp-status-*.json; do [ -f "$f" ] && { echo "--- $f"; sed -n '1,120p' "$f"; }; done echo "== sockets (inside container) =="; docker exec unity-mcp bash -lc 'ss -lntp || netstat -tulpen || true' echo "== tail of Unity log ==" docker logs --tail 200 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true exit 1 fi # ---------- Make MCP config available to the action ---------- - name: Write MCP config (.claude/mcp.json) run: | set -eux mkdir -p .claude cat > .claude/mcp.json < str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) out = Path('reports/junit-for-actions.xml') out.parent.mkdir(parents=True, exist_ok=True) if not src.exists(): # Try to use any existing XML as a source (e.g., claude-nl-tests.xml) candidates = sorted(Path('reports').glob('*.xml')) if candidates: src = candidates[0] else: print("WARN: no XML source found for normalization") if src.exists(): try: root = ET.parse(src).getroot() rtag = localname(root.tag) if rtag == 'testsuites' and len(root) == 1 and localname(root[0].tag) == 'testsuite': ET.ElementTree(root[0]).write(out, encoding='utf-8', xml_declaration=True) else: out.write_bytes(src.read_bytes()) except Exception as e: print("Normalization error:", e) out.write_bytes(src.read_bytes()) # Always create a second copy with a junit-* name so wildcard patterns match too if out.exists(): Path('reports/junit-nl-suite-copy.xml').write_bytes(out.read_bytes()) PY - name: "Debug: list report files" if: always() shell: bash run: | set -eux ls -la reports || true shopt -s nullglob for f in reports/*.xml; do echo "===== $f =====" head -n 40 "$f" || true done # sanitize only the markdown (does not touch JUnit xml) - name: Sanitize markdown (all shards) if: always() run: | set -eu python - <<'PY' from pathlib import Path rp=Path('reports') rp.mkdir(parents=True, exist_ok=True) for p in rp.glob('*.md'): b=p.read_bytes().replace(b'\x00', b'') s=b.decode('utf-8','replace').replace('\r\n','\n') p.write_text(s, encoding='utf-8', newline='\n') PY - name: NL/T details → Job Summary if: always() run: | echo "## Unity NL/T Editing Suite — Full Coverage" >> $GITHUB_STEP_SUMMARY python - <<'PY' >> $GITHUB_STEP_SUMMARY from pathlib import Path p = Path('reports/junit-nl-suite.md') if Path('reports/junit-nl-suite.md').exists() else Path('reports/claude-nl-tests.md') if p.exists(): text = p.read_bytes().decode('utf-8', 'replace') MAX = 65000 print(text[:MAX]) if len(text) > MAX: print("\n\n_…truncated in summary; full report is in artifacts._") else: print("_No markdown report found._") PY - name: Fallback JUnit if missing if: always() run: | set -eu mkdir -p reports if [ ! -f reports/junit-for-actions.xml ]; then printf '%s\n' \ '' \ '' \ ' ' \ ' ' \ ' ' \ '' \ > reports/junit-for-actions.xml fi - name: Publish JUnit reports if: always() uses: mikepenz/action-junit-report@v5 with: report_paths: 'reports/junit-for-actions.xml' include_passed: true detailed_summary: true annotate_notice: true require_tests: false fail_on_parse_error: true - name: Upload artifacts if: always() uses: actions/upload-artifact@v4 with: name: claude-nl-suite-artifacts path: reports/** # ---------- Always stop Unity ---------- - name: Stop Unity if: always() run: | docker logs --tail 400 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true docker rm -f unity-mcp || true