fix(mac): fail fast without full Xcode (#120909)

This commit is contained in:
Peter Steinberger
2026-08-08 23:42:36 -07:00
committed by GitHub
parent a3d2ac7dc8
commit dba99b355b
3 changed files with 148 additions and 40 deletions
+8
View File
@@ -4,6 +4,14 @@ REQUIRED_SWIFT_TOOLS_MAJOR=6
REQUIRED_SWIFT_TOOLS_MINOR=2
require_swift_toolchain() {
if ! xcrun xcodebuild -version >/dev/null 2>&1; then
echo "ERROR: OpenClaw macOS app packaging requires a full Xcode developer directory." >&2
echo " Command Line Tools do not include the required SwiftUI macro plugins." >&2
echo " Use: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" >&2
echo " Or set: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer" >&2
return 1
fi
local swift_version
if ! swift_version="$(swift --version 2>&1)"; then
printf '%s\n' "$swift_version" >&2
+129 -40
View File
@@ -58,6 +58,81 @@ function getSwiftToolchainBlock(): string {
return script.slice(start, end);
}
function runSwiftToolchainHarness(options: {
swiftVersion: string;
selectedDeveloperDir: "command-line-tools" | "custom-xcode" | "invalid" | "xcode";
developerDirOverride?: "custom-xcode" | "invalid" | "xcode";
}) {
const root = tempDirs.make("openclaw-package-swift-root-");
const toolsDir = path.join(root, "tools");
const commandLineToolsDir = path.join(root, "Library", "Developer", "CommandLineTools");
const xcodeDeveloperDir = path.join(root, "Applications", "Xcode.app", "Contents", "Developer");
const customXcodeDeveloperDir = path.join(root, "MountedToolchains", "CustomDeveloper");
const invalidDeveloperDir = path.join(root, "InvalidDeveloper");
const developerDirs = {
"command-line-tools": commandLineToolsDir,
"custom-xcode": customXcodeDeveloperDir,
invalid: invalidDeveloperDir,
xcode: xcodeDeveloperDir,
} as const;
const selectedDeveloperDir = developerDirs[options.selectedDeveloperDir];
mkdirSync(toolsDir, { recursive: true });
mkdirSync(commandLineToolsDir, { recursive: true });
for (const developerDir of [xcodeDeveloperDir, customXcodeDeveloperDir]) {
const xcodebuild = path.join(developerDir, "usr", "bin", "xcodebuild");
mkdirSync(path.dirname(xcodebuild), { recursive: true });
writeFileSync(
xcodebuild,
["#!/usr/bin/env bash", '[[ "$*" == "-version" ]] || exit 2', "echo 'Xcode 26.0'", ""].join(
"\n",
),
"utf8",
);
chmodSync(xcodebuild, 0o755);
}
writeFileSync(
path.join(toolsDir, "xcrun"),
[
"#!/usr/bin/env bash",
'[[ "${1:-}" == "xcodebuild" && "${2:-}" == "-version" ]] || exit 2',
'developer_dir="${DEVELOPER_DIR:-$MOCK_SELECTED_DEVELOPER_DIR}"',
'xcodebuild="$developer_dir/usr/bin/xcodebuild"',
'if [[ ! -x "$xcodebuild" ]]; then',
' echo "xcrun: error: unable to find utility xcodebuild" >&2',
" exit 1",
"fi",
'exec "$xcodebuild" "${@:2}"',
"",
].join("\n"),
"utf8",
);
writeFileSync(
path.join(toolsDir, "swift"),
[
"#!/usr/bin/env bash",
`echo 'swift-driver version: 1.120.0 Apple Swift version ${options.swiftVersion} (swiftlang-${options.swiftVersion} clang-1700.0.13.5)'`,
"",
].join("\n"),
"utf8",
);
for (const tool of ["xcrun", "swift"]) {
chmodSync(path.join(toolsDir, tool), 0o755);
}
const developerDirOverride = options.developerDirOverride
? `export DEVELOPER_DIR=${JSON.stringify(developerDirs[options.developerDirOverride])}`
: "unset DEVELOPER_DIR";
return runHelper(`
set -euo pipefail
PATH=${JSON.stringify(`${toolsDir}:/usr/bin:/bin`)}
export MOCK_SELECTED_DEVELOPER_DIR=${JSON.stringify(selectedDeveloperDir)}
${developerDirOverride}
${getSwiftToolchainBlock()}
require_swift_toolchain
`);
}
function getSparkleBuildHelperBlock(): string {
const script = readFileSync(scriptPath, "utf8");
const start = script.indexOf("sparkle_canonical_build_from_version()");
@@ -734,60 +809,74 @@ describe("package-mac-app plist stamping", () => {
});
it("fails with an actionable error when Swift tools are too old", () => {
const helperBlock = getSwiftToolchainBlock();
const toolsDir = tempDirs.make("openclaw-package-swift-tools-");
const swiftPath = path.join(toolsDir, "swift");
writeFileSync(
swiftPath,
[
"#!/usr/bin/env bash",
"echo 'swift-driver version: 1.115.1 Apple Swift version 6.0.3 (swiftlang-6.0.3.1.10 clang-1600.0.30.1)'",
"",
].join("\n"),
"utf8",
);
chmodSync(swiftPath, 0o755);
const result = runHelper(`
set -euo pipefail
PATH=${JSON.stringify(`${toolsDir}:/usr/bin:/bin`)}
${helperBlock}
require_swift_toolchain
`);
const result = runSwiftToolchainHarness({
swiftVersion: "6.0.3",
selectedDeveloperDir: "xcode",
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("OpenClaw macOS app packaging requires Swift tools 6.2+");
expect(result.stderr).toContain("Current Swift is 6.0");
});
it("accepts Swift tools 6.2 or newer", () => {
const helperBlock = getSwiftToolchainBlock();
const toolsDir = tempDirs.make("openclaw-package-swift-tools-");
it("rejects Command Line Tools even when they provide Swift 6.2", () => {
const result = runSwiftToolchainHarness({
swiftVersion: "6.2.1",
selectedDeveloperDir: "command-line-tools",
});
const swiftPath = path.join(toolsDir, "swift");
writeFileSync(
swiftPath,
[
"#!/usr/bin/env bash",
"echo 'swift-driver version: 1.120.0 Apple Swift version 6.2.1 (swiftlang-6.2.1 clang-1700.0.13.5)'",
"",
].join("\n"),
"utf8",
expect(result.status).toBe(1);
expect(result.stderr).toContain("requires a full Xcode developer directory");
expect(result.stderr).toContain(
"Command Line Tools do not include the required SwiftUI macro plugins",
);
chmodSync(swiftPath, 0o755);
expect(result.stderr).toContain(
"sudo xcode-select -s /Applications/Xcode.app/Contents/Developer",
);
expect(result.stderr).toContain("DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer");
});
const result = runHelper(`
set -euo pipefail
PATH=${JSON.stringify(`${toolsDir}:/usr/bin:/bin`)}
${helperBlock}
require_swift_toolchain
`);
it("accepts Swift 6.2 from a selected full Xcode developer directory", () => {
const result = runSwiftToolchainHarness({
swiftVersion: "6.2.1",
selectedDeveloperDir: "xcode",
});
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
});
it("honors DEVELOPER_DIR when the global selection is Command Line Tools", () => {
const result = runSwiftToolchainHarness({
swiftVersion: "6.2.1",
selectedDeveloperDir: "command-line-tools",
developerDirOverride: "xcode",
});
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
});
it("accepts usable full Xcode tooling from a custom developer directory", () => {
const result = runSwiftToolchainHarness({
swiftVersion: "6.2.1",
selectedDeveloperDir: "custom-xcode",
});
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
});
it("rejects an unusable selected developer directory", () => {
const result = runSwiftToolchainHarness({
swiftVersion: "6.2.1",
selectedDeveloperDir: "invalid",
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("requires a full Xcode developer directory");
});
it("runs Sparkle build metadata derivation from the repository root", () => {
const helperBlock = getSparkleBuildHelperBlock();
const tempRoot = tempDirs.make("openclaw-package-sparkle-root-");
+11
View File
@@ -154,6 +154,17 @@ describe("package-mac-dist plist validation", () => {
"utf8",
);
chmodSync(path.join(toolsDir, "swift"), 0o755);
writeFileSync(
path.join(toolsDir, "xcrun"),
[
"#!/usr/bin/env bash",
'[[ "${1:-}" == "xcodebuild" && "${2:-}" == "-version" ]] || exit 2',
"echo 'Xcode 26.0'",
"",
].join("\n"),
"utf8",
);
chmodSync(path.join(toolsDir, "xcrun"), 0o755);
writeFileSync(
path.join(toolsDir, "node"),
[