diff --git a/lib/entry-points.js b/lib/entry-points.js index 11a8c4c291..cd680ecb7e 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -148660,20 +148660,32 @@ function initializeEnvironment(version) { core2.exportVariable("CODEQL_ACTION_FEATURE_WILL_UPLOAD" /* FEATURE_WILL_UPLOAD */, "true"); core2.exportVariable("CODEQL_ACTION_VERSION" /* VERSION */, version); } -function getRequiredEnvParam(paramName) { - const value = process.env[paramName]; +function getEnv(env = process.env) { + return { + getRequired: (name) => getRequiredEnvVar(env, name), + getOptional: (name) => getOptionalEnvVarFrom(env, name) + }; +} +function getRequiredEnvVar(env, paramName) { + const value = env[paramName]; if (value === void 0 || value.length === 0) { throw new Error(`${paramName} environment variable must be set`); } return value; } -function getOptionalEnvVar(paramName) { - const value = process.env[paramName]; +function getRequiredEnvParam(paramName) { + return getRequiredEnvVar(process.env, paramName); +} +function getOptionalEnvVarFrom(env, paramName) { + const value = env[paramName]; if (value?.trim().length === 0) { return void 0; } return value; } +function getOptionalEnvVar(paramName) { + return getOptionalEnvVarFrom(process.env, paramName); +} var HTTPError = class extends Error { status; constructor(message, status) { @@ -149025,7 +149037,7 @@ var getOptionalInput = function(name) { }; function getTemporaryDirectory() { const value = process.env["CODEQL_ACTION_TEMP"]; - return value !== void 0 && value !== "" ? value : getRequiredEnvParam("RUNNER_TEMP"); + return value !== void 0 && value !== "" ? value : getRequiredEnvParam("RUNNER_TEMP" /* RUNNER_TEMP */); } var PR_DIFF_RANGE_JSON_FILENAME = "pr-diff-range.json"; function getDiffRangesJsonFilePath() { @@ -149035,19 +149047,19 @@ function getActionVersion() { return "4.36.3"; } function getWorkflowEventName() { - return getRequiredEnvParam("GITHUB_EVENT_NAME"); + return getRequiredEnvParam("GITHUB_EVENT_NAME" /* GITHUB_EVENT_NAME */); } function isRunningLocalAction() { const relativeScriptPath = getRelativeScriptPath(); return relativeScriptPath.startsWith("..") || path2.isAbsolute(relativeScriptPath); } function getRelativeScriptPath() { - const runnerTemp = getRequiredEnvParam("RUNNER_TEMP"); + const runnerTemp = getRequiredEnvParam("RUNNER_TEMP" /* RUNNER_TEMP */); const actionsDirectory = path2.join(path2.dirname(runnerTemp), "_actions"); return path2.relative(actionsDirectory, __filename); } function getWorkflowEvent() { - const eventJsonFile = getRequiredEnvParam("GITHUB_EVENT_PATH"); + const eventJsonFile = getRequiredEnvParam("GITHUB_EVENT_PATH" /* GITHUB_EVENT_PATH */); try { return JSON.parse(fs2.readFileSync(eventJsonFile, "utf-8")); } catch (e) { @@ -149104,31 +149116,33 @@ function getUploadValue(input) { } } function getWorkflowRunID() { - const workflowRunIdString = getRequiredEnvParam("GITHUB_RUN_ID"); + const workflowRunIdString = getRequiredEnvParam("GITHUB_RUN_ID" /* GITHUB_RUN_ID */); const workflowRunID = parseInt(workflowRunIdString, 10); if (Number.isNaN(workflowRunID)) { throw new Error( - `GITHUB_RUN_ID must define a non NaN workflow run ID. Current value is ${workflowRunIdString}` + `${"GITHUB_RUN_ID" /* GITHUB_RUN_ID */} must define a non NaN workflow run ID. Current value is ${workflowRunIdString}` ); } if (workflowRunID < 0) { throw new Error( - `GITHUB_RUN_ID must be a non-negative integer. Current value is ${workflowRunIdString}` + `${"GITHUB_RUN_ID" /* GITHUB_RUN_ID */} must be a non-negative integer. Current value is ${workflowRunIdString}` ); } return workflowRunID; } function getWorkflowRunAttempt() { - const workflowRunAttemptString = getRequiredEnvParam("GITHUB_RUN_ATTEMPT"); + const workflowRunAttemptString = getRequiredEnvParam( + "GITHUB_RUN_ATTEMPT" /* GITHUB_RUN_ATTEMPT */ + ); const workflowRunAttempt = parseInt(workflowRunAttemptString, 10); if (Number.isNaN(workflowRunAttempt)) { throw new Error( - `GITHUB_RUN_ATTEMPT must define a non NaN workflow run attempt. Current value is ${workflowRunAttemptString}` + `${"GITHUB_RUN_ATTEMPT" /* GITHUB_RUN_ATTEMPT */} must define a non NaN workflow run attempt. Current value is ${workflowRunAttemptString}` ); } if (workflowRunAttempt <= 0) { throw new Error( - `GITHUB_RUN_ATTEMPT must be a positive integer. Current value is ${workflowRunAttemptString}` + `${"GITHUB_RUN_ATTEMPT" /* GITHUB_RUN_ATTEMPT */} must be a positive integer. Current value is ${workflowRunAttemptString}` ); } return workflowRunAttempt; @@ -149439,8 +149453,8 @@ function createApiClientWithDetails(apiDetails, { allowExternal = false } = {}) function getApiDetails() { return { auth: getRequiredInput("token"), - url: getRequiredEnvParam("GITHUB_SERVER_URL"), - apiURL: getRequiredEnvParam("GITHUB_API_URL") + url: getRequiredEnvParam("GITHUB_SERVER_URL" /* GITHUB_SERVER_URL */), + apiURL: getRequiredEnvParam("GITHUB_API_URL" /* GITHUB_API_URL */) }; } function getApiClient() { @@ -150082,6 +150096,11 @@ var featureConfig = { envVar: "CODEQL_ACTION_JAVA_NETWORK_DEBUGGING", minimumVersion: void 0 }, + ["new_remote_file_addresses" /* NewRemoteFileAddresses */]: { + defaultValue: false, + envVar: "CODEQL_ACTION_NEW_REMOTE_FILE_ADDRESSES", + minimumVersion: void 0 + }, ["overlay_analysis" /* OverlayAnalysis */]: { defaultValue: false, envVar: "CODEQL_ACTION_OVERLAY_ANALYSIS", @@ -151037,11 +151056,16 @@ function getInvalidConfigFileMessage(configFile, messages) { const andMore = messages.length > 10 ? `, and ${messages.length - 10} more.` : "."; return `The configuration file "${configFile}" is invalid: ${messages.slice(0, 10).join(", ")}${andMore}`; } -function getConfigFileRepoFormatInvalidMessage(configFile) { +function getConfigFileRepoOldFormatInvalidMessage(configFile) { let error3 = `The configuration file "${configFile}" is not a supported remote file reference.`; error3 += " Expected format //@"; return error3; } +function getConfigFileRepoFormatInvalidMessage(configFile) { + let error3 = `The configuration file "${configFile}" is not a supported remote file reference.`; + error3 += " Expected format [/][@][:]"; + return error3; +} function getConfigFileFormatInvalidMessage(configFile) { return `The configuration file "${configFile}" could not be read`; } @@ -151420,6 +151444,120 @@ function parseUserConfig(logger, pathInput, contents, validateConfig) { } } +// src/config/remote-file.ts +var DEFAULT_CONFIG_FILE_NAME = ".github/codeql-action.yaml"; +var DEFAULT_CONFIG_FILE_REF = "main"; +function getDefaultOwner(env) { + const currentRepoNwo = env.getRequired("GITHUB_REPOSITORY" /* GITHUB_REPOSITORY */); + const nwoParts = currentRepoNwo.split("/"); + if (nwoParts.length !== 2 || nwoParts[0].trim().length === 0) { + throw new Error( + `Expected ${"GITHUB_REPOSITORY" /* GITHUB_REPOSITORY */} to contain a name with owner, but got '${currentRepoNwo}'.` + ); + } + return nwoParts[0].trim(); +} +var OLD_REMOTE_ADDRESS_FORMAT = new RegExp( + "(?[^/]+)/(?[^/]+)/(?[^@]+)@(?.*)" +); +function parseOldRemoteFileAddress(input) { + const pieces = OLD_REMOTE_ADDRESS_FORMAT.exec(input); + if (pieces?.groups === void 0 || pieces.length < 5) { + return new Failure(void 0); + } + return new Success({ + owner: pieces.groups.owner.trim(), + repo: pieces.groups.repo.trim(), + path: pieces.groups.path.trim(), + ref: pieces.groups.ref.trim() + }); +} +async function parseRemoteFileAddress(actionState, configFile) { + const oldFormatAddressResult = parseOldRemoteFileAddress(configFile); + if (oldFormatAddressResult.isSuccess()) { + return oldFormatAddressResult.value; + } + const allowNewFormat = await actionState.features.getValue( + "new_remote_file_addresses" /* NewRemoteFileAddresses */ + ); + if (!allowNewFormat) { + throw new ConfigurationError( + getConfigFileRepoOldFormatInvalidMessage(configFile) + ); + } + const format = new RegExp( + "^((?[^:@/]+)/)?(?[^:@/]+)(@(?[^:]+))?(:(?.+))?$" + ); + const pieces = format.exec(configFile.trim()); + const repo = pieces?.groups?.repo?.trim(); + if (!pieces?.groups || !repo || repo.length === 0) { + throw new ConfigurationError( + getConfigFileRepoFormatInvalidMessage(configFile) + ); + } + const owner = pieces.groups.owner?.trim(); + const path29 = pieces.groups.path?.trim(); + const ref = pieces.groups.ref?.trim(); + if (path29?.startsWith("/")) { + throw new ConfigurationError( + `The path component of '${configFile}' cannot be an absolute path.` + ); + } + return { + owner: owner || getDefaultOwner(actionState.env), + repo, + path: path29 || DEFAULT_CONFIG_FILE_NAME, + ref: ref || DEFAULT_CONFIG_FILE_REF + }; +} + +// src/config/file.ts +function getConfigFileInput(logger, actions, repositoryProperties) { + const input = actions.getOptionalInput("config-file"); + if (input !== void 0) { + logger.info(`Using configuration file input from workflow: ${input}`); + return input; + } + const propertyValue = repositoryProperties["github-codeql-config-file" /* CONFIG_FILE */]; + if (propertyValue !== void 0 && propertyValue.trim().length > 0) { + logger.info( + `Using configuration file input from repository property: ${propertyValue}` + ); + return propertyValue; + } + return void 0; +} +async function getRemoteConfig(actionState, configFile, apiDetails) { + const address = await parseRemoteFileAddress(actionState, configFile); + const response = await getApiClientWithExternalAuth(apiDetails).rest.repos.getContent({ + owner: address.owner, + repo: address.repo, + path: address.path, + ref: address.ref + }); + let fileContents; + if ("content" in response.data && response.data.content !== void 0) { + fileContents = response.data.content; + } else if (Array.isArray(response.data)) { + throw new ConfigurationError( + getConfigFileDirectoryGivenMessage(configFile) + ); + } else { + throw new ConfigurationError( + getConfigFileFormatInvalidMessage(configFile) + ); + } + const validateConfig = await actionState.features.getValue( + "validate_db_config" /* ValidateDbConfig */ + ); + return parseUserConfig( + actionState.logger, + configFile, + Buffer.from(fileContents, "base64").toString("binary"), + validateConfig + ); +} + // src/diagnostics.ts var import_fs = require("fs"); var import_path = __toESM(require("path")); @@ -152389,7 +152527,7 @@ async function downloadCacheWithTime(codeQL, languages, logger) { const trapCacheDownloadTime = import_perf_hooks.performance.now() - start; return { trapCaches, trapCacheDownloadTime }; } -async function loadUserConfig(logger, configFile, workspacePath, apiDetails, tempDir, validateConfig) { +async function loadUserConfig(actionState, configFile, workspacePath, apiDetails, tempDir) { if (isLocal(configFile)) { if (configFile !== userConfigFromActionPath(tempDir)) { configFile = path10.resolve(workspacePath, configFile); @@ -152399,14 +152537,12 @@ async function loadUserConfig(logger, configFile, workspacePath, apiDetails, tem ); } } - return getLocalConfig(logger, configFile, validateConfig); - } else { - return await getRemoteConfig( - logger, - configFile, - apiDetails, - validateConfig + const validateConfig = await actionState.features.getValue( + "validate_db_config" /* ValidateDbConfig */ ); + return getLocalConfig(actionState.logger, configFile, validateConfig); + } else { + return await getRemoteConfig(actionState, configFile, apiDetails); } } var OVERLAY_ANALYSIS_FEATURES = { @@ -152709,14 +152845,13 @@ async function initConfig(features, inputs) { logger.debug("No configuration file was provided"); } else { logger.debug(`Using configuration file: ${inputs.configFile}`); - const validateConfig = await features.getValue("validate_db_config" /* ValidateDbConfig */); + const actionState = { logger, features, env: getEnv() }; userConfig = await loadUserConfig( - logger, + actionState, inputs.configFile, inputs.workspacePath, inputs.apiDetails, - tempDir, - validateConfig + tempDir ); } const config = await initActionState(inputs, userConfig); @@ -152863,41 +152998,6 @@ function getLocalConfig(logger, configFile, validateConfig) { validateConfig ); } -async function getRemoteConfig(logger, configFile, apiDetails, validateConfig) { - const format = new RegExp( - "(?[^/]+)/(?[^/]+)/(?[^@]+)@(?.*)" - ); - const pieces = format.exec(configFile); - if (pieces?.groups === void 0 || pieces.length < 5) { - throw new ConfigurationError( - getConfigFileRepoFormatInvalidMessage(configFile) - ); - } - const response = await getApiClientWithExternalAuth(apiDetails).rest.repos.getContent({ - owner: pieces.groups.owner, - repo: pieces.groups.repo, - path: pieces.groups.path, - ref: pieces.groups.ref - }); - let fileContents; - if ("content" in response.data && response.data.content !== void 0) { - fileContents = response.data.content; - } else if (Array.isArray(response.data)) { - throw new ConfigurationError( - getConfigFileDirectoryGivenMessage(configFile) - ); - } else { - throw new ConfigurationError( - getConfigFileFormatInvalidMessage(configFile) - ); - } - return parseUserConfig( - logger, - configFile, - Buffer.from(fileContents, "base64").toString("binary"), - validateConfig - ); -} function getPathToParsedConfigFile(tempDir) { return path10.join(tempDir, "config"); } @@ -160713,7 +160813,7 @@ var import_async = __toESM(require_async(), 1); var import_path6 = require("path"); // node_modules/archiver/lib/error.js -var import_util28 = __toESM(require("util"), 1); +var import_util30 = __toESM(require("util"), 1); var ERROR_CODES = { ABORTED: "archive was aborted", DIRECTORYDIRPATHREQUIRED: "diretory dirpath argument must be a non-empty string value", @@ -160738,7 +160838,7 @@ function ArchiverError(code, data) { this.code = code; this.data = data; } -import_util28.default.inherits(ArchiverError, Error); +import_util30.default.inherits(ArchiverError, Error); // node_modules/archiver/lib/core.js var import_readable_stream2 = __toESM(require_ours(), 1); @@ -163682,23 +163782,6 @@ var github3 = __toESM(require_github()); var io7 = __toESM(require_io()); var semver10 = __toESM(require_semver2()); -// src/config/file.ts -function getConfigFileInput(logger, actions, repositoryProperties) { - const input = actions.getOptionalInput("config-file"); - if (input !== void 0) { - logger.info(`Using configuration file input from workflow: ${input}`); - return input; - } - const propertyValue = repositoryProperties["github-codeql-config-file" /* CONFIG_FILE */]; - if (propertyValue !== void 0 && propertyValue.trim().length > 0) { - logger.info( - `Using configuration file input from repository property: ${propertyValue}` - ); - return propertyValue; - } - return void 0; -} - // src/workflow.ts var fs27 = __toESM(require("fs")); var path23 = __toESM(require("path")); diff --git a/src/action-common.ts b/src/action-common.ts new file mode 100644 index 0000000000..5af58f08b7 --- /dev/null +++ b/src/action-common.ts @@ -0,0 +1,37 @@ +import { Env } from "./environment"; +import { FeatureEnablement } from "./feature-flags"; +import { Logger } from "./logging"; + +/** Describes different state features that an Action may have. */ +export interface FeatureState { + Logger: { + /** The logger that is in use. */ + logger: Logger; + }; + Env: { + /** Information about environment variables. */ + env: Env; + }; + FeatureFlags: { + /** Information about enabled feature flags. */ + features: FeatureEnablement; + }; +} + +/** Identifies a type of state an Action may have. */ +export type StateFeature = keyof FeatureState; + +/** Constructs the union of all state types identifies by `Fs`. */ +export type FieldsOf = Fs extends [ + infer Head extends StateFeature, +] + ? FeatureState[Head] + : Fs extends [ + infer Head extends StateFeature, + ...infer Tail extends StateFeature[], + ] + ? FeatureState[Head] & FieldsOf + : never; + +/** Describes the state of an Action that has access to the state corresponding to `Fs`. */ +export type ActionState = FieldsOf; diff --git a/src/actions-util.ts b/src/actions-util.ts index dea22d5c57..d7fbacbf3e 100644 --- a/src/actions-util.ts +++ b/src/actions-util.ts @@ -21,6 +21,28 @@ import { */ declare const __CODEQL_ACTION_VERSION__: string; +/** + * Enumerates known GitHub Actions environment variables that we expect + * to be set in a GitHub Actions environment. + */ +export enum ActionsEnvVars { + GITHUB_ACTION_REPOSITORY = "GITHUB_ACTION_REPOSITORY", + GITHUB_API_URL = "GITHUB_API_URL", + GITHUB_EVENT_NAME = "GITHUB_EVENT_NAME", + GITHUB_EVENT_PATH = "GITHUB_EVENT_PATH", + GITHUB_JOB = "GITHUB_JOB", + GITHUB_REF = "GITHUB_REF", + GITHUB_REPOSITORY = "GITHUB_REPOSITORY", + GITHUB_RUN_ATTEMPT = "GITHUB_RUN_ATTEMPT", + GITHUB_RUN_ID = "GITHUB_RUN_ID", + GITHUB_SERVER_URL = "GITHUB_SERVER_URL", + GITHUB_SHA = "GITHUB_SHA", + GITHUB_WORKFLOW = "GITHUB_WORKFLOW", + RUNNER_NAME = "RUNNER_NAME", + RUNNER_OS = "RUNNER_OS", + RUNNER_TEMP = "RUNNER_TEMP", +} + /** * Abstracts over GitHub Actions functions so that we do not have to stub * global functions in tests. @@ -65,7 +87,7 @@ export function getTemporaryDirectory(): string { const value = process.env["CODEQL_ACTION_TEMP"]; return value !== undefined && value !== "" ? value - : getRequiredEnvParam("RUNNER_TEMP"); + : getRequiredEnvParam(ActionsEnvVars.RUNNER_TEMP); } const PR_DIFF_RANGE_JSON_FILENAME = "pr-diff-range.json"; @@ -84,7 +106,7 @@ export function getActionVersion(): string { * This will be "dynamic" for default setup workflow runs. */ export function getWorkflowEventName() { - return getRequiredEnvParam("GITHUB_EVENT_NAME"); + return getRequiredEnvParam(ActionsEnvVars.GITHUB_EVENT_NAME); } /** @@ -104,14 +126,14 @@ export function isRunningLocalAction(): boolean { * This can be used to get the Action's name or tell if we're running a local Action. */ function getRelativeScriptPath(): string { - const runnerTemp = getRequiredEnvParam("RUNNER_TEMP"); + const runnerTemp = getRequiredEnvParam(ActionsEnvVars.RUNNER_TEMP); const actionsDirectory = path.join(path.dirname(runnerTemp), "_actions"); return path.relative(actionsDirectory, __filename); } /** Returns the contents of `GITHUB_EVENT_PATH` as a JSON object. */ export function getWorkflowEvent(): any { - const eventJsonFile = getRequiredEnvParam("GITHUB_EVENT_PATH"); + const eventJsonFile = getRequiredEnvParam(ActionsEnvVars.GITHUB_EVENT_PATH); try { return JSON.parse(fs.readFileSync(eventJsonFile, "utf-8")); } catch (e) { @@ -181,16 +203,16 @@ export function getUploadValue(input: string | undefined): UploadKind { * Get the workflow run ID. */ export function getWorkflowRunID(): number { - const workflowRunIdString = getRequiredEnvParam("GITHUB_RUN_ID"); + const workflowRunIdString = getRequiredEnvParam(ActionsEnvVars.GITHUB_RUN_ID); const workflowRunID = parseInt(workflowRunIdString, 10); if (Number.isNaN(workflowRunID)) { throw new Error( - `GITHUB_RUN_ID must define a non NaN workflow run ID. Current value is ${workflowRunIdString}`, + `${ActionsEnvVars.GITHUB_RUN_ID} must define a non NaN workflow run ID. Current value is ${workflowRunIdString}`, ); } if (workflowRunID < 0) { throw new Error( - `GITHUB_RUN_ID must be a non-negative integer. Current value is ${workflowRunIdString}`, + `${ActionsEnvVars.GITHUB_RUN_ID} must be a non-negative integer. Current value is ${workflowRunIdString}`, ); } return workflowRunID; @@ -200,16 +222,18 @@ export function getWorkflowRunID(): number { * Get the workflow run attempt number. */ export function getWorkflowRunAttempt(): number { - const workflowRunAttemptString = getRequiredEnvParam("GITHUB_RUN_ATTEMPT"); + const workflowRunAttemptString = getRequiredEnvParam( + ActionsEnvVars.GITHUB_RUN_ATTEMPT, + ); const workflowRunAttempt = parseInt(workflowRunAttemptString, 10); if (Number.isNaN(workflowRunAttempt)) { throw new Error( - `GITHUB_RUN_ATTEMPT must define a non NaN workflow run attempt. Current value is ${workflowRunAttemptString}`, + `${ActionsEnvVars.GITHUB_RUN_ATTEMPT} must define a non NaN workflow run attempt. Current value is ${workflowRunAttemptString}`, ); } if (workflowRunAttempt <= 0) { throw new Error( - `GITHUB_RUN_ATTEMPT must be a positive integer. Current value is ${workflowRunAttemptString}`, + `${ActionsEnvVars.GITHUB_RUN_ATTEMPT} must be a positive integer. Current value is ${workflowRunAttemptString}`, ); } return workflowRunAttempt; diff --git a/src/api-client.ts b/src/api-client.ts index 4a061d4828..16714b4804 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -2,7 +2,11 @@ import * as core from "@actions/core"; import * as githubUtils from "@actions/github/lib/utils"; import * as retry from "@octokit/plugin-retry"; -import { getActionVersion, getRequiredInput } from "./actions-util"; +import { + ActionsEnvVars, + getActionVersion, + getRequiredInput, +} from "./actions-util"; import { EnvVar } from "./environment"; import { Logger } from "./logging"; import { getRepositoryNwo, RepositoryNwo } from "./repository"; @@ -70,8 +74,8 @@ function createApiClientWithDetails( export function getApiDetails(): GitHubApiDetails { return { auth: getRequiredInput("token"), - url: getRequiredEnvParam("GITHUB_SERVER_URL"), - apiURL: getRequiredEnvParam("GITHUB_API_URL"), + url: getRequiredEnvParam(ActionsEnvVars.GITHUB_SERVER_URL), + apiURL: getRequiredEnvParam(ActionsEnvVars.GITHUB_API_URL), }; } diff --git a/src/config-utils.test.ts b/src/config-utils.test.ts index 27de780ad5..830defa712 100644 --- a/src/config-utils.test.ts +++ b/src/config-utils.test.ts @@ -424,34 +424,6 @@ test.serial("load input outside of workspace", async (t) => { }); }); -test.serial("load non-local input with invalid repo syntax", async (t) => { - return await withTmpDir(async (tempDir) => { - // no filename given, just a repo - const configFile = "octo-org/codeql-config@main"; - - try { - await configUtils.initConfig( - createFeatures([]), - createTestInitConfigInputs({ - configFile, - tempDir, - workspacePath: tempDir, - }), - ); - throw new Error("initConfig did not throw error"); - } catch (err) { - t.deepEqual( - err, - new ConfigurationError( - errorMessages.getConfigFileRepoFormatInvalidMessage( - "octo-org/codeql-config@main", - ), - ), - ); - } - }); -}); - test.serial("load non-existent input", async (t) => { return await withTmpDir(async (tempDir) => { const languagesInput = "javascript"; diff --git a/src/config-utils.ts b/src/config-utils.ts index 972734877a..ec47d0f949 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -5,6 +5,7 @@ import { performance } from "perf_hooks"; import * as core from "@actions/core"; import * as yaml from "js-yaml"; +import { ActionState } from "./action-common"; import { getActionVersion, getOptionalInput, @@ -27,6 +28,7 @@ import { parseUserConfig, UserConfig, } from "./config/db-config"; +import { getRemoteConfig } from "./config/file"; import { addNoLanguageDiagnostic, makeTelemetryDiagnostic, @@ -77,6 +79,7 @@ import { Success, Failure, isHostedRunner, + getEnv, } from "./util"; /** @@ -599,12 +602,11 @@ async function downloadCacheWithTime( } async function loadUserConfig( - logger: Logger, + actionState: ActionState<["Logger", "Env", "FeatureFlags"]>, configFile: string, workspacePath: string, apiDetails: api.GitHubApiCombinedDetails, tempDir: string, - validateConfig: boolean, ): Promise { if (isLocal(configFile)) { if (configFile !== userConfigFromActionPath(tempDir)) { @@ -617,14 +619,12 @@ async function loadUserConfig( ); } } - return getLocalConfig(logger, configFile, validateConfig); - } else { - return await getRemoteConfig( - logger, - configFile, - apiDetails, - validateConfig, + const validateConfig = await actionState.features.getValue( + Feature.ValidateDbConfig, ); + return getLocalConfig(actionState.logger, configFile, validateConfig); + } else { + return await getRemoteConfig(actionState, configFile, apiDetails); } } @@ -1160,14 +1160,13 @@ export async function initConfig( logger.debug("No configuration file was provided"); } else { logger.debug(`Using configuration file: ${inputs.configFile}`); - const validateConfig = await features.getValue(Feature.ValidateDbConfig); + const actionState = { logger, features, env: getEnv() }; userConfig = await loadUserConfig( - logger, + actionState, inputs.configFile, inputs.workspacePath, inputs.apiDetails, tempDir, - validateConfig, ); } @@ -1369,54 +1368,6 @@ function getLocalConfig( ); } -async function getRemoteConfig( - logger: Logger, - configFile: string, - apiDetails: api.GitHubApiCombinedDetails, - validateConfig: boolean, -): Promise { - // retrieve the various parts of the config location, and ensure they're present - const format = new RegExp( - "(?[^/]+)/(?[^/]+)/(?[^@]+)@(?.*)", - ); - const pieces = format.exec(configFile); - // 5 = 4 groups + the whole expression - if (pieces?.groups === undefined || pieces.length < 5) { - throw new ConfigurationError( - errorMessages.getConfigFileRepoFormatInvalidMessage(configFile), - ); - } - - const response = await api - .getApiClientWithExternalAuth(apiDetails) - .rest.repos.getContent({ - owner: pieces.groups.owner, - repo: pieces.groups.repo, - path: pieces.groups.path, - ref: pieces.groups.ref, - }); - - let fileContents: string; - if ("content" in response.data && response.data.content !== undefined) { - fileContents = response.data.content; - } else if (Array.isArray(response.data)) { - throw new ConfigurationError( - errorMessages.getConfigFileDirectoryGivenMessage(configFile), - ); - } else { - throw new ConfigurationError( - errorMessages.getConfigFileFormatInvalidMessage(configFile), - ); - } - - return parseUserConfig( - logger, - configFile, - Buffer.from(fileContents, "base64").toString("binary"), - validateConfig, - ); -} - /** * Get the file path where the parsed config will be stored. */ diff --git a/src/config/file.ts b/src/config/file.ts index 24613dc557..66257a5bf8 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -1,9 +1,17 @@ +import { ActionState } from "../action-common"; import { ActionsEnv } from "../actions-util"; +import * as api from "../api-client"; +import * as errorMessages from "../error-messages"; +import { Feature } from "../feature-flags"; import { RepositoryProperties, RepositoryPropertyName, } from "../feature-flags/properties"; import { Logger } from "../logging"; +import { ConfigurationError } from "../util"; + +import { parseUserConfig, UserConfig } from "./db-config"; +import { parseRemoteFileAddress } from "./remote-file"; /** * Gets the value that is configured for the configuration file, if any. @@ -32,3 +40,52 @@ export function getConfigFileInput( return undefined; } + +/** + * Attempts to fetch a `UserConfig` from a remote `address`. + * + * @param actionState The current Action state. + * @param configFile The remote address of the configuration file. + * @param apiDetails Information about how to connect to the API. + * + * @returns The `UserConfig`, if it could be fetched and parsed successfully. + */ +export async function getRemoteConfig( + actionState: ActionState<["Logger", "Env", "FeatureFlags"]>, + configFile: string, + apiDetails: api.GitHubApiCombinedDetails, +): Promise { + const address = await parseRemoteFileAddress(actionState, configFile); + + const response = await api + .getApiClientWithExternalAuth(apiDetails) + .rest.repos.getContent({ + owner: address.owner, + repo: address.repo, + path: address.path, + ref: address.ref, + }); + + let fileContents: string; + if ("content" in response.data && response.data.content !== undefined) { + fileContents = response.data.content; + } else if (Array.isArray(response.data)) { + throw new ConfigurationError( + errorMessages.getConfigFileDirectoryGivenMessage(configFile), + ); + } else { + throw new ConfigurationError( + errorMessages.getConfigFileFormatInvalidMessage(configFile), + ); + } + + const validateConfig = await actionState.features.getValue( + Feature.ValidateDbConfig, + ); + return parseUserConfig( + actionState.logger, + configFile, + Buffer.from(fileContents, "base64").toString("binary"), + validateConfig, + ); +} diff --git a/src/config/remote-file.test.ts b/src/config/remote-file.test.ts new file mode 100644 index 0000000000..c366370293 --- /dev/null +++ b/src/config/remote-file.test.ts @@ -0,0 +1,282 @@ +import test from "ava"; +import sinon from "sinon"; + +import { ActionsEnvVars } from "../actions-util"; +import * as errors from "../error-messages"; +import { Feature } from "../feature-flags"; +import { callee, getTestEnv } from "../testing-utils"; +import { ConfigurationError } from "../util"; + +import { + DEFAULT_CONFIG_FILE_NAME, + DEFAULT_CONFIG_FILE_REF, + parseRemoteFileAddress, + RemoteFileAddress, +} from "./remote-file"; + +type ParseRemoteFileAddressTest = { + input: string; + expected: RemoteFileAddress; +}; + +test("parseRemoteFileAddress accepts full remote addresses", async (t) => { + const target = callee(parseRemoteFileAddress); + + const expected: RemoteFileAddress = { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + }; + + const oldFormatInputs: ParseRemoteFileAddressTest[] = [ + { input: "owner/repo/path@ref", expected }, + { input: "owner /repo/path@ref", expected }, + { input: "owner/ repo/path@ref", expected }, + { input: "owner/repo /path@ref", expected }, + { input: "owner/repo/ path@ref", expected }, + { input: "owner/repo/path @ref", expected }, + { input: "owner/repo/path@ ref", expected }, + { + input: "owner/repo/path/to/codeql.yml@ref/feature", + expected: { ...expected, path: "path/to/codeql.yml", ref: "ref/feature" }, + }, + { + input: " owner/repo/path/to/codeql.yml@ref/feature ", + expected: { ...expected, path: "path/to/codeql.yml", ref: "ref/feature" }, + }, + ]; + + for (const oldFormatInput of oldFormatInputs) { + await target + .withArgs(oldFormatInput.input) + .passes(async (fn) => t.deepEqual(await fn(), oldFormatInput.expected)); + } + + // New format. + const newFormatInputs: ParseRemoteFileAddressTest[] = [ + { input: "owner/repo@ref:path", expected }, + { input: "owner /repo@ref:path", expected }, + { input: "owner/ repo@ref:path", expected }, + { input: "owner/repo @ref:path", expected }, + { input: "owner/repo@ ref:path", expected }, + { input: "owner/repo@ref :path", expected }, + { input: "owner/repo@ref: path", expected }, + { + input: "owner/repo@ref/feature:path/to/codeql.yml", + expected: { ...expected, path: "path/to/codeql.yml", ref: "ref/feature" }, + }, + { + input: " owner/repo@ref/feature:path/to/codeql.yml ", + expected: { ...expected, path: "path/to/codeql.yml", ref: "ref/feature" }, + }, + ]; + + for (const newFormatInput of newFormatInputs) { + const targetWithArgs = target.withArgs(newFormatInput.input); + + // Should fail when the FF is not enabled. + await targetWithArgs + .withFeatures([]) + .passes(async (fn) => + t.throwsAsync(fn, { instanceOf: ConfigurationError }), + ); + + // And pass when the FF is enabled. + await targetWithArgs + .withFeatures([Feature.NewRemoteFileAddresses]) + .passes(async (fn) => t.deepEqual(await fn(), newFormatInput.expected)); + } +}); + +test("parseRemoteFileAddress accepts remote address without an owner", async (t) => { + const target = callee(parseRemoteFileAddress); + + const env = target.getState().env; + const owner = "test-owner"; + const getRequired = sinon.stub(env, "getRequired"); + getRequired + .withArgs(ActionsEnvVars.GITHUB_REPOSITORY) + .returns(`${owner}/current-repo`); + + const targetWithEnv = target.withEnv(env); + + const testCases: ParseRemoteFileAddressTest[] = [ + { + input: "repo@ref:path.yml", + expected: { + owner, + repo: "repo", + path: "path.yml", + ref: "ref", + }, + }, + { + input: "repo@ref", + expected: { + owner, + repo: "repo", + path: DEFAULT_CONFIG_FILE_NAME, + ref: "ref", + }, + }, + { + input: "repo:path.yml", + expected: { + owner, + repo: "repo", + path: "path.yml", + ref: DEFAULT_CONFIG_FILE_REF, + }, + }, + { + input: "repo", + expected: { + owner, + repo: "repo", + path: DEFAULT_CONFIG_FILE_NAME, + ref: DEFAULT_CONFIG_FILE_REF, + }, + }, + ]; + + for (const testCase of testCases) { + const targetWithArgs = targetWithEnv.withArgs(testCase.input); + + // Should fail when the FF is not enabled. + await targetWithArgs + .withFeatures([]) + .passes(async (fn) => + t.throwsAsync(fn, { instanceOf: ConfigurationError }), + ); + + // And pass when the FF is enabled. + await targetWithArgs + .withFeatures([Feature.NewRemoteFileAddresses]) + .passes(async (fn) => t.deepEqual(await fn(), testCase.expected)); + } +}); + +test("parseRemoteFileAddress throws for invalid `GITHUB_REPOSITORY`", async (t) => { + const target = callee(parseRemoteFileAddress).withArgs("repo@ref"); + + const env = target.getState().env; + const getRequired = sinon.stub(env, "getRequired"); + getRequired.withArgs(ActionsEnvVars.GITHUB_REPOSITORY).returns(`not-valid`); + + await target + .withEnv(env) + .withFeatures([Feature.NewRemoteFileAddresses]) + .passes(async (fn) => t.throwsAsync(fn, { instanceOf: Error })); + + t.assert(getRequired.calledOnceWith(ActionsEnvVars.GITHUB_REPOSITORY)); +}); + +test("parseRemoteFileAddress accepts remote address without a path", async (t) => { + const target = callee(parseRemoteFileAddress); + + const testCases: ParseRemoteFileAddressTest[] = [ + { + input: "owner/repo@ref", + expected: { + owner: "owner", + repo: "repo", + path: DEFAULT_CONFIG_FILE_NAME, + ref: "ref", + }, + }, + { + input: "owner/repo", + expected: { + owner: "owner", + repo: "repo", + path: DEFAULT_CONFIG_FILE_NAME, + ref: DEFAULT_CONFIG_FILE_REF, + }, + }, + ]; + + for (const testCase of testCases) { + const targetWithArgs = target.withArgs(testCase.input); + + // Should fail when the FF is not enabled. + await targetWithArgs + .withFeatures([]) + .passes(async (fn) => + t.throwsAsync(fn, { instanceOf: ConfigurationError }), + ); + + // And pass when the FF is enabled. + await targetWithArgs + .withFeatures([Feature.NewRemoteFileAddresses]) + .passes(async (fn) => t.deepEqual(await fn(), testCase.expected)); + } +}); + +test("parseRemoteFileAddress accepts remote address without a ref", async (t) => { + const target = callee(parseRemoteFileAddress).withArgs("owner/repo:path"); + + // Should only accept the input if the FF is enabled. + await target.withFeatures([]).passes(t.throwsAsync); + await target + .withFeatures([Feature.NewRemoteFileAddresses]) + .passes(async (fn) => + t.deepEqual(await fn(), { + owner: "owner", + repo: "repo", + path: "path", + ref: DEFAULT_CONFIG_FILE_REF, + } satisfies RemoteFileAddress), + ); +}); + +test("parseRemoteFileAddress rejects invalid values", async (t) => { + const env = getTestEnv(); + const owner = "owner"; + const getRequired = sinon.stub(env, "getRequired"); + getRequired + .withArgs(ActionsEnvVars.GITHUB_REPOSITORY) + .returns(`${owner}/current-repo`); + + const target = callee(parseRemoteFileAddress).withEnv(env); + + const testInputs = [ + " ", + "repo//absolute", + "repo:/absolute", + "/repo@ref", + " /repo@ref", + "repo@", + "repo:", + "repo/", + "/repo", + ":path", + "@ref", + "@ref:path", + "owner/@ref:path", + "owner/@ref", + "owner/:path", + ]; + + for (const testInput of testInputs) { + const targetWithArgs = target.withArgs(testInput); + + // Should throw both when the new format is and isn't accepted. + await targetWithArgs.withFeatures([]).passes(async (fn) => + t.throwsAsync(fn, { + instanceOf: ConfigurationError, + message: errors.getConfigFileRepoOldFormatInvalidMessage(testInput), + }), + ); + await targetWithArgs + .withFeatures([Feature.NewRemoteFileAddresses]) + .passes(async (fn) => + t.throwsAsync(fn, { + // When the new format is accepted, there are some more specific + // errors in some cases. It is sufficient for us to check that + // an exception is thrown. + instanceOf: ConfigurationError, + }), + ); + } +}); diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts new file mode 100644 index 0000000000..15990ed23f --- /dev/null +++ b/src/config/remote-file.ts @@ -0,0 +1,139 @@ +import { ActionState } from "../action-common"; +import { ActionsEnvVars } from "../actions-util"; +import { Env } from "../environment"; +import * as errorMessages from "../error-messages"; +import { Feature } from "../feature-flags"; +import { ConfigurationError, Failure, Result, Success } from "../util"; + +/** Represents remote file addresses. */ +export interface RemoteFileAddress { + /** The owner of the repository. */ + owner: string; + /** The repository name. */ + repo: string; + /** The path of the file. */ + path: string; + /** The ref of the repository. */ + ref: string; +} + +/** The default file path to use in configuration file shorthands. */ +export const DEFAULT_CONFIG_FILE_NAME = ".github/codeql-action.yaml"; + +/** The default ref to use in configuration file shorthands. */ +export const DEFAULT_CONFIG_FILE_REF = "main"; + +/** Extracts the owner from the `GITHUB_REPOSITORY` environment variable. */ +function getDefaultOwner(env: Env): string { + const currentRepoNwo = env.getRequired(ActionsEnvVars.GITHUB_REPOSITORY); + const nwoParts = currentRepoNwo.split("/"); + + if (nwoParts.length !== 2 || nwoParts[0].trim().length === 0) { + // This shouldn't happen, so we should throw if `GITHUB_REPOSITORY` doesn't match + // our expectations. + throw new Error( + `Expected ${ActionsEnvVars.GITHUB_REPOSITORY} to contain a name with owner, but got '${currentRepoNwo}'.`, + ); + } + + return nwoParts[0].trim(); +} + +/** + * The old remote address format that's always been supported for the `config-file` input. + * All the components are required. Unchanged from the previous implementation. + */ +const OLD_REMOTE_ADDRESS_FORMAT = new RegExp( + "(?[^/]+)/(?[^/]+)/(?[^@]+)@(?.*)", +); + +/** + * Attempts to parse `input` as a `RemoteFileAddress` using the old format. + * + * @param input The input to try and parse. + * @returns A `RemoteFileAddress` value if successful or `undefined` otherwise. + */ +function parseOldRemoteFileAddress( + input: string, +): Result { + const pieces = OLD_REMOTE_ADDRESS_FORMAT.exec(input); + + // 5 = 4 groups + the whole expression + if (pieces?.groups === undefined || pieces.length < 5) { + return new Failure(undefined); + } + + return new Success({ + owner: pieces.groups.owner.trim(), + repo: pieces.groups.repo.trim(), + path: pieces.groups.path.trim(), + ref: pieces.groups.ref.trim(), + }); +} + +/** + * Attempts to parse `configFile` into an array of `RemoteFileAddress` components. + * + * @param actionState The current Action state. + * @param configFile The string to try and parse. + * @returns The successful result of executing the regex. + * @throws `ConfigurationError` if the format of `configFile` is not valid. + */ +export async function parseRemoteFileAddress( + actionState: ActionState<["FeatureFlags", "Env"]>, + configFile: string, +): Promise { + // Try to parse the input using the old format. If successful, return the + // resulting `RemoteFileAddress`. Otherwise, continue using the new format. + const oldFormatAddressResult = parseOldRemoteFileAddress(configFile); + + if (oldFormatAddressResult.isSuccess()) { + return oldFormatAddressResult.value; + } + + // If the FF for the new format is not enabled, throw the old format error. + const allowNewFormat = await actionState.features.getValue( + Feature.NewRemoteFileAddresses, + ); + if (!allowNewFormat) { + throw new ConfigurationError( + errorMessages.getConfigFileRepoOldFormatInvalidMessage(configFile), + ); + } + + // retrieve the various parts of the config location, and ensure they're present + const format = new RegExp( + "^((?[^:@/]+)/)?(?[^:@/]+)(@(?[^:]+))?(:(?.+))?$", + ); + const pieces = format.exec(configFile.trim()); + + const repo: string | undefined = pieces?.groups?.repo?.trim(); + + // Check that the regular expression matched and that we have at least the repo name. + if (!pieces?.groups || !repo || repo.length === 0) { + // Neither the old format nor the new format worked. Throw an error that + // explains the format we accept. We only mention the new format, since that's + // what we want to be used going forward. + throw new ConfigurationError( + errorMessages.getConfigFileRepoFormatInvalidMessage(configFile), + ); + } + + const owner: string | undefined = pieces.groups.owner?.trim(); + const path: string | undefined = pieces.groups.path?.trim(); + const ref: string | undefined = pieces.groups.ref?.trim(); + + // Ensure that the path is a relative path. + if (path?.startsWith("/")) { + throw new ConfigurationError( + `The path component of '${configFile}' cannot be an absolute path.`, + ); + } + + return { + owner: owner || getDefaultOwner(actionState.env), + repo, + path: path || DEFAULT_CONFIG_FILE_NAME, + ref: ref || DEFAULT_CONFIG_FILE_REF, + }; +} diff --git a/src/environment.ts b/src/environment.ts index c3f54ebd27..c0ca050b03 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -160,3 +160,11 @@ export enum EnvVar { /** Used by Code Scanning Risk Assessment to communicate the assessment ID to the CodeQL Action. */ RISK_ASSESSMENT_ID = "CODEQL_ACTION_RISK_ASSESSMENT_ID", } + +/** A wrapper around an environment, to allow abstracting away from `process.env` in tests. */ +export interface Env { + /** Tries to get the value for `name` and throws if there isn't one. */ + getRequired(name: string): string; + /** Gets the value for `name`, or `undefined` if it isn't set or empty. */ + getOptional(name: string): string | undefined; +} diff --git a/src/error-messages.ts b/src/error-messages.ts index 578ec69733..bd32a6a04a 100644 --- a/src/error-messages.ts +++ b/src/error-messages.ts @@ -30,7 +30,7 @@ export function getInvalidConfigFileMessage( return `The configuration file "${configFile}" is invalid: ${messages.slice(0, 10).join(", ")}${andMore}`; } -export function getConfigFileRepoFormatInvalidMessage( +export function getConfigFileRepoOldFormatInvalidMessage( configFile: string, ): string { let error = `The configuration file "${configFile}" is not a supported remote file reference.`; @@ -39,6 +39,15 @@ export function getConfigFileRepoFormatInvalidMessage( return error; } +export function getConfigFileRepoFormatInvalidMessage( + configFile: string, +): string { + let error = `The configuration file "${configFile}" is not a supported remote file reference.`; + error += " Expected format [/][@][:]"; + + return error; +} + export function getConfigFileFormatInvalidMessage(configFile: string): string { return `The configuration file "${configFile}" could not be read`; } diff --git a/src/feature-flags.ts b/src/feature-flags.ts index a796982242..30358c3e24 100644 --- a/src/feature-flags.ts +++ b/src/feature-flags.ts @@ -90,6 +90,8 @@ export enum Feature { ForceNightly = "force_nightly", IgnoreGeneratedFiles = "ignore_generated_files", JavaNetworkDebugging = "java_network_debugging", + /** Allow the new remote file address format. */ + NewRemoteFileAddresses = "new_remote_file_addresses", OverlayAnalysis = "overlay_analysis", OverlayAnalysisCodeScanningCpp = "overlay_analysis_code_scanning_cpp", OverlayAnalysisCodeScanningCsharp = "overlay_analysis_code_scanning_csharp", @@ -248,6 +250,11 @@ export const featureConfig = { envVar: "CODEQL_ACTION_JAVA_NETWORK_DEBUGGING", minimumVersion: undefined, }, + [Feature.NewRemoteFileAddresses]: { + defaultValue: false, + envVar: "CODEQL_ACTION_NEW_REMOTE_FILE_ADDRESSES", + minimumVersion: undefined, + }, [Feature.OverlayAnalysis]: { defaultValue: false, envVar: "CODEQL_ACTION_OVERLAY_ANALYSIS", diff --git a/src/testing-utils.ts b/src/testing-utils.ts index 2660c21a69..80ea557231 100644 --- a/src/testing-utils.ts +++ b/src/testing-utils.ts @@ -10,7 +10,8 @@ import test, { import nock from "nock"; import * as sinon from "sinon"; -import { ActionsEnv, getActionVersion } from "./actions-util"; +import { ActionState, StateFeature } from "./action-common"; +import { ActionsEnv, ActionsEnvVars, getActionVersion } from "./actions-util"; import { AnalysisKind } from "./analyses"; import * as apiClient from "./api-client"; import { GitHubApiDetails } from "./api-client"; @@ -18,6 +19,7 @@ import { CachingKind } from "./caching-utils"; import * as codeql from "./codeql"; import { Config } from "./config-utils"; import * as defaults from "./defaults.json"; +import { Env } from "./environment"; import { CodeQLDefaultVersionInfo, Feature, @@ -29,6 +31,7 @@ import { OverlayDatabaseMode } from "./overlay/overlay-database-mode"; import { DEFAULT_DEBUG_ARTIFACT_NAME, DEFAULT_DEBUG_DATABASE_NAME, + getEnv, GitHubVariant, GitHubVersion, HTTPError, @@ -172,6 +175,11 @@ export function makeMacro( return wrapper; } +export function getTestEnv(): Env { + const testEnv: NodeJS.ProcessEnv = {}; + return getEnv(testEnv); +} + /** * Gets an `ActionsEnv` instance for use in tests. */ @@ -181,6 +189,91 @@ export function getTestActionsEnv(): ActionsEnv { }; } +/** For testing purposes, we make all available state features accessible in `TestEnv`. */ +type AllState = ["Logger", "Env", "FeatureFlags"]; + +/** + * Wraps a function that accepts an `ActionEnv` for testing in different environments. + */ +export class TestEnv< + Args extends readonly any[], + R, + Fs extends ReadonlyArray, +> { + private readonly fn: (state: ActionState, ...args: Args) => R; + private args?: Args; + private state: ActionState; + + constructor( + fn: (state: ActionState, ...args: Args) => R, + args?: Args, + initialState?: ActionState, + ) { + this.fn = fn; + this.args = args; + this.state = initialState || { + logger: new RecordingLogger(), + env: getTestEnv(), + features: createFeatures([]), + }; + } + + private clone(): TestEnv { + return new TestEnv(this.fn, this.args, { ...this.state }); + } + + public getState(): ActionState { + return this.state; + } + + public getArgs(): Args | undefined { + return this.args; + } + + public withArgs(...args: Args) { + const result = this.clone(); + result.args = args; + return result; + } + + public withFeatures(enabled: Feature[]): TestEnv { + const result = this.clone(); + result.state.features = createFeatures(enabled); + return result; + } + + public withEnv(env: Env): TestEnv { + const result = this.clone(); + result.state.env = env; + return result; + } + + call(): R { + if (!this.args) { + throw new Error("Trying to call function in TestEnv without arguments."); + } + return this.fn(this.state as ActionState, ...this.args); + } + + public passes( + assertion: (makeCall: () => R) => T | Promise, + ): T | Promise { + return assertion(() => { + const result = this.call(); + return result; + }); + } +} + +/** Utility function to construct a `TestEnv`. */ +export function callee< + Args extends readonly any[], + R, + Fs extends readonly StateFeature[], +>(fn: (state: ActionState, ...args: Args) => R): TestEnv { + return new TestEnv(fn); +} + /** * Default values for environment variables typically set in an Actions * environment. Tests can override individual variables by passing them in the @@ -200,7 +293,7 @@ export const DEFAULT_ACTIONS_VARS = { GITHUB_WORKFLOW: "test-workflow", RUNNER_NAME: "my-runner", RUNNER_OS: "Linux", -} as const satisfies Record; +} as const satisfies Partial>; /** Partial mappings from GitHub Actions environment variables to values. */ export type ActionVarOverrides = Partial< diff --git a/src/util.ts b/src/util.ts index 200d68d2c2..ed8daaa08d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -13,7 +13,7 @@ import * as apiCompatibility from "./api-compatibility.json"; import type { CodeQL, VersionInfo } from "./codeql"; import type { Pack } from "./config/db-config"; import type { Config } from "./config-utils"; -import { EnvVar } from "./environment"; +import { Env, EnvVar } from "./environment"; import * as json from "./json"; import { Language } from "./languages"; import { Logger } from "./logging"; @@ -566,11 +566,22 @@ export function initializeEnvironment(version: string) { core.exportVariable(EnvVar.VERSION, version); } +/** Gets an `Env` instance for `env`, which is `process.env` by default. */ +export function getEnv(env: NodeJS.ProcessEnv = process.env): Env { + return { + getRequired: (name) => getRequiredEnvVar(env, name), + getOptional: (name) => getOptionalEnvVarFrom(env, name), + }; +} + /** - * Get an environment parameter, but throw an error if it is not set. + * Gets an environment variable, but throws an error if it is not set. */ -export function getRequiredEnvParam(paramName: string): string { - const value = process.env[paramName]; +export function getRequiredEnvVar( + env: NodeJS.ProcessEnv, + paramName: string, +): string { + const value = env[paramName]; if (value === undefined || value.length === 0) { throw new Error(`${paramName} environment variable must be set`); } @@ -578,16 +589,33 @@ export function getRequiredEnvParam(paramName: string): string { } /** - * Get an environment variable, but return `undefined` if it is not set or empty. + * Get an environment parameter, but throw an error if it is not set. */ -export function getOptionalEnvVar(paramName: string): string | undefined { - const value = process.env[paramName]; +export function getRequiredEnvParam(paramName: string): string { + return getRequiredEnvVar(process.env, paramName); +} + +/** + * Gets an environment variable, but returns `undefined` if it is not set or empty. + */ +export function getOptionalEnvVarFrom( + env: NodeJS.ProcessEnv, + paramName: string, +): string | undefined { + const value = env[paramName]; if (value?.trim().length === 0) { return undefined; } return value; } +/** + * Get an environment variable, but return `undefined` if it is not set or empty. + */ +export function getOptionalEnvVar(paramName: string): string | undefined { + return getOptionalEnvVarFrom(process.env, paramName); +} + export class HTTPError extends Error { public status: number;