chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
name: Bug report
description: Something isn't working as expected
title: "[Bug]: "
labels: ["bug", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug. The more specific you can be, the faster we can help.
**Before filing:**
- Search [existing issues](https://github.com/CoplayDev/unity-mcp/issues?q=is%3Aissue) to avoid duplicates
- Check the [troubleshooting docs](https://coplaydev.github.io/unity-mcp/guides/troubleshooting) and [setup wiki](https://github.com/CoplayDev/unity-mcp/wiki)
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Clear, concrete description. What did you expect? What did you get?
placeholder: When I run X, I expect Y, but I see Z.
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Reproduction steps
description: Numbered steps. Include the exact prompt or command if relevant.
placeholder: |
1. Open Window > MCP for Unity
2. Click Start Server
3. In Claude Desktop, prompt: "create a red cube"
4. Observe error in console: ...
validations:
required: true
- type: input
id: unity-version
attributes:
label: Unity version
placeholder: "6000.0.34f1, 2022.3.42f1, etc."
validations:
required: true
- type: input
id: package-version
attributes:
label: MCP for Unity package version
description: Found under Window > Package Manager → MCP for Unity.
placeholder: "9.6.3"
validations:
required: true
- type: input
id: server-version
attributes:
label: Python server version
description: Run `uv pip show mcpforunityserver` or check the bundled wheel.
placeholder: "9.6.3"
validations:
required: false
- type: dropdown
id: client
attributes:
label: MCP client
options:
- Claude Desktop
- Claude Code
- Cursor
- VS Code (Copilot)
- Windsurf
- Cline
- GitHub Copilot CLI
- Codex
- Qwen Code
- Gemini CLI
- OpenClaw
- Other (specify in description)
validations:
required: true
- type: dropdown
id: transport
attributes:
label: Transport
options:
- HTTP (default)
- stdio
- Don't know
validations:
required: true
- type: dropdown
id: os
attributes:
label: OS
options:
- macOS
- Windows
- Linux
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant logs / console output
description: Editor console, MCP for Unity log, client log. Strip secrets.
render: text
validations:
required: false
- type: checkboxes
id: terms
attributes:
label: Checks
options:
- label: I searched existing issues and did not find a duplicate
required: true
- label: I included logs / steps to reproduce
required: true
+14
View File
@@ -0,0 +1,14 @@
blank_issues_enabled: false
contact_links:
- name: Discord — chat with maintainers
url: https://discord.gg/y4p8KfzrN4
about: Best for quick questions, troubleshooting, and design discussion.
- name: Documentation
url: https://coplaydev.github.io/unity-mcp/
about: Getting Started, guides, tool reference, and architecture notes.
- name: Security vulnerability
url: https://github.com/CoplayDev/unity-mcp/security/advisories/new
about: Report privately via GitHub Security Advisories or email security@coplay.dev. Do NOT open a public issue.
- name: Discussion / design idea
url: https://github.com/CoplayDev/unity-mcp/discussions
about: Broad questions, design conversations, show-and-tell.
@@ -0,0 +1,63 @@
name: Feature request
description: Suggest a new tool, capability, or quality-of-life improvement
title: "[Feature]: "
labels: ["enhancement", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Got an idea? Great. The more concrete you can be about *who* this helps and *what* they're trying to do, the easier it is to scope.
For tool requests: please name the Unity API/system you'd be wrapping and what your MCP client would actually say.
- type: textarea
id: problem
attributes:
label: What problem are you trying to solve?
description: Frame the user need first, not the implementation.
placeholder: |
When I'm prototyping with the LLM, I can't get it to build a NavMesh because there's no MCP tool for the AI navigation system. I have to flip back to the Editor manually.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What would you like to see?
description: Be specific. New tool? New action on an existing tool? New resource? Workflow change?
placeholder: |
A new `manage_navigation` tool with actions: bake, clear, set_agent_radius, query_nearest_edge, ...
validations:
required: true
- type: textarea
id: prior-art
attributes:
label: Prior art / how do you work around this today?
placeholder: I currently bake manually via Window > AI > Navigation, then ask the LLM to reason about it.
validations:
required: false
- type: dropdown
id: scope
attributes:
label: Scope hint
options:
- New tool (substantial)
- New action on an existing tool
- New resource
- UX / editor window
- CLI improvement
- Documentation
- Other
validations:
required: true
- type: checkboxes
id: contribute
attributes:
label: Would you be willing to help build this?
options:
- label: I can open a PR if a maintainer sketches the approach
- label: I can help test
- label: I can write the docs
+111
View File
@@ -0,0 +1,111 @@
name: Publish Docker image
description: Build and push the Docker image to Docker Hub
inputs:
docker_username:
required: true
description: Docker Hub username
docker_password:
required: true
description: Docker Hub password
image:
required: true
description: Docker image name (e.g. user/repo)
version:
required: false
default: ""
description: Optional version string (e.g. 1.2.3). If provided, tags are computed from this version instead of the GitHub ref.
include_branch_tags:
required: false
default: "true"
description: Whether to also publish a branch tag when running on a branch ref (e.g. manual runs).
context:
required: false
default: .
description: Docker build context
dockerfile:
required: false
default: Server/Dockerfile
description: Path to Dockerfile
platforms:
required: false
default: linux/amd64
description: Target platforms
runs:
using: composite
steps:
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ inputs.docker_username }}
password: ${{ inputs.docker_password }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
if: ${{ inputs.version == '' && inputs.include_branch_tags == 'true' }}
with:
images: ${{ inputs.image }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch
- name: Extract metadata (tags, labels) for Docker
id: meta_nobranch
uses: docker/metadata-action@v5
if: ${{ inputs.version == '' && inputs.include_branch_tags != 'true' }}
with:
images: ${{ inputs.image }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Compute Docker tags from version
id: version_tags
if: ${{ inputs.version != '' }}
shell: bash
run: |
set -euo pipefail
IFS='.' read -r MA MI PA <<< "${{ inputs.version }}"
echo "major=$MA" >> "$GITHUB_OUTPUT"
echo "minor=$MI" >> "$GITHUB_OUTPUT"
- name: Extract metadata (tags, labels) for Docker
id: meta_version
uses: docker/metadata-action@v5
if: ${{ inputs.version != '' && inputs.include_branch_tags == 'true' }}
with:
images: ${{ inputs.image }}
tags: |
type=raw,value=v${{ inputs.version }}
type=raw,value=v${{ steps.version_tags.outputs.major }}.${{ steps.version_tags.outputs.minor }}
type=raw,value=v${{ steps.version_tags.outputs.major }}
type=ref,event=branch
- name: Extract metadata (tags, labels) for Docker
id: meta_version_nobranch
uses: docker/metadata-action@v5
if: ${{ inputs.version != '' && inputs.include_branch_tags != 'true' }}
with:
images: ${{ inputs.image }}
tags: |
type=raw,value=v${{ inputs.version }}
type=raw,value=v${{ steps.version_tags.outputs.major }}.${{ steps.version_tags.outputs.minor }}
type=raw,value=v${{ steps.version_tags.outputs.major }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: ${{ inputs.platforms }}
push: true
tags: ${{ steps.meta.outputs.tags || steps.meta_nobranch.outputs.tags || steps.meta_version.outputs.tags || steps.meta_version_nobranch.outputs.tags }}
labels: ${{ steps.meta.outputs.labels || steps.meta_nobranch.outputs.labels || steps.meta_version.outputs.labels || steps.meta_version_nobranch.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+22
View File
@@ -0,0 +1,22 @@
name: Publish Python distribution to PyPI
description: Build and publish the Python package from Server/ to PyPI
runs:
using: composite
steps:
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: true
cache-dependency-glob: "Server/uv.lock"
- name: Build a binary wheel and a source tarball
shell: bash
run: uv build
working-directory: ./Server
- name: Publish distribution to PyPI
# Pin to v1.12.4 to avoid Docker container name issue with uppercase repo names in v1.13.0+
uses: pypa/gh-action-pypi-publish@v1.12.4
with:
packages-dir: Server/dist/
+43
View File
@@ -0,0 +1,43 @@
## Description
<!-- Provide a brief description of your changes -->
## Type of Change
<!-- Check all that apply -->
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)
- [ ] Test update
## Changes Made
<!-- List the specific changes in this PR -->
## Compatibility / Package Source
<!-- Especially important for Unity API compatibility, package-source, or transport changes -->
- Unity version(s) tested:
- Package source used (`#beta`, `#main`, tag, branch, or `file:`):
- Resolved commit hash from `Packages/packages-lock.json` (if using a Git package URL):
## Testing/Screenshots/Recordings
<!-- If applicable, add screenshots or recordings to demonstrate the changes -->
- [ ] Python tests (`cd Server && uv run pytest tests/ -v`)
- [ ] Unity EditMode tests
- [ ] Unity PlayMode tests
- [ ] Package import/compile check
- [ ] Not applicable (explain why in Additional Notes)
## Documentation Updates
<!-- Check if you updated documentation for changes to tools/resources -->
- [ ] I have added/removed/modified tools or resources
- [ ] If yes, I have updated all documentation files using:
- [ ] The LLM prompt at `tools/UPDATE_DOCS_PROMPT.md` (recommended)
- [ ] Manual review of the generated changes
## Related Issues
<!-- Link any related issues using "Fixes #123" or "Relates to #123" -->
## Additional Notes
<!-- Any other information that reviewers should know -->
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
Post-processes a JUnit XML so that "expected"/environmental failures
(e.g., permission prompts, empty MCP resources, or schema hiccups)
are converted to <skipped/>. Leaves real failures intact.
Usage:
python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml
"""
from __future__ import annotations
import sys
import os
import re
import xml.etree.ElementTree as ET
PATTERNS = [
r"\bpermission\b",
r"\bpermissions\b",
r"\bautoApprove\b",
r"\bapproval\b",
r"\bdenied\b",
r"requested\s+permissions",
r"^MCP resources list is empty$",
r"No MCP resources detected",
r"aggregator.*returned\s*\[\s*\]",
r"Unknown resource:\s*mcpforunity://",
r"Input should be a valid dictionary.*ctx",
r"validation error .* ctx",
]
def should_skip(msg: str) -> bool:
if not msg:
return False
msg_l = msg.strip()
for pat in PATTERNS:
if re.search(pat, msg_l, flags=re.IGNORECASE | re.MULTILINE):
return True
return False
def summarize_counts(ts: ET.Element):
tests = 0
failures = 0
errors = 0
skipped = 0
for case in ts.findall("testcase"):
tests += 1
if case.find("failure") is not None:
failures += 1
if case.find("error") is not None:
errors += 1
if case.find("skipped") is not None:
skipped += 1
return tests, failures, errors, skipped
def main(path: str) -> int:
if not os.path.exists(path):
print(f"[mark_skipped] No JUnit at {path}; nothing to do.")
return 0
try:
tree = ET.parse(path)
except ET.ParseError as e:
print(f"[mark_skipped] Could not parse {path}: {e}")
return 0
root = tree.getroot()
suites = root.findall("testsuite") if root.tag == "testsuites" else [root]
changed = False
for ts in suites:
for case in list(ts.findall("testcase")):
nodes = [n for n in list(case) if n.tag in ("failure", "error")]
if not nodes:
continue
# If any node matches skip patterns, convert the whole case to skipped.
first_match_text = None
to_skip = False
for n in nodes:
msg = (n.get("message") or "") + "\n" + (n.text or "")
if should_skip(msg):
first_match_text = (
n.text or "").strip() or first_match_text
to_skip = True
if to_skip:
for n in nodes:
case.remove(n)
reason = "Marked skipped: environment/permission precondition not met"
skip = ET.SubElement(case, "skipped")
skip.set("message", reason)
skip.text = first_match_text or reason
changed = True
# Recompute tallies per testsuite
tests, failures, errors, skipped = summarize_counts(ts)
ts.set("tests", str(tests))
ts.set("failures", str(failures))
ts.set("errors", str(errors))
ts.set("skipped", str(skipped))
if changed:
tree.write(path, encoding="utf-8", xml_declaration=True)
print(
f"[mark_skipped] Updated {path}: converted environmental failures to skipped.")
else:
print(f"[mark_skipped] No environmental failures detected in {path}.")
return 0
if __name__ == "__main__":
target = (
sys.argv[1]
if len(sys.argv) > 1
else os.environ.get("JUNIT_OUT", "reports/junit-nl-suite.xml")
)
raise SystemExit(main(target))
+241
View File
@@ -0,0 +1,241 @@
name: Beta Release (PyPI Pre-release)
concurrency:
group: beta-release
cancel-in-progress: true
on:
push:
branches:
- beta
paths:
- "Server/**"
- "MCPForUnity/**"
jobs:
unity_tests:
name: Unity tests gate
if: github.actor != 'github-actions[bot]'
uses: ./.github/workflows/unity-tests.yml
secrets: inherit
python_tests:
name: Python tests gate
if: github.actor != 'github-actions[bot]'
uses: ./.github/workflows/python-tests.yml
update_unity_beta_version:
name: Update Unity package to beta version
runs-on: ubuntu-latest
needs: [unity_tests, python_tests]
# Avoid running when the workflow's own automation merges the PR
# created by this workflow (prevents a version-bump loop).
if: github.actor != 'github-actions[bot]'
permissions:
contents: write
pull-requests: write
outputs:
unity_beta_version: ${{ steps.version.outputs.unity_beta_version }}
version_updated: ${{ steps.commit.outputs.updated }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
ref: beta
- name: Generate beta version for Unity package
id: version
shell: bash
run: |
set -euo pipefail
# Read current Unity package version
CURRENT_VERSION=$(jq -r '.version' MCPForUnity/package.json)
echo "Current Unity package version: $CURRENT_VERSION"
# Check if already a beta version - increment beta number
if [[ "$CURRENT_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-beta\.([0-9]+)$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
BETA_NUM="${BASH_REMATCH[2]}"
NEXT_BETA=$((BETA_NUM + 1))
BETA_VERSION="${BASE_VERSION}-beta.${NEXT_BETA}"
echo "Incrementing beta number: $CURRENT_VERSION -> $BETA_VERSION"
elif [[ "$CURRENT_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
# Stable version - bump patch and add -beta.1 suffix
# This ensures beta is "newer" than stable (9.3.2-beta.1 > 9.3.1)
# The release workflow decides final bump type (patch/minor/major)
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
NEXT_PATCH=$((PATCH + 1))
BETA_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}-beta.1"
echo "Converting stable to beta: $CURRENT_VERSION -> $BETA_VERSION"
else
echo "Error: Could not parse version '$CURRENT_VERSION'" >&2
exit 1
fi
# Always output the computed version
echo "unity_beta_version=$BETA_VERSION" >> "$GITHUB_OUTPUT"
# Only skip update if computed version matches current (no change needed)
if [[ "$BETA_VERSION" == "$CURRENT_VERSION" ]]; then
echo "Version unchanged, skipping update"
echo "needs_update=false" >> "$GITHUB_OUTPUT"
else
echo "Version will be updated: $CURRENT_VERSION -> $BETA_VERSION"
echo "needs_update=true" >> "$GITHUB_OUTPUT"
fi
- name: Update Unity package.json with beta version
if: steps.version.outputs.needs_update == 'true'
env:
BETA_VERSION: ${{ steps.version.outputs.unity_beta_version }}
shell: bash
run: |
set -euo pipefail
# Update package.json version
jq --arg v "$BETA_VERSION" '.version = $v' MCPForUnity/package.json > tmp.json
mv tmp.json MCPForUnity/package.json
echo "Updated MCPForUnity/package.json:"
jq '.version' MCPForUnity/package.json
- name: Commit to temporary branch and create PR
id: commit
if: steps.version.outputs.needs_update == 'true'
env:
GH_TOKEN: ${{ github.token }}
BETA_VERSION: ${{ steps.version.outputs.unity_beta_version }}
shell: bash
run: |
set -euo pipefail
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
if git diff --quiet MCPForUnity/package.json; then
echo "No changes to commit"
echo "updated=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Create a temporary branch for the version update
BRANCH="beta-version-${BETA_VERSION}-${GITHUB_RUN_ID}"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
git checkout -b "$BRANCH"
git add MCPForUnity/package.json
git commit -m "chore: update Unity package to beta version ${BETA_VERSION}"
git push origin "$BRANCH"
# Check if PR already exists
if gh pr view "$BRANCH" >/dev/null 2>&1; then
echo "PR already exists for $BRANCH"
PR_NUMBER=$(gh pr view "$BRANCH" --json number -q '.number')
else
PR_URL=$(gh pr create \
--base beta \
--head "$BRANCH" \
--title "chore: update Unity package to beta version ${BETA_VERSION}" \
--body "Automated beta version bump for the Unity package.")
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
fi
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "updated=true" >> "$GITHUB_OUTPUT"
- name: Auto-merge version bump PR
if: steps.commit.outputs.updated == 'true' && steps.commit.outputs.pr_number != ''
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.commit.outputs.pr_number }}
shell: bash
run: |
set -euo pipefail
gh pr merge "$PR_NUMBER" --merge --delete-branch
publish_pypi_prerelease:
name: Publish beta to PyPI (pre-release)
runs-on: ubuntu-latest
needs: [unity_tests, python_tests]
# Avoid double-publish when the bot merges the version bump PR
if: github.actor != 'github-actions[bot]'
environment:
name: pypi
url: https://pypi.org/p/mcpforunityserver
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
ref: beta
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: true
cache-dependency-glob: "Server/uv.lock"
- name: Generate beta version
id: version
shell: bash
run: |
set -euo pipefail
RAW_VERSION=$(grep -oP '(?<=version = ")[^"]+' Server/pyproject.toml)
echo "Raw version: $RAW_VERSION"
# Check if already a beta/prerelease version
if [[ "$RAW_VERSION" =~ (a|b|rc|\.dev|\.post)[0-9]+$ ]]; then
IS_PRERELEASE=true
# Strip the prerelease suffix to get base version
BASE_VERSION=$(echo "$RAW_VERSION" | sed -E 's/(a|b|rc|\.dev|\.post)[0-9]+$//')
else
IS_PRERELEASE=false
BASE_VERSION="$RAW_VERSION"
fi
# Validate we have a proper X.Y.Z format
if ! [[ "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Could not parse version '$RAW_VERSION' -> '$BASE_VERSION'" >&2
exit 1
fi
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION"
# Only bump patch if coming from stable; keep same base if already prerelease
if [[ "$IS_PRERELEASE" == "true" ]]; then
# Already on a beta series - keep the same base version
NEXT_PATCH="$PATCH"
echo "Already prerelease, keeping base: $BASE_VERSION"
else
# Stable version - bump patch to ensure beta is "newer"
NEXT_PATCH=$((PATCH + 1))
echo "Stable version, bumping patch: $PATCH -> $NEXT_PATCH"
fi
BETA_NUMBER="$(date +%Y%m%d%H%M%S)"
BETA_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}b${BETA_NUMBER}"
echo "Base version: $BASE_VERSION"
echo "Beta version: $BETA_VERSION"
echo "beta_version=$BETA_VERSION" >> "$GITHUB_OUTPUT"
- name: Update version for beta release
env:
BETA_VERSION: ${{ steps.version.outputs.beta_version }}
shell: bash
run: |
set -euo pipefail
sed -i "s/^version = .*/version = \"${BETA_VERSION}\"/" Server/pyproject.toml
echo "Updated pyproject.toml:"
grep "^version" Server/pyproject.toml
- name: Build a binary wheel and a source tarball
shell: bash
run: uv build
working-directory: ./Server
- name: Publish distribution to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: Server/dist/
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
name: Docs — Build & Deploy
# Builds the Docusaurus site under /website and, on push to beta, deploys
# to GitHub Pages at https://coplaydev.github.io/unity-mcp/.
#
# PR runs only build (no deploy) as a preview-validation step.
# Re-deploys also fire when the Python tool/resource registry changes,
# so M3's auto-generated reference pages stay fresh.
on:
push:
branches: [beta]
paths:
- website/**
- docs/**
- Server/src/services/tools/**
- Server/src/services/resources/**
- Server/src/services/registry/**
- .github/workflows/docs-deploy.yml
pull_request:
branches: [beta, main]
paths:
- website/**
- docs/**
- Server/src/services/tools/**
- Server/src/services/resources/**
- Server/src/services/registry/**
- .github/workflows/docs-deploy.yml
workflow_dispatch: {}
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
name: Build site
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history so Docusaurus's showLastUpdateTime / showLastUpdateAuthor
# can resolve real per-file commit metadata. Shallow clones make every
# page report the latest commit instead.
fetch-depth: 0
# No push back from this workflow — the deploy uses the Pages
# OIDC token issued by actions/deploy-pages, not the repo token.
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: website/package-lock.json
- name: Install dependencies
working-directory: website
run: npm ci
- name: Setup Pages
if: github.event_name == 'push' && github.ref == 'refs/heads/beta'
uses: actions/configure-pages@v5
- name: Build
working-directory: website
env:
# Inject the cookieless GoatCounter beacon when provisioned. The site
# gates on process.env.GOATCOUNTER_CODE, which Actions does NOT
# auto-expose — the Actions variable must be mapped in explicitly.
GOATCOUNTER_CODE: ${{ vars.GOATCOUNTER_CODE }}
run: npm run build
- name: Upload Pages artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/beta'
uses: actions/upload-pages-artifact@v3
with:
path: website/build
deploy:
name: Deploy to GitHub Pages
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/beta'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
+72
View File
@@ -0,0 +1,72 @@
name: Docs — Reference Drift Check
# Fails a PR if the committed /website/docs/reference/ output disagrees
# with what tools/generate_docs_reference.py would produce from the live
# Python tool/resource registry. Contributors should regenerate locally:
#
# cd Server && uv run python ../tools/generate_docs_reference.py
#
# or install the pre-commit hook to make this automatic:
#
# tools/install-hooks.sh
on:
pull_request:
branches: [beta, main]
paths:
- Server/src/services/tools/**
- Server/src/services/resources/**
- Server/src/services/registry/**
- website/docs/reference/**
- tools/generate_docs_reference.py
- .github/workflows/docs-generate.yml
push:
branches: [beta]
paths:
- Server/src/services/tools/**
- Server/src/services/resources/**
- Server/src/services/registry/**
- website/docs/reference/**
- tools/generate_docs_reference.py
- .github/workflows/docs-generate.yml
workflow_dispatch: {}
jobs:
check:
name: Check docs reference is fresh
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: latest
- name: Set up Python
run: uv python install 3.10
- name: Sync Server deps
working-directory: Server
run: uv sync
- name: Drift check
working-directory: Server
run: uv run python ../tools/generate_docs_reference.py --check
- name: Tool-count sanity
run: |
set -euo pipefail
# Match the decorator at start-of-line (skips imports and docstring mentions).
decorator_count=$(grep -rhE "^@mcp_for_unity_tool\(" Server/src/services/tools/ | wc -l | tr -d ' ')
# md_count excludes group landing pages (index.md) and the catalog root.
md_count=$(find website/docs/reference/tools -name '*.md' -not -name 'index.md' | wc -l | tr -d ' ')
echo "decorator_count=$decorator_count, md_count=$md_count"
if [[ "$decorator_count" != "$md_count" ]]; then
echo "Mismatch: $decorator_count @mcp_for_unity_tool decorators vs $md_count reference pages." >&2
echo "Run: cd Server && uv run python ../tools/generate_docs_reference.py" >&2
exit 1
fi
+194
View File
@@ -0,0 +1,194 @@
name: E2E Bridge Smoke (deterministic, no LLM)
# Boots a headless Unity Editor, starts the Python MCP server's wire path, and
# drives a fixed sequence of real tool calls with exact assertions
# (Server/tests/e2e/bridge_smoke.py). Unlike claude-nl-suite.yml this needs
# NO Anthropic API key -- it is deterministic and cheap, so it can gate PRs and
# releases. It still needs Unity license secrets to boot the Editor.
on:
workflow_dispatch:
pull_request:
paths:
- "MCPForUnity/Editor/**"
- "MCPForUnity/Runtime/**"
- "Server/src/**"
- "Server/tests/e2e/**"
- "tools/local_harness.py"
- ".github/workflows/e2e-bridge.yml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3
jobs:
e2e-bridge:
runs-on: ubuntu-24.04
timeout-minutes: 40
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"
echo "::warning::Unity license secrets absent; E2E bridge smoke will be skipped (not failed)."
fi
- uses: actions/checkout@v4
if: steps.detect.outputs.unity_ok == 'true'
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v4
if: steps.detect.outputs.unity_ok == 'true'
with:
python-version: "3.11"
- name: Install MCP server
if: steps.detect.outputs.unity_ok == 'true'
run: |
set -eux
uv venv
echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV"
echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH"
uv pip install -e Server
# --- License staging (mirrors claude-nl-suite.yml) ---
- name: Decide license sources
if: steps.detect.outputs.unity_ok == 'true'
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:-}" && -n "${UNITY_SERIAL:-}" ]] && use_ebl=true
echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT"
echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT"
- name: Stage Unity .ulf license (from secret)
if: steps.detect.outputs.unity_ok == 'true' && 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 grep -qi '<Signature>' "$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)
if: steps.detect.outputs.unity_ok == 'true' && 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
/opt/unity/Editor/Unity -batchmode -nographics -logFile - \
-username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true
'
- name: Warm up project (import Library once)
if: steps.detect.outputs.unity_ok == '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
- name: Clean old MCP status
if: steps.detect.outputs.unity_ok == 'true'
run: |
set -eux
mkdir -p "$GITHUB_WORKSPACE/.unity-mcp"
rm -f "$GITHUB_WORKSPACE/.unity-mcp"/unity-mcp-status-*.json || true
- name: Run headless bridge harness (boot + wait + smoke/editmode/playmode)
if: steps.detect.outputs.unity_ok == 'true'
shell: bash
env:
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
ULF_OK: ${{ steps.ulf.outputs.ok }}
run: |
set -euxo pipefail
# In --ci mode the harness drives the DockerLauncher: it runs the same
# docker container (repo .unity-mcp status dir, docker liveness/teardown,
# log redaction), waits on the status file, derives the instance, then
# runs the smoke + EditMode + PlayMode legs over the bridge.
license_args=()
if [[ "${ULF_OK:-false}" == "true" ]]; then
license_args=(--editor-arg -manualLicenseFile \
--editor-arg "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
fi
python3 tools/local_harness.py --ci \
--legs smoke,editmode,playmode \
--project-path TestProjects/UnityMCPTests \
--reports reports \
"${license_args[@]}"
- name: Unity logs on failure
if: failure() && steps.detect.outputs.unity_ok == 'true'
run: docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' || true
- name: Upload E2E report
if: always() && steps.detect.outputs.unity_ok == 'true'
uses: actions/upload-artifact@v4
with:
name: e2e-bridge-report
path: reports/junit-*.xml
if-no-files-found: ignore
+20
View File
@@ -0,0 +1,20 @@
name: github-repo-stats
on:
# schedule:
# Run this once per day, towards the end of the day for keeping the most
# recent data point most meaningful (hours are interpreted in UTC).
#- cron: "0 23 * * *"
workflow_dispatch: # Allow for running this manually.
jobs:
j1:
if: github.repository == 'CoplayDev/unity-mcp'
name: github-repo-stats
runs-on: ubuntu-latest
steps:
- name: run-ghrs
# Use latest release.
uses: jgehrcke/github-repo-stats@RELEASE
with:
ghtoken: ${{ secrets.ghrs_github_api_token }}
+81
View File
@@ -0,0 +1,81 @@
name: Python Tests
on:
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 Server/** fires this workflow twice for the
# same SHA, and GitHub auto-cancels the duplicate.
branches-ignore: [beta, main]
paths:
- Server/**
- tools/**
- .github/workflows/python-tests.yml
pull_request:
branches: [main, beta]
paths:
- Server/**
- tools/**
- .github/workflows/python-tests.yml
workflow_dispatch: {}
workflow_call:
inputs:
ref:
description: "Git ref to test (defaults to the triggering ref)."
type: string
required: false
default: ""
jobs:
test:
name: Run Python Tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Set up Python
run: uv python install 3.10
- name: Install dependencies
run: |
cd Server
uv sync
uv pip install -e ".[dev]"
- name: Run tests with coverage
run: |
cd Server
uv run pytest tests/ -v --tb=short --cov --cov-report=xml --cov-report=html --cov-report=term
- name: Run local harness unit tests (hermetic, no Unity)
run: |
cd Server
uv run python -m pytest "$GITHUB_WORKSPACE/tools/tests/" -v --tb=short
- name: Upload coverage reports
uses: codecov/codecov-action@v4
if: always()
with:
files: ./Server/coverage.xml
flags: python
name: python-coverage
fail_ci_if_error: false
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: pytest-results
path: |
Server/.pytest_cache/
Server/tests/
Server/coverage.xml
Server/htmlcov/
+557
View File
@@ -0,0 +1,557 @@
name: Release
concurrency:
group: release-main
cancel-in-progress: false
on:
workflow_dispatch:
inputs:
version_bump:
description: "Version bump type (none = release beta version as-is)"
type: choice
options:
- patch
- minor
- major
- none
default: patch
required: true
jobs:
unity_tests:
name: Unity tests gate
uses: ./.github/workflows/unity-tests.yml
with:
ref: beta
secrets: inherit
python_tests:
name: Python tests gate
uses: ./.github/workflows/python-tests.yml
with:
ref: beta
bump:
name: Bump version, tag, and create release
runs-on: ubuntu-latest
needs: [unity_tests, python_tests]
permissions:
contents: write
pull-requests: write
outputs:
new_version: ${{ steps.compute.outputs.new_version }}
tag: ${{ steps.tag.outputs.tag }}
bump_branch: ${{ steps.bump_branch.outputs.name }}
steps:
- name: Ensure workflow is running on main
shell: bash
run: |
set -euo pipefail
if [[ "${GITHUB_REF_NAME}" != "main" ]]; then
echo "This workflow must be run on the main branch. Current ref: ${GITHUB_REF_NAME}" >&2
exit 1
fi
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
- name: Show current versions
id: preview
shell: bash
run: |
set -euo pipefail
echo "============================================"
echo "CURRENT VERSION STATUS"
echo "============================================"
# Get main version
MAIN_VERSION=$(jq -r '.version' "MCPForUnity/package.json")
MAIN_PYPI=$(grep -oP '(?<=version = ")[^"]+' Server/pyproject.toml)
echo "Main branch:"
echo " Unity package: $MAIN_VERSION"
echo " PyPI server: $MAIN_PYPI"
echo ""
# Get beta version
git fetch origin beta
BETA_VERSION=$(git show origin/beta:MCPForUnity/package.json | jq -r '.version')
BETA_PYPI=$(git show origin/beta:Server/pyproject.toml | grep -oP '(?<=version = ")[^"]+')
echo "Beta branch:"
echo " Unity package: $BETA_VERSION"
echo " PyPI server: $BETA_PYPI"
echo ""
# Compute stripped version (used for "none" bump option)
STRIPPED=$(echo "$BETA_VERSION" | sed -E 's/-[a-zA-Z]+\.[0-9]+$//')
echo "stripped_version=$STRIPPED" >> "$GITHUB_OUTPUT"
# Show what will happen
BUMP="${{ inputs.version_bump }}"
echo "Selected bump type: $BUMP"
echo "After stripping beta suffix: $STRIPPED"
if [[ "$BUMP" == "none" ]]; then
echo "Release version will be: $STRIPPED"
else
IFS='.' read -r MA MI PA <<< "$STRIPPED"
case "$BUMP" in
major) ((MA+=1)); MI=0; PA=0 ;;
minor) ((MI+=1)); PA=0 ;;
patch) ((PA+=1)) ;;
esac
echo "Release version will be: $MA.$MI.$PA"
fi
echo "============================================"
- name: Merge beta into main
shell: bash
run: |
set -euo pipefail
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
# Fetch beta branch
git fetch origin beta
# Check if beta has changes not in main
if git merge-base --is-ancestor origin/beta HEAD; then
echo "beta is already merged into main. Nothing to merge."
else
echo "Merging beta into main..."
git merge origin/beta --no-edit -m "chore: merge beta into main for release"
echo "Beta merged successfully."
fi
- name: Strip beta suffix from version if present
shell: bash
run: |
set -euo pipefail
CURRENT_VERSION=$(jq -r '.version' "MCPForUnity/package.json")
echo "Current version: $CURRENT_VERSION"
# Strip beta/alpha/rc suffix if present (e.g., "9.4.0-beta.1" -> "9.4.0")
if [[ "$CURRENT_VERSION" == *"-"* ]]; then
STABLE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-[a-zA-Z]+\.[0-9]+$//')
# Validate we have a proper X.Y.Z format after stripping
if ! [[ "$STABLE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Could not parse version '$CURRENT_VERSION' -> '$STABLE_VERSION'" >&2
exit 1
fi
echo "Stripping prerelease suffix: $CURRENT_VERSION -> $STABLE_VERSION"
jq --arg v "$STABLE_VERSION" '.version = $v' MCPForUnity/package.json > tmp.json
mv tmp.json MCPForUnity/package.json
# Also update pyproject.toml
sed -i "s/^version = .*/version = \"${STABLE_VERSION}\"/" Server/pyproject.toml
else
echo "Version is already stable: $CURRENT_VERSION"
fi
- name: Compute new version
id: compute
env:
PREVIEWED_STRIPPED: ${{ steps.preview.outputs.stripped_version }}
shell: bash
run: |
set -euo pipefail
BUMP="${{ inputs.version_bump }}"
CURRENT_VERSION=$(jq -r '.version' "MCPForUnity/package.json")
echo "Current version: $CURRENT_VERSION"
# Sanity check: ensure current version matches what was previewed
if [[ "$CURRENT_VERSION" != "$PREVIEWED_STRIPPED" ]]; then
echo "Warning: Current version ($CURRENT_VERSION) differs from previewed ($PREVIEWED_STRIPPED)"
echo "This may indicate an unexpected merge result. Proceeding with current version."
fi
if [[ "$BUMP" == "none" ]]; then
# Use the previewed stripped version to ensure consistency with what user saw
NEW_VERSION="$PREVIEWED_STRIPPED"
else
IFS='.' read -r MA MI PA <<< "$CURRENT_VERSION"
case "$BUMP" in
major)
((MA+=1)); MI=0; PA=0
;;
minor)
((MI+=1)); PA=0
;;
patch)
((PA+=1))
;;
*)
echo "Unknown version_bump: $BUMP" >&2
exit 1
;;
esac
NEW_VERSION="$MA.$MI.$PA"
fi
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
- name: Compute tag
id: tag
env:
NEW_VERSION: ${{ steps.compute.outputs.new_version }}
shell: bash
run: |
set -euo pipefail
echo "tag=v${NEW_VERSION}" >> "$GITHUB_OUTPUT"
- name: Update files to new version
env:
NEW_VERSION: ${{ steps.compute.outputs.new_version }}
shell: bash
run: |
set -euo pipefail
echo "Updating all version references to $NEW_VERSION"
python3 tools/update_versions.py --version "$NEW_VERSION"
- name: Commit version bump to a temporary branch
id: bump_branch
env:
NEW_VERSION: ${{ steps.compute.outputs.new_version }}
shell: bash
run: |
set -euo pipefail
BRANCH="release/v${NEW_VERSION}"
echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git checkout -b "$BRANCH"
git add MCPForUnity/package.json manifest.json "Server/pyproject.toml" Server/README.md
if git diff --cached --quiet; then
echo "No version changes to commit."
else
git commit -m "chore: bump version to ${NEW_VERSION}"
fi
echo "Pushing bump branch $BRANCH"
git push origin "$BRANCH"
- name: Create PR for version bump into main
id: bump_pr
env:
GH_TOKEN: ${{ github.token }}
NEW_VERSION: ${{ steps.compute.outputs.new_version }}
BRANCH: ${{ steps.bump_branch.outputs.name }}
shell: bash
run: |
set -euo pipefail
PR_URL=$(gh pr create \
--base main \
--head "$BRANCH" \
--title "chore: bump version to ${NEW_VERSION}" \
--body "Automated version bump to ${NEW_VERSION}.")
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
- name: Enable auto-merge and merge PR
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.bump_pr.outputs.pr_number }}
shell: bash
run: |
set -euo pipefail
# Enable auto-merge (requires repo setting "Allow auto-merge")
gh pr merge "$PR_NUMBER" --merge --auto || true
# Wait for PR to be merged (poll up to 2 minutes)
for i in {1..24}; do
STATE=$(gh pr view "$PR_NUMBER" --json state -q '.state')
if [[ "$STATE" == "MERGED" ]]; then
echo "PR merged successfully."
exit 0
fi
echo "Waiting for PR to merge... (state: $STATE)"
sleep 5
done
echo "PR did not merge in time. Attempting direct merge..."
gh pr merge "$PR_NUMBER" --merge
- name: Fetch merged main and create tag
env:
TAG: ${{ steps.tag.outputs.tag }}
shell: bash
run: |
set -euo pipefail
git fetch origin main
git checkout main
git pull origin main
echo "Preparing to create tag $TAG"
if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then
echo "Tag $TAG already exists on remote. Refusing to release." >&2
exit 1
fi
git tag -a "$TAG" -m "Version ${TAG#v}"
git push origin "$TAG"
- name: Clean up release branch
if: always()
env:
GH_TOKEN: ${{ github.token }}
BRANCH: ${{ steps.bump_branch.outputs.name }}
shell: bash
run: |
set -euo pipefail
git push origin --delete "$BRANCH" || true
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: ${{ steps.tag.outputs.tag }}
generate_release_notes: true
sync_beta:
name: Merge main back into beta via PR
needs:
- bump
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout beta
uses: actions/checkout@v6
with:
ref: beta
fetch-depth: 0
- name: Prepare sync branch from beta with merged main
id: sync_branch
env:
NEW_VERSION: ${{ needs.bump.outputs.new_version }}
shell: bash
run: |
set -euo pipefail
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
# Fetch both branches so we can build a merge commit in CI.
git fetch origin main beta
if git merge-base --is-ancestor origin/main origin/beta; then
echo "beta is already up to date with main. Skipping sync."
echo "skipped=true" >> "$GITHUB_OUTPUT"
exit 0
fi
SYNC_BRANCH="sync/main-v${NEW_VERSION}-into-beta-${GITHUB_RUN_ID}"
echo "name=$SYNC_BRANCH" >> "$GITHUB_OUTPUT"
echo "skipped=false" >> "$GITHUB_OUTPUT"
git checkout -b "$SYNC_BRANCH" origin/beta
if git merge origin/main --no-ff --no-commit; then
echo "main merged cleanly into sync branch."
else
echo "Merge conflicts detected. Attempting expected conflict resolution for beta version files."
CONFLICTS=$(git diff --name-only --diff-filter=U || true)
if [[ -n "$CONFLICTS" ]]; then
echo "$CONFLICTS"
fi
# Keep beta-side prerelease versions if these files conflict.
for file in MCPForUnity/package.json Server/pyproject.toml; do
if git ls-files -u -- "$file" | grep -q .; then
echo "Keeping beta version for $file"
git checkout --ours -- "$file"
git add "$file"
fi
done
REMAINING=$(git diff --name-only --diff-filter=U || true)
if [[ -n "$REMAINING" ]]; then
echo "Unexpected unresolved conflicts remain:"
echo "$REMAINING"
exit 1
fi
fi
git commit -m "chore: sync main (v${NEW_VERSION}) into beta"
# After releasing X.Y.Z on main, beta should move to X.Y.(Z+1)-beta.1.
IFS='.' read -r MAJOR MINOR PATCH <<< "$NEW_VERSION"
NEXT_PATCH=$((PATCH + 1))
NEXT_BETA_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}-beta.1"
echo "beta_version=$NEXT_BETA_VERSION" >> "$GITHUB_OUTPUT"
echo "Setting beta version to $NEXT_BETA_VERSION"
CURRENT_BETA_VERSION=$(jq -r '.version' MCPForUnity/package.json)
if [[ "$CURRENT_BETA_VERSION" != "$NEXT_BETA_VERSION" ]]; then
jq --arg v "$NEXT_BETA_VERSION" '.version = $v' MCPForUnity/package.json > tmp.json
mv tmp.json MCPForUnity/package.json
git add MCPForUnity/package.json
git commit -m "chore: set beta version to ${NEXT_BETA_VERSION} after release v${NEW_VERSION}"
else
echo "Beta version already at target: $NEXT_BETA_VERSION"
fi
echo "Pushing sync branch $SYNC_BRANCH"
git push origin "$SYNC_BRANCH"
- name: Create PR to merge sync branch into beta
if: steps.sync_branch.outputs.skipped != 'true'
id: sync_pr
env:
GH_TOKEN: ${{ github.token }}
NEW_VERSION: ${{ needs.bump.outputs.new_version }}
NEXT_BETA_VERSION: ${{ steps.sync_branch.outputs.beta_version }}
SYNC_BRANCH: ${{ steps.sync_branch.outputs.name }}
shell: bash
run: |
set -euo pipefail
PR_URL=$(gh pr create \
--base beta \
--head "$SYNC_BRANCH" \
--title "chore: sync main (v${NEW_VERSION}) into beta" \
--body "Automated sync of main back into beta after release v${NEW_VERSION}, including beta version set to ${NEXT_BETA_VERSION}.")
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
- name: Merge sync PR
if: steps.sync_branch.outputs.skipped != 'true'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.sync_pr.outputs.pr_number }}
shell: bash
run: |
set -euo pipefail
# Best effort: auto-merge if repository settings allow it.
gh pr merge "$PR_NUMBER" --merge --auto --delete-branch || true
# Retry direct merge for up to 2 minutes while checks settle.
for i in {1..24}; do
STATE=$(gh pr view "$PR_NUMBER" --json state -q '.state')
if [[ "$STATE" == "MERGED" ]]; then
echo "Sync PR merged successfully."
exit 0
fi
if gh pr merge "$PR_NUMBER" --merge --delete-branch >/dev/null 2>&1; then
echo "Sync PR merged successfully."
exit 0
fi
echo "Waiting for sync PR to become mergeable... (state: $STATE)"
sleep 5
done
echo "Sync PR did not merge in time."
gh pr view "$PR_NUMBER" --json state,mergeStateStatus,isDraft -q '{state: .state, mergeStateStatus: .mergeStateStatus, isDraft: .isDraft}'
exit 1
publish_docker:
name: Publish Docker image
needs:
- bump
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v6
with:
ref: ${{ needs.bump.outputs.tag }}
fetch-depth: 0
- name: Build and push Docker image
uses: ./.github/actions/publish-docker
with:
docker_username: ${{ secrets.DOCKER_USERNAME }}
docker_password: ${{ secrets.DOCKER_PASSWORD }}
image: ${{ secrets.DOCKER_USERNAME }}/mcp-for-unity-server
version: ${{ needs.bump.outputs.new_version }}
include_branch_tags: "false"
context: .
dockerfile: Server/Dockerfile
platforms: linux/amd64
publish_pypi:
name: Publish Python distribution to PyPI
needs:
- bump
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/mcpforunityserver
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.bump.outputs.tag }}
fetch-depth: 0
# Inlined from .github/actions/publish-pypi to avoid nested composite action issue
# with pypa/gh-action-pypi-publish (see https://github.com/pypa/gh-action-pypi-publish/issues/338)
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: true
cache-dependency-glob: "Server/uv.lock"
- name: Build a binary wheel and a source tarball
shell: bash
run: uv build
working-directory: ./Server
- name: Publish distribution to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: Server/dist/
publish_mcpb:
name: Generate and publish MCPB bundle
needs:
- bump
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out the repo
uses: actions/checkout@v6
with:
ref: ${{ needs.bump.outputs.tag }}
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Generate MCPB bundle
env:
NEW_VERSION: ${{ needs.bump.outputs.new_version }}
shell: bash
run: |
set -euo pipefail
python3 tools/generate_mcpb.py "$NEW_VERSION" \
--output "unity-mcp-${NEW_VERSION}.mcpb" \
--icon docs/images/coplay-logo.png
- name: Upload MCPB to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.bump.outputs.tag }}
files: unity-mcp-${{ needs.bump.outputs.new_version }}.mcpb
+25
View File
@@ -0,0 +1,25 @@
name: Adoption stats (maintainer-only)
# Posts unified PyPI install + docs-traffic numbers to the workflow run summary,
# which is visible ONLY to repo collaborators. Nothing is published publicly.
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch: {}
permissions:
contents: read
jobs:
stats:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- name: Fetch + summarize stats (private to collaborators)
env:
# public stars/forks work with the default token; unique cloners/viewers
# need a maintainer PAT with Administration: read (STATS_GITHUB_TOKEN).
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
STATS_GITHUB_TOKEN: ${{ secrets.STATS_GITHUB_TOKEN }}
GOATCOUNTER_TOKEN: ${{ secrets.GOATCOUNTER_TOKEN }}
GOATCOUNTER_SITE: ${{ secrets.GOATCOUNTER_SITE }}
run: node website/scripts/fetch-stats.mjs >> "$GITHUB_STEP_SUMMARY"
+78
View File
@@ -0,0 +1,78 @@
name: Docs — Sync Release Notes
# Keeps website/docs/releases.md and the README's "Recent Updates" block
# in sync with the GitHub Releases API. Source of truth is GitHub Releases.
#
# Triggers (intentionally narrow):
# - release.published / edited / unpublished / deleted: the only time
# the synced files can legitimately go stale. Fires on every release
# event so the docs reflect the new version within ~30 seconds.
# - workflow_dispatch: manual escape hatch (re-run after a one-off
# GitHub UI edit to a release body, or to backfill after a downtime).
#
# Why no pull_request / schedule triggers:
# - PR-time drift-check would fail outsider PRs that touched README for
# unrelated reasons (e.g. typo fixes) and the contributor has no push
# access to fix it. The synced files are maintained by the release
# pipeline, not by PR authors — drift can't logically be introduced
# by a PR that the workflow couldn't already handle on release.
# - A daily cron would mask the source-of-truth (release events) and
# produce mystery commits unattached to a release. Better to let
# workflow_dispatch handle the rare out-of-band UI edit.
#
# Behavior:
# - Commits directly to `beta` with [skip ci] in the message — the
# change is mechanical (re-render from API output), pre-validated,
# and confined to two well-known files.
on:
release:
types: [published, edited, unpublished, deleted]
workflow_dispatch: {}
permissions:
contents: read
concurrency:
group: docs-sync-releases
cancel-in-progress: false
jobs:
sync:
name: Sync release notes
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout beta
uses: actions/checkout@v4
with:
ref: beta
# Full history so the commit lands on a fresh ref tip.
fetch-depth: 0
# `sync` needs to push back, so the token must persist here.
persist-credentials: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Sync release notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python tools/sync_release_notes.py
- name: Commit & push (if anything changed)
run: |
set -euo pipefail
if git diff --quiet -- website/docs/releases.md README.md; then
echo "No drift — release notes already up to date."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add website/docs/releases.md README.md
git commit -m "docs: sync release notes from GitHub Releases [skip ci]"
git push origin beta
+265
View File
@@ -0,0 +1,265 @@
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 "<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.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 }}