Skip to content

chore: bump version to 0.39.0 #637

chore: bump version to 0.39.0

chore: bump version to 0.39.0 #637

Workflow file for this run

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/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/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
- name: Build and package FakeMirroring
run: |
swift build -c release --product FakeMirroring
./scripts/package-fake-app.sh
- name: Quit Setup Assistant (macos-15 runner intercepts taps)
run: |
# Some macos-15 runners boot with Setup Assistant in the foreground
# (Welcome / first-run dialog). It sits above other windows and
# intercepts mouse events posted via cghidEventTap, so FakeMirroring
# receives no mouseUp and integration taps silently no-op. Quit it
# before launching FakeMirroring. `|| true` keeps the step idempotent
# on runners where Setup Assistant isn't present.
osascript -e 'tell application "Setup Assistant" to quit' 2>/dev/null || true
pkill -f "Setup Assistant" 2>/dev/null || true
sleep 1
ps -ef | grep -i "setup assistant" | grep -v grep \
&& echo "Setup Assistant still running" \
|| echo "Setup Assistant absent or quit"
- name: Ensure display fits the FakeMirroring window
# GH macOS runners boot with a display too short for the 410x930
# window: the bottom ~60pt (the simulated tab bar) is clipped
# off-screen and CGEvent taps there never land. Grow the virtual
# display before launching FakeMirroring.
run: |
brew install --quiet jakehilborn/jakehilborn/displayplacer || brew install --quiet displayplacer || true
echo "--- displays before ---"
displayplacer list 2>/dev/null | grep -E "Persistent|Resolution" || true
SCREEN_ID=$(displayplacer list 2>/dev/null | awk -F': ' '/Persistent screen id/{print $2; exit}')
if [ -n "$SCREEN_ID" ]; then
for RES in 1920x1080 1680x1050 1600x1200; do
if displayplacer "id:$SCREEN_ID res:$RES" 2>/dev/null; then
echo "display set to $RES"
break
fi
done
fi
echo "--- displays after ---"
displayplacer list 2>/dev/null | grep -E "Resolution" || true
- name: Launch FakeMirroring
run: |
open .build/release/FakeMirroring.app
sleep 3
- 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