Skip to content

fix(execute_code): load Roslyn from Unity instead of falling back to C# 6 #515

fix(execute_code): load Roslyn from Unity instead of falling back to C# 6

fix(execute_code): load Roslyn from Unity instead of falling back to C# 6 #515

Workflow file for this run

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 `license` gate job writes unity_ok=false and the test
# job is skipped by its job-level `if:`, so the merge box shows "Skipped" rather than a
# green check. The reason is also written to the gate job's step summary.
#
# There is deliberately no pull_request_target trigger here. Running fork-authored
# C# through game-ci/unity-test-runner with UNITY_* secrets in scope is the classic
# "pwn request" shape — an [InitializeOnLoad] script in the PR is enough to read
# them. To test a fork PR, review the diff and push its branch into this repo; the
# push trigger above then runs the full suite in a genuinely trusted context.
pull_request:
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 / 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
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 a PR carrying the 'full-matrix' label. Note the label is only read when the
# workflow fires, so applying it to an open PR takes effect on the next push.
# 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" ]] && [[ "$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"
license:
# Hoisted out of testAllModes so the gate is a JOB-level condition. A step-level `if:`
# produces step-conclusion `skipped`, which contributes nothing to the job conclusion,
# so the whole suite reported a green check having compiled and tested nothing. A job
# skipped by a job-level `if:` reports "Skipped" in the merge box instead, which is
# what a reviewer actually reads. Branch protection still treats a skipped required
# check as satisfied, so this does not block merges.
name: Detect Unity license secrets
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
unity_ok: ${{ steps.detect.outputs.unity_ok }}
steps:
- 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: Explain why the Unity suite will not run
if: steps.detect.outputs.unity_ok != 'true'
run: |
echo "::warning::Unity tests will be SKIPPED - no license secrets in scope (normal for fork PRs). Nothing will be compiled or tested."
{
echo "## :warning: Unity tests will be SKIPPED"
echo
echo "No Unity license secrets were in scope for this run, so **no C# will be compiled and no test will be executed**."
echo "The dependent job reports **Skipped**, not a pass."
echo
echo "GitHub withholds repository secrets from workflow runs triggered by a fork's pull request."
echo "To get real signal, a maintainer must run the suite against this code from a trusted context -"
echo "push the reviewed fork branch into this repo and the \`push\` trigger runs the full suite."
echo
echo "\`Compile MCPForUnity (win/osx/linux)\` and \`Run Python Tests\` DO run on fork PRs and are real signal."
} >> "$GITHUB_STEP_SUMMARY"
testAllModes:
name: Test in ${{ matrix.testMode }} on Unity ${{ matrix.unityVersion }}
needs: [matrix, license]
if: needs.license.outputs.unity_ok == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
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
- 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
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
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
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 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 "<unknown>"
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.tests.outcome != 'skipped'
with:
name: Test results for ${{ matrix.testMode }} on Unity ${{ matrix.unityVersion }}
path: ${{ steps.tests.outputs.artifactsPath }}