Add fallback JUnit report and adjust publisher #99
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Claude NL suite (Unity live) | ||
| on: | ||
| workflow_dispatch: {} | ||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
| issues: write | ||
| 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 | ||
| 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 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 & 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 || 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 <<'JSON' | ||
| { | ||
| "mcpServers": { | ||
| "unity": { | ||
| "command": "uv", | ||
| "args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], | ||
| "transport": { "type": "stdio" }, | ||
| "env": { | ||
| "PYTHONUNBUFFERED": "1", | ||
| "MCP_LOG_LEVEL": "debug", | ||
| "UNITY_PROJECT_ROOT": "${{ github.workspace }}/TestProjects/UnityMCPTests" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| JSON | ||
| # ---------- Ensure reports dir exists ---------- | ||
| - name: Prepare reports | ||
| run: | | ||
| set -eux | ||
| mkdir -p reports | ||
| # ---------- Run full NL suite once ---------- | ||
| - name: Run Claude NL suite (single pass) | ||
| uses: anthropics/claude-code-base-action@beta | ||
| if: steps.detect.outputs.anthropic_ok == 'true' | ||
| env: | ||
| JUNIT_OUT: reports/junit-nl-suite.xml | ||
| MD_OUT: reports/junit-nl-suite.md | ||
| with: | ||
| use_node_cache: false | ||
| prompt_file: .claude/prompts/nl-unity-suite-full.md | ||
| claude_args: | | ||
| --mcp-config .claude/mcp.json | ||
| --allowedTools Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,Bash(git:*),Bash(mkdir:*),Bash(cat:*),Bash(grep:*),Bash(echo:*) | ||
| --disallowedTools "TodoWrite,Task" | ||
| --model "claude-3-7-sonnet-latest" | ||
| anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} | ||
| # 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: Fallback JUnit if missing | ||
| if: always() | ||
| run: | | ||
| set -eu | ||
| mkdir -p reports | ||
| if ! ls reports/junit-*.xml >/dev/null 2>&1; then | ||
| echo "No JUnit found; writing fallback." | ||
| { | ||
| cat <<'XML' | ||
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <testsuite name="UnityMCP.NL-T" tests="1" failures="1" time="0"> | ||
| <testcase classname="UnityMCP.NL" name="NL-Suite.Execution" time="0.0"> | ||
| <failure><![CDATA[ | ||
| Suite ended without producing reports. Likely mid-run tool failure (e.g., stale_file) or driver termination. | ||
| See the 'Run Claude NL suite (single pass)' logs for details. | ||
| ]]></failure> | ||
| </testcase> | ||
| </testsuite> | ||
| XML | ||
| } > reports/junit-fallback.xml | ||
| fi | ||
| - name: Publish JUnit reports | ||
| if: always() | ||
| uses: mikepenz/action-junit-report@v4 | ||
| with: | ||
| report_paths: 'reports/junit-*.xml' | ||
| require_tests: false | ||
| annotate_notice: true | ||
| - name: Upload artifacts | ||
| if: always() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: claude-nl-suite-artifacts | ||
| path: | | ||
| reports/junit-*.xml | ||
| reports/claude-*.xml | ||
| reports/*.md | ||
| # ---------- Always stop Unity ---------- | ||
| - name: Stop Unity | ||
| if: always() | ||
| run: | | ||
| docker logs unity-mcp || true | ||
| docker rm -f unity-mcp || true | ||