name: Claude GameObject API Tests (Unity live) on: [workflow_dispatch] permissions: contents: read checks: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3 jobs: go-suite: runs-on: ubuntu-24.04 timeout-minutes: 45 env: JUNIT_OUT: reports/junit-go-suite.xml MD_OUT: reports/junit-go-suite.md steps: # ---------- Secrets check ---------- - 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" ]; }; 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 Server/pyproject.toml ]; then uv pip install -e Server elif [ -f Server/requirements.txt ]; then uv pip install -r Server/requirements.txt else echo "No MCP Python deps found (skipping)" fi # --- Licensing --- - name: Decide license sources id: lic shell: bash env: UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} run: | set -eu use_ulf=false; use_ebl=false [[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true [[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" ]] && use_ebl=true echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT" echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT" echo "has_serial=$([[ -n "${UNITY_SERIAL:-}" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - name: Stage Unity .ulf license (from secret) if: steps.lic.outputs.use_ulf == 'true' id: ulf env: UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} shell: bash run: | set -eu mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity" f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf" if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f" else printf "%s" "$UNITY_LICENSE" > "$f" fi chmod 600 "$f" || true if head -c 100 "$f" | grep -qi '<\?xml'; then mkdir -p "$RUNNER_TEMP/unity-config/Unity/licenses" mv "$f" "$RUNNER_TEMP/unity-config/Unity/licenses/UnityEntitlementLicense.xml" echo "ok=false" >> "$GITHUB_OUTPUT" elif grep -qi '' "$f"; then cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf" echo "ok=true" >> "$GITHUB_OUTPUT" else echo "ok=false" >> "$GITHUB_OUTPUT" fi - name: Activate Unity (EBL via container - host-mount) if: steps.lic.outputs.use_ebl == 'true' shell: bash env: UNITY_IMAGE: ${{ env.UNITY_IMAGE }} UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local" docker run --rm --network host \ -e HOME=/root \ -e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \ -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ "$UNITY_IMAGE" bash -lc ' set -euxo pipefail if [[ -n "${UNITY_SERIAL:-}" ]]; then /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true else /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -quit || true fi ls -la /root/.config/unity3d/Unity/licenses || true ' if ! find "$RUNNER_TEMP/unity-config" -type f -iname "*.xml" | grep -q .; then if [[ "${{ steps.ulf.outputs.ok }}" == "true" ]]; then echo "EBL entitlement not found; proceeding with ULF-only (ok=true)." else echo "No entitlement produced and no valid ULF; cannot continue." >&2 exit 1 fi fi # ---------- Warm up project ---------- - name: Warm up project (import Library once) if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true') shell: bash env: UNITY_IMAGE: ${{ env.UNITY_IMAGE }} ULF_OK: ${{ steps.ulf.outputs.ok }} run: | set -euxo pipefail manual_args=() if [[ "${ULF_OK:-false}" == "true" ]]; then manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") fi docker run --rm --network host \ -e HOME=/root \ -v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \ -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ -v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \ "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ -projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \ "${manual_args[@]}" \ -quit # ---------- Clean old MCP status ---------- - name: Clean old MCP status run: | set -eux mkdir -p "$GITHUB_WORKSPACE/.unity-mcp" rm -f "$GITHUB_WORKSPACE/.unity-mcp"/unity-mcp-status-*.json || true # ---------- Start headless Unity ---------- - name: Start Unity (persistent bridge) if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true') shell: bash env: UNITY_IMAGE: ${{ env.UNITY_IMAGE }} ULF_OK: ${{ steps.ulf.outputs.ok }} run: | set -euxo pipefail manual_args=() if [[ "${ULF_OK:-false}" == "true" ]]; then manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") fi mkdir -p "$GITHUB_WORKSPACE/.unity-mcp" 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="${{ github.workspace }}/.unity-mcp" \ -e UNITY_MCP_BIND_HOST=127.0.0.1 \ -v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \ -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ -v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \ "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile /root/.config/unity3d/Editor.log \ -stackTraceLogType Full \ -projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \ "${manual_args[@]}" \ -executeMethod MCPForUnity.Editor.McpCiBoot.StartStdioForCi # ---------- Wait for Unity bridge ---------- - name: Wait for Unity bridge (robust) if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true') shell: bash run: | set -euo pipefail deadline=$((SECONDS+600)) fatal_after=$((SECONDS+120)) ok_pat='(Bridge|MCP(For)?Unity|AutoConnect).*(listening|ready|started|port|bound)' license_fatal='No valid Unity|License is not active|cannot load ULF|Signature element not found|Token not found|0 entitlement|Entitlement.*(failed|denied)|License (activation|return|renewal).*(failed|expired|denied)' while [ $SECONDS -lt $deadline ]; do logs="$(docker logs unity-mcp 2>&1 || true)" port="$(jq -r '.unity_port // empty' "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" if [[ -n "${port:-}" ]] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port"; then echo "Bridge ready on port $port" docker exec unity-mcp chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || true exit 0 fi if echo "$logs" | grep -qiE "$ok_pat"; then echo "Bridge ready (log markers)" docker exec unity-mcp chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || true exit 0 fi if [ $SECONDS -ge $fatal_after ] && echo "$logs" | grep -qiE "$license_fatal"; then echo "::error::Fatal licensing signal detected after warm-up" echo "$logs" | tail -n 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' exit 1 fi st="$(docker inspect -f '{{.State.Status}}' unity-mcp 2>/dev/null || true)" if [[ "$st" != "running" ]]; then echo "::error::Unity container exited during wait"; docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' exit 1 fi sleep 2 done echo "::error::Bridge not ready before deadline" docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' exit 1 - name: Pin Claude tool permissions run: | set -eux mkdir -p .claude cat > .claude/settings.json <<'JSON' { "permissions": { "allow": [ "mcp__unity", "Edit(reports/**)", "MultiEdit(reports/**)" ], "deny": [ "Bash", "WebFetch", "WebSearch", "Task", "TodoWrite", "NotebookEdit", "NotebookRead" ] } } JSON - name: Prepare reports run: | set -eux rm -f reports/*.xml reports/*.md || true mkdir -p reports - name: Create report skeletons run: | set -eu cat > "$JUNIT_OUT" <<'XML' Bootstrap placeholder; suite will append real tests. XML printf '# Unity GameObject API Test Results\n\n' > "$MD_OUT" - name: Verify Unity bridge status run: | set -euxo pipefail shopt -s nullglob status_files=("$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json) if ((${#status_files[@]})); then first_status="${status_files[0]}" fname="$(basename "$first_status")" hash_part="${fname%.json}"; hash_part="${hash_part#unity-mcp-status-}" proj="$(jq -r '.project_name // empty' "$first_status" || true)" if [[ -n "${proj:-}" && -n "${hash_part:-}" ]]; then echo "UNITY_MCP_DEFAULT_INSTANCE=${proj}@${hash_part}" >> "$GITHUB_ENV" echo "Default instance set to ${proj}@${hash_part}" fi fi - name: Write MCP config run: | set -eux mkdir -p .claude python3 - <<'PY' import json import os import textwrap from pathlib import Path workspace = os.environ["GITHUB_WORKSPACE"] default_inst = os.environ.get("UNITY_MCP_DEFAULT_INSTANCE", "").strip() cfg = { "mcpServers": { "unity": { "args": [ "run", "--active", "--directory", "Server", "mcp-for-unity", "--transport", "stdio", ], "transport": {"type": "stdio"}, "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug", "UNITY_PROJECT_ROOT": f"{workspace}/TestProjects/UnityMCPTests", "UNITY_MCP_STATUS_DIR": f"{workspace}/.unity-mcp", "UNITY_MCP_HOST": "127.0.0.1", }, } } } unity = cfg["mcpServers"]["unity"] if default_inst: unity["env"]["UNITY_MCP_DEFAULT_INSTANCE"] = default_inst if "--default-instance" not in unity["args"]: unity["args"] += ["--default-instance", default_inst] runner_script = Path(".claude/run-unity-mcp.sh") workspace_path = Path(workspace) uv_candidate = workspace_path / ".venv" / "bin" / "uv" uv_cmd = uv_candidate.as_posix() if uv_candidate.exists() else "uv" script = textwrap.dedent(f"""\ #!/usr/bin/env bash set -euo pipefail LOG="{workspace}/.unity-mcp/mcp-server-startup-debug.log" mkdir -p "$(dirname "$LOG")" echo "" >> "$LOG" echo "[ $(date -Iseconds) ] Starting unity MCP server" >> "$LOG" exec {uv_cmd} "$@" 2>> "$LOG" """) runner_script.write_text(script) runner_script.chmod(0o755) unity["command"] = runner_script.resolve().as_posix() path = Path(".claude/mcp.json") path.write_text(json.dumps(cfg, indent=2) + "\n") print(f"Wrote {path} and {runner_script}") PY # ---------- Run Claude GO pass ---------- - name: Run Claude GO pass uses: anthropics/claude-code-base-action@beta if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' continue-on-error: true env: UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} with: use_node_cache: false prompt_file: .claude/prompts/nl-gameobject-suite.md mcp_config: .claude/mcp.json settings: .claude/settings.json allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" model: claude-haiku-4-5-20251001 fallback_model: claude-sonnet-4-5-20250929 append_system_prompt: | You are running the GameObject API tests. - Emit exactly GO-0, GO-1, GO-2, GO-3, GO-4, GO-5. - Write each to reports/${ID}_results.xml. - Stop after GO-5_results.xml is written. timeout_minutes: "25" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # ---------- Backfill missing tests ---------- - name: Backfill missing GO tests if: always() shell: bash run: | python3 - <<'PY' from pathlib import Path import xml.etree.ElementTree as ET import re DESIRED = ["GO-0","GO-1","GO-2","GO-3","GO-4","GO-5"] seen = set() def id_from_filename(p: Path): n = p.name m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I) if m: return f"GO-{int(m.group(1))}" return None for p in Path("reports").glob("*_results.xml"): fid = id_from_filename(p) if fid in DESIRED: seen.add(fid) Path("reports").mkdir(parents=True, exist_ok=True) for d in DESIRED: if d in seen: continue frag = Path(f"reports/{d}_results.xml") tc = ET.Element("testcase", {"classname":"UnityMCP.GO-T", "name": d}) fail = ET.SubElement(tc, "failure", {"message":"not produced"}) fail.text = "The agent did not emit a fragment for this test." ET.ElementTree(tc).write(frag, encoding="utf-8", xml_declaration=False) print(f"backfill: {d}") PY # ---------- Merge fragments into JUnit ---------- - name: Assemble JUnit if: always() shell: bash run: | python3 - <<'PY' from pathlib import Path import xml.etree.ElementTree as ET import re, os def localname(tag: str) -> str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-go-suite.xml')) if not src.exists(): raise SystemExit(0) tree = ET.parse(src) root = tree.getroot() suite = root.find('./*') if localname(root.tag) == 'testsuites' else root if suite is None: raise SystemExit(0) def id_from_filename(p: Path): n = p.name m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I) if m: return f"GO-{int(m.group(1))}" return None fragments = sorted(Path('reports').glob('GO-*_results.xml')) added = 0 for frag in fragments: try: froot = ET.parse(frag).getroot() if localname(froot.tag) == 'testcase': suite.append(froot) added += 1 except Exception as e: print(f"Warning: Could not parse fragment {frag}: {e}") if added: for tc in list(suite.findall('.//testcase')): if (tc.get('name') or '') == 'GO-Suite.Bootstrap': suite.remove(tc) testcases = suite.findall('.//testcase') failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None)) suite.set('tests', str(len(testcases))) suite.set('failures', str(failures_cnt)) suite.set('errors', '0') suite.set('skipped', '0') tree.write(src, encoding='utf-8', xml_declaration=True) print(f"Appended {added} testcase(s).") PY # ---------- Build markdown summary ---------- - name: Build markdown summary if: always() shell: bash run: | python3 - <<'PY' import xml.etree.ElementTree as ET from pathlib import Path import os, html, re def localname(tag: str) -> str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-go-suite.xml')) md_out = Path(os.environ.get('MD_OUT', 'reports/junit-go-suite.md')) md_out.parent.mkdir(parents=True, exist_ok=True) if not src.exists(): md_out.write_text("# Unity GameObject API Test Results\n\n(No JUnit found)\n", encoding='utf-8') raise SystemExit(0) tree = ET.parse(src) root = tree.getroot() suite = root.find('./*') if localname(root.tag) == 'testsuites' else root cases = [] if suite is None else list(suite.findall('.//testcase')) desired = ['GO-0','GO-1','GO-2','GO-3','GO-4','GO-5'] default_titles = { 'GO-0': 'Hierarchy with ComponentTypes', 'GO-1': 'Find GameObjects Tool', 'GO-2': 'GameObject Resource Read', 'GO-3': 'Components Resource Read', 'GO-4': 'Manage Components Tool', 'GO-5': 'Deprecation Warnings', } def id_from_case(tc): n = (tc.get('name') or '') m = re.match(r'\s*(GO-\d+)\b', n) if m: return m.group(1) return None id_status = {} for tc in cases: tid = id_from_case(tc) if not tid or tid not in desired or tid in id_status: continue ok = (tc.find('failure') is None and tc.find('error') is None) id_status[tid] = ok total = len(cases) failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None)) passed = total - failures lines = [ '# Unity GameObject API Test Results', '', f'Totals: {passed} passed, {failures} failed, {total} total', '', '## Test Checklist' ] for p in desired: st = id_status.get(p, None) label = f"{p} — {default_titles.get(p, '')}" lines.append(f"- [x] {label}" if st is True else (f"- [ ] {label} (fail)" if st is False else f"- [ ] {label} (not run)")) lines.append('') lines.append('## Test Details') for tc in cases: tid = id_from_case(tc) if not tid: continue title = tc.get('name') or tid ok = (tc.find('failure') is None and tc.find('error') is None) badge = "PASS" if ok else "FAIL" lines.append(f"### {title} — {badge}") so = tc.find('system-out') text = '' if so is None or so.text is None else html.unescape(so.text.strip()) if text: lines += ['```', text[:2000], '```'] else: lines.append('(no system-out)') node = tc.find('failure') or tc.find('error') if node is not None: msg = (node.get('message') or '').strip() if msg: lines.append(f"- Message: {msg}") lines.append('') md_out.write_text('\n'.join(lines), encoding='utf-8') PY - name: GO details -> Job Summary if: always() run: | echo "## Unity GameObject API Tests — Summary" >> $GITHUB_STEP_SUMMARY python3 - <<'PY' >> $GITHUB_STEP_SUMMARY from pathlib import Path p = Path('reports/junit-go-suite.md') if p.exists(): text = p.read_bytes().decode('utf-8', 'replace') print(text[:65000]) else: print("_No markdown report found._") PY - name: Publish JUnit report if: always() uses: mikepenz/action-junit-report@v5 with: report_paths: "${{ env.JUNIT_OUT }}" 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-go-suite-artifacts path: | ${{ env.JUNIT_OUT }} ${{ env.MD_OUT }} reports/*_results.xml retention-days: 7 # ---------- Cleanup ---------- - name: Stop Unity if: always() run: | docker logs --tail 400 unity-mcp | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true docker rm -f unity-mcp || true - name: Return Pro license (if used) if: always() && steps.lic.outputs.use_ebl == 'true' && steps.lic.outputs.has_serial == 'true' uses: game-ci/unity-return-license@v2 continue-on-error: true env: UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}