Compare commits

...

14 Commits

Author SHA1 Message Date
Michael B. Gale
b6de93bcd7 Check if Xcode version is unsupported 2026-10-09 18:11:41 +01:00
Michael B. Gale
bf94034f3c Make FS available to isSwiftCompatible 2026-10-09 17:52:55 +01:00
Michael B. Gale
436656b463 Add FS state feature 2026-10-09 17:50:50 +01:00
Michael B. Gale
130da64af0 Add function to determine Xcode version based on symlink 2026-10-09 17:23:00 +01:00
Michael B. Gale
6745303b3d Add fs.ts to abstract over fs 2026-10-09 17:14:37 +01:00
Michael B. Gale
d09e9c0eb9 Add FF to fail if macOS version is unsupported 2026-10-09 16:01:15 +01:00
Michael B. Gale
ff832f1444 Check if macOS version is unsupported 2026-10-09 15:58:26 +01:00
Michael B. Gale
047473c122 Skip Swift checks if tools feature is enabled 2026-10-09 15:25:46 +01:00
Michael B. Gale
08bb1f303d Make getCodeQLForCmd use Env 2026-10-09 15:13:42 +01:00
Michael B. Gale
b904de6d18 Add swiftSupportsAllPlatforms to ToolsFeature 2026-10-09 14:41:26 +01:00
Michael B. Gale
807d4743d0 Use macOSVersion in isSwiftCompatible 2026-10-09 11:35:54 +01:00
Michael B. Gale
788a22022b Refactor swift check out of init-action.ts 2026-10-09 11:26:35 +01:00
Michael B. Gale
ed72893659 Add macOSVersion helper 2026-10-09 10:48:24 +01:00
Michael B. Gale
acf4e5b3ac Add osRelease to BaseState 2026-10-09 10:41:40 +01:00
14 changed files with 1668 additions and 587 deletions

