Add function to determine Xcode version based on symlink

This commit is contained in:
Michael B. Gale
2026-10-09 17:22:49 +01:00
parent e0c11175e5
commit df26bdf81b
4 changed files with 229 additions and 29 deletions

View File

@@ -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. */

View File

@@ -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) {

View File

@@ -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(
/(?<filename>Xcode_(?<majorMinor>\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.
*