Skip to content

Conversation

@cameroncooke
Copy link
Collaborator

@cameroncooke cameroncooke commented Feb 9, 2026

Adds portable macOS distribution support end-to-end: runtime resource resolution, AXe bundling hardening, per-arch and universal packaging scripts, release workflow wiring, and automated Homebrew tap formula updates.

Motivation is deterministic CLI execution without requiring user-managed Node and a release path that can ship arm64/x64/universal artifacts plus Homebrew metadata from CI.

SEA packaging was evaluated but not kept due to instability with current ESM runtime behavior. The implementation uses bundled Node runtime + compiled JS for predictable startup and compatibility.

Additional correction included after isolated branch validation:

  • package script now fetches Node runtime for requested target arch (arm64/x64) instead of copying host runtime.
  • Homebrew formula install layout changed to preserve wrapper resource-root resolution.

Validation completed on this branch without publishing to npm:

  • local packaging + verify for arm64, x64, and universal archives
  • architecture checks on embedded runtime binaries
  • local Homebrew e2e via temporary tap + local archive install + formula test + CLI execution
  • repo checks: lint, format:check, typecheck, test

Risk areas for review:

  • release workflow behavior differences between push tags and workflow_dispatch
  • Homebrew formula URL/sha generation and tap PR permissions
  • runtime env setup (XCODEBUILDMCP_RESOURCE_ROOT, DYLD_FRAMEWORK_PATH) under packaged installs

Note

Medium Risk
Moderate risk because it significantly changes CI/release automation (publishing, artifact generation/upload, and optional Homebrew tap pushes), where misconfiguration could break releases without affecting runtime code paths.

Overview
Adds a portable macOS distribution pipeline to releases: builds per-arch (arm64, x64) portable artifacts, verifies them, produces a universal variant from the per-arch outputs, and uploads the resulting tarballs + .sha256 files to the GitHub Release.

Refactors the release workflow to standardize npm tagging via a dedicated resolve_npm_tag step (used for both dry-run and production publishes), exposes the release version as a job output for downstream packaging jobs, and removes Smithery-specific verification/deploy steps.

Updates docs to present Homebrew as a first-class install option (with Node optional), refreshes MCP client config examples, and removes Smithery-specific developer documentation; also updates .gitignore to stop ignoring .smithery/.

Written by Cursor Bugbot for commit 5619bbd. This will update automatically on new commits. Configure here.

cameroncooke and others added 8 commits February 9, 2026 15:13
Add a shared resource-root resolver that prioritizes XCODEBUILDMCP_RESOURCE_ROOT, then executable-relative paths, then package-root fallback for npm/source installs. Route manifest and bundled AXe path discovery through the shared resolver so portable distributions and existing workflows use the same deterministic contract.

Also export DYLD_FRAMEWORK_PATH for bundled AXe execution and add tests that cover resource-root precedence and bundled framework environment behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
Make local AXe source path env-driven with a project-local default and add macOS verification gates for bundled artifacts. Validate codesign signatures for the AXe binary and bundled frameworks, run Gatekeeper assessment on the executable, and fail bundling when verification fails.

Also use non-force directory removal where cleanup already guards for existence.

Co-Authored-By: Claude <noreply@anthropic.com>
Add portable packaging and verification scripts for macOS tarball distribution, including bin/libexec layout, bundled runtime resources, production dependencies, and artifact checksum generation. Add npm entrypoints for package and verification workflows.

Harden AXe bundling for release provenance by defaulting to remote artifacts, keeping local AXe as explicit opt-in, and enforcing signature checks with CLI-safe Gatekeeper handling.

Co-Authored-By: Claude <noreply@anthropic.com>
Extend release workflow with per-architecture macOS packaging jobs, universal artifact assembly, and portable artifact verification. Publish arm64, x64, and universal tarballs plus SHA256 files to GitHub Releases.

