Files
codeql-action/src/util.ts
Michael B. Gale f4a15a13fd Try to load failed config if normal config is unavailable
Adapt relevant functions by narrowing their types or skipping steps that would otherwise have been skipped as well
2026-10-12 13:07:48 +01:00

1199 lines
37 KiB
TypeScript

import * as fs from "fs";
import * as fsPromises from "fs/promises";
import * as os from "os";
import * as path from "path";
import { performance } from "perf_hooks";
import * as core from "@actions/core";
import * as io from "@actions/io";
import getFolderSize from "get-folder-size";
import * as yaml from "js-yaml";
import * as semver from "semver";
import * as apiCompatibility from "./api-compatibility.json";
import type { CodeQL } from "./codeql";
import type { ActionConfig } from "./config/action-config";
import type { Pack } from "./config/db-config";
import type { Config } from "./config-utils";
import {
EnvVar,
getRequiredEnvParam,
isInTestMode,
exportEnvVar,
} from "./environment";
import * as json from "./json";
import { Language } from "./languages";
import { Logger } from "./logging";
// Re-export for backwards compatibility to avoid updating a lot of imports elsewhere.
export {
getRequiredEnvParam,
getOptionalEnvVar,
getEnv,
isInTestMode,
} from "./environment";
/**
* The name of the file containing the base database OIDs, as stored in the
* root of the database location.
*/
const BASE_DATABASE_OIDS_FILE_NAME = "base-database-oids.json";
/**
* Specifies bundle versions that are known to be broken
* and will not be used if found in the toolcache.
*/
const BROKEN_VERSIONS = ["0.0.0-20211207"];
/**
* The URL for github.com.
*/
export const GITHUB_DOTCOM_URL = "https://github.com";
/**
* Default name of the debugging artifact.
*/
export const DEFAULT_DEBUG_ARTIFACT_NAME = "debug-artifacts";
/**
* Default name of the database in the debugging artifact.
*/
export const DEFAULT_DEBUG_DATABASE_NAME = "db";
/**
* The default fraction of the total RAM above 8 GB that should be reserved for the system.
*/
const DEFAULT_RESERVED_RAM_SCALING_FACTOR = 0.05;
/**
* The minimum amount of memory imposed by a cgroup limit that we will consider. Memory limits below
* this amount are ignored.
*/
const MINIMUM_CGROUP_MEMORY_LIMIT_BYTES = 1024 * 1024;
/**
* Get the extra options for the codeql commands.
*/
export function getExtraOptionsEnvParam(): object {
const varName = "CODEQL_ACTION_EXTRA_OPTIONS";
const raw = process.env[varName];
if (raw === undefined || raw.length === 0) {
return {};
}
try {
return yaml.load(raw) as object;
} catch (unwrappedError) {
const error = wrapError(unwrappedError);
throw new ConfigurationError(
`${varName} environment variable is set, but does not contain valid JSON: ${error.message}`,
);
}
}
// Creates a random temporary directory, runs the given body, and then deletes the directory.
// Mostly intended for use within tests.
export async function withTmpDir<T>(
body: (tmpDir: string) => Promise<T>,
): Promise<T> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codeql-action-"));
try {
return await body(tmpDir);
} finally {
await fs.promises.rm(tmpDir, { force: true, recursive: true });
}
}
/**
* Creates a temporary file with the given contents, runs the given body, and
* then deletes the file. Note that, to create a temporary file, we first create
* a temporary directory via {@link withTmpDir} and then create the file within
* that directory.
* @param baseFileName The name to assign the temporary file.
* @param contents The contents to write to the temporary file.
* @param body The function to execute with the temporary file.
*/
export async function withTmpFile<T>(
baseFileName: string,
contents: string,
body: (filePath: string) => Promise<T> | T,
): Promise<T> {
return withTmpDir(async (tmpDir) => {
const filePath = path.join(tmpDir, baseFileName);
fs.writeFileSync(filePath, contents);
return body(filePath);
});
}
/**
* Gets an OS-specific amount of memory (in MB) to reserve for OS processes
* when the user doesn't explicitly specify a memory setting.
* This is a heuristic to avoid OOM errors (exit code 137 / SIGKILL)
* from committing too much of the available memory to CodeQL.
* @returns number
*/
function getSystemReservedMemoryMegaBytes(
totalMemoryMegaBytes: number,
platform: string,
): number {
// Windows needs more memory for OS processes.
const fixedAmount = 1024 * (platform === "win32" ? 1.5 : 1);
// Reserve an additional percentage of the amount of memory above 8 GB, since the amount used by
// the kernel for page tables scales with the size of physical memory.
const scaledAmount =
getReservedRamScaleFactor() * Math.max(totalMemoryMegaBytes - 8 * 1024, 0);
return fixedAmount + scaledAmount;
}
function getReservedRamScaleFactor(): number {
const envVar = Number.parseInt(
process.env[EnvVar.SCALING_RESERVED_RAM_PERCENTAGE] || "",
10,
);
if (envVar < 0 || envVar > 100 || Number.isNaN(envVar)) {
return DEFAULT_RESERVED_RAM_SCALING_FACTOR;
}
return envVar / 100;
}
/**
* Get the value of the codeql `--ram` flag as configured by the `ram` input.
* If no value was specified, the total available memory will be used minus a
* threshold reserved for the OS.
*
* @returns {number} the amount of RAM to use, in megabytes
*/
export function getMemoryFlagValueForPlatform(
userInput: string | undefined,
totalMemoryBytes: number,
platform: string,
): number {
let memoryToUseMegaBytes: number;
if (userInput) {
memoryToUseMegaBytes = Number(userInput);
if (Number.isNaN(memoryToUseMegaBytes) || memoryToUseMegaBytes <= 0) {
throw new ConfigurationError(
`Invalid RAM setting "${userInput}", specified.`,
);
}
} else {
const totalMemoryMegaBytes = totalMemoryBytes / (1024 * 1024);
const reservedMemoryMegaBytes = getSystemReservedMemoryMegaBytes(
totalMemoryMegaBytes,
platform,
);
memoryToUseMegaBytes = totalMemoryMegaBytes - reservedMemoryMegaBytes;
}
return Math.floor(memoryToUseMegaBytes);
}
/**
* Get the total amount of memory available to the Action, taking into account constraints imposed
* by cgroups on Linux.
*/
function getTotalMemoryBytes(logger: Logger): number {
const limits = [os.totalmem()];
if (os.platform() === "linux") {
limits.push(
...[
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
"/sys/fs/cgroup/memory.max",
]
.map((file) => getCgroupMemoryLimitBytes(file, logger))
.filter((limit) => limit !== undefined)
.map((limit) => limit),
);
}
const limit = Math.min(...limits);
logger.debug(
`While resolving RAM, determined that the total memory available to the Action is ${
limit / (1024 * 1024)
} MiB.`,
);
return limit;
}
/**
* Gets the number of bytes of available memory specified by the cgroup limit file at the given path.
*
* May be greater than the total memory reported by the operating system if there is no cgroup limit.
*/
function getCgroupMemoryLimitBytes(
limitFile: string,
logger: Logger,
): number | undefined {
if (!fs.existsSync(limitFile)) {
logger.debug(
`While resolving RAM, did not find a cgroup memory limit at ${limitFile}.`,
);
return undefined;
}
const limit = Number(fs.readFileSync(limitFile, "utf8"));
if (!Number.isInteger(limit)) {
logger.debug(
`While resolving RAM, ignored the file ${limitFile} that may contain a cgroup memory limit ` +
"as this file did not contain an integer.",
);
return undefined;
}
const displayLimit = `${Math.floor(limit / (1024 * 1024))} MiB`;
if (limit > os.totalmem()) {
logger.debug(
`While resolving RAM, ignored the file ${limitFile} that may contain a cgroup memory limit as ` +
`its contents ${displayLimit} were greater than the total amount of system memory.`,
);
return undefined;
}
if (limit < MINIMUM_CGROUP_MEMORY_LIMIT_BYTES) {
logger.info(
`While resolving RAM, ignored a cgroup limit of ${displayLimit} in ${limitFile} as it was below ${
MINIMUM_CGROUP_MEMORY_LIMIT_BYTES / (1024 * 1024)
} MiB.`,
);
return undefined;
}
logger.info(
`While resolving RAM, found a cgroup limit of ${displayLimit} in ${limitFile}.`,
);
return limit;
}
/**
* Get the maximum amount of memory CodeQL is allowed to use. If no limit has been
* configured by the user, then the total available memory will be used minus a
* threshold reserved for the OS.
*
* @returns {number} the amount of RAM CodeQL is allowed to use, in megabytes
*/
export function getCodeQLMemoryLimit(
userInput: string | undefined,
logger: Logger,
): number {
return getMemoryFlagValueForPlatform(
userInput,
getTotalMemoryBytes(logger),
process.platform,
);
}
/**
* Get the codeql `--ram` flag as configured by the `ram` input. If no value was
* specified, the total available memory will be used minus a threshold
* reserved for the OS.
*
* @returns string
*/
export function getMemoryFlag(
userInput: string | undefined,
logger: Logger,
): string {
const megabytes = getCodeQLMemoryLimit(userInput, logger);
return `--ram=${megabytes}`;
}
/**
* Get the value of the codeql `--threads` flag specified for the `threads`
* input. If no value was specified, all available threads will be used.
*
* The value will be capped to the number of available CPUs.
*
* @returns {number}
*/
export function getThreadsFlagValue(
userInput: string | undefined,
logger: Logger,
): number {
let numThreads: number;
const maxThreadsCandidates = [os.cpus().length];
if (os.platform() === "linux") {
maxThreadsCandidates.push(
...["/sys/fs/cgroup/cpuset.cpus.effective", "/sys/fs/cgroup/cpuset.cpus"]
.map((file) => getCgroupCpuCountFromCpus(file, logger))
.filter((count) => count !== undefined && count > 0)
.map((count) => count as number),
);
maxThreadsCandidates.push(
...["/sys/fs/cgroup/cpu.max"]
.map((file) => getCgroupCpuCountFromCpuMax(file, logger))
.filter((count) => count !== undefined && count > 0)
.map((count) => count as number),
);
}
const maxThreads = Math.min(...maxThreadsCandidates);
if (userInput) {
numThreads = Number(userInput);
if (Number.isNaN(numThreads)) {
throw new ConfigurationError(
`Invalid threads setting "${userInput}", specified.`,
);
}
if (numThreads > maxThreads) {
logger.info(
`Clamping desired number of threads (${numThreads}) to max available (${maxThreads}).`,
);
numThreads = maxThreads;
}
const minThreads = -maxThreads;
if (numThreads < minThreads) {
logger.info(
`Clamping desired number of free threads (${numThreads}) to max available (${minThreads}).`,
);
numThreads = minThreads;
}
} else {
// Default to using all threads
numThreads = maxThreads;
}
return numThreads;
}
/**
* Gets the number of available cores specified by the cgroup cpu.max file at the given path.
* Format of file: two values, the limit and the duration (period). If the limit is "max" then
* we return undefined and do not use this file to determine CPU limits.
*/
function getCgroupCpuCountFromCpuMax(
cpuMaxFile: string,
logger: Logger,
): number | undefined {
if (!fs.existsSync(cpuMaxFile)) {
logger.debug(
`While resolving threads, did not find a cgroup CPU file at ${cpuMaxFile}.`,
);
return undefined;
}
const cpuMaxString = fs.readFileSync(cpuMaxFile, "utf-8");
const cpuMaxStringSplit = cpuMaxString.split(" ");
if (cpuMaxStringSplit.length !== 2) {
logger.debug(
`While resolving threads, did not use cgroup CPU file at ${cpuMaxFile} because it contained ${cpuMaxStringSplit.length} value(s) rather than the two expected.`,
);
return undefined;
}
const cpuLimit = cpuMaxStringSplit[0];
if (cpuLimit === "max") {
return undefined;
}
const duration = cpuMaxStringSplit[1];
const cpuCount = Math.floor(parseInt(cpuLimit) / parseInt(duration));
logger.info(
`While resolving threads, found a cgroup CPU file with ${cpuCount} CPUs in ${cpuMaxFile}.`,
);
return cpuCount;
}
/**
* Gets the number of available cores listed in the cgroup cpuset.cpus file at the given path.
*/
export function getCgroupCpuCountFromCpus(
cpusFile: string,
logger: Logger,
): number | undefined {
if (!fs.existsSync(cpusFile)) {
logger.debug(
`While resolving threads, did not find a cgroup CPUs file at ${cpusFile}.`,
);
return undefined;
}
let cpuCount = 0;
// Comma-separated numbers and ranges, for eg. 0-1,3
const cpusString = fs.readFileSync(cpusFile, "utf-8").trim();
if (cpusString.length === 0) {
return undefined;
}
for (const token of cpusString.split(",")) {
if (!token.includes("-")) {
// Not a range
++cpuCount;
} else {
const cpuStartIndex = parseInt(token.split("-")[0]);
const cpuEndIndex = parseInt(token.split("-")[1]);
cpuCount += cpuEndIndex - cpuStartIndex + 1;
}
}
logger.info(
`While resolving threads, found a cgroup CPUs file with ${cpuCount} CPUs in ${cpusFile}.`,
);
return cpuCount;
}
/**
* Get the codeql `--threads` flag specified for the `threads` input.
* If no value was specified, all available threads will be used.
*
* The value will be capped to the number of available CPUs.
*
* @returns string
*/
export function getThreadsFlag(
userInput: string | undefined,
logger: Logger,
): string {
return `--threads=${getThreadsFlagValue(userInput, logger)}`;
}
/**
* Get the path where the CodeQL database for the given language lives.
*/
export function getCodeQLDatabasePath(
config: ActionConfig<"dbLocation">,
language: Language,
) {
return path.resolve(config.dbLocation, language);
}
/**
* Get the path where the generated query suite for the given language lives.
*/
export function getGeneratedSuitePath(config: Config, language: Language) {
return path.resolve(
config.dbLocation,
language,
"temp",
"config-queries.qls",
);
}
/**
* Parses user input of a github.com or GHES URL to a canonical form.
* Removes any API prefix or suffix if one is present.
*/
export function parseGitHubUrl(inputUrl: string): string {
const originalUrl = inputUrl;
if (inputUrl.indexOf("://") === -1) {
inputUrl = `https://${inputUrl}`;
}
if (!inputUrl.startsWith("http://") && !inputUrl.startsWith("https://")) {
throw new ConfigurationError(`"${originalUrl}" is not a http or https URL`);
}
let url: URL;
try {
url = new URL(inputUrl);
} catch {
throw new ConfigurationError(`"${originalUrl}" is not a valid URL`);
}
// If we detect this is trying to be to github.com
// then return with a fixed canonical URL.
if (url.hostname === "github.com" || url.hostname === "api.github.com") {
return GITHUB_DOTCOM_URL;
}
// Remove the API prefix if it's present
if (url.pathname.indexOf("/api/v3") !== -1) {
url.pathname = url.pathname.substring(0, url.pathname.indexOf("/api/v3"));
}
// Also consider subdomain isolation on GHES
if (url.hostname.startsWith("api.")) {
url.hostname = url.hostname.substring(4);
}
// Normalise path to having a trailing slash for consistency
if (!url.pathname.endsWith("/")) {
url.pathname = `${url.pathname}/`;
}
return url.toString();
}
const CODEQL_ACTION_WARNED_ABOUT_VERSION_ENV_VAR =
"CODEQL_ACTION_WARNED_ABOUT_VERSION";
let hasBeenWarnedAboutVersion = false;
export enum GitHubVariant {
/** [GitHub.com](https://github.com) */
DOTCOM = "GitHub.com",
/** [GitHub Enterprise Server](https://docs.github.com/en/enterprise-server@latest/admin/overview/about-github-enterprise-server) */
GHES = "GitHub Enterprise Server",
/** [GitHub Enterprise Cloud with data residency](https://docs.github.com/en/enterprise-cloud@latest/admin/data-residency/about-github-enterprise-cloud-with-data-residency) */
GHEC_DR = "GitHub Enterprise Cloud with data residency",
}
export type GitHubVersion =
| { type: GitHubVariant.DOTCOM }
| { type: GitHubVariant.GHEC_DR }
| { type: GitHubVariant.GHES; version: string };
export function checkGitHubVersionInRange(
version: GitHubVersion,
logger: Logger,
) {
if (hasBeenWarnedAboutVersion || version.type !== GitHubVariant.GHES) {
return;
}
const disallowedAPIVersionReason = apiVersionInRange(
version.version,
apiCompatibility.minimumVersion,
apiCompatibility.maximumVersion,
);
if (
disallowedAPIVersionReason === DisallowedAPIVersionReason.ACTION_TOO_OLD
) {
logger.warning(
`The CodeQL Action version you are using is too old to be compatible with GitHub Enterprise ${version.version}. If you experience issues, please upgrade to a more recent version of the CodeQL Action.`,
);
}
if (
disallowedAPIVersionReason === DisallowedAPIVersionReason.ACTION_TOO_NEW
) {
logger.warning(
`GitHub Enterprise ${version.version} is too old to be compatible with this version of the CodeQL Action. If you experience issues, please upgrade to a more recent version of GitHub Enterprise or use an older version of the CodeQL Action.`,
);
}
hasBeenWarnedAboutVersion = true;
exportEnvVar(CODEQL_ACTION_WARNED_ABOUT_VERSION_ENV_VAR, true);
}
export enum DisallowedAPIVersionReason {
ACTION_TOO_OLD,
ACTION_TOO_NEW,
}
export function apiVersionInRange(
version: string,
minimumVersion: string,
maximumVersion: string,
): DisallowedAPIVersionReason | undefined {
if (!semver.satisfies(version, `>=${minimumVersion}`)) {
return DisallowedAPIVersionReason.ACTION_TOO_NEW;
}
if (!semver.satisfies(version, `<=${maximumVersion}`)) {
return DisallowedAPIVersionReason.ACTION_TOO_OLD;
}
return undefined;
}
/**
* This error is used to indicate a runtime failure of an exhaustivity check enforced at compile time.
*/
class ExhaustivityCheckingError extends Error {
constructor(public expectedExhaustiveValue: never) {
super("Internal error: exhaustivity checking failure");
}
}
/**
* Used to perform compile-time exhaustivity checking on a value. This function will not be executed at runtime unless
* the type system has been subverted.
*/
export function assertNever(value: never): never {
throw new ExhaustivityCheckingError(value);
}
/**
* Set some initial environment variables that we can set even without
* knowing what version of CodeQL we're running.
*/
export function initializeEnvironment(version: string) {
exportEnvVar(EnvVar.FEATURE_MULTI_LANGUAGE, "false");
exportEnvVar(EnvVar.FEATURE_SANDWICH, "false");
exportEnvVar(EnvVar.FEATURE_SARIF_COMBINE, "true");
exportEnvVar(EnvVar.FEATURE_WILL_UPLOAD, "true");
exportEnvVar(EnvVar.VERSION, version);
}
export class HTTPError extends Error {
public status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
/**
* An Error class that indicates an error that occurred due to
* a misconfiguration of the action or the CodeQL CLI.
*/
export class ConfigurationError extends Error {}
export function asHTTPError(arg: any): HTTPError | undefined {
if (!json.isObject<any>(arg) || !json.isString(arg.message)) {
return undefined;
}
if (Number.isInteger(arg.status)) {
return new HTTPError(arg.message, arg.status as number);
}
// See https://github.com/actions/toolkit/blob/acb230b99a46ed33a3f04a758cd68b47b9a82908/packages/tool-cache/src/tool-cache.ts#L19
if (Number.isInteger(arg.httpStatusCode)) {
return new HTTPError(arg.message, arg.httpStatusCode as number);
}
return undefined;
}
export async function codeQlVersionAtLeast(
codeql: CodeQL,
requiredVersion: string,
): Promise<boolean> {
return semver.gte((await codeql.getVersion()).version, requiredVersion);
}
export function getBaseDatabaseOidsFilePath(
config: ActionConfig<"dbLocation">,
): string {
return path.join(config.dbLocation, BASE_DATABASE_OIDS_FILE_NAME);
}
/**
* Bundles the database for the given language into a `.zip` file, returning the path to it.
*
* If a bundle for `dbName` already exists (e.g. from an earlier call), it is deleted and
* re-created, so each call produces a fresh bundle reflecting the current database contents and the
* given `includeDiagnostics` value.
*/
export async function bundleDb(
config: ActionConfig<"dbLocation">,
language: Language,
codeql: CodeQL,
dbName: string,
{ includeDiagnostics }: { includeDiagnostics: boolean },
) {
const databasePath = getCodeQLDatabasePath(config, language);
const databaseBundlePath = path.resolve(config.dbLocation, `${dbName}.zip`);
// For a tiny bit of added safety, delete the file if it exists.
// The file is probably from an earlier call to this function, either
// as part of this action step or a previous one, but it could also be
// from somewhere else or someone trying to make the action upload a
// non-database file.
if (fs.existsSync(databaseBundlePath)) {
await fs.promises.rm(databaseBundlePath, { force: true });
}
// When overlay is enabled, the base database OIDs file is included at the
// root of the database cluster. However when we bundle a database, we only
// include the per-language database. So, to ensure the base database OIDs
// file is included in the database bundle, we copy it from the cluster into
// the individual database location before bundling.
const baseDatabaseOidsFilePath = getBaseDatabaseOidsFilePath(config);
const additionalFiles: string[] = [];
if (fs.existsSync(baseDatabaseOidsFilePath)) {
await fsPromises.copyFile(
baseDatabaseOidsFilePath,
path.join(databasePath, BASE_DATABASE_OIDS_FILE_NAME),
);
additionalFiles.push(BASE_DATABASE_OIDS_FILE_NAME);
}
// Create the bundle, including the base database OIDs file if it exists
await codeql.databaseBundle(
databasePath,
databaseBundlePath,
dbName,
includeDiagnostics,
additionalFiles,
);
return databaseBundlePath;
}
/** Returns the elapsed milliseconds, rounded, since `startTime` was recorded with `performance.now()`. */
export function durationMsSince(startTime: number): number {
return Math.round(performance.now() - startTime);
}
/**
* @param milliseconds time to delay
* @param opts options
* @param opts.allowProcessExit if true, the timer will not prevent the process from exiting
*/
export async function delay(
milliseconds: number,
opts?: { allowProcessExit: boolean },
) {
const { allowProcessExit } = opts || {};
return new Promise((resolve) => {
const timer = setTimeout(resolve, milliseconds);
if (allowProcessExit) {
// Immediately `unref` the timer such that it only prevents the process from exiting if the
// surrounding promise is being awaited.
timer.unref();
}
});
}
export function isGoodVersion(versionSpec: string) {
return !BROKEN_VERSIONS.includes(versionSpec);
}
/**
* Returns whether we specifically want to skip uploading SARIF files.
*/
export function shouldSkipSarifUpload(): boolean {
return isInTestMode() || process.env[EnvVar.SKIP_SARIF_UPLOAD] === "true";
}
/**
* Get the testing environment.
*
* This is set if the CodeQL Action is running in a non-production environment.
*/
export function getTestingEnvironment(): string | undefined {
const testingEnvironment = process.env[EnvVar.TESTING_ENVIRONMENT] || "";
if (testingEnvironment === "") {
return undefined;
}
return testingEnvironment;
}
/**
* Returns whether the path in the argument represents an existing directory.
*/
export function doesDirectoryExist(dirPath: string): boolean {
try {
const stats = fs.lstatSync(dirPath);
return stats.isDirectory();
} catch {
return false;
}
}
/**
* Returns a recursive list of files in a given directory.
*/
export function listFolder(dir: string): string[] {
if (!doesDirectoryExist(dir)) {
return [];
}
const entries = fs.readdirSync(dir, { withFileTypes: true });
let files: string[] = [];
for (const entry of entries) {
if (entry.isFile()) {
files.push(path.resolve(dir, entry.name));
} else if (entry.isDirectory()) {
files = files.concat(listFolder(path.resolve(dir, entry.name)));
}
}
return files;
}
/**
* Get the size a folder in bytes. This will log any filesystem errors
* as a warning and then return undefined.
*
* @param cacheDir A directory to get the size of.
* @param logger A logger to log any errors to.
* @param quiet A value indicating whether to suppress warnings for errors (default: false).
* Ignored if the log level is `debug`.
* @returns The size in bytes of the folder, or undefined if errors occurred.
*/
export async function tryGetFolderBytes(
cacheDir: string,
logger: Logger,
quiet: boolean = false,
): Promise<number | undefined> {
try {
// tolerate some errors since we're only estimating the size
return await getFolderSize.loose(cacheDir);
} catch (e) {
if (!quiet || logger.isDebug()) {
logger.warning(
`Encountered an error while getting size of '${cacheDir}': ${e}`,
);
}
return undefined;
}
}
let hadTimeout = false;
/**
* Run a promise for a given amount of time, and if it doesn't resolve within
* that time, call the provided callback and then return undefined. Due to the
* limitation outlined below, using this helper function is not recommended
* unless there is no other option for adding a timeout (e.g. the code that
* would need the timeout added is an external library).
*
* Important: This does NOT cancel the original promise, so that promise will
* continue in the background even after the timeout has expired. If the
* original promise hangs, then this will prevent the process terminating.
* If a timeout has occurred then the global hadTimeout variable will get set
* to true, and the caller is responsible for forcing the process to exit
* if this is the case by calling the `checkForTimeout` function at the end
* of execution.
*
* @param timeoutMs The timeout in milliseconds.
* @param promise The promise to run.
* @param onTimeout A callback to call if the promise times out.
* @returns The result of the promise, or undefined if the promise times out.
*/
export async function waitForResultWithTimeLimit<T>(
timeoutMs: number,
promise: Promise<T>,
onTimeout: () => void,
): Promise<T | undefined> {
let finished = false;
const mainTask = async () => {
const result = await promise;
finished = true;
return result;
};
const timeoutTask = async () => {
await delay(timeoutMs, { allowProcessExit: true });
if (!finished) {
// Workaround: While the promise racing below will allow the main code
// to continue, the process won't normally exit until the asynchronous
// task in the background has finished. We set this variable to force
// an exit at the end of our code when `checkForTimeout` is called.
hadTimeout = true;
onTimeout();
}
return undefined;
};
return await Promise.race([mainTask(), timeoutTask()]);
}
/**
* Check if the global hadTimeout variable has been set, and if so then
* exit the process to ensure any background tasks that are still running
* are killed. This should be called at the end of execution if the
* `waitForResultWithTimeLimit` function has been used.
*/
export async function checkForTimeout() {
if (hadTimeout === true) {
core.info(
"A timeout occurred, force exiting the process after 30 seconds to prevent hanging.",
);
await delay(30_000, { allowProcessExit: true });
process.exit();
}
}
/**
* This function implements a heuristic to determine whether the
* runner we are on is hosted by GitHub. It does this by checking
* the name of the runner against the list of known GitHub-hosted
* runner names. It also checks for the presence of a toolcache
* directory with the name hostedtoolcache which is present on
* GitHub-hosted runners.
*
* Since this is a heuristic over how the runner happens to be named and laid out, it also matches
* self-hosted runners that are configured to resemble hosted ones. Prefer
* `isGitHubHostedRunner` when you need the answer the Actions service reports.
*
* @returns true iff the runner looks like it is hosted by GitHub
*/
export function looksLikeHostedRunner() {
return (
// Name of the runner on hosted Windows runners
process.env["RUNNER_NAME"]?.includes("Hosted Agent") ||
// Name of the runner on hosted POSIX runners
process.env["RUNNER_NAME"]?.includes("GitHub Actions") ||
// Segment of the path to the tool cache on all hosted runners
process.env["RUNNER_TOOL_CACHE"]?.includes("hostedtoolcache")
);
}
export function parseMatrixInput(
matrixInput: string | undefined,
): { [key: string]: string } | undefined {
if (matrixInput === undefined || matrixInput === "null") {
return undefined;
}
try {
return JSON.parse(matrixInput) as { [key: string]: string };
} catch (err) {
throw new Error(
`Failed to parse matrix input '${matrixInput}': ${getErrorMessage(err)}`,
);
}
}
export function wrapError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
/**
* Returns an appropriate message for the error.
*
* If the error is an `Error` instance, this returns the error message without
* an `Error: ` prefix.
*/
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function prettyPrintPack(pack: Pack) {
return `${pack.name}${pack.version ? `@${pack.version}` : ""}${
pack.path ? `:${pack.path}` : ""
}`;
}
export interface DiskUsage {
numAvailableBytes: number;
numTotalBytes: number;
}
export async function checkDiskUsage(
logger: Logger,
): Promise<DiskUsage | undefined> {
try {
const diskUsage = await fsPromises.statfs(
getRequiredEnvParam("GITHUB_WORKSPACE"),
);
const blockSizeInBytes = diskUsage.bsize;
const numBlocksPerMb = (1024 * 1024) / blockSizeInBytes;
const numBlocksPerGb = (1024 * 1024 * 1024) / blockSizeInBytes;
if (diskUsage.bavail < 2 * numBlocksPerGb) {
const message =
"The Actions runner is running low on disk space " +
`(${(diskUsage.bavail / numBlocksPerMb).toPrecision(4)} MB available).`;
if (process.env[EnvVar.HAS_WARNED_ABOUT_DISK_SPACE] !== "true") {
logger.warning(message);
} else {
logger.debug(message);
}
exportEnvVar(EnvVar.HAS_WARNED_ABOUT_DISK_SPACE, "true");
}
return {
numAvailableBytes: diskUsage.bavail * blockSizeInBytes,
numTotalBytes: diskUsage.blocks * blockSizeInBytes,
};
} catch (error) {
logger.warning(
`Failed to check available disk space: ${getErrorMessage(error)}`,
);
return undefined;
}
}
/**
* Prompt the customer to upgrade to CodeQL Action v4, if appropriate.
*
* Check whether a customer is running v3. If they are, and we can determine that the GitHub
* instance supports v4, then log an error prompting the customer to upgrade to v4.
*/
export function checkActionVersion(
version: string,
githubVersion: GitHubVersion,
) {
if (
!semver.satisfies(version, ">=4") && // do not log error if the customer is already running v4
!process.env[EnvVar.LOG_VERSION_DEPRECATION] // do not log error if we have already
) {
// Only error for versions of GHES that are compatible with CodeQL Action version 4.
//
// GHES 3.20 is the first version to ship with the v4 tag and this warning message code.
// Therefore, users who are seeing this warning message code are running on GHES 3.20 or newer,
// and should update to CodeQL Action v4.
if (
githubVersion.type === GitHubVariant.DOTCOM ||
githubVersion.type === GitHubVariant.GHEC_DR ||
(githubVersion.type === GitHubVariant.GHES &&
semver.satisfies(
semver.coerce(githubVersion.version) ?? "0.0.0",
">=3.20",
))
) {
core.warning(
"CodeQL Action v3 will be deprecated in December 2026. " +
"Please update all occurrences of the CodeQL Action in your workflow files to v4. " +
"For more information, see " +
"https://github.blog/changelog/2025-10-28-upcoming-deprecation-of-codeql-action-v3/",
);
// set LOG_VERSION_DEPRECATION env var to prevent the warning from being logged multiple times
exportEnvVar(EnvVar.LOG_VERSION_DEPRECATION, "true");
}
}
}
/**
* This will check whether the given GitHub version satisfies the given range,
* taking into account that a range like >=3.18 will also match the GHES 3.18
* pre-release/RC versions.
*
* When the given `githubVersion` is not a GHES version, or if the version
* is invalid, this will return `defaultIfInvalid`.
*/
export function satisfiesGHESVersion(
ghesVersion: string,
range: string,
defaultIfInvalid: boolean,
): boolean {
const semverVersion = semver.coerce(ghesVersion);
if (semverVersion === null) {
return defaultIfInvalid;
}
// We always drop the pre-release part of the version, since anything that
// applies to GHES 3.18.0 should also apply to GHES 3.18.0.pre1.
semverVersion.prerelease = [];
return semver.satisfies(semverVersion, range);
}
/**
* Supported build modes.
*
* These specify whether the CodeQL database should be created by tracing a build, and if so, how
* this build will be invoked.
*/
export enum BuildMode {
/** The database will be created without building the source root. */
None = "none",
/** The database will be created by attempting to automatically build the source root. */
Autobuild = "autobuild",
/** The database will be created by building the source root using manually specified build steps. */
Manual = "manual",
}
export function cloneObject<T>(obj: T): T {
try {
return JSON.parse(JSON.stringify(obj)) as T;
} catch (err) {
throw new Error(`Cloning object failed: ${getErrorMessage(err)}`);
}
}
export async function cleanUpPath(file: string, name: string, logger: Logger) {
logger.debug(`Cleaning up ${name}.`);
try {
await fs.promises.rm(file, {
force: true,
recursive: true,
});
} catch (e) {
logger.warning(`Failed to clean up ${name}: ${e}.`);
}
}
export async function isBinaryAccessible(
binary: string,
logger: Logger,
): Promise<boolean> {
try {
await io.which(binary, true);
logger.debug(`Found ${binary}.`);
return true;
} catch (e) {
logger.debug(`Could not find ${binary}: ${e}`);
return false;
}
}
export async function asyncFilter<T>(
array: T[],
predicate: (value: T) => Promise<boolean>,
): Promise<T[]> {
const results = await Promise.all(array.map(predicate));
return array.filter((_, index) => results[index]);
}
export async function asyncSome<T>(
array: T[],
predicate: (value: T) => Promise<boolean>,
): Promise<boolean> {
const results = await Promise.all(array.map(predicate));
return results.some((result) => result);
}
/**
* Checks that `value` is neither `undefined` nor `null`.
* @param value The value to test.
* @returns Narrows the type of `value` to exclude `undefined` and `null`.
*/
export function isDefined<T>(value: T | null | undefined): value is T {
return value !== undefined && value !== null;
}
/** Like `Object.entries`, but typed so that the key elements of the result have the
* same type as the keys of the input object. Note that this may not be sound if the input
* object has been cast to `T` from a subtype of `T` and contains additional keys that
* are not represented by `keyof T`.
*/
export function unsafeEntriesInvariant<T extends Record<string, any>>(
object: T,
): Array<[keyof T, Exclude<T[keyof T], undefined>]> {
return Object.entries(object).filter(
([_, val]) => val !== undefined,
) as Array<[keyof T, Exclude<T[keyof T], undefined>]>;
}
export enum CleanupLevel {
Clear = "clear",
Overlay = "overlay",
}
/**
* Like `join`, but limits the number of elements that are joined together to `limit`
* and appends `...` if the limit is exceeded.
*
* @param array The array to join.
* @param separator The separator to join the array with.
* @param limit The maximum number of elements from `array` to join.
* @returns The result of joining at most `limit`-many elements from `array`.
*/
export function joinAtMost(
array: string[],
separator: string,
limit: number,
): string {
if (limit > 0 && array.length > limit) {
array = array.slice(0, limit);
array.push("...");
}
return array.join(separator);
}
/** An interface representing something that is either a success or a failure. */
interface ResultLike<T, E> {
/** The value of the result, which can be either a success value or a failure value. */
value: T | E;
/** Whether this result represents a success. */
isSuccess(): this is Success<T>;
/** Whether this result represents a failure. */
isFailure(): this is Failure<E>;
/** Get the value if this is a success, or return the default value if this is a failure. */
orElse<U>(defaultValue: U): T | U;
}
/** A simple result type representing either a success or a failure. */
export type Result<T, E> = Success<T> | Failure<E>;
/** A result representing a success. */
export class Success<T> implements ResultLike<T, never> {
constructor(public readonly value: T) {}
isSuccess(): this is Success<T> {
return true;
}
isFailure(): this is Failure<never> {
return false;
}
orElse<U>(_defaultValue: U): T {
return this.value;
}
}
/** A result representing a failure. */
export class Failure<E> implements ResultLike<never, E> {
constructor(public readonly value: E) {}
isSuccess(): this is Success<never> {
return false;
}
isFailure(): this is Failure<E> {
return true;
}
orElse<U>(defaultValue: U): U {
return defaultValue;
}
}