# Main CI: build + test + boundary gates for every PR and push to long-lived branches. # # Branch strategy (defaults — override by editing the branches: lists): # - feature/*, fix/* PR-only checks, no tagging # - develop full CI + pre-release tag v0.X.Y-dev. # - rc/*, release/* full CI + tag v0.X.Y-rc.N (N auto-increments) # - main full CI + tag v0.X.Y + GitHub Release (release.yml) # # Versioning: read commits since previous tag, bump per Conventional Commits # (feat -> minor, fix -> patch, feat! / BREAKING CHANGE -> major). Defaults to # v0.1.0 when no tags exist. # # Untrusted input handling: this workflow does NOT consume PR titles, bodies, # issue text, or commit-author fields directly in run: blocks. The only # external inputs are github.ref (controlled) and the constrained # workflow_dispatch choice. github.run_number is GitHub-provided. name: CI on: pull_request: branches: [develop, main, "rc/*", "release/*"] push: branches: [develop, main, "rc/*", "release/*"] workflow_dispatch: inputs: run_real_hardware: description: "Run JARVIS_REAL_MODELS / JARVIS_REAL_CAMERA suites (self-hosted only)" required: false default: "false" type: choice options: - "false" - "true" concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: write pull-requests: read checks: write jobs: boundary-gates: name: Boundary gates runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run all scripts/check-*.sh (excluding app-builds) run: | fail=0 for s in scripts/check-*.sh; do name="$(basename "$s")" if [ "$name" = "check-app-builds.sh" ]; then echo "::group::SKIP $name (needs xcodebuild)" echo "::endgroup::" continue fi echo "::group::$name" if bash "$s"; then echo "PASS: $name" else echo "::error::FAIL: $name" fail=1 fi echo "::endgroup::" done exit "$fail" webview: name: Webview (pnpm test + build) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 10.30.2 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "pnpm" cache-dependency-path: webview/pnpm-lock.yaml - name: pnpm install working-directory: webview run: pnpm install --frozen-lockfile # Build before typecheck: @jarvis/bus emits its dist/index.d.ts here, # which packages/hud's TS imports require. scripts/build-webview.sh # also rsyncs the production bundle to App/Resources/webview/ so the # downstream SPM jobs (via the artifact below) and app-build (xcodebuild) # both find a working HUD bundle. - name: Build HUD bundle (produces @jarvis/bus dist + App/Resources/webview/) run: bash scripts/build-webview.sh # Typecheck is blocking. The historical ~30 errors reported in CI run # 25797533005 were almost entirely downstream of `TS2307: Cannot find # module '@jarvis/bus'` and disappear once `build-webview.sh` runs above # (which produces `packages/bus/dist/index.d.ts`). The one real strict- # undefined error in SegmentedRing.tsx was fixed in PR #165. - name: Typecheck working-directory: webview run: pnpm -r typecheck - name: Test (vitest) working-directory: webview run: pnpm -r test # Artifact is the production-stamped App/Resources/webview/ tree. # Downstream SPM + app-build jobs depend on this artifact name. - name: Upload App/Resources/webview artifact uses: actions/upload-artifact@v4 with: name: webview-resources path: App/Resources/webview/ retention-days: 7 if-no-files-found: error swift-packages: name: SPM ${{ matrix.package }} # Self-hosted on Grogu (labels: self-hosted, macOS, ARM64, jarvis) per #180. # The github-hosted macos-15 pool was the rate-limiting step for v0.1: # 13 matrix jobs + 1 app-build job all needed macOS slots, GitHub # scheduled them 1-3 at a time, and total wall-clock was ~1-2h per run # versus actual work of <10m. Self-hosted eliminates the queue. runs-on: [self-hosted, macOS, jarvis] # Security gate: self-hosted runners + public repo would let a fork PR # execute arbitrary code on this machine. Push events only ever come from # maintainers (branch protection blocks unauthorized writes). PRs are # only allowed onto the self-hosted runner when they originate from a # branch on this same repo (i.e., not a fork). Also gates manual dispatch. if: | github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) needs: [webview] strategy: fail-fast: false matrix: # VoiceLog removed: no `packages/VoiceLog` directory exists in the # repo. The matrix entry was a phantom that failed with `chdir error: # No such file or directory`. Voice-log work tracked under v1.1. package: - AgentCore - Bus - Config - DevOverlay - Harness - Keychain - Logging - MCP - Memory - Replay - Shell - Vision - Voice steps: # Grogu is a shared workstation, not an ephemeral VM. Interactive # Xcode test sessions, debugging, or aborted `swift test` runs can # leave orphan xctest/swift-test/swift-driver processes around for # hours, holding Mach services + sockets + ports that a new # WKWebView mount or test-target launch then blocks on (observed: # AgentCore hung 34min on develop CI 25824607025 with 6 zombie # xctests aged 22-24h from prior interactive use). # # Age filter (>= 1 hour): Grogu now has SIBLING runners # (Grogu-jarvis, -2, -3) so concurrent CI jobs can be in flight # simultaneously. CI jobs complete in <10 min, so a 1-hour cutoff # safely reaps interactive-use orphans without killing concurrent # CI work on sibling runners. `ps -o etime` formats: `MM:SS` # (<1h, skip), `HH:MM:SS` (>=1h, kill), `DD-HH:MM:SS` (days, kill). # github-hosted VMs start clean, so this step is gated to self-hosted. - name: Reap orphan xctest/swift-test processes older than 1h (self-hosted only) if: runner.environment == 'self-hosted' run: | age_filter='/(xctest|swift-test|swift-driver)$/ && !/awk/ && (match($2, /-/) || match($2, /[0-9]+:[0-9]+:[0-9]+/)) {print $1}' /bin/ps -A -o pid,etime,comm \ | awk "$age_filter" \ | xargs -r -I{} kill -TERM {} 2>/dev/null || true sleep 2 /bin/ps -A -o pid,etime,comm \ | awk "$age_filter" \ | xargs -r -I{} kill -9 {} 2>/dev/null || true echo "Surviving xctest/swift-test processes (any age — includes concurrent CI work on sibling runners):" /bin/ps -A -o pid,etime,comm | grep -E '(xctest|swift-test|swift-driver)$' || echo " (none)" - uses: actions/checkout@v4 # Project targets macOS 26 Tahoe + Swift 6.2/6.3. Xcode 16 (Swift 6.0) # cannot parse `mlx-audio-swift` v0.1.2's `swift-tools-version:6.2`, # lacks the `withThrowingTaskGroup` overload used by MCP `swift-sdk` # 0.12.0, and rejects `@MainActor` XCTestCase `super.setUp()` patterns # that Swift 6.1+ accepts. The first two paths cover github-hosted # macos-15 (which ships Xcode 26.3); the bare `Xcode.app` fallback # covers self-hosted runners with whatever local Xcode 26.x is the # default (Grogu currently has 26.5). Using DEVELOPER_DIR (per-process) # rather than `sudo xcode-select -s` (system-wide, needs passwordless # sudo on self-hosted) — both paths respect DEVELOPER_DIR identically. - name: Select Xcode run: | for path in \ /Applications/Xcode_26.3.app/Contents/Developer \ /Applications/Xcode_26.app/Contents/Developer \ /Applications/Xcode.app/Contents/Developer; do if [ -d "$path" ]; then echo "DEVELOPER_DIR=$path" >> "$GITHUB_ENV" echo "Using Xcode at: $path" exit 0 fi done echo "No Xcode 26.x found in any expected path" >&2 exit 1 - name: Show Swift toolchain run: | swift --version xcodebuild -version # Download the production webview bundle so Bus + downstream packages # (AgentCore / Harness / MCP / Voice) can mount a real WKWebView with # a working JS bridge. Without this, BusTests fail with `Error: bus # not mounted` because index.html + the JS bundle are missing from # App/Resources/webview/. - name: Download webview resources uses: actions/download-artifact@v4 with: name: webview-resources path: App/Resources/webview/ # actions/cache@v4 restores the package's `.build/` and SwiftPM's # global cache. On github-hosted this is a real perf win; on self-hosted # it's both unnecessary (the _work/ dir persists between jobs naturally) # and actively harmful (caches saved on github-hosted have absolute # paths like /Users/runner/work/Jarvis/.../onnxruntime.xcframework # baked into .build/artifacts, which don't resolve on Grogu and break # Voice + Harness with "XCFramework Info.plist not found"). Restrict # the cache action to github-hosted only. - name: Cache SPM build artifacts (github-hosted only) if: runner.environment == 'github-hosted' uses: actions/cache@v4 with: path: | packages/${{ matrix.package }}/.build ~/Library/Caches/org.swift.swiftpm key: spm-${{ matrix.package }}-${{ runner.os }}-${{ hashFiles(format('packages/{0}/Package.resolved', matrix.package), format('packages/{0}/Package.swift', matrix.package)) }} restore-keys: | spm-${{ matrix.package }}-${{ runner.os }}- # On self-hosted (Grogu) the runner is a LaunchAgent with no UI session, # so SecItemAdd against the locked login keychain returns # errSecInteractionNotAllowed (-25308). Use a pre-created dedicated # keychain `jarvis-runner.keychain-db` (one-time setup on Grogu) and # prepend it to the user search list for this run so test SecItemAdd # calls without an explicit keychain target write there. github-hosted # bakes its own unlocked keychain into the runner image and skips this. - name: Unlock runner keychain (self-hosted only) if: runner.environment == 'self-hosted' env: KEYCHAIN_PASSWORD: ${{ secrets.JARVIS_RUNNER_KEYCHAIN_PASSWORD }} run: | KEYCHAIN_PATH="$HOME/Library/Keychains/jarvis-runner.keychain-db" if [ ! -f "$KEYCHAIN_PATH" ]; then echo "FATAL: runner keychain missing at $KEYCHAIN_PATH" >&2 echo " Create with: security create-keychain -p \"\$KEYCHAIN_PASSWORD\" \"$KEYCHAIN_PATH\"" >&2 exit 1 fi security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" # NOTE: do NOT re-run `set-keychain-settings` here — the Security # framework requires user interaction for that operation even with # the keychain unlocked, and the LaunchAgent has no UI. The # settings (no-timeout, no-lock-on-sleep) were applied once at # keychain-creation time and persist; we just need to unlock. security list-keychains -d user -s "$KEYCHAIN_PATH" "$HOME/Library/Keychains/login.keychain-db" # On the legacy file-based macOS keychain, SecItemAdd writes to the # user's DEFAULT keychain — the search list only determines read # order. Changing list-keychains alone leaves SecItemAdd targeting # login.keychain-db (locked, returns -25308). Also redirect the # default. SIDE EFFECT documented in CLAUDE.md: this is persistent # for james.maes's user prefs. security default-keychain -d user -s "$KEYCHAIN_PATH" - name: swift test run: swift test --package-path packages/${{ matrix.package }} app-build: name: App target (xcodebuild) runs-on: macos-15 needs: [boundary-gates, webview] steps: - uses: actions/checkout@v4 # Same Xcode 26.x pin as swift-packages — Xcode 16 cannot resolve # mlx-audio-swift's swift-tools-version:6.2 manifest. - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_26.3.app/Contents/Developer || sudo xcode-select -s /Applications/Xcode_26.app/Contents/Developer - name: Install xcodegen run: brew install xcodegen # pnpm + node need to be on PATH because Xcode.app's project has a # build-phase script (Script-4B80D79B2A0254274258618C.sh) that calls # scripts/build-webview.sh during the build itself. Even though we # download the prebuilt artifact below, the build phase still runs # and re-stamps the bundle — and would error with # "[build-webview] FATAL: pnpm not found on PATH" without these. - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 10.30.2 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "pnpm" cache-dependency-path: webview/pnpm-lock.yaml # Reuse the webview build from the upstream job rather than rebuilding. # The Xcode build phase will re-stamp these on top. - name: Download webview resources uses: actions/download-artifact@v4 with: name: webview-resources path: App/Resources/webview/ - name: check-app-builds.sh run: bash scripts/check-app-builds.sh - name: verify-codesign-settings.sh run: bash scripts/verify-codesign-settings.sh - name: Upload Jarvis.app artifact uses: actions/upload-artifact@v4 with: name: Jarvis-app-debug path: build/Build/Products/Debug/Jarvis.app retention-days: 7 if-no-files-found: warn real-hardware: name: Real-hardware tests (opt-in) runs-on: [self-hosted, macOS] if: github.event_name == 'workflow_dispatch' && github.event.inputs.run_real_hardware == 'true' needs: [swift-packages] steps: - uses: actions/checkout@v4 - name: JARVIS_REAL_MODELS Memory regression corpus env: JARVIS_REAL_MODELS: "1" run: swift test --package-path packages/Memory --filter MemoryRegressionCorpusTests - name: JARVIS_REAL_CAMERA capture suite env: JARVIS_REAL_CAMERA: "1" run: swift test --package-path packages/Vision --filter CameraCaptureRealHardwareTests - name: JARVIS_REAL_MODELS Orpheus TTFA env: JARVIS_REAL_MODELS: "1" run: swift test --package-path packages/Voice --filter OrpheusTTFATests publish-tag: name: Publish tag runs-on: ubuntu-latest needs: [boundary-gates, webview, swift-packages, app-build] if: | github.event_name == 'push' && ( github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/rc/') || startsWith(github.ref, 'refs/heads/release/') ) steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Compute next version id: version env: GH_REF: ${{ github.ref }} RUN_NUMBER: ${{ github.run_number }} run: | set -euo pipefail git fetch --tags --force --quiet last_tag=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' \ | grep -Ev -- '-(dev|rc|alpha|beta)' \ | sort -V | tail -n 1 || true) if [ -z "$last_tag" ]; then base="v0.1.0" from="$(git rev-list --max-parents=0 HEAD | tail -n 1)" echo "No prior tag; defaulting base=$base, scanning all history." else base="$last_tag" from="$last_tag" echo "Last final tag: $last_tag" fi version="${base#v}" IFS='.' read -r major minor patch <<< "$version" bump="patch" if git log "$from"..HEAD --pretty=%B 2>/dev/null | grep -qE '(^|\n)BREAKING CHANGE:|^[a-zA-Z]+(\([^)]+\))?!:'; then bump="major" elif git log "$from"..HEAD --pretty=%B 2>/dev/null | grep -qE '^feat(\([^)]+\))?:'; then bump="minor" elif git log "$from"..HEAD --pretty=%B 2>/dev/null | grep -qE '^fix(\([^)]+\))?:'; then bump="patch" fi if [ -n "$last_tag" ]; then case "$bump" in major) major=$((major+1)); minor=0; patch=0 ;; minor) minor=$((minor+1)); patch=0 ;; patch) patch=$((patch+1)) ;; esac fi next="${major}.${minor}.${patch}" case "$GH_REF" in refs/heads/develop) tag="v${next}-dev.${RUN_NUMBER}" prerelease=true ;; refs/heads/rc/*|refs/heads/release/*) existing_rc=$(git tag --list "v${next}-rc.*" | sed -E "s/.*-rc\.([0-9]+)/\1/" | sort -n | tail -n 1) n=$(( ${existing_rc:-0} + 1 )) tag="v${next}-rc.${n}" prerelease=true ;; refs/heads/main) tag="v${next}" prerelease=false ;; *) echo "Unexpected ref: $GH_REF" >&2 exit 1 ;; esac echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" echo "Next tag: $tag (prerelease=$prerelease)" - name: Create + push tag env: TAG: ${{ steps.version.outputs.tag }} GH_REF: ${{ github.ref }} run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" if git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then echo "Tag $TAG already exists; skipping." exit 0 fi git tag -a "$TAG" -m "Automated tag from CI for $GH_REF" git push origin "$TAG" # GitHub Actions GITHUB_TOKEN cannot trigger downstream workflows. The # separate `release.yml` workflow listens for `push: tags:` events but # never sees a CI-pushed tag — observed on v0.1.0-dev.50 (tag created, # Release object missing, had to be manually created). Create the # Release object directly here in the same job so the Releases page # reflects CI-pushed tags without a manual step. `release.yml` stays # as a fallback for manually-pushed tags (which CAN trigger workflows). - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.version.outputs.tag }} PRERELEASE: ${{ steps.version.outputs.prerelease }} RUN_URL: ${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }} run: | set -euo pipefail if ! git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then echo "Tag $TAG not present locally; nothing to release." >&2 exit 0 fi if gh release view "$TAG" >/dev/null 2>&1; then echo "Release $TAG already exists — skipping create (idempotent)." exit 0 fi prev=$(git tag --list 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -n 1 || true) notes_file=$(mktemp) { echo "## Changes" echo if [ -n "$prev" ]; then git log --pretty='format:- %s (%h)' "${prev}..${TAG}" else git log --pretty='format:- %s (%h)' -20 fi echo echo echo "## Verification" echo echo "- CI build: $RUN_URL" echo "- Boundary gates, webview, app-build, 13/13 SPM packages: all green on this commit" } > "$notes_file" flag=() if [ "$PRERELEASE" = "true" ]; then flag+=("--prerelease") fi gh release create "$TAG" \ --title "$TAG" \ --notes-file "$notes_file" \ "${flag[@]}"