Keep existing npm and MCP registry release flow intact while wiring version output from the release job into portable packaging stages.

Co-Authored-By: Claude <noreply@anthropic.com>
Add a Homebrew formula generator script and release workflow automation that computes artifact SHAs, generates Formula/xcodebuildmcp.rb, and opens a PR in cameroncooke/homebrew-xcodebuildmcp when credentials are configured.

Keep the tap update flow token-gated and best-effort so releases continue when tap credentials are unavailable.

Co-Authored-By: Claude <noreply@anthropic.com>
Download the Node runtime for the requested target architecture instead of\ncopying the host runtime so x64 packaging and universal lipo assembly\nwork reliably from arm64 builders.\n\nInstall portable archive contents at formula prefix to preserve wrapper\nresource-root path resolution during Homebrew installs.\n\nCo-Authored-By: Claude <noreply@anthropic.com>
Smithery capability scanning runs a CJS bundle in CI. Accessing fileURLToPath(importMetaUrl) without a guard can throw when import.meta is present but does not include a usable url, which broke the build-and-test job.

Guard the import-meta candidate so package-root discovery still works via process.cwd() and argv fallbacks in CJS environments.

Co-Authored-By: Claude <noreply@anthropic.com>
Rename the section heading to avoid the docs command validator parsing
"is now" as a CLI subcommand reference in MIGRATION_V2.

Co-Authored-By: Claude <noreply@anthropic.com>
@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 9, 2026

Open in StackBlitz

npm i https://pkg.pr.new/cameroncooke/XcodeBuildMCP/xcodebuildmcp@209

commit: 5619bbd

cameroncooke and others added 3 commits February 9, 2026 18:35
Restrict docs command validation to fenced and inline code snippets so
prose headings are not misclassified as CLI invocations.

Restore the original migration heading text now that scanner behavior is
context-aware.

Co-Authored-By: Claude <noreply@anthropic.com>
Enable update_homebrew_tap for manual test runs so Homebrew automation can
be validated in isolation without tag releases.

The workflow_dispatch path still skips npm publish and GitHub release
creation because those steps remain push-only.

Co-Authored-By: Claude <noreply@anthropic.com>
Replace secrets context in step if expressions with env-based checks so
workflow_dispatch validation succeeds and isolated Homebrew tests can run.

Co-Authored-By: Claude <noreply@anthropic.com>
Use an explicit npm tag during manual dry-run publish so prerelease test
versions do not fail workflow_dispatch runs.

Co-Authored-By: Claude <noreply@anthropic.com>
The configured macos-13 hosted runner is unavailable in this environment.
Build both arm64 and x64 portable artifacts on macos-14, which is valid now
that packaging fetches target-arch Node runtimes.

Co-Authored-By: Claude <noreply@anthropic.com>
Resolve portable artifact roots dynamically after download to avoid path layout
mismatches in the universal packaging job.

Bootstrap an empty Homebrew tap by creating Formula/, committing to the
default branch, and exiting cleanly. For non-empty repos, detect default
branch for PR base and keep formula updates on version branch.

Co-Authored-By: Claude <noreply@anthropic.com>
Untar per-arch portable archives into deterministic unpack roots before
running universal packaging. This avoids runtime-root resolution issues
from artifact directory layout differences.

Co-Authored-By: Claude <noreply@anthropic.com>
Allow formula generation to accept a configurable base URL.

For workflow_dispatch runs, publish portable archives into the tap repo and
point formula URLs at raw artifact paths so Homebrew install can be tested
end-to-end without creating GitHub releases or publishing to npm.

Co-Authored-By: Claude <noreply@anthropic.com>
Make portable launcher wrappers resolve symlink targets before deriving
resource paths. This keeps libexec lookup correct when binaries are
invoked via /opt/homebrew/bin symlinks.

