diff --git a/lib/entry-points.js b/lib/entry-points.js index 0d44bf881..2f9741b59 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -183478,6 +183478,64 @@ function xcodeVersionFromLink(logger2) { return void 0; } } +async function xcodeVersionFromXcodeBuild(logger2) { + try { + const xcrun = await io4.which("xcrun", true); + let stdout = ""; + let stderr = ""; + const exitCode = await new toolrunner3.ToolRunner( + xcrun, + ["xcodebuild", "-version"], + { + silent: true, + ignoreReturnCode: true, + listeners: { + stdout: (data) => { + stdout += data.toString("utf-8"); + }, + stderr: (data) => { + stderr += data.toString("utf-8"); + } + } + } + ).exec(); + if (exitCode !== 0) { + logger2.debug(`xcodebuild returned exit code ${exitCode}`); + logger2.debug(`stdout: +${stdout}`); + logger2.debug(`stderr: +${stderr}`); + return void 0; + } + const match2 = stdout.match(/^Xcode (\d+(\.\d+)?)/); + if (match2 === null) { + logger2.debug( + `Output doesn't appear to contain the Xcode product version.` + ); + logger2.debug(`stdout: +${stdout}`); + logger2.debug(`stderr: +${stderr}`); + return void 0; + } + const version = semver6.coerce(match2[1].trim()); + if (version === null) { + logger2.debug( + `xcodebuild's output couldn't be parsed as a version: +${stdout}` + ); + logger2.debug(`stderr: +${stderr}`); + return void 0; + } + return version; + } catch (err) { + logger2.debug( + `Unable to determine Xcode version from xcodebuild: ${getErrorMessage(err)}` + ); + return void 0; + } +} async function xcodeVersionFromSdk(logger2) { try { const xcrun = await io4.which("xcrun", true); @@ -194063,7 +194121,7 @@ async function checkRunnerSupportsTracedAnalysis(action, config, codeql) { ); return; } - const xcodeVer = xcodeVersionFromLink(action.logger) ?? await xcodeVersionFromSdk(action.logger); + const xcodeVer = xcodeVersionFromLink(action.logger) ?? await xcodeVersionFromXcodeBuild(action.logger) ?? await xcodeVersionFromSdk(action.logger); if (xcodeVer !== void 0 && xcodeVer.major >= UNSUPPORTED_XCODE_FOR_TRACED) { handleUnsupportedVersion( action.logger, diff --git a/src/languages/traced.test.ts b/src/languages/traced.test.ts index f317306ef..250295ebf 100644 --- a/src/languages/traced.test.ts +++ b/src/languages/traced.test.ts @@ -1,5 +1,6 @@ import fs from "fs"; +import * as toolrunner from "@actions/exec/lib/toolrunner"; import * as io from "@actions/io"; import test from "ava"; import * as sinon from "sinon"; @@ -428,7 +429,7 @@ test.serial( ); test.serial( - "checkRunnerSupportsTracedAnalysis uses Xcode SDK version as fallback", + "checkRunnerSupportsTracedAnalysis attempts fallbacks if needed", async (t) => withTmpDir(async (tmpDir) => { const logger = new RecordingLogger(); @@ -439,7 +440,16 @@ test.serial( const readlinkSync = sinon .stub(fs, "readlinkSync") .throws(new Error("Not found")); - const which = sinon.stub(io, "which").throws(new Error("Tool not found")); + const which = sinon.stub(io, "which").resolves("/path/to/xcrun"); + const exec = sinon.stub<[], Promise>().callsFake(async () => { + throw new Error("Error to force next fallback"); + }); + const runner = sinon.createStubInstance(toolrunner.ToolRunner, { exec }); + const toolrunnerStub = sinon + .stub(toolrunner, "ToolRunner") + .callsFake(() => { + return runner; + }); const codeql = await getCodeQLWithTracedStub( "codeql-for-testing", @@ -468,11 +478,27 @@ test.serial( t.is(readlinkSync.callCount, 1); t.deepEqual(readlinkSync.args[0], [XCODE_SELECT_LINK_PATH]); - t.is(which.callCount, 1); + t.is(which.callCount, 2); t.deepEqual(which.args[0], ["xcrun", true]); + t.deepEqual(which.args[1], ["xcrun", true]); + + t.is(toolrunnerStub.callCount, 2); + t.is(exec.callCount, 2); + + t.is(toolrunnerStub.args[0][0], "/path/to/xcrun"); + t.deepEqual(toolrunnerStub.args[0][1], ["xcodebuild", "-version"]); + + t.is(toolrunnerStub.args[1][0], "/path/to/xcrun"); + t.deepEqual(toolrunnerStub.args[1][1], [ + "--sdk", + "macosx", + "--show-sdk-version", + ]); checkExpectedLogMessages(t, logger.messages, [ - "Unable to determine Xcode version: Tool not found", + "Unable to determine Xcode version: Not found", + "Unable to determine Xcode version from xcodebuild: Error to force next fallback", + "Unable to determine Xcode version: Error to force next fallback", ]); checkUnexpectedLogMessages(t, logger.messages, [ "Traced analysis is not supported on Xcode 27", diff --git a/src/languages/traced.ts b/src/languages/traced.ts index 929019022..090068e49 100644 --- a/src/languages/traced.ts +++ b/src/languages/traced.ts @@ -11,6 +11,7 @@ import { darwinVersion, xcodeVersionFromLink, xcodeVersionFromSdk, + xcodeVersionFromXcodeBuild, } from "../platform"; import { ConfigurationError } from "../util"; @@ -201,10 +202,12 @@ 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. We first try to obtain the version of Xcode itself - // starting with the cheapest approach. If that doesn't work, we obtain the version from the SDK instead. - // Since either Xcode or the SDK at version 27 or above is incompatible, the major version of either is fine. + // starting with the cheapest approach and then call out to an external program. If neither works, we try + // to obtain the version from the SDK instead. Since either Xcode or the SDK at version 27 or above is + // incompatible, either is fine as we only check the major version below. const xcodeVer = xcodeVersionFromLink(action.logger) ?? + (await xcodeVersionFromXcodeBuild(action.logger)) ?? (await xcodeVersionFromSdk(action.logger)); if ( diff --git a/src/platform.test.ts b/src/platform.test.ts index 152d5a810..a0d2d34bf 100644 --- a/src/platform.test.ts +++ b/src/platform.test.ts @@ -13,6 +13,7 @@ import { XCODE_SELECT_LINK_PATH, xcodeVersionFromLink, xcodeVersionFromSdk, + xcodeVersionFromXcodeBuild, } from "./platform"; import { checkExpectedLogMessages, @@ -228,6 +229,74 @@ test.serial( }, ); +test.serial( + "xcodeVersionFromXcodeBuild 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>().resolves(1); + const runner = sinon.createStubInstance(toolrunner.ToolRunner, { exec }); + const toolrunnerStub = sinon.stub(toolrunner, "ToolRunner").returns(runner); + + await t.notThrowsAsync(async () => { + const result = await xcodeVersionFromXcodeBuild(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, [ + "xcodebuild returned exit code 1", + ]); + }); + }, +); + +test.serial( + "xcodeVersionFromXcodeBuild 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>().callsFake(async () => { + if (stdoutListener !== undefined) { + stdoutListener(Buffer.from("Xcode 26.5")); + stdoutListener(Buffer.from("Build version 26F9")); + } + 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 xcodeVersionFromXcodeBuild(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( "xcodeVersionFromSdk returns undefined for non-zero exit code", async (t) => { diff --git a/src/platform.ts b/src/platform.ts index 26c81b7aa..ef66de300 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -115,6 +115,76 @@ export function xcodeVersionFromLink( } } +/** + * Tries to determine the version of Xcode that is installed by running `xcrun xcodebuild -version`. + * + * @param logger The logger to use. + * @returns The Xcode version or `undefined` if it couldn't be determined. + */ +export async function xcodeVersionFromXcodeBuild( + logger: Logger, +): Promise { + try { + const xcrun = await io.which("xcrun", true); + let stdout = ""; + let stderr = ""; + + const exitCode = await new toolrunner.ToolRunner( + xcrun, + ["xcodebuild", "-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(`xcodebuild returned exit code ${exitCode}`); + logger.debug(`stdout:\n${stdout}`); + logger.debug(`stderr:\n${stderr}`); + return undefined; + } + + // The output contains a couple of lines, but we only care about the product version. + const match = stdout.match(/^Xcode (\d+(\.\d+)?)/); + + if (match === null) { + logger.debug( + `Output doesn't appear to contain the Xcode product version.`, + ); + 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(match[1].trim()); + + if (version === null) { + logger.debug( + `xcodebuild'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 from xcodebuild: ${getErrorMessage(err)}`, + ); + return undefined; + } +} + /** * Tries to determine the version of Xcode that is installed by running `xcrun --sdk macosx --show-sdk-version`. *