From c05a5b71ad32145af66247be1af075b8cf321ecd Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 16 Jun 2026 13:00:13 +0200 Subject: [PATCH 01/23] Add `ActionsEnvVars` enum --- lib/entry-points.js | 26 ++++++++++++++------------ src/actions-util.ts | 44 ++++++++++++++++++++++++++++++++++---------- src/api-client.ts | 10 +++++++--- src/testing-utils.ts | 4 ++-- 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 11a8c4c291..1ac3da8524 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -149025,7 +149025,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 +149035,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 +149104,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 +149441,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() { 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/testing-utils.ts b/src/testing-utils.ts index 2660c21a69..411cb87319 100644 --- a/src/testing-utils.ts +++ b/src/testing-utils.ts @@ -10,7 +10,7 @@ import test, { import nock from "nock"; import * as sinon from "sinon"; -import { ActionsEnv, getActionVersion } from "./actions-util"; +import { ActionsEnv, ActionsEnvVars, getActionVersion } from "./actions-util"; import { AnalysisKind } from "./analyses"; import * as apiClient from "./api-client"; import { GitHubApiDetails } from "./api-client"; @@ -200,7 +200,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< From 652296eb9e76be692cbe3c9faa47dec498736254 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 16 Jun 2026 13:22:35 +0200 Subject: [PATCH 02/23] Allow abstracting over `process.env` --- lib/entry-points.js | 14 ++++++++++---- src/environment.ts | 8 ++++++++ src/testing-utils.ts | 7 +++++++ src/util.ts | 42 +++++++++++++++++++++++++++++++++++------- 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 1ac3da8524..fc5fa66dd6 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -148660,20 +148660,26 @@ 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 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) { 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/testing-utils.ts b/src/testing-utils.ts index 411cb87319..22ed1e21a5 100644 --- a/src/testing-utils.ts +++ b/src/testing-utils.ts @@ -18,6 +18,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 +30,7 @@ import { OverlayDatabaseMode } from "./overlay/overlay-database-mode"; import { DEFAULT_DEBUG_ARTIFACT_NAME, DEFAULT_DEBUG_DATABASE_NAME, + getEnv, GitHubVariant, GitHubVersion, HTTPError, @@ -172,6 +174,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. */ diff --git a/src/util.ts b/src/util.ts index 200d68d2c2..e2632bc957 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) => getOptionalEnvVar(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; From 9c4ee01a28d40524447d2ec6975079340d81e70c Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 16:39:06 +0100 Subject: [PATCH 03/23] Add `RemoteFileAddress` type --- src/config/remote-file.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/config/remote-file.ts diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts new file mode 100644 index 0000000000..cb90b68208 --- /dev/null +++ b/src/config/remote-file.ts @@ -0,0 +1,11 @@ +/** 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; +} From 07b6b1eac5245f536d915a7628b4244d38e7bed4 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 16:29:22 +0100 Subject: [PATCH 04/23] Move `getRemoteConfig` to `config/file.ts` --- lib/entry-points.js | 108 ++++++++++++++++++++++---------------------- src/config-utils.ts | 49 +------------------- src/config/file.ts | 53 ++++++++++++++++++++++ 3 files changed, 108 insertions(+), 102 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index fc5fa66dd6..93b8254ac3 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151428,6 +151428,58 @@ function parseUserConfig(logger, pathInput, contents, validateConfig) { } } +// 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(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 + ); +} + // src/diagnostics.ts var import_fs = require("fs"); var import_path = __toESM(require("path")); @@ -152871,41 +152923,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"); } @@ -160721,7 +160738,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_util29 = __toESM(require("util"), 1); var ERROR_CODES = { ABORTED: "archive was aborted", DIRECTORYDIRPATHREQUIRED: "diretory dirpath argument must be a non-empty string value", @@ -160746,7 +160763,7 @@ function ArchiverError(code, data) { this.code = code; this.data = data; } -import_util28.default.inherits(ArchiverError, Error); +import_util29.default.inherits(ArchiverError, Error); // node_modules/archiver/lib/core.js var import_readable_stream2 = __toESM(require_ours(), 1); @@ -163690,23 +163707,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/config-utils.ts b/src/config-utils.ts index 972734877a..39e612674a 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -27,6 +27,7 @@ import { parseUserConfig, UserConfig, } from "./config/db-config"; +import { getRemoteConfig } from "./config/file"; import { addNoLanguageDiagnostic, makeTelemetryDiagnostic, @@ -1369,54 +1370,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..537d42f651 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -1,9 +1,14 @@ import { ActionsEnv } from "../actions-util"; +import * as api from "../api-client"; +import * as errorMessages from "../error-messages"; import { RepositoryProperties, RepositoryPropertyName, } from "../feature-flags/properties"; import { Logger } from "../logging"; +import { ConfigurationError } from "../util"; + +import { parseUserConfig, UserConfig } from "./db-config"; /** * Gets the value that is configured for the configuration file, if any. @@ -32,3 +37,51 @@ export function getConfigFileInput( return undefined; } + +export 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, + ); +} From 9ef8be7176ff5afdc4c6ac2856a68855b885505b Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 16:44:31 +0100 Subject: [PATCH 05/23] Refactor `parseRemoteFileAddress` out of `getRemoteConfig` --- lib/entry-points.js | 36 +++++++++++++++++++++--------------- src/config/file.ts | 21 ++++++--------------- src/config/remote-file.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 30 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 93b8254ac3..792a8cbd8e 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151428,6 +151428,20 @@ function parseUserConfig(logger, pathInput, contents, validateConfig) { } } +// src/config/remote-file.ts +function parseRemoteFileAddress(configFile) { + const format = new RegExp( + "(?[^/]+)/(?[^/]+)/(?[^@]+)@(?.*)" + ); + const pieces = format.exec(configFile); + if (pieces?.groups === void 0 || pieces.length < 5) { + throw new ConfigurationError( + getConfigFileRepoFormatInvalidMessage(configFile) + ); + } + return pieces.groups; +} + // src/config/file.ts function getConfigFileInput(logger, actions, repositoryProperties) { const input = actions.getOptionalInput("config-file"); @@ -151445,20 +151459,12 @@ function getConfigFileInput(logger, actions, repositoryProperties) { return void 0; } 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 groups = parseRemoteFileAddress(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 + owner: groups.owner, + repo: groups.repo, + path: groups.path, + ref: groups.ref }); let fileContents; if ("content" in response.data && response.data.content !== void 0) { @@ -160738,7 +160744,7 @@ var import_async = __toESM(require_async(), 1); var import_path6 = require("path"); // node_modules/archiver/lib/error.js -var import_util29 = __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", @@ -160763,7 +160769,7 @@ function ArchiverError(code, data) { this.code = code; this.data = data; } -import_util29.default.inherits(ArchiverError, Error); +import_util30.default.inherits(ArchiverError, Error); // node_modules/archiver/lib/core.js var import_readable_stream2 = __toESM(require_ours(), 1); diff --git a/src/config/file.ts b/src/config/file.ts index 537d42f651..b48a27c5c4 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -9,6 +9,7 @@ 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. @@ -44,25 +45,15 @@ export async function getRemoteConfig( 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 groups = parseRemoteFileAddress(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, + owner: groups.owner, + repo: groups.repo, + path: groups.path, + ref: groups.ref, }); let fileContents: string; diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts index cb90b68208..a50e197eb8 100644 --- a/src/config/remote-file.ts +++ b/src/config/remote-file.ts @@ -1,3 +1,6 @@ +import * as errorMessages from "../error-messages"; +import { ConfigurationError } from "../util"; + /** Represents remote file addresses. */ export interface RemoteFileAddress { /** The owner of the repository. */ @@ -9,3 +12,27 @@ export interface RemoteFileAddress { /** The ref of the repository. */ ref: string; } + +/** + * Attempts to parse `configFile` into an array of `RemoteFileAddress` components. + * + * @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 function parseRemoteFileAddress(configFile: string) { + // 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), + ); + } + + return pieces.groups; +} From 82e5ca6c55fb2fe8ccb303cb2c9a82798b6ed5fa Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 16:48:34 +0100 Subject: [PATCH 06/23] Return `RemoteFileAddress` from `parseRemoteFileAddress` --- lib/entry-points.js | 17 +++++++++++------ src/config/file.ts | 10 +++++----- src/config/remote-file.ts | 9 +++++++-- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 792a8cbd8e..40188eb2d7 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151439,7 +151439,12 @@ function parseRemoteFileAddress(configFile) { getConfigFileRepoFormatInvalidMessage(configFile) ); } - return pieces.groups; + return { + owner: pieces.groups.owner, + repo: pieces.groups.repo, + path: pieces.groups.path, + ref: pieces.groups.ref + }; } // src/config/file.ts @@ -151459,12 +151464,12 @@ function getConfigFileInput(logger, actions, repositoryProperties) { return void 0; } async function getRemoteConfig(logger, configFile, apiDetails, validateConfig) { - const groups = parseRemoteFileAddress(configFile); + const address = parseRemoteFileAddress(configFile); const response = await getApiClientWithExternalAuth(apiDetails).rest.repos.getContent({ - owner: groups.owner, - repo: groups.repo, - path: groups.path, - ref: groups.ref + owner: address.owner, + repo: address.repo, + path: address.path, + ref: address.ref }); let fileContents; if ("content" in response.data && response.data.content !== void 0) { diff --git a/src/config/file.ts b/src/config/file.ts index b48a27c5c4..693a353061 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -45,15 +45,15 @@ export async function getRemoteConfig( apiDetails: api.GitHubApiCombinedDetails, validateConfig: boolean, ): Promise { - const groups = parseRemoteFileAddress(configFile); + const address = parseRemoteFileAddress(configFile); const response = await api .getApiClientWithExternalAuth(apiDetails) .rest.repos.getContent({ - owner: groups.owner, - repo: groups.repo, - path: groups.path, - ref: groups.ref, + owner: address.owner, + repo: address.repo, + path: address.path, + ref: address.ref, }); let fileContents: string; diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts index a50e197eb8..66328586f3 100644 --- a/src/config/remote-file.ts +++ b/src/config/remote-file.ts @@ -20,7 +20,7 @@ export interface RemoteFileAddress { * @returns The successful result of executing the regex. * @throws `ConfigurationError` if the format of `configFile` is not valid. */ -export function parseRemoteFileAddress(configFile: string) { +export function parseRemoteFileAddress(configFile: string): RemoteFileAddress { // retrieve the various parts of the config location, and ensure they're present const format = new RegExp( "(?[^/]+)/(?[^/]+)/(?[^@]+)@(?.*)", @@ -34,5 +34,10 @@ export function parseRemoteFileAddress(configFile: string) { ); } - return pieces.groups; + return { + owner: pieces.groups.owner, + repo: pieces.groups.repo, + path: pieces.groups.path, + ref: pieces.groups.ref, + }; } From c7a94c979d8510f500182a32005bbbce141a5487 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 16:55:23 +0100 Subject: [PATCH 07/23] Add `getRemoteConfig` JSDoc --- src/config/file.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/config/file.ts b/src/config/file.ts index 693a353061..420e418e04 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -39,6 +39,16 @@ export function getConfigFileInput( return undefined; } +/** + * Attempts to fetch a `UserConfig` from a remote `address`. + * + * @param logger The logger to use. + * @param configFile The remote address of the configuration file. + * @param apiDetails Information about how to connect to the API. + * @param validateConfig Whether to validate the configuration. + * + * @returns The `UserConfig`, if it could be fetched and parsed successfully. + */ export async function getRemoteConfig( logger: Logger, configFile: string, From 85c8a8cebeaacc948166643ed1d796e7d1baa3cd Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 17:14:06 +0100 Subject: [PATCH 08/23] Add tests for `parseRemoteFileAddress` --- src/config/remote-file.test.ts | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/config/remote-file.test.ts diff --git a/src/config/remote-file.test.ts b/src/config/remote-file.test.ts new file mode 100644 index 0000000000..fe8589d945 --- /dev/null +++ b/src/config/remote-file.test.ts @@ -0,0 +1,36 @@ +import test from "ava"; + +import { ConfigurationError } from "../util"; + +import { parseRemoteFileAddress, RemoteFileAddress } from "./remote-file"; + +test("expandConfigFileInput accepts full remote addresses", async (t) => { + t.deepEqual(parseRemoteFileAddress("owner/repo/path@ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual( + parseRemoteFileAddress("owner/repo/path/to/codeql.yml@ref/feature"), + { + owner: "owner", + repo: "repo", + path: "path/to/codeql.yml", + ref: "ref/feature", + } satisfies RemoteFileAddress, + ); +}); + +test("expandConfigFileInput rejects invalid values", async (t) => { + t.throws(() => parseRemoteFileAddress(" "), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress("repo:/absolute"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress("repo:file.yml:unexpected"), { + instanceOf: ConfigurationError, + }); +}); From 598d00854a4bd82afc63bb4f0a7705e978b478dc Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 17:28:20 +0100 Subject: [PATCH 09/23] Make `ref` optional in `parseRemoteFileAddress` --- lib/entry-points.js | 7 ++++--- src/config/remote-file.test.ts | 22 +++++++++++++++++++++- src/config/remote-file.ts | 15 +++++++++++---- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 40188eb2d7..48a157aa64 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151429,12 +151429,13 @@ function parseUserConfig(logger, pathInput, contents, validateConfig) { } // src/config/remote-file.ts +var DEFAULT_CONFIG_FILE_REF = "main"; function parseRemoteFileAddress(configFile) { const format = new RegExp( - "(?[^/]+)/(?[^/]+)/(?[^@]+)@(?.*)" + "(?[^/]+)/(?[^/]+)/(?[^@]+)(@(?.*))?" ); const pieces = format.exec(configFile); - if (pieces?.groups === void 0 || pieces.length < 5) { + if (!pieces?.groups?.owner || !pieces?.groups?.repo || !pieces?.groups?.path) { throw new ConfigurationError( getConfigFileRepoFormatInvalidMessage(configFile) ); @@ -151443,7 +151444,7 @@ function parseRemoteFileAddress(configFile) { owner: pieces.groups.owner, repo: pieces.groups.repo, path: pieces.groups.path, - ref: pieces.groups.ref + ref: pieces.groups.ref || DEFAULT_CONFIG_FILE_REF }; } diff --git a/src/config/remote-file.test.ts b/src/config/remote-file.test.ts index fe8589d945..d45c2ec6dd 100644 --- a/src/config/remote-file.test.ts +++ b/src/config/remote-file.test.ts @@ -2,7 +2,11 @@ import test from "ava"; import { ConfigurationError } from "../util"; -import { parseRemoteFileAddress, RemoteFileAddress } from "./remote-file"; +import { + DEFAULT_CONFIG_FILE_REF, + parseRemoteFileAddress, + RemoteFileAddress, +} from "./remote-file"; test("expandConfigFileInput accepts full remote addresses", async (t) => { t.deepEqual(parseRemoteFileAddress("owner/repo/path@ref"), { @@ -23,6 +27,22 @@ test("expandConfigFileInput accepts full remote addresses", async (t) => { ); }); +test("expandConfigFileInput accepts remote address without a ref", async (t) => { + t.deepEqual(parseRemoteFileAddress("owner/repo/path"), { + owner: "owner", + repo: "repo", + path: "path", + ref: DEFAULT_CONFIG_FILE_REF, + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress("owner/repo/path@"), { + owner: "owner", + repo: "repo", + path: "path", + ref: DEFAULT_CONFIG_FILE_REF, + } satisfies RemoteFileAddress); +}); + test("expandConfigFileInput rejects invalid values", async (t) => { t.throws(() => parseRemoteFileAddress(" "), { instanceOf: ConfigurationError, diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts index 66328586f3..9ab89ad4a2 100644 --- a/src/config/remote-file.ts +++ b/src/config/remote-file.ts @@ -13,6 +13,9 @@ export interface RemoteFileAddress { ref: string; } +/** The default ref to use in configuration file shorthands. */ +export const DEFAULT_CONFIG_FILE_REF = "main"; + /** * Attempts to parse `configFile` into an array of `RemoteFileAddress` components. * @@ -23,12 +26,16 @@ export interface RemoteFileAddress { export function parseRemoteFileAddress(configFile: string): RemoteFileAddress { // 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) { + // Check that the regular expression matched and that we have at least the required components. + if ( + !pieces?.groups?.owner || + !pieces?.groups?.repo || + !pieces?.groups?.path + ) { throw new ConfigurationError( errorMessages.getConfigFileRepoFormatInvalidMessage(configFile), ); @@ -38,6 +45,6 @@ export function parseRemoteFileAddress(configFile: string): RemoteFileAddress { owner: pieces.groups.owner, repo: pieces.groups.repo, path: pieces.groups.path, - ref: pieces.groups.ref, + ref: pieces.groups.ref || DEFAULT_CONFIG_FILE_REF, }; } From e537ff20a45eb030d9b475d95f9a39313bd474c2 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 17:32:44 +0100 Subject: [PATCH 10/23] Make `path` optional in `parseRemoteFileAddress` --- lib/entry-points.js | 7 ++++--- src/config/remote-file.test.ts | 19 ++++++++++++++++++- src/config/remote-file.ts | 13 ++++++------- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 48a157aa64..7ad70e03d8 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151429,13 +151429,14 @@ 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 parseRemoteFileAddress(configFile) { const format = new RegExp( - "(?[^/]+)/(?[^/]+)/(?[^@]+)(@(?.*))?" + "(?[^/]+)/(?[^/@]+)(/(?[^@]+))?(@(?.*))?" ); const pieces = format.exec(configFile); - if (!pieces?.groups?.owner || !pieces?.groups?.repo || !pieces?.groups?.path) { + if (!pieces?.groups?.owner || !pieces?.groups?.repo) { throw new ConfigurationError( getConfigFileRepoFormatInvalidMessage(configFile) ); @@ -151443,7 +151444,7 @@ function parseRemoteFileAddress(configFile) { return { owner: pieces.groups.owner, repo: pieces.groups.repo, - path: pieces.groups.path, + path: pieces.groups.path || DEFAULT_CONFIG_FILE_NAME, ref: pieces.groups.ref || DEFAULT_CONFIG_FILE_REF }; } diff --git a/src/config/remote-file.test.ts b/src/config/remote-file.test.ts index d45c2ec6dd..457c24120c 100644 --- a/src/config/remote-file.test.ts +++ b/src/config/remote-file.test.ts @@ -3,6 +3,7 @@ import test from "ava"; import { ConfigurationError } from "../util"; import { + DEFAULT_CONFIG_FILE_NAME, DEFAULT_CONFIG_FILE_REF, parseRemoteFileAddress, RemoteFileAddress, @@ -27,6 +28,22 @@ test("expandConfigFileInput accepts full remote addresses", async (t) => { ); }); +test("expandConfigFileInput accepts remote address without a path", async (t) => { + t.deepEqual(parseRemoteFileAddress("owner/repo@ref"), { + owner: "owner", + repo: "repo", + path: DEFAULT_CONFIG_FILE_NAME, + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress("owner/repo"), { + owner: "owner", + repo: "repo", + path: DEFAULT_CONFIG_FILE_NAME, + ref: DEFAULT_CONFIG_FILE_REF, + } satisfies RemoteFileAddress); +}); + test("expandConfigFileInput accepts remote address without a ref", async (t) => { t.deepEqual(parseRemoteFileAddress("owner/repo/path"), { owner: "owner", @@ -47,7 +64,7 @@ test("expandConfigFileInput rejects invalid values", async (t) => { t.throws(() => parseRemoteFileAddress(" "), { instanceOf: ConfigurationError, }); - t.throws(() => parseRemoteFileAddress("repo:/absolute"), { + t.throws(() => parseRemoteFileAddress("repo//absolute"), { instanceOf: ConfigurationError, }); t.throws(() => parseRemoteFileAddress("repo:file.yml:unexpected"), { diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts index 9ab89ad4a2..ad34864875 100644 --- a/src/config/remote-file.ts +++ b/src/config/remote-file.ts @@ -13,6 +13,9 @@ export interface RemoteFileAddress { 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"; @@ -26,16 +29,12 @@ export const DEFAULT_CONFIG_FILE_REF = "main"; export function parseRemoteFileAddress(configFile: string): RemoteFileAddress { // retrieve the various parts of the config location, and ensure they're present const format = new RegExp( - "(?[^/]+)/(?[^/]+)/(?[^@]+)(@(?.*))?", + "(?[^/]+)/(?[^/@]+)(/(?[^@]+))?(@(?.*))?", ); const pieces = format.exec(configFile); // Check that the regular expression matched and that we have at least the required components. - if ( - !pieces?.groups?.owner || - !pieces?.groups?.repo || - !pieces?.groups?.path - ) { + if (!pieces?.groups?.owner || !pieces?.groups?.repo) { throw new ConfigurationError( errorMessages.getConfigFileRepoFormatInvalidMessage(configFile), ); @@ -44,7 +43,7 @@ export function parseRemoteFileAddress(configFile: string): RemoteFileAddress { return { owner: pieces.groups.owner, repo: pieces.groups.repo, - path: pieces.groups.path, + path: pieces.groups.path || DEFAULT_CONFIG_FILE_NAME, ref: pieces.groups.ref || DEFAULT_CONFIG_FILE_REF, }; } From 12821cff0c52b3d5e5c6601a0903cdecd6a8a7ce Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 17:49:21 +0100 Subject: [PATCH 11/23] Make `owner` optional in `parseRemoteFileAddress` --- lib/entry-points.js | 33 ++++++++++++++--- src/config-utils.test.ts | 28 -------------- src/config/file.ts | 4 +- src/config/remote-file.test.ts | 68 ++++++++++++++++++++++++++++------ src/config/remote-file.ts | 41 +++++++++++++++++--- 5 files changed, 121 insertions(+), 53 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 7ad70e03d8..d1ce66f802 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -148660,6 +148660,12 @@ function initializeEnvironment(version) { core2.exportVariable("CODEQL_ACTION_FEATURE_WILL_UPLOAD" /* FEATURE_WILL_UPLOAD */, "true"); core2.exportVariable("CODEQL_ACTION_VERSION" /* VERSION */, version); } +function getEnv(env = process.env) { + return { + getRequired: (name) => getRequiredEnvVar(env, name), + getOptional: (name) => getOptionalEnvVar(name) + }; +} function getRequiredEnvVar(env, paramName) { const value = env[paramName]; if (value === void 0 || value.length === 0) { @@ -151431,19 +151437,34 @@ 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 parseRemoteFileAddress(configFile) { +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(); +} +function parseRemoteFileAddress(env, configFile) { const format = new RegExp( - "(?[^/]+)/(?[^/@]+)(/(?[^@]+))?(@(?.*))?" + "((?[^/]+)/)?(?[^/@]+)(/(?[^@]+))?(@(?.*))?" ); const pieces = format.exec(configFile); - if (!pieces?.groups?.owner || !pieces?.groups?.repo) { + if (!pieces?.groups?.repo || pieces.groups.repo.trim().length === 0) { throw new ConfigurationError( getConfigFileRepoFormatInvalidMessage(configFile) ); } + if (pieces.groups.path?.startsWith("/")) { + throw new ConfigurationError( + `The path component of '${configFile}' cannot be an absolute path.` + ); + } return { - owner: pieces.groups.owner, - repo: pieces.groups.repo, + owner: pieces.groups.owner || getDefaultOwner(env), + repo: pieces.groups.repo.trim(), path: pieces.groups.path || DEFAULT_CONFIG_FILE_NAME, ref: pieces.groups.ref || DEFAULT_CONFIG_FILE_REF }; @@ -151466,7 +151487,7 @@ function getConfigFileInput(logger, actions, repositoryProperties) { return void 0; } async function getRemoteConfig(logger, configFile, apiDetails, validateConfig) { - const address = parseRemoteFileAddress(configFile); + const address = parseRemoteFileAddress(getEnv(), configFile); const response = await getApiClientWithExternalAuth(apiDetails).rest.repos.getContent({ owner: address.owner, repo: address.repo, 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/file.ts b/src/config/file.ts index 420e418e04..f178386512 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -6,7 +6,7 @@ import { RepositoryPropertyName, } from "../feature-flags/properties"; import { Logger } from "../logging"; -import { ConfigurationError } from "../util"; +import { ConfigurationError, getEnv } from "../util"; import { parseUserConfig, UserConfig } from "./db-config"; import { parseRemoteFileAddress } from "./remote-file"; @@ -55,7 +55,7 @@ export async function getRemoteConfig( apiDetails: api.GitHubApiCombinedDetails, validateConfig: boolean, ): Promise { - const address = parseRemoteFileAddress(configFile); + const address = parseRemoteFileAddress(getEnv(), configFile); const response = await api .getApiClientWithExternalAuth(apiDetails) diff --git a/src/config/remote-file.test.ts b/src/config/remote-file.test.ts index 457c24120c..456213a1a3 100644 --- a/src/config/remote-file.test.ts +++ b/src/config/remote-file.test.ts @@ -1,5 +1,8 @@ import test from "ava"; +import sinon from "sinon"; +import { ActionsEnvVars } from "../actions-util"; +import { getTestEnv } from "../testing-utils"; import { ConfigurationError } from "../util"; import { @@ -10,7 +13,9 @@ import { } from "./remote-file"; test("expandConfigFileInput accepts full remote addresses", async (t) => { - t.deepEqual(parseRemoteFileAddress("owner/repo/path@ref"), { + const env = getTestEnv(); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path@ref"), { owner: "owner", repo: "repo", path: "path", @@ -18,7 +23,7 @@ test("expandConfigFileInput accepts full remote addresses", async (t) => { } satisfies RemoteFileAddress); t.deepEqual( - parseRemoteFileAddress("owner/repo/path/to/codeql.yml@ref/feature"), + parseRemoteFileAddress(env, "owner/repo/path/to/codeql.yml@ref/feature"), { owner: "owner", repo: "repo", @@ -28,15 +33,50 @@ test("expandConfigFileInput accepts full remote addresses", async (t) => { ); }); +test("expandConfigFileInput accepts remote address without an owner", async (t) => { + const env = getTestEnv(); + const owner = "test-owner"; + const getRequired = sinon.stub(env, "getRequired"); + getRequired + .withArgs(ActionsEnvVars.GITHUB_REPOSITORY) + .returns(`${owner}/current-repo`); + + t.deepEqual(parseRemoteFileAddress(env, "repo@ref"), { + owner, + repo: "repo", + path: DEFAULT_CONFIG_FILE_NAME, + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "repo"), { + owner, + repo: "repo", + path: DEFAULT_CONFIG_FILE_NAME, + ref: DEFAULT_CONFIG_FILE_REF, + } satisfies RemoteFileAddress); +}); + +test("expandConfigFileInput throws for invalid `GITHUB_REPOSITORY`", async (t) => { + const env = getTestEnv(); + const getRequired = sinon.stub(env, "getRequired"); + getRequired.withArgs(ActionsEnvVars.GITHUB_REPOSITORY).returns(`not-valid`); + + t.throws(() => parseRemoteFileAddress(env, "repo@ref"), { + instanceOf: Error, + }); +}); + test("expandConfigFileInput accepts remote address without a path", async (t) => { - t.deepEqual(parseRemoteFileAddress("owner/repo@ref"), { + const env = getTestEnv(); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo@ref"), { owner: "owner", repo: "repo", path: DEFAULT_CONFIG_FILE_NAME, ref: "ref", } satisfies RemoteFileAddress); - t.deepEqual(parseRemoteFileAddress("owner/repo"), { + t.deepEqual(parseRemoteFileAddress(env, "owner/repo"), { owner: "owner", repo: "repo", path: DEFAULT_CONFIG_FILE_NAME, @@ -45,14 +85,16 @@ test("expandConfigFileInput accepts remote address without a path", async (t) => }); test("expandConfigFileInput accepts remote address without a ref", async (t) => { - t.deepEqual(parseRemoteFileAddress("owner/repo/path"), { + const env = getTestEnv(); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path"), { owner: "owner", repo: "repo", path: "path", ref: DEFAULT_CONFIG_FILE_REF, } satisfies RemoteFileAddress); - t.deepEqual(parseRemoteFileAddress("owner/repo/path@"), { + t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path@"), { owner: "owner", repo: "repo", path: "path", @@ -61,13 +103,17 @@ test("expandConfigFileInput accepts remote address without a ref", async (t) => }); test("expandConfigFileInput rejects invalid values", async (t) => { - t.throws(() => parseRemoteFileAddress(" "), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress("repo//absolute"), { + const env = getTestEnv(); + const owner = "owner"; + const getRequired = sinon.stub(env, "getRequired"); + getRequired + .withArgs(ActionsEnvVars.GITHUB_REPOSITORY) + .returns(`${owner}/current-repo`); + + t.throws(() => parseRemoteFileAddress(env, " "), { instanceOf: ConfigurationError, }); - t.throws(() => parseRemoteFileAddress("repo:file.yml:unexpected"), { + t.throws(() => parseRemoteFileAddress(env, "repo//absolute"), { instanceOf: ConfigurationError, }); }); diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts index ad34864875..9b8e370560 100644 --- a/src/config/remote-file.ts +++ b/src/config/remote-file.ts @@ -1,3 +1,5 @@ +import { ActionsEnvVars } from "../actions-util"; +import { Env } from "../environment"; import * as errorMessages from "../error-messages"; import { ConfigurationError } from "../util"; @@ -19,30 +21,57 @@ 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(); +} + /** * Attempts to parse `configFile` into an array of `RemoteFileAddress` components. * + * @param env The current environment variables. * @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 function parseRemoteFileAddress(configFile: string): RemoteFileAddress { +export function parseRemoteFileAddress( + env: Env, + configFile: string, +): RemoteFileAddress { // retrieve the various parts of the config location, and ensure they're present const format = new RegExp( - "(?[^/]+)/(?[^/@]+)(/(?[^@]+))?(@(?.*))?", + "((?[^/]+)/)?(?[^/@]+)(/(?[^@]+))?(@(?.*))?", ); const pieces = format.exec(configFile); - // Check that the regular expression matched and that we have at least the required components. - if (!pieces?.groups?.owner || !pieces?.groups?.repo) { + // Check that the regular expression matched and that we have at least the repo name. + if (!pieces?.groups?.repo || pieces.groups.repo.trim().length === 0) { throw new ConfigurationError( errorMessages.getConfigFileRepoFormatInvalidMessage(configFile), ); } + // Ensure that the path is a relative path. + if (pieces.groups.path?.startsWith("/")) { + throw new ConfigurationError( + `The path component of '${configFile}' cannot be an absolute path.`, + ); + } + return { - owner: pieces.groups.owner, - repo: pieces.groups.repo, + owner: pieces.groups.owner || getDefaultOwner(env), + repo: pieces.groups.repo.trim(), path: pieces.groups.path || DEFAULT_CONFIG_FILE_NAME, ref: pieces.groups.ref || DEFAULT_CONFIG_FILE_REF, }; From 81ad479e13cf3898f40b8117f84811b3bba8b8c2 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 18:31:16 +0100 Subject: [PATCH 12/23] Fix test names --- src/config/remote-file.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/config/remote-file.test.ts b/src/config/remote-file.test.ts index 456213a1a3..a914fde0c8 100644 --- a/src/config/remote-file.test.ts +++ b/src/config/remote-file.test.ts @@ -12,7 +12,7 @@ import { RemoteFileAddress, } from "./remote-file"; -test("expandConfigFileInput accepts full remote addresses", async (t) => { +test("parseRemoteFileAddress accepts full remote addresses", async (t) => { const env = getTestEnv(); t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path@ref"), { @@ -33,7 +33,7 @@ test("expandConfigFileInput accepts full remote addresses", async (t) => { ); }); -test("expandConfigFileInput accepts remote address without an owner", async (t) => { +test("parseRemoteFileAddress accepts remote address without an owner", async (t) => { const env = getTestEnv(); const owner = "test-owner"; const getRequired = sinon.stub(env, "getRequired"); @@ -56,7 +56,7 @@ test("expandConfigFileInput accepts remote address without an owner", async (t) } satisfies RemoteFileAddress); }); -test("expandConfigFileInput throws for invalid `GITHUB_REPOSITORY`", async (t) => { +test("parseRemoteFileAddress throws for invalid `GITHUB_REPOSITORY`", async (t) => { const env = getTestEnv(); const getRequired = sinon.stub(env, "getRequired"); getRequired.withArgs(ActionsEnvVars.GITHUB_REPOSITORY).returns(`not-valid`); @@ -66,7 +66,7 @@ test("expandConfigFileInput throws for invalid `GITHUB_REPOSITORY`", async (t) = }); }); -test("expandConfigFileInput accepts remote address without a path", async (t) => { +test("parseRemoteFileAddress accepts remote address without a path", async (t) => { const env = getTestEnv(); t.deepEqual(parseRemoteFileAddress(env, "owner/repo@ref"), { @@ -84,7 +84,7 @@ test("expandConfigFileInput accepts remote address without a path", async (t) => } satisfies RemoteFileAddress); }); -test("expandConfigFileInput accepts remote address without a ref", async (t) => { +test("parseRemoteFileAddress accepts remote address without a ref", async (t) => { const env = getTestEnv(); t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path"), { @@ -102,7 +102,7 @@ test("expandConfigFileInput accepts remote address without a ref", async (t) => } satisfies RemoteFileAddress); }); -test("expandConfigFileInput rejects invalid values", async (t) => { +test("parseRemoteFileAddress rejects invalid values", async (t) => { const env = getTestEnv(); const owner = "owner"; const getRequired = sinon.stub(env, "getRequired"); From 8102fa6675c005e2d4bf5079b09bb98dc5ed1f54 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 18:32:55 +0100 Subject: [PATCH 13/23] Anchor regex and trim input --- lib/entry-points.js | 4 ++-- src/config/remote-file.test.ts | 13 +++++++++++++ src/config/remote-file.ts | 4 ++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index d1ce66f802..76c0e27d15 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151449,9 +151449,9 @@ function getDefaultOwner(env) { } function parseRemoteFileAddress(env, configFile) { const format = new RegExp( - "((?[^/]+)/)?(?[^/@]+)(/(?[^@]+))?(@(?.*))?" + "^((?[^/]+)/)?(?[^/@]+)(/(?[^@]+))?(@(?.*))?$" ); - const pieces = format.exec(configFile); + const pieces = format.exec(configFile.trim()); if (!pieces?.groups?.repo || pieces.groups.repo.trim().length === 0) { throw new ConfigurationError( getConfigFileRepoFormatInvalidMessage(configFile) diff --git a/src/config/remote-file.test.ts b/src/config/remote-file.test.ts index a914fde0c8..1d4ed1f724 100644 --- a/src/config/remote-file.test.ts +++ b/src/config/remote-file.test.ts @@ -31,6 +31,19 @@ test("parseRemoteFileAddress accepts full remote addresses", async (t) => { ref: "ref/feature", } satisfies RemoteFileAddress, ); + + t.deepEqual( + parseRemoteFileAddress( + env, + " owner/repo/path/to/codeql.yml@ref/feature ", + ), + { + owner: "owner", + repo: "repo", + path: "path/to/codeql.yml", + ref: "ref/feature", + } satisfies RemoteFileAddress, + ); }); test("parseRemoteFileAddress accepts remote address without an owner", async (t) => { diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts index 9b8e370560..c2303417e8 100644 --- a/src/config/remote-file.ts +++ b/src/config/remote-file.ts @@ -51,9 +51,9 @@ export function parseRemoteFileAddress( ): RemoteFileAddress { // retrieve the various parts of the config location, and ensure they're present const format = new RegExp( - "((?[^/]+)/)?(?[^/@]+)(/(?[^@]+))?(@(?.*))?", + "^((?[^/]+)/)?(?[^/@]+)(/(?[^@]+))?(@(?.*))?$", ); - const pieces = format.exec(configFile); + const pieces = format.exec(configFile.trim()); // Check that the regular expression matched and that we have at least the repo name. if (!pieces?.groups?.repo || pieces.groups.repo.trim().length === 0) { From 00e5a58139c86f1c0da29580717054c1caccd56a Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 18:34:25 +0100 Subject: [PATCH 14/23] Update format in error message --- lib/entry-points.js | 2 +- src/error-messages.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 76c0e27d15..05a52df4a0 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151053,7 +151053,7 @@ function getInvalidConfigFileMessage(configFile, messages) { } function getConfigFileRepoFormatInvalidMessage(configFile) { let error3 = `The configuration file "${configFile}" is not a supported remote file reference.`; - error3 += " Expected format //@"; + error3 += " Expected format [/][/][@]"; return error3; } function getConfigFileFormatInvalidMessage(configFile) { diff --git a/src/error-messages.ts b/src/error-messages.ts index 578ec69733..b45d9562b1 100644 --- a/src/error-messages.ts +++ b/src/error-messages.ts @@ -34,7 +34,7 @@ export function getConfigFileRepoFormatInvalidMessage( configFile: string, ): string { let error = `The configuration file "${configFile}" is not a supported remote file reference.`; - error += " Expected format //@"; + error += " Expected format [/][/][@]"; return error; } From 8d69da902e3da6c8d555cb2cb9a96efca8ab2b76 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 18:42:43 +0100 Subject: [PATCH 15/23] Improve whitespace handling --- lib/entry-points.js | 16 +++++++----- src/config/remote-file.test.ts | 48 ++++++++++++++++++++++++++++++++++ src/config/remote-file.ts | 18 ++++++++----- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 05a52df4a0..dff19f1be8 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151452,21 +151452,25 @@ function parseRemoteFileAddress(env, configFile) { "^((?[^/]+)/)?(?[^/@]+)(/(?[^@]+))?(@(?.*))?$" ); const pieces = format.exec(configFile.trim()); - if (!pieces?.groups?.repo || pieces.groups.repo.trim().length === 0) { + const repo = pieces?.groups?.repo?.trim(); + if (!pieces?.groups || !repo || repo.length === 0) { throw new ConfigurationError( getConfigFileRepoFormatInvalidMessage(configFile) ); } - if (pieces.groups.path?.startsWith("/")) { + 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: pieces.groups.owner || getDefaultOwner(env), - repo: pieces.groups.repo.trim(), - path: pieces.groups.path || DEFAULT_CONFIG_FILE_NAME, - ref: pieces.groups.ref || DEFAULT_CONFIG_FILE_REF + owner: owner || getDefaultOwner(env), + repo, + path: path29 || DEFAULT_CONFIG_FILE_NAME, + ref: ref || DEFAULT_CONFIG_FILE_REF }; } diff --git a/src/config/remote-file.test.ts b/src/config/remote-file.test.ts index 1d4ed1f724..36b68fd552 100644 --- a/src/config/remote-file.test.ts +++ b/src/config/remote-file.test.ts @@ -22,6 +22,48 @@ test("parseRemoteFileAddress accepts full remote addresses", async (t) => { ref: "ref", } satisfies RemoteFileAddress); + t.deepEqual(parseRemoteFileAddress(env, "owner /repo/path@ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/ repo/path@ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo /path@ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo/ path@ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path @ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path@ ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + t.deepEqual( parseRemoteFileAddress(env, "owner/repo/path/to/codeql.yml@ref/feature"), { @@ -129,4 +171,10 @@ test("parseRemoteFileAddress rejects invalid values", async (t) => { t.throws(() => parseRemoteFileAddress(env, "repo//absolute"), { instanceOf: ConfigurationError, }); + t.throws(() => parseRemoteFileAddress(env, "/repo@ref"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, " /repo@ref"), { + instanceOf: ConfigurationError, + }); }); diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts index c2303417e8..a4f8ea890a 100644 --- a/src/config/remote-file.ts +++ b/src/config/remote-file.ts @@ -55,24 +55,30 @@ export function parseRemoteFileAddress( ); 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 || pieces.groups.repo.trim().length === 0) { + if (!pieces?.groups || !repo || repo.length === 0) { 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 (pieces.groups.path?.startsWith("/")) { + if (path?.startsWith("/")) { throw new ConfigurationError( `The path component of '${configFile}' cannot be an absolute path.`, ); } return { - owner: pieces.groups.owner || getDefaultOwner(env), - repo: pieces.groups.repo.trim(), - path: pieces.groups.path || DEFAULT_CONFIG_FILE_NAME, - ref: pieces.groups.ref || DEFAULT_CONFIG_FILE_REF, + owner: owner || getDefaultOwner(env), + repo, + path: path || DEFAULT_CONFIG_FILE_NAME, + ref: ref || DEFAULT_CONFIG_FILE_REF, }; } From 3fe7ef97d30a60efd078de62d1c03699744655c0 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 18:43:56 +0100 Subject: [PATCH 16/23] Fix `getEnv` not using `getOptionalEnvVarFrom` --- lib/entry-points.js | 2 +- src/util.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index dff19f1be8..c91850e7a7 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -148663,7 +148663,7 @@ function initializeEnvironment(version) { function getEnv(env = process.env) { return { getRequired: (name) => getRequiredEnvVar(env, name), - getOptional: (name) => getOptionalEnvVar(name) + getOptional: (name) => getOptionalEnvVarFrom(env, name) }; } function getRequiredEnvVar(env, paramName) { diff --git a/src/util.ts b/src/util.ts index e2632bc957..ed8daaa08d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -570,7 +570,7 @@ export function initializeEnvironment(version: string) { export function getEnv(env: NodeJS.ProcessEnv = process.env): Env { return { getRequired: (name) => getRequiredEnvVar(env, name), - getOptional: (name) => getOptionalEnvVar(name), + getOptional: (name) => getOptionalEnvVarFrom(env, name), }; } From f77cf557cd51d2cc751a624a67b77eceb922b5e2 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 26 Jun 2026 18:09:25 +0100 Subject: [PATCH 17/23] Support old and new formats --- lib/entry-points.js | 23 +++++- src/config/remote-file.test.ts | 130 +++++++++++++++++++++++++++++++-- src/config/remote-file.ts | 47 +++++++++++- src/error-messages.ts | 2 +- 4 files changed, 189 insertions(+), 13 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index c91850e7a7..ab20d259ab 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151053,7 +151053,7 @@ function getInvalidConfigFileMessage(configFile, messages) { } function getConfigFileRepoFormatInvalidMessage(configFile) { let error3 = `The configuration file "${configFile}" is not a supported remote file reference.`; - error3 += " Expected format [/][/][@]"; + error3 += " Expected format [/][@][:]"; return error3; } function getConfigFileFormatInvalidMessage(configFile) { @@ -151447,9 +151447,28 @@ function getDefaultOwner(env) { } 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() + }); +} function parseRemoteFileAddress(env, configFile) { + const oldFormatAddressResult = parseOldRemoteFileAddress(configFile); + if (oldFormatAddressResult.isSuccess()) { + return oldFormatAddressResult.value; + } const format = new RegExp( - "^((?[^/]+)/)?(?[^/@]+)(/(?[^@]+))?(@(?.*))?$" + "^((?[^:@/]+)/)?(?[^:@/]+)(@(?[^:]+))?(:(?.+))?$" ); const pieces = format.exec(configFile.trim()); const repo = pieces?.groups?.repo?.trim(); diff --git a/src/config/remote-file.test.ts b/src/config/remote-file.test.ts index 36b68fd552..f0ef1c13ce 100644 --- a/src/config/remote-file.test.ts +++ b/src/config/remote-file.test.ts @@ -15,6 +15,7 @@ import { test("parseRemoteFileAddress accepts full remote addresses", async (t) => { const env = getTestEnv(); + // Old format. t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path@ref"), { owner: "owner", repo: "repo", @@ -86,6 +87,79 @@ test("parseRemoteFileAddress accepts full remote addresses", async (t) => { ref: "ref/feature", } satisfies RemoteFileAddress, ); + + // New format. + t.deepEqual(parseRemoteFileAddress(env, "owner/repo@ref:path"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner /repo@ref:path"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/ repo@ref:path"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo @ref:path"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo@ ref:path"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo@ref :path"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo@ref: path"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual( + parseRemoteFileAddress(env, "owner/repo@ref/feature:path/to/codeql.yml"), + { + owner: "owner", + repo: "repo", + path: "path/to/codeql.yml", + ref: "ref/feature", + } satisfies RemoteFileAddress, + ); + + t.deepEqual( + parseRemoteFileAddress( + env, + " owner/repo@ref/feature:path/to/codeql.yml ", + ), + { + owner: "owner", + repo: "repo", + path: "path/to/codeql.yml", + ref: "ref/feature", + } satisfies RemoteFileAddress, + ); }); test("parseRemoteFileAddress accepts remote address without an owner", async (t) => { @@ -96,6 +170,13 @@ test("parseRemoteFileAddress accepts remote address without an owner", async (t) .withArgs(ActionsEnvVars.GITHUB_REPOSITORY) .returns(`${owner}/current-repo`); + t.deepEqual(parseRemoteFileAddress(env, "repo@ref:path.yml"), { + owner, + repo: "repo", + path: "path.yml", + ref: "ref", + } satisfies RemoteFileAddress); + t.deepEqual(parseRemoteFileAddress(env, "repo@ref"), { owner, repo: "repo", @@ -103,6 +184,13 @@ test("parseRemoteFileAddress accepts remote address without an owner", async (t) ref: "ref", } satisfies RemoteFileAddress); + t.deepEqual(parseRemoteFileAddress(env, "repo:path.yml"), { + owner, + repo: "repo", + path: "path.yml", + ref: DEFAULT_CONFIG_FILE_REF, + } satisfies RemoteFileAddress); + t.deepEqual(parseRemoteFileAddress(env, "repo"), { owner, repo: "repo", @@ -142,14 +230,7 @@ test("parseRemoteFileAddress accepts remote address without a path", async (t) = test("parseRemoteFileAddress accepts remote address without a ref", async (t) => { const env = getTestEnv(); - t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path"), { - owner: "owner", - repo: "repo", - path: "path", - ref: DEFAULT_CONFIG_FILE_REF, - } satisfies RemoteFileAddress); - - t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path@"), { + t.deepEqual(parseRemoteFileAddress(env, "owner/repo:path"), { owner: "owner", repo: "repo", path: "path", @@ -171,10 +252,43 @@ test("parseRemoteFileAddress rejects invalid values", async (t) => { t.throws(() => parseRemoteFileAddress(env, "repo//absolute"), { instanceOf: ConfigurationError, }); + t.throws(() => parseRemoteFileAddress(env, "repo:/absolute"), { + instanceOf: ConfigurationError, + }); t.throws(() => parseRemoteFileAddress(env, "/repo@ref"), { instanceOf: ConfigurationError, }); t.throws(() => parseRemoteFileAddress(env, " /repo@ref"), { instanceOf: ConfigurationError, }); + t.throws(() => parseRemoteFileAddress(env, "repo@"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, "repo:"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, "repo/"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, "/repo"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, ":path"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, "@ref"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, "@ref:path"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, "owner/@ref:path"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, "owner/@ref"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, "owner/:path"), { + instanceOf: ConfigurationError, + }); }); diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts index a4f8ea890a..af68380e16 100644 --- a/src/config/remote-file.ts +++ b/src/config/remote-file.ts @@ -1,7 +1,7 @@ import { ActionsEnvVars } from "../actions-util"; import { Env } from "../environment"; import * as errorMessages from "../error-messages"; -import { ConfigurationError } from "../util"; +import { ConfigurationError, Failure, Result, Success } from "../util"; /** Represents remote file addresses. */ export interface RemoteFileAddress { @@ -37,6 +37,38 @@ function getDefaultOwner(env: Env): string { 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. * @@ -49,9 +81,17 @@ export function parseRemoteFileAddress( env: Env, configFile: string, ): RemoteFileAddress { + // 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; + } + // retrieve the various parts of the config location, and ensure they're present const format = new RegExp( - "^((?[^/]+)/)?(?[^/@]+)(/(?[^@]+))?(@(?.*))?$", + "^((?[^:@/]+)/)?(?[^:@/]+)(@(?[^:]+))?(:(?.+))?$", ); const pieces = format.exec(configFile.trim()); @@ -59,6 +99,9 @@ export function parseRemoteFileAddress( // 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), ); diff --git a/src/error-messages.ts b/src/error-messages.ts index b45d9562b1..377310933b 100644 --- a/src/error-messages.ts +++ b/src/error-messages.ts @@ -34,7 +34,7 @@ export function getConfigFileRepoFormatInvalidMessage( configFile: string, ): string { let error = `The configuration file "${configFile}" is not a supported remote file reference.`; - error += " Expected format [/][/][@]"; + error += " Expected format [/][@][:]"; return error; } From d8d1d6d754fd6f3ab2fb81749cea00805340d97b Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 24 Jun 2026 14:45:38 +0100 Subject: [PATCH 18/23] Add FF for shorthands --- lib/entry-points.js | 5 +++++ src/feature-flags.ts | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/lib/entry-points.js b/lib/entry-points.js index ab20d259ab..6df93ea014 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -150096,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", 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", From e446d55e5a9db0bf9d6f295adec61ea6c50eb277 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 30 Jun 2026 12:59:35 +0100 Subject: [PATCH 19/23] Add initial `ActionState` type --- src/action-common.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/action-common.ts diff --git a/src/action-common.ts b/src/action-common.ts new file mode 100644 index 0000000000..3b22386728 --- /dev/null +++ b/src/action-common.ts @@ -0,0 +1,14 @@ +import { Env } from "./environment"; +import { FeatureEnablement } from "./feature-flags"; +import { Logger } from "./logging"; + +export interface ActionState { + /** The logger that is in use. */ + logger: Logger; + + /** Information about environment variables. */ + env: Env; + + /** Information about enabled feature flags. */ + features: FeatureEnablement; +} From f86aa521ce7c74cf07f5502abfd462dacecf760e Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 30 Jun 2026 13:01:58 +0100 Subject: [PATCH 20/23] Use `ActionState` for `getRemoteConfig` --- lib/entry-points.js | 28 ++++++++++++++-------------- src/config-utils.ts | 22 ++++++++++------------ src/config/file.ts | 13 ++++++++----- 3 files changed, 32 insertions(+), 31 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 6df93ea014..9609b90e45 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151514,7 +151514,7 @@ function getConfigFileInput(logger, actions, repositoryProperties) { } return void 0; } -async function getRemoteConfig(logger, configFile, apiDetails, validateConfig) { +async function getRemoteConfig(actionState, configFile, apiDetails) { const address = parseRemoteFileAddress(getEnv(), configFile); const response = await getApiClientWithExternalAuth(apiDetails).rest.repos.getContent({ owner: address.owner, @@ -151534,8 +151534,11 @@ async function getRemoteConfig(logger, configFile, apiDetails, validateConfig) { getConfigFileFormatInvalidMessage(configFile) ); } + const validateConfig = await actionState.features.getValue( + "validate_db_config" /* ValidateDbConfig */ + ); return parseUserConfig( - logger, + actionState.logger, configFile, Buffer.from(fileContents, "base64").toString("binary"), validateConfig @@ -152511,7 +152514,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); @@ -152521,14 +152524,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 = { @@ -152831,14 +152832,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); diff --git a/src/config-utils.ts b/src/config-utils.ts index 39e612674a..e246339a81 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, @@ -78,6 +79,7 @@ import { Success, Failure, isHostedRunner, + getEnv, } from "./util"; /** @@ -600,12 +602,11 @@ async function downloadCacheWithTime( } async function loadUserConfig( - logger: Logger, + actionState: ActionState, configFile: string, workspacePath: string, apiDetails: api.GitHubApiCombinedDetails, tempDir: string, - validateConfig: boolean, ): Promise { if (isLocal(configFile)) { if (configFile !== userConfigFromActionPath(tempDir)) { @@ -618,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); } } @@ -1161,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: ActionState = { logger, features, env: getEnv() }; userConfig = await loadUserConfig( - logger, + actionState, inputs.configFile, inputs.workspacePath, inputs.apiDetails, tempDir, - validateConfig, ); } diff --git a/src/config/file.ts b/src/config/file.ts index f178386512..e1fa7c2d39 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -1,6 +1,8 @@ +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, @@ -42,18 +44,16 @@ export function getConfigFileInput( /** * Attempts to fetch a `UserConfig` from a remote `address`. * - * @param logger The logger to use. + * @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. - * @param validateConfig Whether to validate the configuration. * * @returns The `UserConfig`, if it could be fetched and parsed successfully. */ export async function getRemoteConfig( - logger: Logger, + actionState: ActionState, configFile: string, apiDetails: api.GitHubApiCombinedDetails, - validateConfig: boolean, ): Promise { const address = parseRemoteFileAddress(getEnv(), configFile); @@ -79,8 +79,11 @@ export async function getRemoteConfig( ); } + const validateConfig = await actionState.features.getValue( + Feature.ValidateDbConfig, + ); return parseUserConfig( - logger, + actionState.logger, configFile, Buffer.from(fileContents, "base64").toString("binary"), validateConfig, From 7b2af8940d57e291177e1a252ae6d80088008ca5 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 30 Jun 2026 13:19:10 +0100 Subject: [PATCH 21/23] Add `TestEnv` class to simplify testing of `ActionState`-dependent functions --- src/testing-utils.ts | 77 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/testing-utils.ts b/src/testing-utils.ts index 22ed1e21a5..1f962d4902 100644 --- a/src/testing-utils.ts +++ b/src/testing-utils.ts @@ -10,6 +10,7 @@ import test, { import nock from "nock"; import * as sinon from "sinon"; +import { ActionState } from "./action-common"; import { ActionsEnv, ActionsEnvVars, getActionVersion } from "./actions-util"; import { AnalysisKind } from "./analyses"; import * as apiClient from "./api-client"; @@ -188,6 +189,82 @@ export function getTestActionsEnv(): ActionsEnv { }; } +/** + * Wraps a function that accepts an `ActionEnv` for testing in different environments. + */ +export class TestEnv { + 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, ...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( + 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 From b6be81d5b987eab1b0884b6deba97f963dff48e9 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 30 Jun 2026 13:19:30 +0100 Subject: [PATCH 22/23] Gate new remote file format behind FF --- lib/entry-points.js | 19 +- src/config/file.ts | 4 +- src/config/remote-file.test.ts | 458 ++++++++++++++++----------------- src/config/remote-file.ts | 22 +- src/error-messages.ts | 9 + 5 files changed, 267 insertions(+), 245 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 9609b90e45..cd680ecb7e 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151056,6 +151056,11 @@ 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 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 [/][@][:]"; @@ -151467,11 +151472,19 @@ function parseOldRemoteFileAddress(input) { ref: pieces.groups.ref.trim() }); } -function parseRemoteFileAddress(env, configFile) { +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( "^((?[^:@/]+)/)?(?[^:@/]+)(@(?[^:]+))?(:(?.+))?$" ); @@ -151491,7 +151504,7 @@ function parseRemoteFileAddress(env, configFile) { ); } return { - owner: owner || getDefaultOwner(env), + owner: owner || getDefaultOwner(actionState.env), repo, path: path29 || DEFAULT_CONFIG_FILE_NAME, ref: ref || DEFAULT_CONFIG_FILE_REF @@ -151515,7 +151528,7 @@ function getConfigFileInput(logger, actions, repositoryProperties) { return void 0; } async function getRemoteConfig(actionState, configFile, apiDetails) { - const address = parseRemoteFileAddress(getEnv(), configFile); + const address = await parseRemoteFileAddress(actionState, configFile); const response = await getApiClientWithExternalAuth(apiDetails).rest.repos.getContent({ owner: address.owner, repo: address.repo, diff --git a/src/config/file.ts b/src/config/file.ts index e1fa7c2d39..59b667dddc 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -8,7 +8,7 @@ import { RepositoryPropertyName, } from "../feature-flags/properties"; import { Logger } from "../logging"; -import { ConfigurationError, getEnv } from "../util"; +import { ConfigurationError } from "../util"; import { parseUserConfig, UserConfig } from "./db-config"; import { parseRemoteFileAddress } from "./remote-file"; @@ -55,7 +55,7 @@ export async function getRemoteConfig( configFile: string, apiDetails: api.GitHubApiCombinedDetails, ): Promise { - const address = parseRemoteFileAddress(getEnv(), configFile); + const address = await parseRemoteFileAddress(actionState, configFile); const response = await api .getApiClientWithExternalAuth(apiDetails) diff --git a/src/config/remote-file.test.ts b/src/config/remote-file.test.ts index f0ef1c13ce..c366370293 100644 --- a/src/config/remote-file.test.ts +++ b/src/config/remote-file.test.ts @@ -2,7 +2,9 @@ import test from "ava"; import sinon from "sinon"; import { ActionsEnvVars } from "../actions-util"; -import { getTestEnv } from "../testing-utils"; +import * as errors from "../error-messages"; +import { Feature } from "../feature-flags"; +import { callee, getTestEnv } from "../testing-utils"; import { ConfigurationError } from "../util"; import { @@ -12,230 +14,220 @@ import { RemoteFileAddress, } from "./remote-file"; -test("parseRemoteFileAddress accepts full remote addresses", async (t) => { - const env = getTestEnv(); - - // Old format. - t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path@ref"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual(parseRemoteFileAddress(env, "owner /repo/path@ref"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual(parseRemoteFileAddress(env, "owner/ repo/path@ref"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual(parseRemoteFileAddress(env, "owner/repo /path@ref"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual(parseRemoteFileAddress(env, "owner/repo/ path@ref"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); +type ParseRemoteFileAddressTest = { + input: string; + expected: RemoteFileAddress; +}; - t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path @ref"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); +test("parseRemoteFileAddress accepts full remote addresses", async (t) => { + const target = callee(parseRemoteFileAddress); - t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path@ ref"), { + const expected: RemoteFileAddress = { owner: "owner", repo: "repo", path: "path", ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual( - parseRemoteFileAddress(env, "owner/repo/path/to/codeql.yml@ref/feature"), + }; + + 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 }, { - owner: "owner", - repo: "repo", - path: "path/to/codeql.yml", - ref: "ref/feature", - } satisfies RemoteFileAddress, - ); - - t.deepEqual( - parseRemoteFileAddress( - env, - " owner/repo/path/to/codeql.yml@ref/feature ", - ), + input: "owner/repo/path/to/codeql.yml@ref/feature", + expected: { ...expected, path: "path/to/codeql.yml", ref: "ref/feature" }, + }, { - owner: "owner", - repo: "repo", - path: "path/to/codeql.yml", - ref: "ref/feature", - } satisfies RemoteFileAddress, - ); - - // New format. - t.deepEqual(parseRemoteFileAddress(env, "owner/repo@ref:path"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual(parseRemoteFileAddress(env, "owner /repo@ref:path"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual(parseRemoteFileAddress(env, "owner/ repo@ref:path"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual(parseRemoteFileAddress(env, "owner/repo @ref:path"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); + input: " owner/repo/path/to/codeql.yml@ref/feature ", + expected: { ...expected, path: "path/to/codeql.yml", ref: "ref/feature" }, + }, + ]; - t.deepEqual(parseRemoteFileAddress(env, "owner/repo@ ref:path"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual(parseRemoteFileAddress(env, "owner/repo@ref :path"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); + for (const oldFormatInput of oldFormatInputs) { + await target + .withArgs(oldFormatInput.input) + .passes(async (fn) => t.deepEqual(await fn(), oldFormatInput.expected)); + } - t.deepEqual(parseRemoteFileAddress(env, "owner/repo@ref: path"), { - owner: "owner", - repo: "repo", - path: "path", - ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual( - parseRemoteFileAddress(env, "owner/repo@ref/feature:path/to/codeql.yml"), + // 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 }, { - owner: "owner", - repo: "repo", - path: "path/to/codeql.yml", - ref: "ref/feature", - } satisfies RemoteFileAddress, - ); - - t.deepEqual( - parseRemoteFileAddress( - env, - " owner/repo@ref/feature:path/to/codeql.yml ", - ), + input: "owner/repo@ref/feature:path/to/codeql.yml", + expected: { ...expected, path: "path/to/codeql.yml", ref: "ref/feature" }, + }, { - owner: "owner", - repo: "repo", - path: "path/to/codeql.yml", - ref: "ref/feature", - } satisfies RemoteFileAddress, - ); + 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 env = getTestEnv(); + 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`); - t.deepEqual(parseRemoteFileAddress(env, "repo@ref:path.yml"), { - owner, - repo: "repo", - path: "path.yml", - ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual(parseRemoteFileAddress(env, "repo@ref"), { - owner, - repo: "repo", - path: DEFAULT_CONFIG_FILE_NAME, - ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual(parseRemoteFileAddress(env, "repo:path.yml"), { - owner, - repo: "repo", - path: "path.yml", - ref: DEFAULT_CONFIG_FILE_REF, - } satisfies RemoteFileAddress); + const targetWithEnv = target.withEnv(env); - t.deepEqual(parseRemoteFileAddress(env, "repo"), { - owner, - repo: "repo", - path: DEFAULT_CONFIG_FILE_NAME, - ref: DEFAULT_CONFIG_FILE_REF, - } satisfies RemoteFileAddress); + 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 env = getTestEnv(); + 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`); - t.throws(() => parseRemoteFileAddress(env, "repo@ref"), { - instanceOf: Error, - }); + 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 env = getTestEnv(); + const target = callee(parseRemoteFileAddress); - t.deepEqual(parseRemoteFileAddress(env, "owner/repo@ref"), { - owner: "owner", - repo: "repo", - path: DEFAULT_CONFIG_FILE_NAME, - ref: "ref", - } satisfies RemoteFileAddress); - - t.deepEqual(parseRemoteFileAddress(env, "owner/repo"), { - owner: "owner", - repo: "repo", - path: DEFAULT_CONFIG_FILE_NAME, - ref: DEFAULT_CONFIG_FILE_REF, - } satisfies RemoteFileAddress); + 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 env = getTestEnv(); - - t.deepEqual(parseRemoteFileAddress(env, "owner/repo:path"), { - owner: "owner", - repo: "repo", - path: "path", - ref: DEFAULT_CONFIG_FILE_REF, - } satisfies RemoteFileAddress); + 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) => { @@ -246,49 +238,45 @@ test("parseRemoteFileAddress rejects invalid values", async (t) => { .withArgs(ActionsEnvVars.GITHUB_REPOSITORY) .returns(`${owner}/current-repo`); - t.throws(() => parseRemoteFileAddress(env, " "), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, "repo//absolute"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, "repo:/absolute"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, "/repo@ref"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, " /repo@ref"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, "repo@"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, "repo:"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, "repo/"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, "/repo"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, ":path"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, "@ref"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, "@ref:path"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, "owner/@ref:path"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, "owner/@ref"), { - instanceOf: ConfigurationError, - }); - t.throws(() => parseRemoteFileAddress(env, "owner/:path"), { - instanceOf: ConfigurationError, - }); + 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 index af68380e16..bbcc96db0d 100644 --- a/src/config/remote-file.ts +++ b/src/config/remote-file.ts @@ -1,6 +1,8 @@ +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. */ @@ -72,15 +74,15 @@ function parseOldRemoteFileAddress( /** * Attempts to parse `configFile` into an array of `RemoteFileAddress` components. * - * @param env The current environment variables. + * @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 function parseRemoteFileAddress( - env: Env, +export async function parseRemoteFileAddress( + actionState: ActionState, configFile: string, -): RemoteFileAddress { +): 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); @@ -89,6 +91,16 @@ export function parseRemoteFileAddress( 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( "^((?[^:@/]+)/)?(?[^:@/]+)(@(?[^:]+))?(:(?.+))?$", @@ -119,7 +131,7 @@ export function parseRemoteFileAddress( } return { - owner: owner || getDefaultOwner(env), + owner: owner || getDefaultOwner(actionState.env), repo, path: path || DEFAULT_CONFIG_FILE_NAME, ref: ref || DEFAULT_CONFIG_FILE_REF, diff --git a/src/error-messages.ts b/src/error-messages.ts index 377310933b..bd32a6a04a 100644 --- a/src/error-messages.ts +++ b/src/error-messages.ts @@ -30,6 +30,15 @@ export function getInvalidConfigFileMessage( return `The configuration file "${configFile}" is invalid: ${messages.slice(0, 10).join(", ")}${andMore}`; } +export function getConfigFileRepoOldFormatInvalidMessage( + configFile: string, +): string { + let error = `The configuration file "${configFile}" is not a supported remote file reference.`; + error += " Expected format //@"; + + return error; +} + export function getConfigFileRepoFormatInvalidMessage( configFile: string, ): string { From 708e10653366421552971c6d675904adccdcc2f4 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 30 Jun 2026 17:19:20 +0100 Subject: [PATCH 23/23] Index `ActionState` over the types of state used --- src/action-common.ts | 39 +++++++++++++++++++++++++++++++-------- src/config-utils.ts | 4 ++-- src/config/file.ts | 2 +- src/config/remote-file.ts | 2 +- src/testing-utils.ts | 37 +++++++++++++++++++++++-------------- 5 files changed, 58 insertions(+), 26 deletions(-) diff --git a/src/action-common.ts b/src/action-common.ts index 3b22386728..5af58f08b7 100644 --- a/src/action-common.ts +++ b/src/action-common.ts @@ -2,13 +2,36 @@ import { Env } from "./environment"; import { FeatureEnablement } from "./feature-flags"; import { Logger } from "./logging"; -export interface ActionState { - /** The logger that is in use. */ - logger: Logger; +/** 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; + }; +} - /** Information about environment variables. */ - env: Env; +/** Identifies a type of state an Action may have. */ +export type StateFeature = keyof FeatureState; - /** Information about enabled feature flags. */ - features: FeatureEnablement; -} +/** 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/config-utils.ts b/src/config-utils.ts index e246339a81..ec47d0f949 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -602,7 +602,7 @@ async function downloadCacheWithTime( } async function loadUserConfig( - actionState: ActionState, + actionState: ActionState<["Logger", "Env", "FeatureFlags"]>, configFile: string, workspacePath: string, apiDetails: api.GitHubApiCombinedDetails, @@ -1160,7 +1160,7 @@ export async function initConfig( logger.debug("No configuration file was provided"); } else { logger.debug(`Using configuration file: ${inputs.configFile}`); - const actionState: ActionState = { logger, features, env: getEnv() }; + const actionState = { logger, features, env: getEnv() }; userConfig = await loadUserConfig( actionState, inputs.configFile, diff --git a/src/config/file.ts b/src/config/file.ts index 59b667dddc..66257a5bf8 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -51,7 +51,7 @@ export function getConfigFileInput( * @returns The `UserConfig`, if it could be fetched and parsed successfully. */ export async function getRemoteConfig( - actionState: ActionState, + actionState: ActionState<["Logger", "Env", "FeatureFlags"]>, configFile: string, apiDetails: api.GitHubApiCombinedDetails, ): Promise { diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts index bbcc96db0d..15990ed23f 100644 --- a/src/config/remote-file.ts +++ b/src/config/remote-file.ts @@ -80,7 +80,7 @@ function parseOldRemoteFileAddress( * @throws `ConfigurationError` if the format of `configFile` is not valid. */ export async function parseRemoteFileAddress( - actionState: ActionState, + actionState: ActionState<["FeatureFlags", "Env"]>, configFile: string, ): Promise { // Try to parse the input using the old format. If successful, return the diff --git a/src/testing-utils.ts b/src/testing-utils.ts index 1f962d4902..80ea557231 100644 --- a/src/testing-utils.ts +++ b/src/testing-utils.ts @@ -10,7 +10,7 @@ import test, { import nock from "nock"; import * as sinon from "sinon"; -import { ActionState } from "./action-common"; +import { ActionState, StateFeature } from "./action-common"; import { ActionsEnv, ActionsEnvVars, getActionVersion } from "./actions-util"; import { AnalysisKind } from "./analyses"; import * as apiClient from "./api-client"; @@ -189,18 +189,25 @@ 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 { - private readonly fn: (state: ActionState, ...args: Args) => R; +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; + private state: ActionState; constructor( - fn: (state: ActionState, ...args: Args) => R, + fn: (state: ActionState, ...args: Args) => R, args?: Args, - initialState?: ActionState, + initialState?: ActionState, ) { this.fn = fn; this.args = args; @@ -211,11 +218,11 @@ export class TestEnv { }; } - private clone(): TestEnv { + private clone(): TestEnv { return new TestEnv(this.fn, this.args, { ...this.state }); } - public getState(): ActionState { + public getState(): ActionState { return this.state; } @@ -229,13 +236,13 @@ export class TestEnv { return result; } - public withFeatures(enabled: Feature[]): TestEnv { + public withFeatures(enabled: Feature[]): TestEnv { const result = this.clone(); result.state.features = createFeatures(enabled); return result; } - public withEnv(env: Env): TestEnv { + public withEnv(env: Env): TestEnv { const result = this.clone(); result.state.env = env; return result; @@ -245,7 +252,7 @@ export class TestEnv { if (!this.args) { throw new Error("Trying to call function in TestEnv without arguments."); } - return this.fn(this.state, ...this.args); + return this.fn(this.state as ActionState, ...this.args); } public passes( @@ -259,9 +266,11 @@ export class TestEnv { } /** Utility function to construct a `TestEnv`. */ -export function callee( - fn: (state: ActionState, ...args: Args) => R, -): TestEnv { +export function callee< + Args extends readonly any[], + R, + Fs extends readonly StateFeature[], +>(fn: (state: ActionState, ...args: Args) => R): TestEnv { return new TestEnv(fn); }