Co-Authored-By: Claude <noreply@anthropic.com>
Use AXe's Homebrew-specific unsigned archive when available and fall back\nto the legacy signed archive for older releases. This aligns the\nbundling pipeline with AXe's Homebrew packaging and reduces signature\nnoise during downstream install flows.\n\nCo-Authored-By: Claude <noreply@anthropic.com>
AXe's Homebrew artifact is intentionally unsigned, so Gatekeeper assessment\nreliably fails during CI bundling. Skip only that check for the unsigned\narchive flavor while keeping runtime execution validation in place.\n\nCo-Authored-By: Claude <noreply@anthropic.com>
Portable packaging currently assumes AXe binaries are signed, which breaks\nwhen consuming AXe's Homebrew-specific unsigned archive. Keep strict\nverification for signed artifacts and fall back to runtime execution\nvalidation for unsigned artifacts.\n\nCo-Authored-By: Claude <noreply@anthropic.com>
cameroncooke and others added 5 commits February 10, 2026 09:31
Stop defaulting AXE_LOCAL_DIR to a user-specific filesystem path.\nLocal AXe bundling now requires explicit AXE_LOCAL_DIR when\nAXE_USE_LOCAL=1, preventing accidental machine-coupled behavior.\n\nCo-Authored-By: Claude <noreply@anthropic.com>
Run Homebrew tap updates only for tag-push release runs and push formula\nupdates directly to the tap default branch after release checks pass.\nManual workflow_dispatch runs now avoid Homebrew deployment entirely,\nmatching npm dry-run behavior.\n\nCo-Authored-By: Claude <noreply@anthropic.com>
Make portable verification architecture-aware by checking the packaged\nnode-runtime architecture before running wrapper binaries. When host and\nartifact architectures do not match, keep structural validation and skip\nexecution checks to avoid cross-arch runner failures.\n\nCo-Authored-By: Claude <noreply@anthropic.com>
Restructure README and GETTING_STARTED installation sections into
two clear paths (Homebrew vs npm/npx) with a single-package intro.
Replace all stale @beta references with @latest to match the 2.0.0
stable release. Add Homebrew substitution note before client-specific
config sections. Document AXe local bundling and release workflow
modes.

Co-Authored-By: Claude <noreply@anthropic.com>
Remove Smithery from build scripts, release workflow, package metadata,\nand source entrypoints.\n\nDelete Smithery-specific docs and update remaining docs and tests to\nreflect the supported distribution paths without Smithery artifacts.

Co-Authored-By: Claude <noreply@anthropic.com>
@cameroncooke cameroncooke marked this pull request as ready for review February 10, 2026 11:17
Remove the CI step that runs the deleted verify:smithery-bundle script\nand rename the build step label to match current packaging flow.\n\nInclude the .gitignore update with this CI cleanup to keep the branch\nstate consistent.

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 10, 2026

Walkthrough

This pull request removes Smithery-based deployment and replaces it with Homebrew distribution and direct portable packaging. The GitHub Actions release workflow is restructured to build and package macOS binaries (arm64 and universal), publish portable assets to GitHub Releases, and update the Homebrew tap. Documentation is updated to reflect dual installation methods (Homebrew and npm). Supporting infrastructure includes new scripts for macOS portable builds, verification, and Homebrew formula generation. A new resource-root module centralises management of bundled asset paths. Version is bumped to 2.0.0, and all Smithery-related configurations, documentation, and entrypoints are removed.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarises the main change: adding portable macOS distribution and Homebrew automation. It directly reflects the primary objectives of the changeset.
Description check ✅ Passed The pull request description clearly relates to the changeset, detailing portable macOS distribution support, AXe bundling, packaging scripts, release workflow changes, and Homebrew automation across multiple files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In @.github/workflows/release.yml:
- Around line 91-97: The dry-run block sets NPM_TAG to "next" for any VERSION
containing a hyphen, which diverges from the production publish logic that
checks for explicit pre-release tags like "beta", "alpha", and "rc"; update the
dry-run logic so it uses the same tag resolution as production (i.e., inspect
VERSION for specific prerelease identifiers and set NPM_TAG to "beta", "alpha",
"rc", or "latest" accordingly) or factor the tag computation into a shared
step/output (compute NPM_TAG once from VERSION and reuse it for both npm publish
--dry-run and the production npm publish) so both paths are consistent.