1250
lib/entry-points.js generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,13 @@
import * as fs from "fs";
import * as os from "os";
import * as core from "@actions/core";
import { ActionsEnv, getActionsEnv } from "./actions-util";
import type { ApiClient } from "./api-client";
import { Env, ReadOnlyEnv } from "./environment";
import type { FeatureEnablement } from "./feature-flags";
import type { FileSystem } from "./fs";
import { getActionsLogger, Logger } from "./logging";
import {
ActionName,
@@ -13,6 +17,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. */
@@ -23,6 +29,8 @@ export interface BaseState {
platform: NodeJS.Platform;
/** The architecture of the host. */
arch: NodeJS.Architecture;
/** The version of the operating system. */
osRelease: string;
}
/** Describes different state features that an Action may have. */
@@ -51,6 +59,10 @@ export interface FeatureState {
/** Information about enabled feature flags. */
features: FeatureEnablement;
};
FS: {
/** The file system operations to use. */
fs: FileSystem;
};
}
/** Identifies a type of state an Action may have. */
@@ -74,7 +86,7 @@ export type ActionState<Fs extends readonly StateFeature[]> = FieldsOf<Fs>;
* Each Action can then augment the `state` further if additional features are required.
*/
export type ActionMain = (
state: ActionState<["Base", "Logger", "Env", "Actions"]>,
state: ActionState<["Base", "FS", "Logger", "Env", "Actions"]>,
) => Promise<void>;
/** A specification for a CodeQL Action step. */
@@ -104,6 +116,8 @@ export async function runInActions(action: Action) {
startedAt,
platform: process.platform,
arch: process.arch,
osRelease: os.release(),
fs,
logger,
env,
actions: actionsEnv,

View File

@@ -74,7 +74,7 @@ export const getOptionalInput = function (name: string): string | undefined {
* directory that has been set in `CODEQL_ACTION_TEMP` by e.g. a previous step, or the
* value of `RUNNER_TEMP` otherwise.
*/
export function getTemporaryDirectory(env: Env = getEnv()): string {
export function getTemporaryDirectory(env: ReadOnlyEnv = getEnv()): string {
return (
env.getOptional(EnvVar.TEMP) ?? env.getRequired(ActionsEnvVars.RUNNER_TEMP)
);

View File

@@ -2,7 +2,7 @@ import * as fs from "fs";
import path from "path";
import { getTemporaryDirectory } from "../actions-util";
import { Env } from "../environment";
import { ReadOnlyEnv } from "../environment";
import * as json from "../json";
import { Logger } from "../logging";
@@ -51,7 +51,7 @@ export function resetCachedCodeQlVersion(): void {
* Returns the path to the temporary file that backs the
* on-disk cache of CLI responses between workflow steps.
*/
export function getCommandCacheFilePath(env: Env): string {
export function getCommandCacheFilePath(env: ReadOnlyEnv): string {
return path.join(getTemporaryDirectory(env), COMMAND_CACHE_FILENAME);
}

View File

@@ -17,7 +17,7 @@ import type { VersionInfo } from "./cli/types";
import { CliError, wrapCliConfigurationError } from "./cli-errors";
import { appendExtraQueryExclusions, type Config } from "./config-utils";
import { DocUrl } from "./doc-url";
import { EnvVar, getEnv } from "./environment";
import { Env, EnvVar, getEnv, ReadOnlyEnv } from "./environment";
import {
CodeQLDefaultVersionInfo,
Feature,
@@ -493,8 +493,9 @@ export function createStubCodeQL(partialCodeql: Partial<CodeQL>): CodeQL {
export async function getCodeQLForTesting(
cmd = "codeql-for-testing",
logger: Logger = getRunnerLogger(true),
env: Env = getEnv(),
): Promise<CodeQL> {
return getCodeQLForCmd(logger, cmd, false);
return getCodeQLForCmd(logger, cmd, false, env);
}
/**
@@ -509,13 +510,14 @@ async function getCodeQLForCmd(
logger: Logger,
cmd: string,
checkVersion: boolean,
env: Env = getEnv(),
): Promise<CodeQL> {
const codeql: CodeQL = {
getPath() {
return cmd;
},
async getVersion() {
const cacheFilePath = outputCache.getCommandCacheFilePath(getEnv());
const cacheFilePath = outputCache.getCommandCacheFilePath(env);
let result = outputCache.getCachedCodeQlVersion(
logger,
cacheFilePath,
@@ -641,7 +643,7 @@ async function getCodeQLForCmd(
}
},
async runAutobuild(config: Config, language: Language) {
applyAutobuildAzurePipelinesTimeoutFix();
applyAutobuildAzurePipelinesTimeoutFix(env);
const autobuildCmd = path.join(
await this.resolveExtractor(language),
@@ -651,8 +653,11 @@ async function getCodeQLForCmd(
// Bump the verbosity of the autobuild command if we're in debug mode
if (config.debugMode) {
process.env[EnvVar.CLI_VERBOSITY] =
process.env[EnvVar.CLI_VERBOSITY] || EXTRACTION_DEBUG_MODE_VERBOSITY;
env.set(
EnvVar.CLI_VERBOSITY,
env.getOptional(EnvVar.CLI_VERBOSITY) ??
EXTRACTION_DEBUG_MODE_VERBOSITY,
);
}
// On macOS, System Integrity Protection (SIP) typically interferes with
@@ -684,7 +689,7 @@ async function getCodeQLForCmd(
},
async extractUsingBuildMode(config: Config, language: Language) {
if (config.buildMode === BuildMode.Autobuild) {
applyAutobuildAzurePipelinesTimeoutFix();
applyAutobuildAzurePipelinesTimeoutFix(env);
}
try {
await runCli(cmd, [
@@ -816,7 +821,7 @@ async function getCodeQLForCmd(
"--sarif-group-rules-by-pack",
"--sarif-include-query-help=always",
"--sublanguage-file-coverage",
...(await getJobRunUuidSarifOptions()),
...(await getJobRunUuidSarifOptions(env)),
...getExtraOptionsFromEnv(["database", "interpret-results"]),
];
if (sarifRunPropertyFlag !== undefined) {
@@ -1036,7 +1041,7 @@ async function getCodeQLForCmd(
);
} else if (
checkVersion &&
process.env[EnvVar.SUPPRESS_DEPRECATED_SOON_WARNING] !== "true" &&
env.getOptional(EnvVar.SUPPRESS_DEPRECATED_SOON_WARNING) !== "true" &&
!(await util.codeQlVersionAtLeast(codeql, CODEQL_NEXT_MINIMUM_VERSION))
) {
const result = await codeql.getVersion();
@@ -1256,17 +1261,20 @@ function getExtractionVerbosityArguments(
* Without the fix, long build processes will timeout when pulling down Java packages
* https://developercommunity.visualstudio.com/content/problem/292284/maven-hosted-agent-connection-timeout.html
*/
function applyAutobuildAzurePipelinesTimeoutFix() {
const javaToolOptions = process.env["JAVA_TOOL_OPTIONS"] || "";
process.env["JAVA_TOOL_OPTIONS"] = [
...javaToolOptions.split(/\s+/),
"-Dhttp.keepAlive=false",
"-Dmaven.wagon.http.pool=false",
].join(" ");
function applyAutobuildAzurePipelinesTimeoutFix(env: Env) {
const javaToolOptions = env.getOptional("JAVA_TOOL_OPTIONS") ?? "";
env.set(
"JAVA_TOOL_OPTIONS",
[
...javaToolOptions.split(/\s+/),
"-Dhttp.keepAlive=false",
"-Dmaven.wagon.http.pool=false",
].join(" "),
);
}
async function getJobRunUuidSarifOptions() {
const jobRunUuid = process.env[EnvVar.JOB_RUN_UUID];
async function getJobRunUuidSarifOptions(env: ReadOnlyEnv) {
const jobRunUuid = env.getOptional(EnvVar.JOB_RUN_UUID);
return jobRunUuid ? [`--sarif-run-property=jobRunUuid=${jobRunUuid}`] : [];
}

View File

@@ -170,6 +170,8 @@ export enum Feature {
*/
PerLanguageBundles = "per_language_bundles_v2",
QaTelemetryEnabled = "qa_telemetry_enabled",
/** Whether we should fail early if we detect that traced Swift analysis is unsupported. */
SwiftSkipUnsupportedTracedAnalysis = "swift_skip_unsupported_traced_analysis",
/** Routes (some) API requests through the registry proxy. */
ProxyApiRequests = "proxy_api_requests",
/** Note that this currently only disables baseline file coverage information. */
@@ -466,6 +468,11 @@ export const featureConfig = {
envVar: "CODEQL_ACTION_START_PROXY_USE_FEATURES_RELEASE",
minimumVersion: undefined,
},
[Feature.SwiftSkipUnsupportedTracedAnalysis]: {
defaultValue: false,
envVar: "CODEQL_ACTION_SWIFT_SKIP_UNSUPPORTED_TRACED_ANALYSIS",
minimumVersion: undefined,
},
[Feature.ToolsRepositoryProperty]: {
defaultValue: false,
envVar: "CODEQL_ACTION_TOOLS_REPOSITORY_PROPERTY",

35
src/fs.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* This module exports a `FileSystem` type which corresponds to the interface of the "fs" module.
*
* Functions which are parameterised over this type can then be passed a different implementation in tests:
*
* ```typescript
* import * as nodefs from "fs";
*
* function foo(fs: FileSystem = nodefs) {
* // Uses the real "fs" module by default, but can be given a different implementation.
* }
* ```
*
* The type can also be constrained to a subset of available operations. For example, in the following
* case we have a function that only needs `statSync`:
*
* ```
* function bar(fs: FileSystem<"statSync"> = nodefs) {
* // This function can only use `statSync`.
* }
* ```
*
* This is useful to define a clearer interface for what the function does and also only requires stubbing
* of the relevant functions.
*/
import * as fs from "fs";
/** Represents the names of operations exported from "fs". */
export type FileOperation = keyof typeof fs;
/** Represents the type of "fs", optionally filtered down to just `Ops`. */
export type FileSystem<Ops extends FileOperation = keyof typeof fs> = {
[Key in Ops]: (typeof fs)[Key];
};

View File

@@ -56,6 +56,7 @@ import {
runDatabaseInitCluster,
} from "./init";
import { JavaEnvVars, BuiltInLanguage } from "./languages";
import { isSwiftCompatible } from "./languages/swift";
import { Logger, withGroupAsync } from "./logging";
import {
downloadOverlayBaseDatabaseFromCache,
@@ -200,7 +201,7 @@ async function sendCompletedStatusReport(
}
async function run(
actionState: ActionState<["Base", "Logger", "Env", "Actions"]>,
actionState: ActionState<["Base", "Logger", "Env", "Actions", "FS"]>,
) {
// To capture errors appropriately, keep as much code within the try-catch as
// possible, and only use safe functions outside.
@@ -418,14 +419,7 @@ async function run(
logger,
});
if (
config.languages.includes(BuiltInLanguage.swift) &&
process.platform !== "darwin"
) {
throw new ConfigurationError(
`Swift analysis is only supported on macOS runner images. Please migrate to a macOS runner.`,
);
}
await isSwiftCompatible(actionStateWithFeatures, config, codeql);
if (repositoryPropertiesResult.isFailure()) {
addNoLanguageDiagnostic(

589
src/languages/swift.test.ts Normal file
View File

@@ -0,0 +1,589 @@
import * as fs from "fs";
import test from "ava";
import * as sinon from "sinon";
import { getCodeQLForTesting } from "../codeql";
import * as diagnostics from "../diagnostics";
import { ActionsEnvVars } from "../environment";
import { Feature } from "../feature-flags";
import { FileSystem } from "../fs";
import {
checkExpectedLogMessages,
checkUnexpectedLogMessages,
createFeatures,
createTestConfig,
getTestEnv,
initAllState,
makeVersionInfo,
RecordingLogger,
setupTests,
} from "../testing-utils";
import { ToolsFeature } from "../tools-features";
import { withTmpDir } from "../util";
import {
isSwiftCompatible,
XCODE_SELECT_LINK_PATH,
xcodeVersion,
} from "./swift";
import { BuiltInLanguage } from ".";
setupTests(test);
type RequiredFS = FileSystem<"statSync" | "readlinkSync">;
/**
* 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");
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);
t.deepEqual(statSync.args[0], [XCODE_SELECT_LINK_PATH]);
checkExpectedLogMessages(t, logger.messages, [
"Unable to determine Xcode version: ENOENT",
]);
});
test("xcodeVersion returns undefined if file is not a symlink", (t) => {
const logger = new RecordingLogger();
const { stubbedFs, statSync } = mockFs(false);
t.is(xcodeVersion(logger, stubbedFs), 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("xcodeVersion returns undefined if resolving the symlink returns nothing", (t) => {
const logger = new RecordingLogger();
const { stubbedFs, statSync, readlinkSync } = mockFs(true);
t.is(xcodeVersion(logger, stubbedFs), 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("xcodeVersion returns undefined if resolved path doesn't include pattern", (t) => {
const logger = new RecordingLogger();
const { stubbedFs, statSync, readlinkSync } = mockFs(
true,
"/Applications/Xcode.app/Contents/Developer",
);
t.is(xcodeVersion(logger, stubbedFs), 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("xcodeVersion returns undefined if match can't be parsed", (t) => {
const logger = new RecordingLogger();
const { stubbedFs, statSync, readlinkSync } = mockFs(
true,
"/Applications/Xcode_00.0.app/Contents/Developer",
);
t.is(xcodeVersion(logger, stubbedFs), 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("xcodeVersion returns version from resolved path", (t) => {
const logger = new RecordingLogger();
const { stubbedFs, statSync, readlinkSync } = mockFs(
true,
"/Applications/Xcode_16.4.app/Contents/Developer",
);
const result = xcodeVersion(logger, stubbedFs);
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) {
continue;
}
const codeql = await getCodeQLForTesting();
await t.notThrowsAsync(
isSwiftCompatible(
initAllState(),
createTestConfig({ languages: [language] }),
codeql,
),
);
}
});
test("isSwiftCompatible doesn't throw for Swift if CLI supports swiftSupportsAllPlatforms", async (t) =>
withTmpDir(async (tmpDir) => {
const logger = new RecordingLogger();
const env = getTestEnv();
env.set(ActionsEnvVars.RUNNER_TEMP, tmpDir);
const codeql = await getCodeQLForTesting("codeql-for-testing", logger, env);
const supportsFeature = sinon
.stub(codeql, "supportsFeature")
.withArgs(ToolsFeature.SwiftSupportsAllPlatforms)
.resolves(true);
await t.notThrowsAsync(
isSwiftCompatible(
initAllState({ platform: "darwin", env, logger }),
createTestConfig({ languages: [BuiltInLanguage.swift] }),
codeql,
),
);
t.is(supportsFeature.callCount, 1);
t.deepEqual(supportsFeature.args[0], [
ToolsFeature.SwiftSupportsAllPlatforms,
]);
}));
test("isSwiftCompatible doesn't throw for Swift on darwin", async (t) =>
withTmpDir(async (tmpDir) => {
const logger = new RecordingLogger();
const env = getTestEnv();
env.set(ActionsEnvVars.RUNNER_TEMP, tmpDir);
const codeql = await getCodeQLForTesting("codeql-for-testing", logger, env);
sinon.stub(codeql, "getVersion").resolves(makeVersionInfo("2.27.0"));
await t.notThrowsAsync(
isSwiftCompatible(
initAllState({ platform: "darwin", env, logger }),
createTestConfig({ languages: [BuiltInLanguage.swift] }),
codeql,
),
);
}));
const nonDarwinPlatforms: NodeJS.Platform[] = ["linux", "win32"];
for (const nonDarwinPlatform of nonDarwinPlatforms) {
test(`isSwiftCompatible throws for Swift on ${nonDarwinPlatform}`, async (t) =>
withTmpDir(async (tmpDir) => {
const logger = new RecordingLogger();
const env = getTestEnv();
env.set(ActionsEnvVars.RUNNER_TEMP, tmpDir);
const codeql = await getCodeQLForTesting(
"codeql-for-testing",
logger,
env,
);
sinon.stub(codeql, "getVersion").resolves(makeVersionInfo("2.27.0"));
await t.throwsAsync(
isSwiftCompatible(
initAllState({ platform: nonDarwinPlatform, env, logger }),
createTestConfig({ languages: [BuiltInLanguage.swift] }),
codeql,
),
);
}));
}
test("isSwiftCompatible warns if version string is not a semver", async (t) =>
withTmpDir(async (tmpDir) => {
const logger = new RecordingLogger();
const env = getTestEnv();
env.set(ActionsEnvVars.RUNNER_TEMP, tmpDir);
const codeql = await getCodeQLForTesting("codeql-for-testing", logger, env);
sinon.stub(codeql, "getVersion").resolves(makeVersionInfo("2.27.0"));
await isSwiftCompatible(
initAllState({
logger,
platform: "darwin",
osRelease: "unexpected",
env,
}),
createTestConfig({ languages: [BuiltInLanguage.swift] }),
codeql,
);
checkExpectedLogMessages(t, logger.messages, [
"Unable to determine version of macOS, got: unexpected",
]);
}));
// `addDiagnostic` changes global state and we must stub it, so this test must be serial.
test.serial(
"isSwiftCompatible logs and adds diagnostic if macOS version is unsupported",
async (t) =>
withTmpDir(async (tmpDir) => {
const logger = new RecordingLogger();
const env = getTestEnv();
env.set(ActionsEnvVars.RUNNER_TEMP, tmpDir);
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: "27.0.0",
env,
}),
config,
codeql,
);
checkExpectedLogMessages(t, logger.messages, [
"Traced Swift analysis is not supported on macOS 27",
]);
t.is(addDiagnostic.callCount, 1);
t.like(addDiagnostic.args[0], [
config,
BuiltInLanguage.swift,
{
attributes: {
languages: [BuiltInLanguage.swift],
macOSVersion: "27.0.0",
},
severity: "warning",
source: {
id: "codeql-action/unsupported-traced-swift-analysis-macos",
name: "Traced Swift analysis is not supported on this version of macOS",
},
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 macOS 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 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: "27.0.0",
env,
features,
}),
config,
codeql,
),
);
t.is(addDiagnostic.callCount, 1);
t.like(addDiagnostic.args[0], [
config,
BuiltInLanguage.swift,
{
attributes: {
languages: [BuiltInLanguage.swift],
macOSVersion: "27.0.0",
},
severity: "error",
source: {
id: "codeql-action/unsupported-traced-swift-analysis-macos",
name: "Traced Swift analysis is not supported on this version of macOS",
},
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 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>,
]);
}),
);

218
src/languages/swift.ts Normal file
View File

@@ -0,0 +1,218 @@
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 { FileSystem } from "../fs";
import { macOSVersion } from "../platform";
import { ToolsFeature } from "../tools-features";
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,
fs: FileSystem<"statSync" | "readlinkSync">,
): 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;
}
}
/**
* 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.
*
* @param action The Action state.
* @param config The Action configuration.
*
* @throws {ConfigurationError} If Swift analysis is not possible on the current runner.
* @returns True if we can run a Swift analysis.
*/
export async function isSwiftCompatible(
action: ActionState<["Base", "Logger", "FeatureFlags", "FS"]>,
config: Config,
codeql: CodeQL,
) {
// The checks are not relevant if we are not trying to analyse Swift.
if (!config.languages.includes(BuiltInLanguage.swift)) {
return;
}
// Skip the checks if the `swiftSupportsAllPlatforms` feature is supported by the CLI.
// This is a forward-looking measure that allows a future CLI update to disable these
// platform checks in the Action when they shouldn't be enforced anymore.
if (await codeql.supportsFeature(ToolsFeature.SwiftSupportsAllPlatforms)) {
return;
}
// Try to get the macOS version.
const version = macOSVersion(action);
// If `version` is undefined, then we are not on macOS.
if (version === undefined) {
throw new ConfigurationError(
`Swift analysis is only supported on macOS runner images. Please migrate to a macOS runner.`,
);
}
const skipUnsupportedTracedAnalysis = await action.features.getValue(
Feature.SwiftSkipUnsupportedTracedAnalysis,
);
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,
);
}
// 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,
);
}
}

View File

@@ -1,6 +1,7 @@
import test from "ava";
import { BundlePlatform, getBundlePlatform } from "./platform";
import { BundlePlatform, getBundlePlatform, macOSVersion } from "./platform";
import { initAllState } from "./testing-utils";
for (const [platform, arch, expected] of [
["linux", "x64", BundlePlatform.Linux64],
@@ -16,3 +17,40 @@ for (const [platform, arch, expected] of [
t.is(getBundlePlatform(platform, arch), expected);
});
}
const platforms: NodeJS.Platform[] = ["linux", "win32", "freebsd"];
for (const platform of platforms) {
test(`macOSVersion returns undefined on ${platform}`, (t) => {
t.is(macOSVersion(initAllState({ platform })), undefined);
});
}
test("macOSVersion returns raw string if semver parsing fails", (t) => {
const invalidSemVer = "sealOS-2026";
t.is(
macOSVersion(
initAllState({ platform: "darwin", osRelease: invalidSemVer }),
),
invalidSemVer,
);
});
test("macOSVersion returns semver if parsing succeeds", (t) => {
const validSemVer = "27.0.1";
const version = macOSVersion(
initAllState({ platform: "darwin", osRelease: validSemVer }),
);
// Check that `version` is not undefined and narrow the type; throws if undefined.
if (t.truthy(version)) {
// Check that it's also not just a string.
t.not(typeof version, "string");
// Should be an object with the expected properties.
t.is(typeof version, "object");
t.is(version["major"], 27);
t.is(version["minor"], 0);
t.is(version["patch"], 1);
}
});

View File

@@ -1,3 +1,7 @@
import * as semver from "semver";
import type { ActionState } from "./action-common";
/** Platform identifiers used in CodeQL bundle asset names. */
export enum BundlePlatform {
Linux64 = "linux64",
@@ -24,3 +28,28 @@ export function getBundlePlatform(
return undefined;
}
}
/**
* Tries to determine the version of macOS.
*
* @returns
* The version as either a semantic version object, the raw version string
* if it is not a semantic version, or `undefined` if we are not on macOS.
*/
export function macOSVersion(
action: ActionState<["Base"]>,
): semver.SemVer | string | undefined {
// Skip if we are not running on macOS.
if (action.platform !== "darwin") {
return undefined;
}
// Try to parse the OS version string.
const version = semver.parse(action.osRelease);
if (version === null) {
return action.osRelease;
}
return version;
}

View File

@@ -1,4 +1,6 @@
import * as fs from "fs";
import { TextDecoder } from "node:util";
import * as os from "os";
import path from "path";
import * as github from "@actions/github";
@@ -34,6 +36,7 @@ import { Logger } from "./logging";
import { OverlayDatabaseMode } from "./overlay/overlay-database-mode";
import { getBundlePlatform } from "./platform";
import { ActionName } from "./status-report";
import { ToolsFeature } from "./tools-features";
import {
DEFAULT_DEBUG_ARTIFACT_NAME,
DEFAULT_DEBUG_DATABASE_NAME,
@@ -224,6 +227,7 @@ type AllState = [
"Actions",
"Api",
"FeatureFlags",
"FS",
];
/** Initialise a fresh `ActionState<AllState>` value. */
@@ -236,11 +240,13 @@ export function initAllState(
startedAt: new Date(),
platform: process.platform,
arch: process.arch,
osRelease: os.release(),
logger: new RecordingLogger(),
env,
actions: getTestActionsEnv(env),
apiClient: github.getOctokit("123"),
features: createFeatures([]),
fs,
...overrides,
};
}
@@ -874,7 +880,7 @@ export function mockLanguagesInRepo(languages: string[]) {
*/
export const makeVersionInfo = (
version: string,
features?: { [name: string]: boolean },
features?: { [key in ToolsFeature]?: boolean },
overlayVersion?: number,
): VersionInfo => ({
version,

View File

@@ -8,6 +8,7 @@ export enum ToolsFeature {
BundleSupportsOverlay = "bundleSupportsOverlay",
IndirectTracingSupportsStaticBinaries = "indirectTracingSupportsStaticBinaries",
SuppressesMissingFileBaselineWarning = "suppressesMissingFileBaselineWarning",
SwiftSupportsAllPlatforms = "swiftSupportsAllPlatforms",
}
/**