Check if Xcode version is unsupported

This commit is contained in:
Michael B. Gale
2026-10-09 18:11:41 +01:00
parent a4bb2b4672
commit 8baf1db394
3 changed files with 416 additions and 133 deletions

126
lib/entry-points.js generated
View File

@@ -186034,11 +186034,83 @@ async function getToolsInput(action, repositoryProperties) {
// src/languages/swift.ts
var semver12 = __toESM(require_semver2());
var XCODE_SELECT_LINK_PATH = "/private/var/db/xcode_select_link";
var XCODE_APP_FILENAME_PATTERN = new RegExp(
/(?<filename>Xcode_(?<majorMinor>\d+.\d+).app)/
);
var SWIFT_TRACED_UNSUPPORTED_MACOS = 27;
var SWIFT_TRACED_UNSUPPORTED_XCODE = 27;
function xcodeVersion(logger2, fs33) {
try {
const stats = fs33.statSync(XCODE_SELECT_LINK_PATH);
if (!stats.isSymbolicLink()) {
logger2.warning(
`${XCODE_SELECT_LINK_PATH} exists, but is not a symbolic link.`
);
return void 0;
}
const xcodePath = fs33.readlinkSync(XCODE_SELECT_LINK_PATH);
if (xcodePath === "") {
logger2.warning(
`Resolving ${XCODE_SELECT_LINK_PATH} unexpectedly returned nothing.`
);
return void 0;
}
const matchResult = xcodePath.match(XCODE_APP_FILENAME_PATTERN);
if (matchResult?.groups === void 0) {
logger2.warning(
`Xcode path '${xcodePath}' does not contain expected pattern.`
);
return void 0;
}
const majorMinor = matchResult.groups["majorMinor"];
const version = semver12.coerce(majorMinor);
if (version === null) {
logger2.warning(`Couldn't parse '${majorMinor}' as a semantic version.`);
return void 0;
}
return version;
} catch (err) {
logger2.warning(
`Unable to determine Xcode version: ${getErrorMessage(err)}`
);
return void 0;
}
}
function handleUnsupportedVersion(logger2, config, skipUnsupportedTracedAnalysis, product, version) {
const baseMessage = [
`Traced Swift analysis is not supported on ${product} ${SWIFT_TRACED_UNSUPPORTED_MACOS} or above.`,
`Configure your analysis to run on macOS ${SWIFT_TRACED_UNSUPPORTED_MACOS - 1} or below`,
`and XCode ${SWIFT_TRACED_UNSUPPORTED_XCODE - 1} or below.`
].join(" ");
const attributeName = product === "macOS" ? "macOSVersion" : "xcodeVersion";
addDiagnostic(
config,
"swift" /* swift */,
makeDiagnostic(
`codeql-action/unsupported-traced-swift-analysis-${product.toLowerCase()}`,
`Traced Swift analysis is not supported on this version of ${product}`,
{
attributes: {
languages: config.languages,
[attributeName]: version.toString()
},
markdownMessage: baseMessage,
severity: skipUnsupportedTracedAnalysis ? "error" : "warning",
visibility: {
cliSummaryTable: true,
statusPage: true,
telemetry: true
}
}
)
);
if (skipUnsupportedTracedAnalysis) {
throw new ConfigurationError(baseMessage);
} else {
logger2.warning(baseMessage);
}
}
async function isSwiftCompatible(action, config, codeql) {
if (!config.languages.includes("swift" /* swift */)) {
return;
@@ -186052,47 +186124,31 @@ async function isSwiftCompatible(action, config, codeql) {
`Swift analysis is only supported on macOS runner images. Please migrate to a macOS runner.`
);
}
const skipUnsupportedTracedAnalysis = await action.features.getValue(
"swift_skip_unsupported_traced_analysis" /* SwiftSkipUnsupportedTracedAnalysis */
);
if (typeof version === "string") {
action.logger.warning(
`Unable to determine version of macOS, got: ${version}`
);
return;
}
const skipUnsupportedTracedAnalysis = await action.features.getValue(
"swift_skip_unsupported_traced_analysis" /* SwiftSkipUnsupportedTracedAnalysis */
);
if (version.major >= SWIFT_TRACED_UNSUPPORTED_MACOS) {
const baseMessage = [
`Traced Swift analysis is not supported on macOS ${SWIFT_TRACED_UNSUPPORTED_MACOS} or above.`,
`Configure your analysis to run on macOS ${SWIFT_TRACED_UNSUPPORTED_MACOS - 1} or below`,
`and XCode ${SWIFT_TRACED_UNSUPPORTED_XCODE - 1} or below.`
].join(" ");
addDiagnostic(
} else if (version.major >= SWIFT_TRACED_UNSUPPORTED_MACOS) {
handleUnsupportedVersion(
action.logger,
config,
"swift" /* swift */,
makeDiagnostic(
"codeql-action/unsupported-traced-swift-analysis-macos",
"Traced Swift analysis is not supported on this version of macOS",
{
attributes: {
languages: config.languages,
macOSVersion: version.toString()
},
markdownMessage: baseMessage,
severity: skipUnsupportedTracedAnalysis ? "error" : "warning",
visibility: {
cliSummaryTable: true,
statusPage: true,
telemetry: true
}
}
)
skipUnsupportedTracedAnalysis,
"macOS",
version
);
}
const xcodeVer = xcodeVersion(action.logger, action.fs);
if (xcodeVer !== void 0 && xcodeVer.major >= SWIFT_TRACED_UNSUPPORTED_XCODE) {
handleUnsupportedVersion(
action.logger,
config,
skipUnsupportedTracedAnalysis,
"Xcode",
xcodeVer
);
if (skipUnsupportedTracedAnalysis) {
throw new ConfigurationError(baseMessage);
} else {
action.logger.warning(baseMessage);
}
}
}

View File

@@ -10,6 +10,7 @@ import { Feature } from "../feature-flags";
import { FileSystem } from "../fs";
import {
checkExpectedLogMessages,
checkUnexpectedLogMessages,
createFeatures,
createTestConfig,
getTestEnv,
@@ -33,16 +34,41 @@ setupTests(test);
type RequiredFS = FileSystem<"statSync" | "readlinkSync">;
test("xcodeVersion returns undefined if symlink doesn't exist", (t) => {
const logger = new RecordingLogger();
/**
* Sets up a suitable mock `FileSystem` for use with `xcodeVersion`.
*
* @param statSyncResult The result of `statSync`.
* @param readlinkSyncResult The result of `readlinkSync`.
*
* @returns The mocked `FileSystem` and stubs.
*/
function mockFs(
statSyncResult: boolean | Error,
readlinkSyncResult: string = "",
) {
const stubbedFs: RequiredFS = {
statSync: fs.statSync,
readlinkSync: fs.readlinkSync,
};
const statSync = sinon
.stub(stubbedFs, "statSync")
.throws(new Error("ENOENT"));
const statSync = sinon.stub(stubbedFs, "statSync");
if (typeof statSyncResult === "boolean") {
statSync.returns({ isSymbolicLink: () => statSyncResult } as fs.Stats);
} else {
statSync.throws(new Error("ENOENT"));
}
const readlinkSync = sinon
.stub(stubbedFs, "readlinkSync")
.returns(readlinkSyncResult);
return { stubbedFs, statSync, readlinkSync };
}
test("xcodeVersion returns undefined if symlink doesn't exist", (t) => {
const logger = new RecordingLogger();
const { stubbedFs, statSync } = mockFs(new Error("ENOENT"));
t.is(xcodeVersion(logger, stubbedFs), undefined);
t.is(statSync.callCount, 1);
@@ -56,13 +82,7 @@ test("xcodeVersion returns undefined if symlink doesn't exist", (t) => {
test("xcodeVersion returns undefined if file is not a symlink", (t) => {
const logger = new RecordingLogger();
const stubbedFs: RequiredFS = {
statSync: fs.statSync,
readlinkSync: fs.readlinkSync,
};
const statSync = sinon
.stub(stubbedFs, "statSync")
.returns({ isSymbolicLink: () => false } as fs.Stats);
const { stubbedFs, statSync } = mockFs(false);
t.is(xcodeVersion(logger, stubbedFs), undefined);
t.is(statSync.callCount, 1);
@@ -76,14 +96,7 @@ test("xcodeVersion returns undefined if file is not a symlink", (t) => {
test("xcodeVersion returns undefined if resolving the symlink returns nothing", (t) => {
const logger = new RecordingLogger();
const stubbedFs: RequiredFS = {
statSync: fs.statSync,
readlinkSync: fs.readlinkSync,
};
const statSync = sinon
.stub(stubbedFs, "statSync")
.returns({ isSymbolicLink: () => true } as fs.Stats);
const readlinkSync = sinon.stub(stubbedFs, "readlinkSync").returns("");
const { stubbedFs, statSync, readlinkSync } = mockFs(true);
t.is(xcodeVersion(logger, stubbedFs), undefined);
t.is(statSync.callCount, 1);
@@ -99,16 +112,10 @@ test("xcodeVersion returns undefined if resolving the symlink returns nothing",
test("xcodeVersion returns undefined if resolved path doesn't include pattern", (t) => {
const logger = new RecordingLogger();
const stubbedFs: RequiredFS = {
statSync: fs.statSync,
readlinkSync: fs.readlinkSync,
};
const statSync = sinon
.stub(stubbedFs, "statSync")
.returns({ isSymbolicLink: () => true } as fs.Stats);
const readlinkSync = sinon
.stub(stubbedFs, "readlinkSync")
.returns("/Applications/Xcode.app/Contents/Developer");
const { stubbedFs, statSync, readlinkSync } = mockFs(
true,
"/Applications/Xcode.app/Contents/Developer",
);
t.is(xcodeVersion(logger, stubbedFs), undefined);
t.is(statSync.callCount, 1);
@@ -124,16 +131,10 @@ test("xcodeVersion returns undefined if resolved path doesn't include pattern",
test("xcodeVersion returns undefined if match can't be parsed", (t) => {
const logger = new RecordingLogger();
const stubbedFs: RequiredFS = {
statSync: fs.statSync,
readlinkSync: fs.readlinkSync,
};
const statSync = sinon
.stub(stubbedFs, "statSync")
.returns({ isSymbolicLink: () => true } as fs.Stats);
const readlinkSync = sinon
.stub(stubbedFs, "readlinkSync")
.returns("/Applications/Xcode_00.0.app/Contents/Developer");
const { stubbedFs, statSync, readlinkSync } = mockFs(
true,
"/Applications/Xcode_00.0.app/Contents/Developer",
);
t.is(xcodeVersion(logger, stubbedFs), undefined);
t.is(statSync.callCount, 1);
@@ -149,16 +150,10 @@ test("xcodeVersion returns undefined if match can't be parsed", (t) => {
test("xcodeVersion returns version from resolved path", (t) => {
const logger = new RecordingLogger();
const stubbedFs: RequiredFS = {
statSync: fs.statSync,
readlinkSync: fs.readlinkSync,
};
const statSync = sinon
.stub(stubbedFs, "statSync")
.returns({ isSymbolicLink: () => true } as fs.Stats);
const readlinkSync = sinon
.stub(stubbedFs, "readlinkSync")
.returns("/Applications/Xcode_16.4.app/Contents/Developer");
const { stubbedFs, statSync, readlinkSync } = mockFs(
true,
"/Applications/Xcode_16.4.app/Contents/Developer",
);
const result = xcodeVersion(logger, stubbedFs);
@@ -402,3 +397,193 @@ test.serial(
]);
}),
);
// `addDiagnostic` changes global state and we must stub it, so this test must be serial.
test.serial(
"isSwiftCompatible doesn't add a diagnostic if Xcode version is supported",
async (t) =>
withTmpDir(async (tmpDir) => {
const logger = new RecordingLogger();
const env = getTestEnv();
env.set(ActionsEnvVars.RUNNER_TEMP, tmpDir);
const { stubbedFs, statSync, readlinkSync } = mockFs(
true,
"/Applications/Xcode_26.0.app/Contents/Developer",
);
const codeql = await getCodeQLForTesting(
"codeql-for-testing",
logger,
env,
);
sinon.stub(codeql, "getVersion").resolves(makeVersionInfo("2.27.0"));
const addDiagnostic = sinon.stub(diagnostics, "addDiagnostic");
const config = createTestConfig({ languages: [BuiltInLanguage.swift] });
await isSwiftCompatible(
initAllState({
logger,
platform: "darwin",
osRelease: "26.0.0",
env,
fs: stubbedFs as FileSystem,
}),
config,
codeql,
);
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]);
checkUnexpectedLogMessages(t, logger.messages, [
"Traced Swift analysis is not supported on Xcode 27",
]);
t.is(addDiagnostic.callCount, 0);
}),
);
// `addDiagnostic` changes global state and we must stub it, so this test must be serial.
test.serial(
"isSwiftCompatible logs and adds diagnostic if Xcode version is unsupported",
async (t) =>
withTmpDir(async (tmpDir) => {
const logger = new RecordingLogger();
const env = getTestEnv();
env.set(ActionsEnvVars.RUNNER_TEMP, tmpDir);
const { stubbedFs, statSync, readlinkSync } = mockFs(
true,
"/Applications/Xcode_27.0.app/Contents/Developer",
);
const codeql = await getCodeQLForTesting(
"codeql-for-testing",
logger,
env,
);
sinon.stub(codeql, "getVersion").resolves(makeVersionInfo("2.27.0"));
const addDiagnostic = sinon.stub(diagnostics, "addDiagnostic");
const config = createTestConfig({ languages: [BuiltInLanguage.swift] });
await isSwiftCompatible(
initAllState({
logger,
platform: "darwin",
osRelease: "26.0.0",
env,
fs: stubbedFs as FileSystem,
}),
config,
codeql,
);
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, [
"Traced Swift analysis is not supported on Xcode 27",
]);
t.is(addDiagnostic.callCount, 1);
t.like(addDiagnostic.args[0], [
config,
BuiltInLanguage.swift,
{
attributes: {
languages: [BuiltInLanguage.swift],
xcodeVersion: "27.0.0",
},
severity: "warning",
source: {
id: "codeql-action/unsupported-traced-swift-analysis-xcode",
name: "Traced Swift analysis is not supported on this version of Xcode",
},
visibility: {
cliSummaryTable: true,
statusPage: true,
telemetry: true,
},
} satisfies Partial<diagnostics.DiagnosticMessage>,
]);
}),
);
// `addDiagnostic` changes global state and we must stub it, so this test must be serial.
test.serial(
"isSwiftCompatible throws if Xcode version is unsupported and FF is enabled",
async (t) =>
withTmpDir(async (tmpDir) => {
const logger = new RecordingLogger();
const env = getTestEnv();
env.set(ActionsEnvVars.RUNNER_TEMP, tmpDir);
const { stubbedFs, statSync, readlinkSync } = mockFs(
true,
"/Applications/Xcode_27.0.app/Contents/Developer",
);
const features = createFeatures([
Feature.SwiftSkipUnsupportedTracedAnalysis,
]);
const codeql = await getCodeQLForTesting(
"codeql-for-testing",
logger,
env,
);
sinon.stub(codeql, "getVersion").resolves(makeVersionInfo("2.27.0"));
const addDiagnostic = sinon.stub(diagnostics, "addDiagnostic");
const config = createTestConfig({ languages: [BuiltInLanguage.swift] });
await t.throwsAsync(
isSwiftCompatible(
initAllState({
logger,
platform: "darwin",
osRelease: "26.0.0",
env,
features,
fs: stubbedFs as FileSystem,
}),
config,
codeql,
),
);
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]);
t.is(addDiagnostic.callCount, 1);
t.like(addDiagnostic.args[0], [
config,
BuiltInLanguage.swift,
{
attributes: {
languages: [BuiltInLanguage.swift],
xcodeVersion: "27.0.0",
},
severity: "error",
source: {
id: "codeql-action/unsupported-traced-swift-analysis-xcode",
name: "Traced Swift analysis is not supported on this version of Xcode",
},
visibility: {
cliSummaryTable: true,
statusPage: true,
telemetry: true,
},
} satisfies Partial<diagnostics.DiagnosticMessage>,
]);
}),
);

View File

@@ -1,5 +1,3 @@
import * as nodefs from "fs";
import * as semver from "semver";
import { ActionState, Logger } from "../action-common";
@@ -36,7 +34,7 @@ export const SWIFT_TRACED_UNSUPPORTED_XCODE = 27;
*/
export function xcodeVersion(
logger: Logger,
fs: FileSystem<"statSync" | "readlinkSync"> = nodefs,
fs: FileSystem<"statSync" | "readlinkSync">,
): semver.SemVer | undefined {
try {
// Stat the expected symbolic link to check that it exists and is a symbolic link.
@@ -87,6 +85,66 @@ export function xcodeVersion(
}
}
/**
* Creates a diagnostic indicating that `version` of `product` is unsupported for traced Swift analysis.
* Depending on `skipUnsupportedTracedAnalysis`, this function then either throws a {@link ConfigurationError}
* or logs the problem as a warning.
*
* @param logger The logger to use.
* @param config The CodeQL Action configuration.
* @param skipUnsupportedTracedAnalysis Whether this is a fatal error.
* @param product The product that the version is unsupported of.
* @param version The unsupported version.
*/
function handleUnsupportedVersion(
logger: Logger,
config: Config,
skipUnsupportedTracedAnalysis: boolean,
product: "macOS" | "Xcode",
version: semver.SemVer,
) {
const baseMessage = [
`Traced Swift analysis is not supported on ${product} ${SWIFT_TRACED_UNSUPPORTED_MACOS} or above.`,
`Configure your analysis to run on macOS ${SWIFT_TRACED_UNSUPPORTED_MACOS - 1} or below`,
`and XCode ${SWIFT_TRACED_UNSUPPORTED_XCODE - 1} or below.`,
].join(" ");
const attributeName = product === "macOS" ? "macOSVersion" : "xcodeVersion";
// Create a diagnostic that will show up on the TSP.
addDiagnostic(
config,
BuiltInLanguage.swift,
makeDiagnostic(
`codeql-action/unsupported-traced-swift-analysis-${product.toLowerCase()}`,
`Traced Swift analysis is not supported on this version of ${product}`,
{
attributes: {
languages: config.languages,
[attributeName]: version.toString(),
},
markdownMessage: baseMessage,
severity: skipUnsupportedTracedAnalysis ? "error" : "warning",
visibility: {
cliSummaryTable: true,
statusPage: true,
telemetry: true,
},
},
),
);
// Throw an error to abort the analysis if the FF is enabled or log the message.
if (skipUnsupportedTracedAnalysis) {
// ConfigurationErrors are converted to the "aborted" status by the exception handler
// in `init-action.ts` that guards the call to `isSwiftCompatible`.
throw new ConfigurationError(baseMessage);
} else {
// This will also show up as a workflow annotation.
logger.warning(baseMessage);
}
}
/**
* Determines whether we can run a Swift analysis on the current runner.
*
@@ -122,55 +180,39 @@ export async function isSwiftCompatible(
`Swift analysis is only supported on macOS runner images. Please migrate to a macOS runner.`,
);
}
// If we got a string, we are on macOS but couldn't parse the version string.
if (typeof version === "string") {
action.logger.warning(
`Unable to determine version of macOS, got: ${version}`,
);
return;
}
const skipUnsupportedTracedAnalysis = await action.features.getValue(
Feature.SwiftSkipUnsupportedTracedAnalysis,
);
if (version.major >= SWIFT_TRACED_UNSUPPORTED_MACOS) {
const baseMessage = [
`Traced Swift analysis is not supported on macOS ${SWIFT_TRACED_UNSUPPORTED_MACOS} or above.`,
`Configure your analysis to run on macOS ${SWIFT_TRACED_UNSUPPORTED_MACOS - 1} or below`,
`and XCode ${SWIFT_TRACED_UNSUPPORTED_XCODE - 1} or below.`,
].join(" ");
// Create a diagnostic that will show up on the TSP.
addDiagnostic(
config,
BuiltInLanguage.swift,
makeDiagnostic(
"codeql-action/unsupported-traced-swift-analysis-macos",
"Traced Swift analysis is not supported on this version of macOS",
{
attributes: {
languages: config.languages,
macOSVersion: version.toString(),
},
markdownMessage: baseMessage,
severity: skipUnsupportedTracedAnalysis ? "error" : "warning",
visibility: {
cliSummaryTable: true,
statusPage: true,
telemetry: true,
},
},
),
if (typeof version === "string") {
// If we got a string, we are on macOS but couldn't parse the version string.
action.logger.warning(
`Unable to determine version of macOS, got: ${version}`,
);
} else if (version.major >= SWIFT_TRACED_UNSUPPORTED_MACOS) {
handleUnsupportedVersion(
action.logger,
config,
skipUnsupportedTracedAnalysis,
"macOS",
version,
);
}
// Throw an error to abort the analysis if the FF is enabled or log the message.
if (skipUnsupportedTracedAnalysis) {
// ConfigurationErrors are converted to the "aborted" status by the exception handler
// in `init-action.ts` that guards the call to `isSwiftCompatible`.
throw new ConfigurationError(baseMessage);
} else {
// This will also show up as a workflow annotation.
action.logger.warning(baseMessage);
}
// 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, action.fs);
if (
xcodeVer !== undefined &&
xcodeVer.major >= SWIFT_TRACED_UNSUPPORTED_XCODE
) {
handleUnsupportedVersion(
action.logger,
config,
skipUnsupportedTracedAnalysis,
"Xcode",
xcodeVer,
);
}
}