In `@scripts/bundle-axe.sh`:
- Line 34: The script uses rm -r for cleanup which can fail on read-only files;
update both removal calls to force removal by using rm -rf instead of rm -r for
the "$BUNDLED_DIR" deletion and the later temp-directory deletion (the second rm
-r near line 235), so change those invocations to use the -f flag to ensure
cleanup doesn't prompt or error under set -e.

In `@scripts/package-macos-portable.sh`:
- Around line 222-235: The .sha256 file currently contains "hash 
/full/path/to/file" because create_tarball_and_checksum uses shasum directly;
change the shasum invocation in create_tarball_and_checksum to write only the
hex hash (e.g. pipe shasum -a 256 "$tarball_path" through awk '{print $1}' or
cut to extract the first field) and redirect that single-field hash into
"$checksum_path" so the checksum file contains a bare, path-relative hash
without leaking CI paths.
- Around line 136-173: The install_node_runtime_for_arch function currently
downloads node-v${node_version}-darwin-${node_arch}.tar.gz without integrity
checks; update it to fetch the corresponding SHASUMS256.txt (and optionally
SHASUMS256.txt.sig) from https://nodejs.org/dist/v${node_version}/, extract the
SHA-256 line that matches ${archive_name}, compute the downloaded file's
sha256sum (using shasum -a 256 or similar) and compare values before running tar
-xzf; if the checksums do not match, delete temp_dir and exit non‑zero. Also add
an optional GPG verification step using gpg --verify on SHASUMS256.txt.sig
against the NodeJS release key before trusting the checksum, and ensure all temp
files (archive, SHASUMS256.txt, signature) are cleaned up on both success and
failure.
- Around line 28-52: The argument parsing currently does "${2:-}" and "shift 2"
for value-taking flags (handled in the while/case block) which will fail with a
cryptic error if the flag is last; update each value-taking case for --arch,
--arm64-root, --x64-root, --dist-dir, and --version to first verify that a
non-empty next argument exists and does not start with "-" (i.e., is not another
flag), and if missing print a clear error like "missing value for --arch" (or
the appropriate flag name) and exit non-zero; only then assign the next value to
ARCH, ARM64_ROOT, X64_ROOT, DIST_DIR, or VERSION and perform "shift 2".

In `@scripts/verify-portable-install.sh`:
- Around line 44-60: The trap for cleanup is registered too late causing
TEMP_DIR to leak on early exits; after creating TEMP_DIR with mktemp in the
ARCHIVE_PATH branch (where TEMP_DIR is assigned), register the trap immediately
(trap cleanup EXIT) so cleanup runs on any exit, and ensure the cleanup()
function is defined or moved above that point; update the ARCHIVE_PATH block
around TEMP_DIR, mktemp, tar extraction, extracted_count checks and
PORTABLE_ROOT so the trap is set right after TEMP_DIR creation.
🧹 Nitpick comments (12)
docs/dev/PROJECT_CONFIG_PLAN.md (1)

120-122: Minor grammar nit in the description.

Consider adding "the" before "highest precedence" for readability.

Suggested tweak
 ### 7) Runtime overrides
 **File:** runtime entrypoints
-- Pass overrides into bootstrap/config store, so explicit runtime overrides have highest precedence.
+- Pass overrides into bootstrap/config store, so explicit runtime overrides have the highest precedence.
scripts/check-docs-cli-commands.js (1)

84-125: Tilde-style fences (~~~) are not recognised.

