From 242ea52c053a1f486f451825bf91e7230fccd010 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 28 Oct 2026 12:57:13 +0000 Subject: [PATCH] Add function to determine Xcode version based on symlink --- src/action-common.ts | 2 + src/platform.test.ts | 142 ++++++++++++++++++++++++++++++++++++++++++- src/platform.ts | 69 ++++++++++++++++++++- 3 files changed, 209 insertions(+), 4 deletions(-) diff --git a/src/action-common.ts b/src/action-common.ts index b27286906..efb0bd97a 100644 --- a/src/action-common.ts +++ b/src/action-common.ts @@ -15,6 +15,8 @@ import { } from "./status-report"; import { getEnv, getErrorMessage, wrapError } from "./util"; +export type { Logger } from "./logging"; + /** Base state that is available to an Action on startup. */ export interface BaseState { /** The name of the Action. */ diff --git a/src/platform.test.ts b/src/platform.test.ts index 803e28b90..5de468d82 100644 --- a/src/platform.test.ts +++ b/src/platform.test.ts @@ -1,7 +1,23 @@ -import test from "ava"; +import fs from "fs"; -import { BundlePlatform, getBundlePlatform, macOSVersion } from "./platform"; -import { initAllState } from "./testing-utils"; +import test from "ava"; +import * as sinon from "sinon"; + +import { + BundlePlatform, + getBundlePlatform, + macOSVersion, + XCODE_SELECT_LINK_PATH, + xcodeVersion, +} from "./platform"; +import { + checkExpectedLogMessages, + initAllState, + RecordingLogger, + setupTests, +} from "./testing-utils"; + +setupTests(test); for (const [platform, arch, expected] of [ ["linux", "x64", BundlePlatform.Linux64], @@ -54,3 +70,123 @@ test("macOSVersion returns semver if parsing succeeds", (t) => { t.is(version["patch"], 1); } }); + +test.serial("xcodeVersion returns undefined if symlink doesn't exist", (t) => { + const logger = new RecordingLogger(); + + const statSync = sinon.stub(fs, "statSync").throws(new Error("ENOENT")); + + t.is(xcodeVersion(logger), undefined); + t.is(statSync.callCount, 1); + t.deepEqual(statSync.args[0], [XCODE_SELECT_LINK_PATH]); + + checkExpectedLogMessages(t, logger.messages, [ + "Unable to determine Xcode version: ENOENT", + ]); +}); + +test.serial("xcodeVersion returns undefined if file is not a symlink", (t) => { + const logger = new RecordingLogger(); + + const statSync = sinon + .stub(fs, "statSync") + .returns({ isSymbolicLink: () => false } as fs.Stats); + + t.is(xcodeVersion(logger), undefined); + t.is(statSync.callCount, 1); + t.deepEqual(statSync.args[0], [XCODE_SELECT_LINK_PATH]); + + checkExpectedLogMessages(t, logger.messages, [ + "exists, but is not a symbolic link", + ]); +}); + +test.serial( + "xcodeVersion returns undefined if resolving the symlink returns nothing", + (t) => { + const logger = new RecordingLogger(); + + const statSync = sinon + .stub(fs, "statSync") + .returns({ isSymbolicLink: () => true } as fs.Stats); + const readlinkSync = sinon.stub(fs, "readlinkSync").returns(""); + + t.is(xcodeVersion(logger), undefined); + t.is(statSync.callCount, 1); + t.deepEqual(statSync.args[0], [XCODE_SELECT_LINK_PATH]); + t.is(readlinkSync.callCount, 1); + t.deepEqual(readlinkSync.args[0], [XCODE_SELECT_LINK_PATH]); + + checkExpectedLogMessages(t, logger.messages, [ + "unexpectedly returned nothing", + ]); + }, +); + +test.serial( + "xcodeVersion returns undefined if resolved path doesn't include pattern", + (t) => { + const logger = new RecordingLogger(); + + const statSync = sinon + .stub(fs, "statSync") + .returns({ isSymbolicLink: () => true } as fs.Stats); + const readlinkSync = sinon + .stub(fs, "readlinkSync") + .returns("/Applications/Xcode.app/Contents/Developer"); + + t.is(xcodeVersion(logger), undefined); + t.is(statSync.callCount, 1); + t.deepEqual(statSync.args[0], [XCODE_SELECT_LINK_PATH]); + t.is(readlinkSync.callCount, 1); + t.deepEqual(readlinkSync.args[0], [XCODE_SELECT_LINK_PATH]); + + checkExpectedLogMessages(t, logger.messages, [ + "does not contain expected pattern", + ]); + }, +); + +test.serial("xcodeVersion returns undefined if match can't be parsed", (t) => { + const logger = new RecordingLogger(); + + const statSync = sinon + .stub(fs, "statSync") + .returns({ isSymbolicLink: () => true } as fs.Stats); + const readlinkSync = sinon + .stub(fs, "readlinkSync") + .returns("/Applications/Xcode_00.0.app/Contents/Developer"); + + t.is(xcodeVersion(logger), undefined); + t.is(statSync.callCount, 1); + t.deepEqual(statSync.args[0], [XCODE_SELECT_LINK_PATH]); + t.is(readlinkSync.callCount, 1); + t.deepEqual(readlinkSync.args[0], [XCODE_SELECT_LINK_PATH]); + + checkExpectedLogMessages(t, logger.messages, [ + "Couldn't parse '00.0' as a semantic version.", + ]); +}); + +test.serial("xcodeVersion returns version from resolved path", (t) => { + const logger = new RecordingLogger(); + + const statSync = sinon + .stub(fs, "statSync") + .returns({ isSymbolicLink: () => true } as fs.Stats); + const readlinkSync = sinon + .stub(fs, "readlinkSync") + .returns("/Applications/Xcode_16.4.app/Contents/Developer"); + + const result = xcodeVersion(logger); + + if (t.truthy(result)) { + t.is(result.major, 16); + t.is(result.minor, 4); + } + + t.is(statSync.callCount, 1); + t.deepEqual(statSync.args[0], [XCODE_SELECT_LINK_PATH]); + t.is(readlinkSync.callCount, 1); + t.deepEqual(readlinkSync.args[0], [XCODE_SELECT_LINK_PATH]); +}); diff --git a/src/platform.ts b/src/platform.ts index 59785d312..d5c77c9f7 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -1,6 +1,9 @@ +import fs from "fs"; + import * as semver from "semver"; -import type { ActionState } from "./action-common"; +import type { ActionState, Logger } from "./action-common"; +import { getErrorMessage } from "./util"; /** Platform identifiers used in CodeQL bundle asset names. */ export enum BundlePlatform { @@ -53,3 +56,67 @@ export function macOSVersion( return version; } + +/** The static path we check for a symbolic link to the (dynamic) Xcode location. */ +export const XCODE_SELECT_LINK_PATH = "/private/var/db/xcode_select_link"; + +/** The pattern we expect to find in the Xcode path. */ +export const XCODE_APP_FILENAME_PATTERN = new RegExp( + /(?Xcode_(?\d+.\d+).app)/, +); + +/** + * 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 { + try { + // Stat the expected symbolic link to check that it exists and is a symbolic link. + // The `readlinkSync` call below returns an empty string in either case and so + // this check allows us to distinguish between the two cases. + const stats = fs.statSync(XCODE_SELECT_LINK_PATH); + + if (!stats.isSymbolicLink()) { + logger.warning( + `${XCODE_SELECT_LINK_PATH} exists, but is not a symbolic link.`, + ); + return undefined; + } + + // Read what the symbolic link points to. + const xcodePath = fs.readlinkSync(XCODE_SELECT_LINK_PATH); + + if (xcodePath === "") { + logger.warning( + `Resolving ${XCODE_SELECT_LINK_PATH} unexpectedly returned nothing.`, + ); + return undefined; + } + + // Try to extract the version from the path. + const matchResult = xcodePath.match(XCODE_APP_FILENAME_PATTERN); + + if (matchResult?.groups === undefined) { + logger.warning( + `Xcode path '${xcodePath}' does not contain expected pattern.`, + ); + return undefined; + } + + const majorMinor = matchResult.groups["majorMinor"]; + const version = semver.coerce(majorMinor); + + if (version === null) { + logger.warning(`Couldn't parse '${majorMinor}' as a semantic version.`); + return undefined; + } + return version; + } catch (err) { + logger.warning( + `Unable to determine Xcode version: ${getErrorMessage(err)}`, + ); + return undefined; + } +}