Skip to content

Commit 961a40d

Browse files
cameroncookeclaude
andcommitted
build(portable): Add macOS portable packaging scripts
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>
1 parent 2c772a5 commit 961a40d

4 files changed

Lines changed: 369 additions & 7 deletions

File tree

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
"hooks:install": "node scripts/install-git-hooks.js",
2424
"generate:version": "npx tsx scripts/generate-version.ts",
2525
"bundle:axe": "scripts/bundle-axe.sh",
26+
"package:macos": "scripts/package-macos-portable.sh",
27+
"package:macos:universal": "scripts/package-macos-portable.sh --universal",
28+
"verify:portable": "scripts/verify-portable-install.sh",
2629
"lint": "eslint 'src/**/*.{js,ts}'",
2730
"lint:fix": "eslint 'src/**/*.{js,ts}' --fix",
2831
"format": "prettier --write 'src/**/*.{js,ts}'",

scripts/bundle-axe.sh

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,13 @@ fi
3737
# Create bundled directory
3838
mkdir -p "$BUNDLED_DIR"
3939

40-
# Use local AXe build if available (unless AXE_FORCE_REMOTE=1), otherwise download from GitHub releases
41-
if [ -z "${AXE_FORCE_REMOTE}" ] && [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then
40+
USE_LOCAL_AXE=false
41+
if [ -z "${AXE_FORCE_REMOTE}" ] && [ "${AXE_USE_LOCAL:-0}" = "1" ]; then
42+
USE_LOCAL_AXE=true
43+
fi
44+
45+
# Use local AXe build only when explicitly requested, otherwise download from GitHub releases.
46+
if [ "$USE_LOCAL_AXE" = true ] && [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then
4247
echo "🏠 Using local AXe source at $AXE_LOCAL_DIR"
4348
cd "$AXE_LOCAL_DIR"
4449

@@ -80,6 +85,11 @@ if [ -z "${AXE_FORCE_REMOTE}" ] && [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_D
8085
fi
8186
done
8287
else
88+
if [ "$USE_LOCAL_AXE" = true ]; then
89+
echo "❌ AXE_USE_LOCAL=1 was set, but AXE_LOCAL_DIR is missing or invalid: $AXE_LOCAL_DIR"
90+
exit 1
91+
fi
92+
8393
echo "📥 Downloading latest AXe release from GitHub..."
8494

8595
# Construct release download URL from pinned version
@@ -154,17 +164,34 @@ if [ "$OS_NAME" = "Darwin" ]; then
154164
fi
155165

156166
while IFS= read -r framework_path; do
157-
if ! codesign --verify --deep --strict "$framework_path"; then
158-
echo "❌ Signature verification failed for framework: $framework_path"
167+
framework_name="$(basename "$framework_path" .framework)"
168+
framework_binary="$framework_path/Versions/A/$framework_name"
169+
if [ ! -f "$framework_binary" ]; then
170+
framework_binary="$framework_path/Versions/Current/$framework_name"
171+
fi
172+
if [ ! -f "$framework_binary" ]; then
173+
echo "❌ Framework binary not found: $framework_binary"
174+
exit 1
175+
fi
176+
if ! codesign --verify --deep --strict "$framework_binary"; then
177+
echo "❌ Signature verification failed for framework binary: $framework_binary"
159178
exit 1
160179
fi
161180
done < <(find "$BUNDLED_DIR/Frameworks" -name "*.framework" -type d)
162181

163182
echo "🛡️ Assessing AXe with Gatekeeper..."
164-
if ! spctl --assess --type execute "$BUNDLED_DIR/axe"; then
165-
echo "❌ Gatekeeper assessment failed for bundled AXe binary"
166-
exit 1
183+
SPCTL_LOG="$(mktemp)"
184+
if ! spctl --assess --type execute "$BUNDLED_DIR/axe" 2>"$SPCTL_LOG"; then
185+
if grep -q "does not seem to be an app" "$SPCTL_LOG"; then
186+
echo "⚠️ Gatekeeper execute assessment is inconclusive for CLI binaries; continuing"
187+
else
188+
cat "$SPCTL_LOG"
189+
echo "❌ Gatekeeper assessment failed for bundled AXe binary"
190+
rm "$SPCTL_LOG"
191+
exit 1
192+
fi
167193
fi
194+
rm "$SPCTL_LOG"
168195

169196
echo "🧪 Testing bundled AXe binary..."
170197
if DYLD_FRAMEWORK_PATH="$BUNDLED_DIR/Frameworks" "$BUNDLED_DIR/axe" --version > /dev/null 2>&1; then

scripts/package-macos-portable.sh

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
7+
DIST_DIR_DEFAULT="$PROJECT_ROOT/dist/portable"
8+
9+
ARCH=""
10+
UNIVERSAL=false
11+
ARM64_ROOT=""
12+
X64_ROOT=""
13+
DIST_DIR="$DIST_DIR_DEFAULT"
14+
VERSION=""
15+
16+
usage() {
17+
cat <<'EOF'
18+
Usage:
19+
scripts/package-macos-portable.sh [--arch arm64|x64] [--dist-dir <path>] [--version <semver>]
20+
scripts/package-macos-portable.sh --universal --arm64-root <path> --x64-root <path> [--dist-dir <path>] [--version <semver>]
21+
22+
Notes:
23+
- Arch mode packages a bundled Node runtime plus compiled JS entrypoint.
24+
- Universal mode expects prebuilt arm64/x64 roots and combines Node runtimes with lipo.
25+
EOF
26+
}
27+
28+
while [[ $# -gt 0 ]]; do
29+
case "$1" in
30+
--arch)
31+
ARCH="${2:-}"
32+
shift 2
33+
;;
34+
--universal)
35+
UNIVERSAL=true
36+
shift
37+
;;
38+
--arm64-root)
39+
ARM64_ROOT="${2:-}"
40+
shift 2
41+
;;
42+
--x64-root)
43+
X64_ROOT="${2:-}"
44+
shift 2
45+
;;
46+
--dist-dir)
47+
DIST_DIR="${2:-}"
48+
shift 2
49+
;;
50+
--version)
51+
VERSION="${2:-}"
52+
shift 2
53+
;;
54+
-h|--help)
55+
usage
56+
exit 0
57+
;;
58+
*)
59+
echo "Unknown argument: $1"
60+
usage
61+
exit 1
62+
;;
63+
esac
64+
done
65+
66+
if [[ -z "$VERSION" ]]; then
67+
VERSION="$(node -p "require('$PROJECT_ROOT/package.json').version")"
68+
fi
69+
70+
mkdir -p "$DIST_DIR"
71+
72+
verify_axe_assets() {
73+
local bundled_dir="$PROJECT_ROOT/bundled"
74+
local axe_bin="$bundled_dir/axe"
75+
local frameworks_dir="$bundled_dir/Frameworks"
76+
77+
if [[ ! -x "$axe_bin" ]]; then
78+
echo "Missing executable AXe binary at $axe_bin"
79+
exit 1
80+
fi
81+
if [[ ! -d "$frameworks_dir" ]]; then
82+
echo "Missing AXe frameworks at $frameworks_dir"
83+
exit 1
84+
fi
85+
if [[ "$(find "$frameworks_dir" -name "*.framework" -type d | wc -l | tr -d ' ')" -eq 0 ]]; then
86+
echo "No frameworks found under $frameworks_dir"
87+
exit 1
88+
fi
89+
90+
if [[ "$(uname -s)" == "Darwin" ]]; then
91+
codesign --verify --deep --strict "$axe_bin"
92+
while IFS= read -r framework_path; do
93+
framework_name="$(basename "$framework_path" .framework)"
94+
framework_binary="$framework_path/Versions/A/$framework_name"
95+
if [[ ! -f "$framework_binary" ]]; then
96+
framework_binary="$framework_path/Versions/Current/$framework_name"
97+
fi
98+
if [[ ! -f "$framework_binary" ]]; then
99+
echo "Missing framework binary at $framework_binary"
100+
exit 1
101+
fi
102+
codesign --verify --deep --strict "$framework_binary"
103+
done < <(find "$frameworks_dir" -name "*.framework" -type d)
104+
spctl_log="$(mktemp)"
105+
if ! spctl --assess --type execute "$axe_bin" 2>"$spctl_log"; then
106+
if grep -q "does not seem to be an app" "$spctl_log"; then
107+
echo "Gatekeeper execute assessment is inconclusive for CLI binaries; continuing"
108+
else
109+
cat "$spctl_log"
110+
rm "$spctl_log"
111+
exit 1
112+
fi
113+
fi
114+
rm "$spctl_log"
115+
fi
116+
}
117+
118+
write_wrapper_scripts() {
119+
local root="$1"
120+
local bin_dir="$root/bin"
121+
local libexec_dir="$root/libexec"
122+
123+
cat > "$libexec_dir/xcodebuildmcp" <<'EOF'
124+
#!/usr/bin/env bash
125+
set -euo pipefail
126+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
127+
exec "$ROOT/node-runtime" "$ROOT/build/cli.js" "$@"
128+
EOF
129+
130+
cat > "$bin_dir/xcodebuildmcp" <<'EOF'
131+
#!/usr/bin/env bash
132+
set -euo pipefail
133+
RESOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../libexec" && pwd)"
134+
export XCODEBUILDMCP_RESOURCE_ROOT="$RESOURCE_ROOT"
135+
export DYLD_FRAMEWORK_PATH="$RESOURCE_ROOT/bundled/Frameworks${DYLD_FRAMEWORK_PATH:+:$DYLD_FRAMEWORK_PATH}"
136+
exec "$RESOURCE_ROOT/xcodebuildmcp" "$@"
137+
EOF
138+
139+
cat > "$bin_dir/xcodebuildmcp-doctor" <<'EOF'
140+
#!/usr/bin/env bash
141+
set -euo pipefail
142+
RESOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../libexec" && pwd)"
143+
export XCODEBUILDMCP_RESOURCE_ROOT="$RESOURCE_ROOT"
144+
export DYLD_FRAMEWORK_PATH="$RESOURCE_ROOT/bundled/Frameworks${DYLD_FRAMEWORK_PATH:+:$DYLD_FRAMEWORK_PATH}"
145+
exec "$RESOURCE_ROOT/xcodebuildmcp" doctor "$@"
146+
EOF
147+
148+
chmod +x "$libexec_dir/xcodebuildmcp" "$bin_dir/xcodebuildmcp" "$bin_dir/xcodebuildmcp-doctor"
149+
}
150+
151+
create_tarball_and_checksum() {
152+
local portable_root="$1"
153+
local artifact_name="$2"
154+
local tarball_path="$DIST_DIR/$artifact_name.tar.gz"
155+
local checksum_path="$tarball_path.sha256"
156+
157+
(
158+
cd "$(dirname "$portable_root")"
159+
tar -czf "$tarball_path" "$(basename "$portable_root")"
160+
)
161+
shasum -a 256 "$tarball_path" > "$checksum_path"
162+
echo "Created artifact: $tarball_path"
163+
echo "Created checksum: $checksum_path"
164+
}
165+
166+
if [[ "$UNIVERSAL" == "true" ]]; then
167+
if [[ -z "$ARM64_ROOT" || -z "$X64_ROOT" ]]; then
168+
echo "--universal requires --arm64-root and --x64-root"
169+
exit 1
170+
fi
171+
if [[ ! -x "$ARM64_ROOT/libexec/node-runtime" || ! -x "$X64_ROOT/libexec/node-runtime" ]]; then
172+
echo "Missing per-arch node runtimes under provided roots"
173+
exit 1
174+
fi
175+
176+
UNIVERSAL_ROOT="$DIST_DIR/xcodebuildmcp-$VERSION-darwin-universal"
177+
if [[ -d "$UNIVERSAL_ROOT" ]]; then
178+
rm -r "$UNIVERSAL_ROOT"
179+
fi
180+
mkdir -p "$UNIVERSAL_ROOT/bin" "$UNIVERSAL_ROOT/libexec"
181+
cp -R "$ARM64_ROOT/libexec/build" "$UNIVERSAL_ROOT/libexec/"
182+
cp -R "$ARM64_ROOT/libexec/manifests" "$UNIVERSAL_ROOT/libexec/"
183+
cp -R "$ARM64_ROOT/libexec/bundled" "$UNIVERSAL_ROOT/libexec/"
184+
cp -R "$ARM64_ROOT/libexec/node_modules" "$UNIVERSAL_ROOT/libexec/"
185+
cp "$ARM64_ROOT/libexec/package.json" "$UNIVERSAL_ROOT/libexec/package.json"
186+
187+
lipo -create \
188+
"$ARM64_ROOT/libexec/node-runtime" \
189+
"$X64_ROOT/libexec/node-runtime" \
190+
-output "$UNIVERSAL_ROOT/libexec/node-runtime"
191+
chmod +x "$UNIVERSAL_ROOT/libexec/node-runtime"
192+
193+
write_wrapper_scripts "$UNIVERSAL_ROOT"
194+
create_tarball_and_checksum "$UNIVERSAL_ROOT" "xcodebuildmcp-$VERSION-darwin-universal"
195+
exit 0
196+
fi
197+
198+
if [[ -z "$ARCH" ]]; then
199+
machine_arch="$(uname -m)"
200+
if [[ "$machine_arch" == "arm64" ]]; then
201+
ARCH="arm64"
202+
elif [[ "$machine_arch" == "x86_64" ]]; then
203+
ARCH="x64"
204+
else
205+
echo "Unsupported machine architecture: $machine_arch"
206+
exit 1
207+
fi
208+
fi
209+
210+
if [[ "$ARCH" != "arm64" && "$ARCH" != "x64" ]]; then
211+
echo "Unsupported arch: $ARCH (expected arm64 or x64)"
212+
exit 1
213+
fi
214+
215+
cd "$PROJECT_ROOT"
216+
npm run build:tsup
217+
AXE_FORCE_REMOTE=1 npm run bundle:axe
218+
verify_axe_assets
219+
220+
PORTABLE_ROOT="$DIST_DIR/xcodebuildmcp-$VERSION-darwin-$ARCH"
221+
if [[ -d "$PORTABLE_ROOT" ]]; then
222+
rm -r "$PORTABLE_ROOT"
223+
fi
224+
mkdir -p "$PORTABLE_ROOT/bin" "$PORTABLE_ROOT/libexec"
225+
226+
cp "$(command -v node)" "$PORTABLE_ROOT/libexec/node-runtime"
227+
chmod +x "$PORTABLE_ROOT/libexec/node-runtime"
228+
229+
cp -R "$PROJECT_ROOT/build" "$PORTABLE_ROOT/libexec/"
230+
cp -R "$PROJECT_ROOT/manifests" "$PORTABLE_ROOT/libexec/"
231+
cp -R "$PROJECT_ROOT/bundled" "$PORTABLE_ROOT/libexec/"
232+
cp "$PROJECT_ROOT/package.json" "$PORTABLE_ROOT/libexec/package.json"
233+
cp "$PROJECT_ROOT/package-lock.json" "$PORTABLE_ROOT/libexec/package-lock.json"
234+
npm ci --omit=dev --ignore-scripts --prefix "$PORTABLE_ROOT/libexec"
235+
236+
write_wrapper_scripts "$PORTABLE_ROOT"
237+
create_tarball_and_checksum "$PORTABLE_ROOT" "xcodebuildmcp-$VERSION-darwin-$ARCH"

0 commit comments

Comments
 (0)