From abe66b06ae9506a516fbb705b6a692ecc51c2061 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 9 Oct 2026 17:22:49 +0100 Subject: [PATCH] Add function to determine Xcode version based on symlink --- lib/entry-points.js | 56 ++++++++-------- src/action-common.ts | 2 + src/languages/swift.test.ts | 128 +++++++++++++++++++++++++++++++++++- src/languages/swift.ts | 72 +++++++++++++++++++- 4 files changed, 229 insertions(+), 29 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index d5a8480f2..a267f83ad 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -27288,7 +27288,7 @@ var require_coerce = __commonJS({ var SemVer = require_semver(); var parse4 = require_parse2(); var { safeRe: re, t } = require_re(); - var coerce3 = (version, options) => { + var coerce4 = (version, options) => { if (version instanceof SemVer) { return version; } @@ -27323,7 +27323,7 @@ var require_coerce = __commonJS({ const build2 = options.includePrerelease && match2[6] ? `+${match2[6]}` : ""; return parse4(`${major}.${minor}.${patch}${prerelease}${build2}`, options); }; - module2.exports = coerce3; + module2.exports = coerce4; } }); @@ -28409,7 +28409,7 @@ var require_semver2 = __commonJS({ var gte7 = require_gte(); var lte2 = require_lte(); var cmp = require_cmp(); - var coerce3 = require_coerce(); + var coerce4 = require_coerce(); var truncate = require_truncate(); var Comparator = require_comparator(); var Range2 = require_range(); @@ -28448,7 +28448,7 @@ var require_semver2 = __commonJS({ gte: gte7, lte: lte2, cmp, - coerce: coerce3, + coerce: coerce4, truncate, Comparator, Range: Range2, @@ -34547,8 +34547,8 @@ var require_semver3 = __commonJS({ r2 = new Range2(r2, options); return r1.intersects(r2); } - exports2.coerce = coerce3; - function coerce3(version, options) { + exports2.coerce = coerce4; + function coerce4(version, options) { if (version instanceof SemVer) { return version; } @@ -34719,7 +34719,7 @@ var require_cacheUtils = __commonJS({ var crypto3 = __importStar2(require("crypto")); var fs33 = __importStar2(require("fs")); var path30 = __importStar2(require("path")); - var semver12 = __importStar2(require_semver3()); + var semver13 = __importStar2(require_semver3()); var util4 = __importStar2(require("util")); var constants_1 = require_constants7(); var versionSalt = "1.0"; @@ -34812,7 +34812,7 @@ var require_cacheUtils = __commonJS({ function getCompressionMethod() { return __awaiter2(this, void 0, void 0, function* () { const versionOutput = yield getVersion("zstd", ["--quiet"]); - const version = semver12.clean(versionOutput); + const version = semver13.clean(versionOutput); core29.debug(`zstd version: ${version}`); if (versionOutput === "") { return constants_1.CompressionMethod.Gzip; @@ -37599,7 +37599,7 @@ var require_common = __commonJS({ function setup(env) { createDebug.debug = createDebug; createDebug.default = createDebug; - createDebug.coerce = coerce3; + createDebug.coerce = coerce4; createDebug.disable = disable; createDebug.enable = enable; createDebug.enabled = enabled; @@ -37754,7 +37754,7 @@ var require_common = __commonJS({ } return false; } - function coerce3(val) { + function coerce4(val) { if (val instanceof Error) { return val.stack || val.message; } @@ -106346,7 +106346,7 @@ var require_manifest = __commonJS({ exports2._findMatch = _findMatch; exports2._getOsVersion = _getOsVersion; exports2._readLinuxVersionFile = _readLinuxVersionFile; - var semver12 = __importStar2(require_semver2()); + var semver13 = __importStar2(require_semver2()); var core_1 = require_core(); var os8 = require("os"); var cp = require("child_process"); @@ -106360,7 +106360,7 @@ var require_manifest = __commonJS({ for (const candidate of candidates) { const version = candidate.version; (0, core_1.debug)(`check ${version} satisfies ${versionSpec}`); - if (semver12.satisfies(version, versionSpec) && (!stable || candidate.stable === stable)) { + if (semver13.satisfies(version, versionSpec) && (!stable || candidate.stable === stable)) { file = candidate.files.find((item) => { (0, core_1.debug)(`${item.arch}===${archFilter} && ${item.platform}===${platFilter}`); let chk = item.arch === archFilter && item.platform === platFilter; @@ -106369,7 +106369,7 @@ var require_manifest = __commonJS({ if (osVersion === item.platform_version) { chk = true; } else { - chk = semver12.satisfies(osVersion, item.platform_version); + chk = semver13.satisfies(osVersion, item.platform_version); } } return chk; @@ -106629,7 +106629,7 @@ var require_tool_cache = __commonJS({ var os8 = __importStar2(require("os")); var path30 = __importStar2(require("path")); var httpm = __importStar2(require_lib()); - var semver12 = __importStar2(require_semver2()); + var semver13 = __importStar2(require_semver2()); var stream2 = __importStar2(require("stream")); var util4 = __importStar2(require("util")); var assert_1 = require("assert"); @@ -106902,7 +106902,7 @@ var require_tool_cache = __commonJS({ } function cacheDir2(sourceDir, tool, version, arch2) { return __awaiter2(this, void 0, void 0, function* () { - version = semver12.clean(version) || version; + version = semver13.clean(version) || version; arch2 = arch2 || os8.arch(); core29.debug(`Caching tool ${tool} ${version} ${arch2}`); core29.debug(`source dir: ${sourceDir}`); @@ -106920,7 +106920,7 @@ var require_tool_cache = __commonJS({ } function cacheFile(sourceFile, targetFile, tool, version, arch2) { return __awaiter2(this, void 0, void 0, function* () { - version = semver12.clean(version) || version; + version = semver13.clean(version) || version; arch2 = arch2 || os8.arch(); core29.debug(`Caching tool ${tool} ${version} ${arch2}`); core29.debug(`source file: ${sourceFile}`); @@ -106950,7 +106950,7 @@ var require_tool_cache = __commonJS({ } let toolPath = ""; if (versionSpec) { - versionSpec = semver12.clean(versionSpec) || ""; + versionSpec = semver13.clean(versionSpec) || ""; const cachePath = path30.join(_getCacheDirectory(), toolName, versionSpec, arch2); core29.debug(`checking cache: ${cachePath}`); if (fs33.existsSync(cachePath) && fs33.existsSync(`${cachePath}.complete`)) { @@ -107030,7 +107030,7 @@ var require_tool_cache = __commonJS({ } function _createToolPath(tool, version, arch2) { return __awaiter2(this, void 0, void 0, function* () { - const folderPath = path30.join(_getCacheDirectory(), tool, semver12.clean(version) || version, arch2 || ""); + const folderPath = path30.join(_getCacheDirectory(), tool, semver13.clean(version) || version, arch2 || ""); core29.debug(`destination ${folderPath}`); const markerPath = `${folderPath}.complete`; yield io9.rmRF(folderPath); @@ -107040,15 +107040,15 @@ var require_tool_cache = __commonJS({ }); } function _completeToolPath(tool, version, arch2) { - const folderPath = path30.join(_getCacheDirectory(), tool, semver12.clean(version) || version, arch2 || ""); + const folderPath = path30.join(_getCacheDirectory(), tool, semver13.clean(version) || version, arch2 || ""); const markerPath = `${folderPath}.complete`; fs33.writeFileSync(markerPath, ""); core29.debug("finished caching tool"); } function isExplicitVersion(versionSpec) { - const c = semver12.clean(versionSpec) || ""; + const c = semver13.clean(versionSpec) || ""; core29.debug(`isExplicit: ${c}`); - const valid4 = semver12.valid(c) != null; + const valid4 = semver13.valid(c) != null; core29.debug(`explicit? ${valid4}`); return valid4; } @@ -107056,14 +107056,14 @@ var require_tool_cache = __commonJS({ let version = ""; core29.debug(`evaluating ${versions.length} versions`); versions = versions.sort((a, b) => { - if (semver12.gt(a, b)) { + if (semver13.gt(a, b)) { return 1; } return -1; }); for (let i = versions.length - 1; i >= 0; i--) { const potential = versions[i]; - const satisfied = semver12.satisfies(potential, versionSpec); + const satisfied = semver13.satisfies(potential, versionSpec); if (satisfied) { version = potential; break; @@ -175694,7 +175694,7 @@ function macOSVersion(action) { if (action.platform !== "darwin") { return void 0; } - const version = semver7.parse(action.osRelease); + const version = semver6.parse(action.osRelease); if (version === null) { return action.osRelease; } @@ -186051,7 +186051,7 @@ var fs30 = __toESM(require("fs")); var path25 = __toESM(require("path")); var core19 = __toESM(require_core()); var io7 = __toESM(require_io()); -var semver11 = __toESM(require_semver2()); +var semver12 = __toESM(require_semver2()); // src/config/inputs.ts async function getToolsInput(action, repositoryProperties) { @@ -186088,6 +186088,10 @@ async function getToolsInput(action, repositoryProperties) { } // src/languages/swift.ts +var semver11 = __toESM(require_semver2()); +var XCODE_APP_FILENAME_PATTERN = new RegExp( + /(?Xcode_(?\d+.\d+).app)/ +); var SWIFT_TRACED_UNSUPPORTED_MACOS = 27; var SWIFT_TRACED_UNSUPPORTED_XCODE = 27; async function isSwiftCompatible(action, config, codeql) { @@ -186616,7 +186620,7 @@ async function run3(actionState) { ) { const publicPreview = "2.22.1"; const actualVer = (await codeql.getVersion()).version; - if (semver11.lt(actualVer, publicPreview)) { + if (semver12.lt(actualVer, publicPreview)) { actionState.env.export("CODEQL_ENABLE_EXPERIMENTAL_FEATURES" /* EXPERIMENTAL_FEATURES */, "true"); logger2.info("Experimental Rust analysis enabled"); } 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/languages/swift.test.ts b/src/languages/swift.test.ts index c9e90ad45..c45d97c90 100644 --- a/src/languages/swift.test.ts +++ b/src/languages/swift.test.ts @@ -1,3 +1,5 @@ +import fs from "fs"; + import test from "ava"; import * as sinon from "sinon"; @@ -18,12 +20,136 @@ import { import { ToolsFeature } from "../tools-features"; import { withTmpDir } from "../util"; -import { isSwiftCompatible } from "./swift"; +import { + isSwiftCompatible, + XCODE_SELECT_LINK_PATH, + xcodeVersion, +} from "./swift"; import { BuiltInLanguage } from "."; setupTests(test); +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]); +}); + test("isSwiftCompatible doesn't throw for non-Swift languages", async (t) => { for (const language of Object.values(BuiltInLanguage)) { if (language === BuiltInLanguage.swift) { diff --git a/src/languages/swift.ts b/src/languages/swift.ts index e73e40e59..7e70f113c 100644 --- a/src/languages/swift.ts +++ b/src/languages/swift.ts @@ -1,20 +1,88 @@ -import { ActionState } from "../action-common"; +import * as fs from "fs"; + +import * as semver from "semver"; + +import { ActionState, Logger } from "../action-common"; import { CodeQL } from "../codeql"; import { Config } from "../config-utils"; import { addDiagnostic, makeDiagnostic } from "../diagnostics"; import { Feature } from "../feature-flags"; import { macOSVersion } from "../platform"; import { ToolsFeature } from "../tools-features"; -import { ConfigurationError } from "../util"; +import { ConfigurationError, getErrorMessage } from "../util"; import { BuiltInLanguage } from "."; +/** 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)/, +); + /** macOS 27 and above do not support traced extraction for Swift. */ export const SWIFT_TRACED_UNSUPPORTED_MACOS = 27; /** Xcode 27 and above do not support traced extraction for Swift. */ export const SWIFT_TRACED_UNSUPPORTED_XCODE = 27; +/** + * 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; + } +} + /** * Determines whether we can run a Swift analysis on the current runner. *