name: Unity Tests on: workflow_dispatch: {} workflow_call: inputs: ref: description: "Git ref to test (defaults to the triggering ref)." type: string required: false default: "" push: # Exclude beta and main: those branches re-trigger this workflow via # workflow_call from beta-release.yml / release.yml. Without the exclusion, # a push to beta that touches MCPForUnity/** fires this workflow twice # for the same SHA, and GitHub's auto-generated concurrency group # (`unity-tests-refs/heads/beta`) cancels the older run with the noisy # "higher priority waiting request" annotation. branches-ignore: [beta, main] paths: - TestProjects/UnityMCPTests/** - MCPForUnity/Editor/** - MCPForUnity/Runtime/** - .github/workflows/unity-tests.yml # Same-repo PRs get a unity-tests status check on every open / push via this trigger # (mirrors python-tests.yml). Fork PRs ALSO fire this trigger but run in the fork's # context without secrets — the detect step downstream writes unity_ok=false and the # job exits clean with a "missing license secrets" notice so the status check still # appears. Maintainers apply 'safe-to-test' to invoke pull_request_target below for # a real fork-PR test run. pull_request: branches: [main, beta] paths: - TestProjects/UnityMCPTests/** - MCPForUnity/Editor/** - MCPForUnity/Runtime/** - .github/workflows/unity-tests.yml # Fork PRs: maintainer applies the 'safe-to-test' label after reviewing # the diff. The workflow runs with UNITY_LICENSE in scope against the # PR's head SHA. Re-pushed commits do NOT auto-trigger — maintainer must # remove and re-apply the label to re-run after additional review. pull_request_target: types: [labeled] branches: [main, beta] paths: - TestProjects/UnityMCPTests/** - MCPForUnity/Editor/** - MCPForUnity/Runtime/** - .github/workflows/unity-tests.yml # Dedup runs for the same branch across push / pull_request / pull_request_target / workflow_call. # Same-repo PRs would otherwise fire both push (on the branch SHA) AND pull_request (on the PR); # concurrency keeps only the newer in-flight run per branch. concurrency: group: unity-tests-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: matrix: name: Compute Unity version matrix runs-on: ubuntu-latest permissions: contents: read # Gate (mirrored by testAllModes below): # - Always run for non-PR triggers (push / workflow_call / workflow_dispatch). # - Fork PRs: require 'safe-to-test' to be applied (existing secret-safety gate); # 'full-matrix' may be added on top to opt into the full 4-version matrix. # - In-repo PRs: only re-run via pull_request_target when 'full-matrix' is the # label that just fired (the push-event run already covered the default leg). if: > github.event_name != 'pull_request_target' || ( github.event.pull_request.head.repo.full_name != github.repository && contains(github.event.pull_request.labels.*.name, 'safe-to-test') && (github.event.label.name == 'safe-to-test' || github.event.label.name == 'full-matrix') ) || ( github.event.pull_request.head.repo.full_name == github.repository && github.event.label.name == 'full-matrix' ) outputs: versions: ${{ steps.set.outputs.versions }} steps: - name: Checkout version manifest uses: actions/checkout@v4 with: ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }} sparse-checkout: tools/unity-versions.json sparse-checkout-cone-mode: false persist-credentials: false - name: Select versions for this trigger id: set env: EVENT_NAME: ${{ github.event_name }} GH_REF: ${{ github.ref }} FULL_MATRIX_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'full-matrix') }} run: | set -euo pipefail # Full matrix on: beta push, workflow_call (release pipelines), workflow_dispatch, # or any PR (pull_request OR pull_request_target) labeled with 'full-matrix'. # Default (single defaultVersion from tools/unity-versions.json) otherwise — fast PR feedback. if [[ "$EVENT_NAME" == "workflow_dispatch" ]] || \ [[ "$EVENT_NAME" == "workflow_call" ]] || \ { [[ "$EVENT_NAME" == "push" ]] && [[ "$GH_REF" == "refs/heads/beta" ]]; } || \ { { [[ "$EVENT_NAME" == "pull_request" ]] || [[ "$EVENT_NAME" == "pull_request_target" ]]; } && [[ "$FULL_MATRIX_LABEL" == "true" ]]; }; then versions=$(jq -c '[.versions[].id]' tools/unity-versions.json) echo "Trigger '$EVENT_NAME' on ref '$GH_REF' (full_matrix_label=$FULL_MATRIX_LABEL) → full matrix: $versions" else versions=$(jq -c '[.defaultVersion]' tools/unity-versions.json) echo "Trigger '$EVENT_NAME' on ref '$GH_REF' → default only: $versions" fi echo "versions=$versions" >> "$GITHUB_OUTPUT" testAllModes: name: Test in ${{ matrix.testMode }} on Unity ${{ matrix.unityVersion }} needs: matrix runs-on: ubuntu-latest permissions: contents: read if: > github.event_name != 'pull_request_target' || ( github.event.pull_request.head.repo.full_name != github.repository && contains(github.event.pull_request.labels.*.name, 'safe-to-test') && (github.event.label.name == 'safe-to-test' || github.event.label.name == 'full-matrix') ) || ( github.event.pull_request.head.repo.full_name == github.repository && github.event.label.name == 'full-matrix' ) strategy: fail-fast: false matrix: projectPath: - TestProjects/UnityMCPTests testMode: - editmode unityVersion: ${{ fromJson(needs.matrix.outputs.versions) }} steps: - name: Checkout repository uses: actions/checkout@v4 with: lfs: true ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }} persist-credentials: false - 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 key: Library-${{ matrix.projectPath }}-${{ matrix.unityVersion }} restore-keys: | Library-${{ matrix.projectPath }}- Library- # 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 }} testMode: ${{ matrix.testMode }} customParameters: -testCategory domain_reload - name: Run tests if: steps.detect.outputs.unity_ok == 'true' uses: game-ci/unity-test-runner@v4 id: tests continue-on-error: true 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 }} - name: Check test results if: steps.detect.outputs.unity_ok == 'true' env: ARTIFACTS_PATH: ${{ steps.tests.outputs.artifactsPath }} run: | set -euo pipefail # `|| true` so a missing $ARTIFACTS_PATH (Unity crashed before producing any) doesn't trip # `pipefail` and skip the explicit empty-check diagnostic below. RESULTS_XML=$(find "$ARTIFACTS_PATH" -name '*.xml' 2>/dev/null | head -1 || true) if [ -z "$RESULTS_XML" ]; then echo "::error::No test results XML found — Unity may have crashed" exit 1 fi python3 - "$RESULTS_XML" <<'PY' import sys, xml.etree.ElementTree as ET # Escape workflow-command payloads so test-controlled XML (under pull_request_target this # is fork-supplied) can't break annotation rendering or inject extra workflow commands. # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions def esc_data(s): return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") def esc_prop(s): return esc_data(s).replace(":", "%3A").replace(",", "%2C") root = ET.parse(sys.argv[1]).getroot() totals = root.attrib passed = totals.get("passed", "?") failed = totals.get("failed", "?") total = totals.get("total", "?") incon = totals.get("inconclusive", "?") skipped = totals.get("skipped", "?") print(f"Results: {passed} passed, {failed} failed, {incon} inconclusive, {skipped} skipped (total: {total})") fails = [tc for tc in root.iter("test-case") if tc.attrib.get("result") == "Failed"] if not fails: sys.exit(0) # Surface every failure inline so a CI watcher doesn't need to download the NUnit XML artifact. for tc in fails: name = tc.attrib.get("fullname") or tc.attrib.get("name") or "" f = tc.find("failure") msg = (f.findtext("message") or "").strip() if f is not None else "" stack = (f.findtext("stack-trace") or "").strip() if f is not None else "" # First line of the message becomes the GitHub annotation title. first_line = msg.splitlines()[0] if msg else "(no message)" # GitHub annotations don't render multi-line bodies, so emit the full failure inside a collapsible group. print(f"::error title=Failed: {esc_prop(name)}::{esc_data(first_line)}") print(f"::group::Failure details — {esc_data(name)}") if msg: print("Message:") print(msg) if stack: print("Stack trace:") print(stack) print("::endgroup::") print(f"::error::{len(fails)} test(s) failed") sys.exit(1) PY - uses: actions/upload-artifact@v4 if: always() && steps.detect.outputs.unity_ok == 'true' && steps.tests.outcome != 'skipped' with: name: Test results for ${{ matrix.testMode }} on Unity ${{ matrix.unityVersion }} path: ${{ steps.tests.outputs.artifactsPath }}