Skip to content

Merge pull request #207 from ForkHorizon/ci-scope/single-action #275

Merge pull request #207 from ForkHorizon/ci-scope/single-action

Merge pull request #207 from ForkHorizon/ci-scope/single-action #275

Workflow file for this run

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