From 8baf1db3949412281d22936d930cb754977beac8 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 9 Oct 2026 18:11:41 +0100 Subject: [PATCH] Check if Xcode version is unsupported --- lib/entry-points.js | 126 +++++++++++----- src/languages/swift.test.ts | 287 +++++++++++++++++++++++++++++------- src/languages/swift.ts | 136 +++++++++++------ 3 files changed, 416 insertions(+), 133 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 0f8bb756d..ae68201b6 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -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( /(?Xcode_(?\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); - } } } diff --git a/src/languages/swift.test.ts b/src/languages/swift.test.ts index 12364da2d..93a3abc3f 100644 --- a/src/languages/swift.test.ts +++ b/src/languages/swift.test.ts @@ -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, + ]); + }), +); + +// `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, + ]); + }), +); diff --git a/src/languages/swift.ts b/src/languages/swift.ts index 3964bd700..e6341f6c0 100644 --- a/src/languages/swift.ts +++ b/src/languages/swift.ts @@ -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, + ); } }