Markdown also supports ~~~ as a code fence delimiter. The current fenceHeaderRegex only matches triple backticks, so commands inside ~~~bash blocks would be picked up as inline code candidates instead, potentially producing false positives. If the docs only use backtick fences this is a non-issue today, but worth noting.

Proposed fix
-  const fenceHeaderRegex = /^\s*```([a-z0-9_-]*)\s*$/iu;
+  const fenceHeaderRegex = /^\s*(?:```|~~~)([a-z0-9_-]*)\s*$/iu;
scripts/create-homebrew-formula.sh (1)

18-50: shift 2 will abort if a flag is passed without a value.

When set -euo pipefail is active, passing e.g. --version as the very last argument (no value) causes shift 2 to fail and the script to exit silently with a non-zero code before the friendly validation message on line 52 is reached. This is acceptable fail-fast behaviour, but a small guard would give a clearer error.

Example guard
     --version)
+      [[ $# -ge 2 ]] || { echo "Error: --version requires a value"; usage; exit 1; }
       VERSION="${2:-}"
       shift 2
       ;;
scripts/verify-portable-install.sh (1)

107-110: Host-architecture normalisation is a no-op.

NORMALIZED_HOST_ARCH is set to $HOST_ARCH unconditionally, and the if on line 108 only reassigns the same value (x86_64 → x86_64). If this was meant to map aarch64arm64 (for potential Linux cross-use), the mapping is missing; otherwise the block can be removed.

Option: add the likely intended mapping or remove
 NORMALIZED_HOST_ARCH="$HOST_ARCH"
-if [[ "$HOST_ARCH" == "x86_64" ]]; then
-  NORMALIZED_HOST_ARCH="x86_64"
+if [[ "$HOST_ARCH" == "aarch64" ]]; then
+  NORMALIZED_HOST_ARCH="arm64"
 fi
src/mcp/tools/ui-automation/__tests__/key_press.test.ts (1)

82-94: Consider extracting the duplicated mockAxeHelpers object into a shared fixture.

The identical mockAxeHelpers literal is repeated in every single test case (~10 times). A single declaration at the describe block level (or a factory function) would eliminate significant duplication and make future message changes a one-line edit.

Example refactor
// At the top of the outer `describe` block:
const defaultMockAxeHelpers = {
  getAxePath: () => '/usr/local/bin/axe',
  getBundledAxeEnvironment: () => ({}),
  createAxeNotAvailableResponse: () => ({
    content: [
      {
        type: 'text' as const,
        text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nEnsure bundled artifacts are included or PATH is configured.',
      },
    ],
    isError: true,
  }),
};

// Then in each test, just reference `defaultMockAxeHelpers`
// or spread + override for the few tests that need different getAxePath values.

This applies equally to the other UI-automation test files (button, gesture, long_press, snapshot_ui, swipe, tap, touch, type_text, key_sequence).

Also applies to: 126-138, 173-185, 217-229, 261-273, 297-309, 328-340, 342-354, 369-381, 408-420, 442-454, 476-488

src/utils/axe-helpers.ts (1)

40-51: Bundled path resolution uses existsSync while other resolvers use isExecutable.

resolveBundledAxePath checks existence only (existsSync), whereas resolveAxePathFromConfig and resolveAxePathFromPath both verify executability (isExecutable). If a bundled axe file exists but lacks the execute bit (e.g., extracted on a filesystem that strips permissions), this would return a non-executable path, and the caller would get a confusing failure later.

This is likely fine in practice since bundle-axe.sh applies chmod +x, but it's worth considering adding an isExecutable check for consistency.

scripts/bundle-axe.sh (1)

22-28: AXE_VERSION variable is used for both input (pinning) and output (detected version).

On line 22 AXE_VERSION is read from the environment to pin the download version, then on line 225 it's unconditionally overwritten with the detected runtime version. This shadow doesn't break anything (the pinned value has already been consumed into PINNED_AXE_VERSION), but it could confuse future maintainers or downstream consumers that expect AXE_VERSION to retain the requested version.

Consider using a distinct name such as DETECTED_AXE_VERSION on line 225.

Also applies to: 225-225

.github/workflows/release.yml (2)

315-317: publish_portable_assets does not directly declare build_and_package_macos in needs.

This job downloads the portable-arm64 and portable-x64 artifacts produced by build_and_package_macos, but only transitively depends on it through build_universal_and_verify. It works today because transitive ordering guarantees the job has finished, but an explicit dependency would make the data-flow clearer and protect against future refactors that might remove the intermediate dependency.

Suggested fix
   publish_portable_assets:
     if: github.event_name == 'push'
-    needs: [release, build_universal_and_verify]
+    needs: [release, build_and_package_macos, build_universal_and_verify]
     runs-on: ubuntu-latest

396-426: Homebrew tap update pushes directly to the default branch — consider a PR-based flow.

Pushing commits directly to the tap's default branch bypasses any branch-protection rules on the tap repository and removes the opportunity for review. A PR-based approach (create a branch, push, open a PR via gh pr create) would be safer and is the standard pattern used by other Homebrew auto-update bots.

Also, the git clone on Line 403 embeds the token in the URL, which can surface in CI logs if set -x or debug mode is enabled. Using git -c http.extraheader="AUTHORIZATION: basic $(echo -n x-access-token:${GH_TOKEN} | base64)" clone ... or the gh repo clone helper avoids this.

scripts/package-macos-portable.sh (1)

175-220: Symlink-resolution boilerplate is duplicated across generated wrapper scripts.

The xcodebuildmcp and xcodebuildmcp-doctor wrappers in bin/ share identical symlink-resolution and environment-setup logic (Lines 190–199 vs 206–215). If a third wrapper is ever added, or a bug is fixed in the resolution logic, the duplication increases maintenance risk.

Since these are generated strings, one approach is to emit a shared _resolve.sh helper into libexec/ and source it from both wrappers.

src/core/resource-root.ts (2)

22-42: getPackageRoot is called on every resource-path access — consider memoising.

getPackageRoot (and by extension getResourceRoot) performs synchronous fs.existsSync traversals up the directory tree. Every call to getManifestsDir, getBundledAxePath, or getBundledFrameworksDir re-does this work. For a CLI tool the overhead is negligible, but a one-liner cache (let _packageRoot: string | undefined) would eliminate repeated filesystem walks and make the intent clearer.

Optional memoisation sketch
+let _packageRoot: string | undefined;
+
 export function getPackageRoot(): string {
+  if (_packageRoot !== undefined) return _packageRoot;
   const candidates: string[] = [];
   ...
-  throw new Error('Could not find package root ...');
+  // on success
+  _packageRoot = found;
+  return _packageRoot;
+  ...
+  throw new Error('Could not find package root ...');
 }

Apply the same pattern to getResourceRoot.


44-58: process.execPath is never falsy in Node.js.

process.execPath always returns the absolute path to the Node binary; the !execPath guard on Line 46 will never be true in any standard Node environment. This is harmless (purely dead code), but worth knowing it's there for defensive reasons only.

Align npm tag resolution between dry-run and production publish, enforce\nrelease asset ordering before Homebrew tap updates, and harden portable\npackaging scripts with checksum verification and safer argument parsing.\n\nAlso improve docs command fence parsing, memoize resource-root resolution\nwith test reset hooks, and reduce duplicated AXe helper fixtures in key\npress UI automation tests.

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

Restore symlink resolution in generated bin wrappers before sourcing
resource-root helpers. This preserves correct libexec lookup when
xcodebuildmcp is invoked via /opt/homebrew/bin symlinks.

Co-Authored-By: Claude <noreply@anthropic.com>
@cameroncooke cameroncooke merged commit 354277f into main Feb 10, 2026
8 checks passed
@cameroncooke cameroncooke deleted the codex/macos-portable-distribution branch February 10, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant