Merge pull request #207 from ForkHorizon/ci-scope/single-action #275
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: Validate package | |
| on: | |
| push: | |
| branches: [main, development] | |
| pull_request: | |
| branches: [main, development] | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| concurrency: | |
| group: nexus-unity-ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| UNITY_EDITOR_PATH: /Applications/Unity/Hub/Editor/6000.4.3f1/Unity.app/Contents/MacOS/Unity | |
| jobs: | |
| pr-target-policy: | |
| name: PR target policy | |
| runs-on: [self-hosted, macOS, ARM64, ci-scope] | |
| steps: | |
| - name: Validate protected branch workflow | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| REPOSITORY: ${{ github.repository }} | |
| HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} | |
| BASE_REF: ${{ github.base_ref }} | |
| HEAD_REF: ${{ github.head_ref }} | |
| ACTOR: ${{ github.actor }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$EVENT_NAME" != "pull_request" ]; then | |
| echo "Push event: branch protection policy is enforced by GitHub." | |
| exit 0 | |
| fi | |
| if [ -n "${HEAD_REPOSITORY:-}" ] && [ "$HEAD_REPOSITORY" != "$REPOSITORY" ]; then | |
| echo "::warning::External PR from fork: $HEAD_REPOSITORY (by $ACTOR). Running CI — add code-safety scan in future." | |
| fi | |
| if [ "$BASE_REF" = "development" ]; then | |
| echo "Contributor PR target is development." | |
| exit 0 | |
| fi | |
| if [ "$BASE_REF" = "main" ]; then | |
| case "$HEAD_REF" in | |
| development|release/*|ci-scope/*) | |
| ;; | |
| *) | |
| echo "Pull requests to main are release-only. Open contributor PRs against development." | |
| exit 1 | |
| ;; | |
| esac | |
| if [ "$ACTOR" != "Daliys" ]; then | |
| echo "Only Daliys may open release PRs to main." | |
| exit 1 | |
| fi | |
| echo "Release PR target is main." | |
| exit 0 | |
| fi | |
| echo "Unsupported pull request target: $BASE_REF" | |
| exit 1 | |
| static-validation: | |
| name: Static validation | |
| runs-on: [self-hosted, macOS, ARM64, ci-scope] | |
| needs: pr-target-policy | |
| steps: | |
| - name: Check out repository | |
| uses: actions/checkout@v4 | |
| - name: Verify local toolchain | |
| run: | | |
| set -euo pipefail | |
| python3 --version | |
| dotnet --version | |
| - name: Run static package validation | |
| run: bash scripts/prepush-validate.sh --static-only | |
| python-unit-tests: | |
| name: Python unit tests | |
| runs-on: [self-hosted, macOS, ARM64, ci-scope] | |
| needs: static-validation | |
| steps: | |
| - name: Check out repository | |
| uses: actions/checkout@v4 | |
| - name: Run Python unit tests | |
| run: | | |
| set -euo pipefail | |
| PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s Editor/tests -v | |
| echo "Python unit tests passed." | |
| documentation-quality-ai: | |
| name: Documentation quality AI | |
| # ci-scope-job-doc-ai disambiguates this from unity-package-smoke below: | |
| # two same-run jobs with byte-identical runs-on let GitHub's scheduler | |
| # hand a connecting runner EITHER one, not necessarily the one the broker | |
| # leased it for (NexusUnity PR #145, 2026-07-04). | |
| runs-on: [self-hosted, macOS, ARM64, ci-scope-ai] | |
| needs: static-validation | |
| concurrency: | |
| group: ci-scope-slop-review | |
| cancel-in-progress: false | |
| env: | |
| NEXUS_DOC_AI_MAX_PARALLEL: "1" | |
| NEXUS_OLLAMA_URL: http://127.0.0.1:11434 | |
| NEXUS_DOC_AI_MODEL: qwen3-coder:30b-a3b-q4_K_M | |
| NEXUS_DOC_AI_KEEP_ALIVE: "30s" | |
| NEXUS_DOC_AI_UNLOAD_ON_EXIT: "true" | |
| NEXUS_DOC_AI_TIMEOUT_SECONDS: "300" | |
| steps: | |
| - name: Check out trusted validator | |
| uses: actions/checkout@v4 | |
| with: | |
| path: trusted | |
| ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }} | |
| - name: Check out candidate package | |
| uses: actions/checkout@v4 | |
| with: | |
| path: candidate | |
| - name: Run required Ollama documentation and checklist review | |
| env: | |
| IS_FORK_PR: ${{ github.event.pull_request && github.event.pull_request.head.repo.fork || false }} | |
| run: | | |
| set -euo pipefail | |
| dotnet --version | |
| QUALITY_GATE_PROJECT="trusted/tools~/NexusQualityGate/NexusQualityGate.csproj" | |
| if [ ! -f "$QUALITY_GATE_PROJECT" ] || ! grep -q -- "--checklist-ai" trusted/tools~/NexusQualityGate/QualityGateOptions.cs; then | |
| if [ "${IS_FORK_PR:-false}" = "true" ]; then | |
| echo "::warning::Trusted branch does not support the required NexusQualityGate version, and candidate tool cannot be executed for fork PRs due to security restrictions. Skipping AI documentation check." | |
| exit 0 | |
| else | |
| echo "::notice::Trusted branch does not have the required NexusQualityGate version; using candidate tool for bootstrap validation." | |
| QUALITY_GATE_PROJECT="candidate/tools~/NexusQualityGate/NexusQualityGate.csproj" | |
| fi | |
| fi | |
| dotnet run --project "$QUALITY_GATE_PROJECT" -- \ | |
| --root "$GITHUB_WORKSPACE/candidate" \ | |
| --ai required \ | |
| --checklist-ai required \ | |
| --format github | |
| - name: Unload Ollama quality model | |
| if: ${{ always() }} | |
| shell: bash | |
| run: | | |
| if [ -z "${NEXUS_DOC_AI_MODEL:-}" ]; then | |
| echo "No NEXUS_DOC_AI_MODEL configured; nothing to unload." | |
| exit 0 | |
| fi | |
| ps_output="$(mktemp)" | |
| if command -v ollama >/dev/null 2>&1 && ollama ps >"$ps_output" 2>/dev/null; then | |
| if awk 'NR > 1 { print $1 }' "$ps_output" | grep -Fx "$NEXUS_DOC_AI_MODEL" >/dev/null; then | |
| if ollama stop "$NEXUS_DOC_AI_MODEL"; then | |
| echo "Stopped loaded Ollama model: $NEXUS_DOC_AI_MODEL" | |
| rm -f "$ps_output" | |
| exit 0 | |
| else | |
| echo "::warning::ollama stop failed for loaded model: $NEXUS_DOC_AI_MODEL" | |
| fi | |
| else | |
| echo "Ollama model is not loaded: $NEXUS_DOC_AI_MODEL" | |
| rm -f "$ps_output" | |
| exit 0 | |
| fi | |
| rm -f "$ps_output" | |
| fi | |
| rm -f "$ps_output" | |
| escaped_model=${NEXUS_DOC_AI_MODEL//\\/\\\\} | |
| escaped_model=${escaped_model//\"/\\\"} | |
| payload="{\"model\":\"$escaped_model\",\"prompt\":\"\",\"stream\":false,\"keep_alive\":0}" | |
| if curl -fsS \ | |
| -H "Content-Type: application/json" \ | |
| -d "$payload" \ | |
| "$NEXUS_OLLAMA_URL/api/generate" >/dev/null; then | |
| echo "Unloaded Ollama model through API: $NEXUS_DOC_AI_MODEL" | |
| exit 0 | |
| fi | |
| echo "::warning::Ollama API unload failed; trying 'ollama stop'." | |
| ollama stop "$NEXUS_DOC_AI_MODEL" || true | |
| unity-package-smoke: | |
| name: Unity package smoke | |
| # ci-scope-job-unity-smoke disambiguates this from documentation-quality-ai | |
| # above — see that job's comment. | |
| runs-on: [self-hosted, macOS, ARM64, ci-scope] | |
| needs: static-validation | |
| steps: | |
| - name: Check out repository | |
| uses: actions/checkout@v4 | |
| - name: Verify Unity editor | |
| run: | | |
| set -euo pipefail | |
| test -x "$UNITY_EDITOR_PATH" | |
| "$UNITY_EDITOR_PATH" -version | |
| - name: Create temporary Unity project | |
| run: | | |
| set -euo pipefail | |
| SMOKE_ROOT="$RUNNER_TEMP/nexus-unity-package-smoke-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| SMOKE_CREATE_LOG="$RUNNER_TEMP/nexus-unity-package-smoke-create.log" | |
| SMOKE_IMPORT_LOG="$RUNNER_TEMP/nexus-unity-package-smoke-import.log" | |
| rm -rf "$SMOKE_ROOT" | |
| "$UNITY_EDITOR_PATH" \ | |
| -batchmode \ | |
| -quit \ | |
| -createProject "$SMOKE_ROOT" \ | |
| -logFile "$SMOKE_CREATE_LOG" | |
| echo "SMOKE_ROOT=$SMOKE_ROOT" >> "$GITHUB_ENV" | |
| echo "SMOKE_IMPORT_LOG=$SMOKE_IMPORT_LOG" >> "$GITHUB_ENV" | |
| echo "SMOKE_TEST_LOG=$RUNNER_TEMP/nexus-unity-package-smoke-tests.log" >> "$GITHUB_ENV" | |
| echo "SMOKE_TEST_RESULTS=$SMOKE_ROOT/nexus-unity-package-smoke-tests.xml" >> "$GITHUB_ENV" | |
| - name: Install candidate package | |
| run: | | |
| set -euo pipefail | |
| python3 - <<'PY' | |
| import json | |
| import os | |
| from pathlib import Path | |
| project_root = Path(os.environ["SMOKE_ROOT"]) | |
| manifest_path = project_root / "Packages" / "manifest.json" | |
| manifest = json.loads(manifest_path.read_text(encoding="utf-8")) | |
| dependencies = manifest.setdefault("dependencies", {}) | |
| dependencies["com.forkhorizon.nexus.unity"] = f"file:{os.environ['GITHUB_WORKSPACE']}" | |
| dependencies["com.unity.test-framework"] = "1.5.1" | |
| testables = manifest.setdefault("testables", []) | |
| if "com.forkhorizon.nexus.unity" not in testables: | |
| testables.append("com.forkhorizon.nexus.unity") | |
| manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") | |
| PY | |
| - name: Install package Editor smoke tests | |
| run: | | |
| set -euo pipefail | |
| TEST_DIR="$SMOKE_ROOT/Assets/Tests/Editor/NexusUnityPackageSmoke" | |
| mkdir -p "$TEST_DIR" | |
| cat > "$TEST_DIR/NexusUnity.PackageSmoke.EditorTests.asmdef" <<'JSON' | |
| { | |
| "name": "NexusUnity.PackageSmoke.EditorTests", | |
| "references": [], | |
| "includePlatforms": [ | |
| "Editor" | |
| ], | |
| "optionalUnityReferences": [ | |
| "TestAssemblies" | |
| ] | |
| } | |
| JSON | |
| cat > "$TEST_DIR/NexusUnityPackageSmokeTests.cs" <<'CS' | |
| using System; | |
| using System.Linq; | |
| using System.Reflection; | |
| using NUnit.Framework; | |
| using UnityEditor.PackageManager; | |
| public sealed class NexusUnityPackageSmokeTests | |
| { | |
| [Test] | |
| public void PackageManifestIsResolvedByUnity() | |
| { | |
| var package = PackageInfo.FindForAssetPath("Packages/com.forkhorizon.nexus.unity/package.json"); | |
| Assert.That(package, Is.Not.Null); | |
| Assert.That(package.name, Is.EqualTo("com.forkhorizon.nexus.unity")); | |
| } | |
| [Test] | |
| public void EditorBridgeAssemblyIsCompiled() | |
| { | |
| var methodsType = AppDomain.CurrentDomain.GetAssemblies() | |
| .Select(assembly => assembly.GetType("UnityMCP.Editor.MCPServerMethods", false)) | |
| .FirstOrDefault(type => type != null); | |
| Assert.That(methodsType, Is.Not.Null); | |
| Assert.That( | |
| methodsType.GetMethods(BindingFlags.Public | BindingFlags.Static).Any(method => method.Name == "ProcessJsonRpc"), | |
| Is.True); | |
| } | |
| } | |
| CS | |
| - name: Import and compile candidate package | |
| run: | | |
| set -euo pipefail | |
| if ! "$UNITY_EDITOR_PATH" \ | |
| -batchmode \ | |
| -quit \ | |
| -projectPath "$SMOKE_ROOT" \ | |
| -logFile "$SMOKE_IMPORT_LOG"; then | |
| echo "::error::Unity package smoke import failed." | |
| tail -n 200 "$SMOKE_IMPORT_LOG" || true | |
| exit 1 | |
| fi | |
| - name: Validate Unity smoke output | |
| run: | | |
| set -euo pipefail | |
| failure_pattern='error CS|Scripts have compiler errors|A meta data file \(.meta\) exists but its asset|Couldn.t delete .*immutable folder|CS0234|CS0246|Assembly has reference errors|Package Manager error' | |
| if grep -E "$failure_pattern" "$SMOKE_IMPORT_LOG"; then | |
| echo "::error::Unity package smoke found package import or compile errors." | |
| tail -n 200 "$SMOKE_IMPORT_LOG" || true | |
| exit 1 | |
| fi | |
| python3 - <<'PY' | |
| import json | |
| import os | |
| import sys | |
| from pathlib import Path | |
| lock_path = Path(os.environ["SMOKE_ROOT"]) / "Packages" / "packages-lock.json" | |
| if not lock_path.exists(): | |
| print(f"::error::Missing Unity package lock file: {lock_path}") | |
| sys.exit(1) | |
| dependencies = json.loads(lock_path.read_text(encoding="utf-8")).get("dependencies", {}) | |
| required = [ | |
| "com.forkhorizon.nexus.unity", | |
| "com.unity.inputsystem", | |
| "com.unity.nuget.newtonsoft-json", | |
| "com.unity.test-framework", | |
| ] | |
| missing = [package for package in required if package not in dependencies] | |
| if missing: | |
| print(f"::error::Unity package smoke did not resolve dependencies: {', '.join(missing)}") | |
| sys.exit(1) | |
| forbidden = [ | |
| "com.unity.project-auditor", | |
| "com.unity.project-auditor-rules", | |
| ] | |
| present_forbidden = [package for package in forbidden if package in dependencies] | |
| if present_forbidden: | |
| print(f"::error::Unity package smoke resolved forbidden Project Auditor dependencies: {', '.join(present_forbidden)}") | |
| sys.exit(1) | |
| print("Unity package smoke resolved Nexus Unity and required dependencies without Project Auditor packages.") | |
| PY | |
| - name: Run package Editor tests | |
| run: | | |
| set -euo pipefail | |
| if ! "$UNITY_EDITOR_PATH" \ | |
| -batchmode \ | |
| -projectPath "$SMOKE_ROOT" \ | |
| -runTests \ | |
| -testPlatform editmode \ | |
| -assemblyNames "NexusUnity.PackageSmoke.EditorTests;UnityMCP.Editor.Tests" \ | |
| -testResults "$SMOKE_TEST_RESULTS" \ | |
| -logFile "$SMOKE_TEST_LOG"; then | |
| echo "::error::Unity package Editor tests failed." | |
| tail -n 200 "$SMOKE_TEST_LOG" || true | |
| exit 1 | |
| fi | |
| python3 - <<'PY' | |
| import os | |
| import sys | |
| import xml.etree.ElementTree as ET | |
| from pathlib import Path | |
| result_path = Path(os.environ["SMOKE_TEST_RESULTS"]) | |
| if not result_path.exists(): | |
| print(f"::error::Missing Unity test results file: {result_path}") | |
| log_path = Path(os.environ["SMOKE_TEST_LOG"]) | |
| if log_path.exists(): | |
| print("Last Unity test log lines:") | |
| print("\n".join(log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-200:])) | |
| sys.exit(1) | |
| root = ET.parse(result_path).getroot() | |
| total = int(root.attrib.get("total", "0")) | |
| failed = int(root.attrib.get("failed", "0")) | |
| if total <= 0: | |
| print("::error::Unity package Editor test run reported zero tests.") | |
| sys.exit(1) | |
| if failed: | |
| print(f"::error::Unity package Editor tests reported {failed} failure(s).") | |
| sys.exit(1) | |
| print(f"Unity package Editor tests passed: {total} test(s).") | |
| PY |