From 9b3ba61b39fa66d752e57874213fac731311e5be Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 15 Jan 2026 08:26:01 -0800 Subject: [PATCH 1/3] fix: parse and validate read_console types --- Server/src/services/tools/read_console.py | 42 +++++++++- .../integration/test_read_console_truncate.py | 77 +++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/Server/src/services/tools/read_console.py b/Server/src/services/tools/read_console.py index 5f61362f9..64ea8317e 100644 --- a/Server/src/services/tools/read_console.py +++ b/Server/src/services/tools/read_console.py @@ -8,7 +8,7 @@ from services.registry import mcp_for_unity_tool from services.tools import get_unity_instance_from_context -from services.tools.utils import coerce_int, coerce_bool +from services.tools.utils import coerce_int, coerce_bool, parse_json_payload from transport.unity_transport import send_with_unity_instance from transport.legacy.unity_connection import async_send_command_with_retry @@ -31,7 +31,8 @@ async def read_console( action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console. Defaults to 'get' if omitted."] | None = None, types: Annotated[list[Literal['error', 'warning', - 'log', 'all']], "Message types to get"] | None = None, + 'log', 'all']] | str, + "Message types to get (accepts list or JSON string)"] | None = None, count: Annotated[int | str, "Max messages to return in non-paging mode (accepts int or string, e.g., 5 or '5'). Ignored when paging with page_size/cursor."] | None = None, filter_text: Annotated[str, "Text filter for messages"] | None = None, @@ -51,7 +52,42 @@ async def read_console( unity_instance = get_unity_instance_from_context(ctx) # Set defaults if values are None action = action if action is not None else 'get' - types = types if types is not None else ['error', 'warning', 'log'] + + # Parse types if it's a JSON string (handles client compatibility issue #561) + if isinstance(types, str): + types = parse_json_payload(types) + # Validate types is a list after parsing + if types is not None and not isinstance(types, list): + return { + "success": False, + "message": ( + f"types must be a list, got {type(types).__name__}. " + "If passing as JSON string, use format: '[\"error\", \"warning\"]'" + ) + } + if types is not None: + allowed_types = {"error", "warning", "log", "all"} + normalized_types = [] + for entry in types: + if not isinstance(entry, str): + return { + "success": False, + "message": f"types entries must be strings, got {type(entry).__name__}" + } + normalized = entry.strip().lower() + if normalized not in allowed_types: + return { + "success": False, + "message": ( + f"invalid types entry '{entry}'. " + f"Allowed values: {sorted(allowed_types)}" + ) + } + normalized_types.append(normalized) + types = normalized_types + else: + types = ['error', 'warning', 'log'] + format = format if format is not None else 'plain' # Coerce booleans defensively (strings like 'true'/'false') diff --git a/Server/tests/integration/test_read_console_truncate.py b/Server/tests/integration/test_read_console_truncate.py index 86fcd6a25..a1d734794 100644 --- a/Server/tests/integration/test_read_console_truncate.py +++ b/Server/tests/integration/test_read_console_truncate.py @@ -183,3 +183,80 @@ async def fake_send(_cmd, params, **_kwargs): assert len(resp["data"]["items"]) == 5 assert resp["data"]["truncated"] is False assert resp["data"]["nextCursor"] is None + + +@pytest.mark.asyncio +async def test_read_console_types_json_string(monkeypatch): + """Test that read_console handles types parameter as JSON string (fixes issue #561).""" + tools = setup_tools() + read_console = tools["read_console"] + + captured = {} + + async def fake_send_with_unity_instance(_send_fn, _unity_instance, _command_type, params, **_kwargs): + captured["params"] = params + return { + "success": True, + "data": {"lines": [{"level": "error", "message": "test error"}]}, + } + + import services.tools.read_console as read_console_mod + monkeypatch.setattr( + read_console_mod, + "send_with_unity_instance", + fake_send_with_unity_instance, + ) + + # Test with types as JSON string (the problematic case from issue #561) + resp = await read_console(ctx=DummyContext(), action="get", types='["error", "warning", "all"]') + assert resp["success"] is True + # Verify types was parsed correctly and sent as a list + assert isinstance(captured["params"]["types"], list) + assert captured["params"]["types"] == ["error", "warning", "all"] + + # Test case normalization to lowercase + captured.clear() + resp = await read_console(ctx=DummyContext(), action="get", types='["ERROR", "Warning", "LOG"]') + assert resp["success"] is True + assert captured["params"]["types"] == ["error", "warning", "log"] + + # Test with types as actual list (should still work) + captured.clear() + resp = await read_console(ctx=DummyContext(), action="get", types=["error", "warning"]) + assert resp["success"] is True + assert isinstance(captured["params"]["types"], list) + assert captured["params"]["types"] == ["error", "warning"] + + +@pytest.mark.asyncio +async def test_read_console_types_validation(monkeypatch): + """Test that read_console validates types entries and rejects invalid values.""" + tools = setup_tools() + read_console = tools["read_console"] + + captured = {} + + async def fake_send_with_unity_instance(_send_fn, _unity_instance, _command_type, params, **_kwargs): + captured["params"] = params + return {"success": True, "data": {"lines": []}} + + import services.tools.read_console as read_console_mod + monkeypatch.setattr( + read_console_mod, + "send_with_unity_instance", + fake_send_with_unity_instance, + ) + + # Invalid entry in list should return a clear error and not send. + captured.clear() + resp = await read_console(ctx=DummyContext(), action="get", types='["error", "nope"]') + assert resp["success"] is False + assert "invalid types entry" in resp["message"] + assert captured == {} + + # Non-string entry should return a clear error and not send. + captured.clear() + resp = await read_console(ctx=DummyContext(), action="get", types='[1, "error"]') + assert resp["success"] is False + assert "types entries must be strings" in resp["message"] + assert captured == {} \ No newline at end of file From 8fc51a21fff186a5f3af3405be52ef46dc9e025c Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 15 Jan 2026 12:19:55 -0800 Subject: [PATCH 2/3] Remove unused GitHub workflow files --- .github/workflows/claude-gameobject-suite.yml | 637 ------------------ .github/workflows/claude-mcp-preflight.yml | 55 -- .github/workflows/unity-tests-fork.yml | 199 ------ 3 files changed, 891 deletions(-) delete mode 100644 .github/workflows/claude-gameobject-suite.yml delete mode 100644 .github/workflows/claude-mcp-preflight.yml delete mode 100644 .github/workflows/unity-tests-fork.yml diff --git a/.github/workflows/claude-gameobject-suite.yml b/.github/workflows/claude-gameobject-suite.yml deleted file mode 100644 index e8a56aa47..000000000 --- a/.github/workflows/claude-gameobject-suite.yml +++ /dev/null @@ -1,637 +0,0 @@ -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 }} - diff --git a/.github/workflows/claude-mcp-preflight.yml b/.github/workflows/claude-mcp-preflight.yml deleted file mode 100644 index dff69bd7b..000000000 --- a/.github/workflows/claude-mcp-preflight.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Claude MCP Preflight (no Unity) - -on: [workflow_dispatch] - -permissions: - contents: read - -jobs: - mcp-preflight: - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: astral-sh/setup-uv@v4 - with: - python-version: "3.11" - - - name: Install MCP server deps - 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" >&2 - exit 1 - fi - - - name: Preflight MCP server (stdio) - env: - PYTHONUNBUFFERED: "1" - MCP_LOG_LEVEL: debug - UNITY_PROJECT_ROOT: ${{ github.workspace }}/TestProjects/UnityMCPTests - UNITY_MCP_STATUS_DIR: ${{ github.workspace }}/.unity-mcp-dummy - UNITY_MCP_HOST: 127.0.0.1 - run: | - set -euxo pipefail - mkdir -p "$UNITY_MCP_STATUS_DIR" - # Create a dummy status file with an unreachable port; help should not require it - cat > "$UNITY_MCP_STATUS_DIR/unity-mcp-status-dummy.json" < /tmp/mcp-preflight.log 2>&1 || { cat /tmp/mcp-preflight.log; exit 1; } - cat /tmp/mcp-preflight.log - - diff --git a/.github/workflows/unity-tests-fork.yml b/.github/workflows/unity-tests-fork.yml deleted file mode 100644 index e8fdae258..000000000 --- a/.github/workflows/unity-tests-fork.yml +++ /dev/null @@ -1,199 +0,0 @@ -name: Unity Tests (fork) - -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: - test-editmode: - # Guard: run only on the fork owner's repo - if: github.repository_owner == 'dsarno' - name: Test in editmode (fork) - runs-on: ubuntu-latest - timeout-minutes: 90 - - steps: - # ---------- Secrets check ---------- - - name: Detect Unity credentials (outputs) - id: detect - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} - run: | - set -e - if [ -n "$UNITY_LICENSE" ]; then echo "unity_ok=true" >> "$GITHUB_OUTPUT"; else echo "unity_ok=false" >> "$GITHUB_OUTPUT"; fi - if [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; then echo "ebl_ok=true" >> "$GITHUB_OUTPUT"; else echo "ebl_ok=false" >> "$GITHUB_OUTPUT"; fi - if [ -n "$UNITY_SERIAL" ]; then echo "has_serial=true" >> "$GITHUB_OUTPUT"; else echo "has_serial=false" >> "$GITHUB_OUTPUT"; fi - - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Prepare reports - run: | - set -eux - rm -f reports/*.xml || true - mkdir -p reports - - # ---------- Licensing: allow both ULF and EBL ---------- - - 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 someone pasted an entitlement XML into UNITY_LICENSE by mistake, re-home it: - 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 - # provide it in the standard local-share path too - 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 -euxo pipefail - mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local" - - # Try Pro first if serial is present, otherwise named-user EBL. - 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 - ' - - # Verify entitlement written to host mount; allow ULF-only runs to proceed - 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 (import Library once) ---------- - - name: Warm up project (import Library once) - if: 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 }}:/workspace" -w /workspace \ - -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ - -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ - "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ - -projectPath /workspace/TestProjects/UnityMCPTests \ - "${manual_args[@]}" \ - -quit - - # ---------- Run editmode tests ---------- - - name: Run editmode tests (Unity CLI) - if: 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 }}:/workspace" -w /workspace \ - -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ - -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ - "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ - -projectPath /workspace/TestProjects/UnityMCPTests \ - -runTests \ - -testPlatform editmode \ - -testResults /workspace/reports/editmode-results.xml \ - -testResultsFormatter NUnit \ - "${manual_args[@]}" \ - -quit - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: unity-editmode-results - path: reports - - - name: License diagnostics when missing - if: steps.lic.outputs.use_ulf != 'true' && steps.lic.outputs.use_ebl != 'true' - run: | - echo "::error::No Unity credentials were supplied. Set UNITY_LICENSE or UNITY_EMAIL/UNITY_PASSWORD (and optionally UNITY_SERIAL) secrets in this fork." - From dcfb995a9cff401695399f1dcc68ca80349dc44a Mon Sep 17 00:00:00 2001 From: dsarno Date: Thu, 15 Jan 2026 12:37:25 -0800 Subject: [PATCH 3/3] Allow Unity workflows to run in forks Also adjust Claude NL suite licensing checks so it can run in the top-level repo. --- .github/workflows/claude-nl-suite.yml | 4 ++-- .github/workflows/unity-tests.yml | 28 ++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 4bae2e624..da67d85d8 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -34,7 +34,7 @@ jobs: 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 + 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" @@ -76,7 +76,7 @@ jobs: set -eu use_ulf=false; use_ebl=false [[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true - [[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" ]] && use_ebl=true + [[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" && -n "${UNITY_SERIAL:-}" ]] && 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" diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 191c161b1..6a2388149 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -11,8 +11,6 @@ on: jobs: testAllModes: - # Guard: only run on upstream repo; skip on forks - if: github.repository_owner == 'CoplayDev' name: Test in ${{ matrix.testMode }} runs-on: ubuntu-latest strategy: @@ -30,6 +28,26 @@ jobs: with: lfs: true + - name: Detect Unity license secrets + id: detect + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -e + 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 + + - name: Skip Unity tests (missing license secrets) + if: steps.detect.outputs.unity_ok != 'true' + run: | + echo "Unity license secrets missing; skipping Unity tests." + - uses: actions/cache@v4 with: path: ${{ matrix.projectPath }}/Library @@ -40,12 +58,14 @@ jobs: # Run domain reload tests first (they're [Explicit] so need explicit category) - name: Run domain reload tests + if: steps.detect.outputs.unity_ok == 'true' uses: game-ci/unity-test-runner@v4 id: domain-tests env: UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} with: projectPath: ${{ matrix.projectPath }} unityVersion: ${{ matrix.unityVersion }} @@ -53,19 +73,21 @@ jobs: customParameters: -testCategory domain_reload - name: Run tests + if: steps.detect.outputs.unity_ok == 'true' uses: game-ci/unity-test-runner@v4 id: tests env: UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} with: projectPath: ${{ matrix.projectPath }} unityVersion: ${{ matrix.unityVersion }} testMode: ${{ matrix.testMode }} - uses: actions/upload-artifact@v4 - if: always() + if: always() && steps.detect.outputs.unity_ok == 'true' with: name: Test results for ${{ matrix.testMode }} path: ${{ steps.tests.outputs.artifactsPath }}