Add xcrun-based fallback for Xcode version detection

This commit is contained in:
Michael B. Gale
2026-10-29 17:19:57 +00:00
parent 4a6767cdd0
commit be54d791a1
4 changed files with 270 additions and 67 deletions

View File

@@ -197,7 +197,7 @@ export async function checkRunnerSupportsTracedAnalysis(
// Determining whether the Xcode version is supported only makes sense on macOS, so we only do it
// after determining that we are running on macOS.
const xcodeVer = xcodeVersion(action.logger);
const xcodeVer = await xcodeVersion(action.logger);
if (
xcodeVer !== undefined &&

View File

@@ -1,5 +1,8 @@
import fs from "fs";
import type { ExecOptions } from "@actions/exec/lib/interfaces";
import * as toolrunner from "@actions/exec/lib/toolrunner";
import * as io from "@actions/io";
import test from "ava";
import * as sinon from "sinon";
@@ -9,6 +12,8 @@ import {
darwinVersion,
XCODE_SELECT_LINK_PATH,
xcodeVersionFromLink,
xcodeVersion,
xcodeVersionFromSdk,
} from "./platform";
import {
checkExpectedLogMessages,
@@ -220,3 +225,94 @@ test.serial(
t.deepEqual(readlinkSync.args[0], [XCODE_SELECT_LINK_PATH]);
},
);
test.serial(
"xcodeVersionFromSdk returns undefined for non-zero exit code",
async (t) => {
const logger = new RecordingLogger();
const which = sinon.stub(io, "which").resolves("/path/to/xcrun");
const exec = sinon.stub<[], Promise<number>>().resolves(1);
const runner = sinon.createStubInstance(toolrunner.ToolRunner, { exec });
const toolrunnerStub = sinon.stub(toolrunner, "ToolRunner").returns(runner);
await t.notThrowsAsync(async () => {
const result = await xcodeVersionFromSdk(logger);
t.is(result, undefined);
t.is(which.callCount, 1);
t.deepEqual(which.args[0], ["xcrun", true]);
t.is(toolrunnerStub.callCount, 1);
t.is(exec.callCount, 1);
checkExpectedLogMessages(t, logger.messages, [
"xcrun returned exit code 1",
]);
});
},
);
test.serial(
"xcodeVersionFromSdk extracts version from xcrun stdout",
async (t) => {
const logger = new RecordingLogger();
const which = sinon.stub(io, "which").resolves("/path/to/xcrun");
let stdoutListener: ((data: Buffer) => void) | undefined = undefined;
const exec = sinon.stub<[], Promise<number>>().callsFake(async () => {
if (stdoutListener !== undefined) {
stdoutListener(Buffer.from("26.5"));
}
return 0;
});
const runner = sinon.createStubInstance(toolrunner.ToolRunner, { exec });
const toolrunnerStub = sinon
.stub(toolrunner, "ToolRunner")
.callsFake((_cmd, _args, opts: ExecOptions) => {
stdoutListener = opts.listeners?.stdout;
return runner;
});
await t.notThrowsAsync(async () => {
const result = await xcodeVersionFromSdk(logger);
t.not(result, undefined);
t.is(result?.major, 26);
t.is(result?.minor, 5);
t.is(which.callCount, 1);
t.deepEqual(which.args[0], ["xcrun", true]);
t.is(toolrunnerStub.callCount, 1);
t.is(exec.callCount, 1);
});
},
);
test.serial("xcodeVersion falls back to xcodeVersionFromSdk", async (t) => {
const logger = new RecordingLogger();
const readlinkSync = sinon
.stub(fs, "readlinkSync")
.throws(new Error("ENOENT: no such file or directory"));
const which = sinon.stub(io, "which").throws(new Error("No such tool"));
await t.notThrowsAsync(async () => {
const result = await xcodeVersion(logger);
t.is(result, undefined);
t.is(readlinkSync.callCount, 1);
t.is(which.callCount, 1);
t.deepEqual(which.args[0], ["xcrun", true]);
checkExpectedLogMessages(t, logger.messages, [
"Unable to determine Xcode version: ENOENT: no such file or directory",
"Unable to determine Xcode version: No such tool",
]);
});
});

View File

@@ -1,5 +1,7 @@
import fs from "fs";
import * as toolrunner from "@actions/exec/lib/toolrunner";
import * as io from "@actions/io";
import * as semver from "semver";
import type { ActionState, Logger } from "./action-common";
@@ -113,12 +115,70 @@ export function xcodeVersionFromLink(
}
}
/**
* Tries to determine the version of Xcode that is installed by running `xcrun --sdk macosx --show-sdk-version`.
*
* @param logger The logger to use.
* @returns The Xcode version or `undefined` if it couldn't be determined.
*/
export async function xcodeVersionFromSdk(
logger: Logger,
): Promise<semver.SemVer | undefined> {
try {
const xcrun = await io.which("xcrun", true);
let stdout = "";
let stderr = "";
const exitCode = await new toolrunner.ToolRunner(
xcrun,
["--sdk", "macosx", "--show-sdk-version"],
{
silent: true,
ignoreReturnCode: true,
listeners: {
stdout: (data: Buffer) => {
stdout += data.toString("utf-8");
},
stderr: (data: Buffer) => {
stderr += data.toString("utf-8");
},
},
},
).exec();
if (exitCode !== 0) {
logger.debug(`xcrun returned exit code ${exitCode}`);
logger.debug(`stdout:\n${stdout}`);
logger.debug(`stderr:\n${stderr}`);
return undefined;
}
// We expect the output to only be a version string. Try to parse it into a semver.
const version = semver.coerce(stdout.trim());
if (version === null) {
logger.debug(
`xcrun's output couldn't be parsed as a version:\n${stdout}`,
);
logger.debug(`stderr:\n${stderr}`);
return undefined;
}
return version;
} catch (err) {
logger.debug(`Unable to determine Xcode version: ${getErrorMessage(err)}`);
return undefined;
}
}
/**
* Tries to determine the version of Xcode that is installed.
*
* @param logger The logger to use.
* @returns The Xcode version or `undefined` if it couldn't be determined.
*/
export function xcodeVersion(logger: Logger): semver.SemVer | undefined {
return xcodeVersionFromLink(logger);
export async function xcodeVersion(
logger: Logger,
): Promise<semver.SemVer | undefined> {
return xcodeVersionFromLink(logger) ?? (await xcodeVersionFromSdk(logger));
}