ci: list every composite action in the paths filters that use it #638
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: Build | |
| on: | |
| push: | |
| # Every branch, not just main: this repo squash-merges locally and opens no | |
| # pull requests of its own, so a feature branch has no other way to reach | |
| # these lanes before it lands. `branches-ignore` rather than deleting the | |
| # key — a push trigger that names `tags` and omits `branches` entirely fires | |
| # ONLY on tags, which would disable branch CI outright. Dependabot is | |
| # excluded because it pushes its branch AND opens a PR; the two land in | |
| # different concurrency groups, so the push run is a pure duplicate. | |
| branches-ignore: ['dependabot/**'] | |
| # Path filters ARE evaluated for tag pushes, against the tagged commit's own | |
| # diff. release.yml tags its "chore: bump version" commit, which writes | |
| # Sources/mirroir-mcp/Version.swift — so `Sources/**` keeps this lane firing | |
| # on releases. | |
| tags: ['v*'] | |
| paths: | |
| - 'Sources/**' | |
| - 'Tests/**' | |
| - 'Package.swift' | |
| - 'Package.resolved' | |
| - 'scripts/**' | |
| - '.github/actions/setup-embacle/**' | |
| - '.github/actions/setup-fake-mirroring/**' | |
| - '.github/workflows/build.yml' | |
| pull_request: | |
| branches: [main] | |
| # Same list as `push` — the Actions parser has no YAML anchors, so it is | |
| # repeated (as in runner.yml and limitation-register.yml). Without it every | |
| # dependabot bump for /website, /runner or /npm burns both macOS jobs on | |
| # code this workflow never compiles. | |
| paths: | |
| - 'Sources/**' | |
| - 'Tests/**' | |
| - 'Package.swift' | |
| - 'Package.resolved' | |
| - 'scripts/**' | |
| - '.github/actions/setup-embacle/**' | |
| - '.github/actions/setup-fake-mirroring/**' | |
| - '.github/workflows/build.yml' | |
| workflow_dispatch: | |
| # Cancel superseded runs to save the scarce macOS runner slots. Keyed on | |
| # `head_ref || ref` so a branch push and the pull request for it share one group. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| build: | |
| runs-on: macos-15 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: ./.github/actions/setup-embacle | |
| - name: Clone skills repo | |
| run: | | |
| git clone https://github.com/jfarcand/mirroir-skills.git ../mirroir-skills | |
| mkdir -p .mirroir-mcp | |
| ln -s "$(cd ../mirroir-skills && pwd)" .mirroir-mcp/skills | |
| - name: Build (debug) | |
| run: swift build | |
| - name: Build (release) | |
| run: swift build -c release | |
| - name: Unit tests (XCTest) | |
| timeout-minutes: 15 | |
| run: swift test --skip IntegrationTests --disable-swift-testing | |
| - name: Unit tests (Swift Testing) | |
| timeout-minutes: 5 | |
| run: swift test --skip IntegrationTests --disable-xctest --no-parallel | |
| - uses: ./.github/actions/setup-fake-mirroring | |
| - name: Pre-test runner diagnostics | |
| run: | | |
| echo "=== sw_vers ===" | |
| sw_vers | |
| echo "" | |
| echo "=== uname ===" | |
| uname -a | |
| echo "" | |
| echo "=== ioreg display ===" | |
| ioreg -l -w 0 -d 0 -p IODisplayWrangler 2>/dev/null | head -40 || true | |
| echo "" | |
| echo "=== displayplacer / displays ===" | |
| system_profiler SPDisplaysDataType 2>/dev/null | head -40 || true | |
| echo "" | |
| echo "=== ps for FakeMirroring ===" | |
| ps -ef | grep -i FakeMirroring | grep -v grep || echo "FakeMirroring not yet running" | |
| echo "" | |
| echo "=== pmset -g ===" | |
| pmset -g 2>/dev/null | head -20 || true | |
| echo "" | |
| echo "=== AX/Input Monitoring TCC (read-only check) ===" | |
| ls -l /Library/Application\ Support/com.apple.TCC/TCC.db 2>/dev/null || echo "no system TCC.db readable" | |
| - name: Integration tests (FakeMirroring) | |
| env: | |
| MIRROIR_BUNDLE_ID: com.jfarcand.FakeMirroring | |
| MIRROIR_PROCESS_NAME: FakeMirroring | |
| run: swift test --filter IntegrationTests | |
| - name: Post-test runner diagnostics (on failure) | |
| if: failure() | |
| run: | | |
| echo "=== ps for FakeMirroring after tests ===" | |
| ps -ef | grep -i FakeMirroring | grep -v grep || echo "FakeMirroring not running" | |
| echo "" | |
| echo "=== CGWindowList via osascript ===" | |
| osascript -e 'tell application "System Events" to get every process whose visible is true' 2>&1 | head -10 || true | |
| echo "" | |
| echo "=== FakeMirroring NSLog output (last 10 min, full) ===" | |
| log show --last 10m --predicate 'process == "FakeMirroring"' --info --debug 2>&1 | grep -E "fakemirror-tap|FakeMirroring\]" | tail -200 || true | |
| echo "" | |
| echo "=== WindowServer log (last 5 min) ===" | |
| log show --last 5m --predicate 'subsystem == "com.apple.WindowServer"' --info 2>&1 | tail -100 || true | |
| echo "" | |
| echo "=== HIToolbox / event tap log (last 5 min) ===" | |
| log show --last 5m --predicate 'subsystem == "com.apple.HIToolbox" OR subsystem == "com.apple.coreservices"' --info 2>&1 | tail -50 || true | |
| - name: Test runner (FakeMirroring) | |
| env: | |
| MIRROIR_BUNDLE_ID: com.jfarcand.FakeMirroring | |
| MIRROIR_PROCESS_NAME: FakeMirroring | |
| run: | | |
| .build/release/mirroir-mcp test --junit test-results.xml --verbose Tests/Fixtures/skills/fake-mirroring-check.yaml | |
| - name: Compile skill (FakeMirroring) | |
| env: | |
| MIRROIR_BUNDLE_ID: com.jfarcand.FakeMirroring | |
| MIRROIR_PROCESS_NAME: FakeMirroring | |
| run: | | |
| .build/release/mirroir-mcp compile Tests/Fixtures/skills/fake-mirroring-compiled.yaml | |
| - name: Test compiled skill (FakeMirroring) | |
| env: | |
| MIRROIR_BUNDLE_ID: com.jfarcand.FakeMirroring | |
| MIRROIR_PROCESS_NAME: FakeMirroring | |
| run: | | |
| .build/release/mirroir-mcp test --verbose Tests/Fixtures/skills/fake-mirroring-compiled.yaml | |
| - name: Upload test results | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: test-runner-results | |
| path: | | |
| test-results.xml | |
| mirroir-test-results/ | |
| - name: Binary info | |
| run: | | |
| ls -lh .build/release/mirroir-mcp | |
| file .build/release/mirroir-mcp | |
| - name: Test MCP initialize | |
| run: | | |
| echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ | |
| | .build/release/mirroir-mcp 2>/dev/null \ | |
| | python3 -c " | |
| import sys, json | |
| obj = json.loads(sys.stdin.readline()) | |
| assert obj['result']['serverInfo']['name'] == 'mirroir-mcp' | |
| assert obj['result']['protocolVersion'] == '2025-11-25', f'Expected protocol version 2025-11-25, got: {obj[\"result\"][\"protocolVersion\"]}' | |
| print('MCP initialize: OK (protocol version 2025-11-25)') | |
| " | |
| - name: Test tools/list | |
| run: | | |
| printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n' \ | |
| | .build/release/mirroir-mcp --dangerously-skip-permissions 2>/dev/null \ | |
| | python3 -c " | |
| import sys, json | |
| lines = sys.stdin.readlines() | |
| obj = json.loads(lines[1]) | |
| tools = [t['name'] for t in obj['result']['tools']] | |
| expected = {'screenshot','start_recording','stop_recording','tap','swipe','drag','type_text','press_key','long_press','double_tap','shake','launch_app','open_url','press_home','press_app_switcher','press_back','spotlight','get_orientation','status','check_health','describe_screen','list_skills','get_skill','scroll_to','reset_app','measure','set_network','list_targets','switch_target','record_step','save_compiled','generate_skill','calibrate_component','classify_screen'} | |
| assert set(tools) == expected, f'Expected: {expected}\nGot: {set(tools)}\nMissing: {expected - set(tools)}\nExtra: {set(tools) - expected}' | |
| print(f'tools/list: {len(tools)} tools registered') | |
| " | |
| - name: Test tool schemas | |
| run: | | |
| printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n' \ | |
| | .build/release/mirroir-mcp --dangerously-skip-permissions 2>/dev/null \ | |
| | python3 -c " | |
| import sys, json | |
| lines = sys.stdin.readlines() | |
| obj = json.loads(lines[1]) | |
| tools = {t['name']: t for t in obj['result']['tools']} | |
| # tap requires x and y | |
| tap = tools['tap']['inputSchema'] | |
| assert 'x' in tap['properties'], 'tap missing x' | |
| assert 'y' in tap['properties'], 'tap missing y' | |
| assert set(tap.get('required', [])) == {'x', 'y'}, 'tap should require x and y' | |
| # swipe requires from_x, from_y, to_x, to_y | |
| swipe = tools['swipe']['inputSchema'] | |
| assert all(k in swipe['properties'] for k in ['from_x','from_y','to_x','to_y']), 'swipe missing coords' | |
| # type_text requires text | |
| tt = tools['type_text']['inputSchema'] | |
| assert 'text' in tt['properties'], 'type_text missing text' | |
| # screenshot has no required params | |
| ss = tools['screenshot']['inputSchema'] | |
| assert ss.get('required', []) == [] or 'properties' not in ss or len(ss['properties']) == 0 | |
| print('Tool schemas: OK') | |
| " | |
| - name: Test fail-closed default (readonly only) | |
| run: | | |
| # Run from a scratch directory granting nothing. The policy reads the | |
| # project-local config ahead of the global one, so the question stays | |
| # "granted nothing, what is exposed?" whether or not the machine | |
| # running this has a permissions file of its own. | |
| FAILCLOSED=$(mktemp -d) | |
| mkdir -p "$FAILCLOSED/.mirroir-mcp" | |
| echo '{}' > "$FAILCLOSED/.mirroir-mcp/permissions.json" | |
| BIN="$PWD/.build/release/mirroir-mcp" | |
| (cd "$FAILCLOSED" && printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n' \ | |
| | "$BIN" 2>/dev/null) \ | |
| | python3 -c " | |
| import sys, json | |
| lines = sys.stdin.readlines() | |
| obj = json.loads(lines[1]) | |
| tools = [t['name'] for t in obj['result']['tools']] | |
| expected_readonly = {'screenshot','start_recording','stop_recording','get_orientation','status','check_health','describe_screen','list_skills','get_skill','list_targets','calibrate_component','classify_screen'} | |
| assert set(tools) == expected_readonly, f'Fail-closed should show only readonly tools.\nExpected: {expected_readonly}\nGot: {set(tools)}\nExtra: {set(tools) - expected_readonly}\nMissing: {expected_readonly - set(tools)}' | |
| print(f'Fail-closed default: {len(tools)} readonly tools (OK)') | |
| " | |
| - name: Test --yolo alias matches --dangerously-skip-permissions | |
| run: | | |
| # The step tests that the two flags are aliases, so it compares them | |
| # against each other rather than restating the roster — a count here | |
| # would need updating on every tool added, in a file no local check | |
| # reads. | |
| REQUESTS='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n' | |
| printf "$REQUESTS" | .build/release/mirroir-mcp --yolo 2>/dev/null | tail -1 > /tmp/yolo.json | |
| printf "$REQUESTS" | .build/release/mirroir-mcp --dangerously-skip-permissions 2>/dev/null | tail -1 > /tmp/skip.json | |
| python3 -c " | |
| import json | |
| yolo = {t['name'] for t in json.load(open('/tmp/yolo.json'))['result']['tools']} | |
| skip = {t['name'] for t in json.load(open('/tmp/skip.json'))['result']['tools']} | |
| assert yolo, '--yolo exposed no tools' | |
| assert yolo == skip, f'--yolo differs from --dangerously-skip-permissions: {yolo ^ skip}' | |
| print(f'--yolo alias: {len(yolo)} tools, identical to --dangerously-skip-permissions (OK)') | |
| " | |
| - name: Test unknown method returns error | |
| run: | | |
| printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"nonexistent/method","params":{}}\n' \ | |
| | .build/release/mirroir-mcp 2>/dev/null \ | |
| | python3 -c " | |
| import sys, json | |
| lines = sys.stdin.readlines() | |
| resp = json.loads(lines[1]) | |
| assert 'error' in resp, f'Expected error response, got: {resp}' | |
| assert resp['error']['code'] != 0, 'Error code should be non-zero' | |
| print(f'Unknown method error: code={resp[\"error\"][\"code\"]}') | |
| " | |
| - name: Test invalid JSON is handled | |
| run: | | |
| printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\nthis is not json\n' \ | |
| | .build/release/mirroir-mcp 2>/dev/null \ | |
| | python3 -c " | |
| import sys, json | |
| lines = sys.stdin.readlines() | |
| # First line should be valid initialize response | |
| init_resp = json.loads(lines[0]) | |
| assert 'result' in init_resp, 'Initialize should succeed' | |
| # Second line should be a parse error (-32700) | |
| if len(lines) > 1: | |
| err = json.loads(lines[1]) | |
| assert 'error' in err, f'Expected error for bad JSON, got: {err}' | |
| print(f'Invalid JSON error: code={err[\"error\"][\"code\"]}') | |
| else: | |
| print('Server dropped invalid JSON (acceptable)') | |
| " | |
| # The install path most people take: a machine with no embacle-ffi. Every | |
| # other job installs it first, so without this one the source build is never | |
| # link-checked the way a new user experiences it — which is how issue #38 | |
| # shipped. Deliberately does NOT install embacle: absence is the test. | |
| build-without-embacle: | |
| name: Build without embacle-ffi | |
| runs-on: macos-15 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v4 | |
| # The canary APP.md tests read ../mirroir-skills like the primary build | |
| # job does — absence of embacle is the test here, not absence of skills. | |
| - name: Clone skills repo | |
| run: | | |
| git clone https://github.com/jfarcand/mirroir-skills.git ../mirroir-skills | |
| mkdir -p .mirroir-mcp | |
| ln -s "$(cd ../mirroir-skills && pwd)" .mirroir-mcp/skills | |
| - name: Confirm the runner really has no embacle-ffi | |
| run: | | |
| set -euo pipefail | |
| for lib in /opt/homebrew/opt/embacle-ffi/lib/libembacle.a \ | |
| /opt/homebrew/lib/libembacle.a \ | |
| /usr/local/opt/embacle-ffi/lib/libembacle.a \ | |
| /usr/local/lib/libembacle.a; do | |
| if [ -f "$lib" ]; then | |
| echo "::error::$lib exists — this job must run on a machine without embacle" | |
| exit 1 | |
| fi | |
| done | |
| echo "no libembacle.a anywhere — this is a clean machine" | |
| - name: Build (release) — must link without embacle | |
| run: swift build -c release | |
| - name: Binary runs and falls back to Apple Vision OCR | |
| run: | | |
| set -euo pipefail | |
| printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"check_health","arguments":{}}}\n' \ | |
| | .build/release/mirroir-mcp 2>/dev/null \ | |
| | python3 -c " | |
| import sys, json | |
| lines = [json.loads(l) for l in sys.stdin if l.strip()] | |
| init = next(m for m in lines if m['id'] == 1) | |
| assert init['result']['serverInfo']['name'] == 'mirroir-mcp', init | |
| health = next(m for m in lines if m['id'] == 2) | |
| text = health['result']['content'][0]['text'] | |
| # OCR is the documented fallback when embacle is absent, so it must work. | |
| assert 'Text recognition working' in text, f'OCR unavailable without embacle:\n{text}' | |
| print('runs without embacle, OCR works') | |
| " | |
| - name: Unit tests still pass on the stub path | |
| timeout-minutes: 20 | |
| run: swift test --skip IntegrationTests --disable-swift-testing |