From 9c0c11a49e9c78c4cc08ffff52ed0d688a070de8 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 20:09:59 +0100 Subject: [PATCH 01/11] Add tests for `loadUserConfig` --- src/config-utils.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++ src/config-utils.ts | 2 +- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/config-utils.test.ts b/src/config-utils.test.ts index 377a1efe98..5cc62334bb 100644 --- a/src/config-utils.test.ts +++ b/src/config-utils.test.ts @@ -6,12 +6,14 @@ import test, { ExecutionContext } from "ava"; import * as yaml from "js-yaml"; import * as sinon from "sinon"; +import { ActionState } from "./action-common"; import * as actionsUtil from "./actions-util"; import { AnalysisKind, supportedAnalysisKinds } from "./analyses"; import * as api from "./api-client"; import { CachingKind } from "./caching-utils"; import { createStubCodeQL } from "./codeql"; import { UserConfig } from "./config/db-config"; +import * as file from "./config/file"; import * as configUtils from "./config-utils"; import * as errorMessages from "./error-messages"; import { Feature } from "./feature-flags"; @@ -38,6 +40,7 @@ import { makeMacro, initAllState, callee, + SAMPLE_DOTCOM_API_DETAILS, } from "./testing-utils"; import { GitHubVariant, @@ -2529,3 +2532,69 @@ test("determineUserConfig - ignores config file input outside Default Setup if F }); }); }); + +test("loadUserConfig - loads local configuration files", async (t) => { + await withTmpDir(async (workspaceDir) => { + await withTmpDir(async (tmpDir) => { + // Construct the test target. + const loadUserConfig = ( + actionState: ActionState<["Logger", "Env", "FeatureFlags"]>, + filePath: string, + ) => + configUtils.loadUserConfig( + actionState, + filePath, + workspaceDir, + SAMPLE_DOTCOM_API_DETAILS, + tmpDir, + ); + const target = callee(loadUserConfig); + + // `loadUserConfig` should load local configuration files if they are inside the workspace: + const insideOfWorkspace = path.join(workspaceDir, "some-file.yml"); + fs.writeFileSync(insideOfWorkspace, "test-key: present", "utf8"); + + await target + .withArgs(insideOfWorkspace) + .passes(t.deepEqual, { "test-key": "present" }); + + // `loadUserConfig` should normally throw if the path is outside of the workspace: + const outsideOfWorkspace = path.join( + tmpDir, + "not-the-generated-file.yml", + ); + fs.writeFileSync(outsideOfWorkspace, "test-key: present", "utf8"); + + await target + .withArgs(outsideOfWorkspace) + .throws(t, { instanceOf: ConfigurationError }); + + // `loadUserConfig` does not throw if the path is the result of `userConfigFromActionPath`: + const generatedPath = configUtils.userConfigFromActionPath(tmpDir); + fs.writeFileSync(generatedPath, "test-key: present", "utf8"); + + await target + .withArgs(generatedPath) + .passes(t.deepEqual, { "test-key": "present" }); + }); + }); +}); + +test.serial("loadUserConfig - loads remote configuration files", async (t) => { + await withTmpDir(async (tmpDir) => { + const getRemoteConfig = sinon.stub(file, "getRemoteConfig").resolves({}); + + const remoteAddress = "owner/repo/file@ref"; + await callee(configUtils.loadUserConfig) + .withArgs(remoteAddress, tmpDir, SAMPLE_DOTCOM_API_DETAILS, tmpDir) + .passes(t.deepEqual, {}); + + t.true( + getRemoteConfig.calledOnceWithExactly( + sinon.match.any, + remoteAddress, + SAMPLE_DOTCOM_API_DETAILS, + ), + ); + }); +}); diff --git a/src/config-utils.ts b/src/config-utils.ts index b48d5c1d7c..b14fd248ae 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -478,7 +478,7 @@ async function downloadCacheWithTime( * @param tempDir The temporary directory which may contain a CodeQL Action-generated configuration file. * @returns The loaded configuration file, if successful. */ -async function loadUserConfig( +export async function loadUserConfig( actionState: ActionState<["Logger", "Env", "FeatureFlags"]>, configFile: string, workspacePath: string, From c73d965e34def7a23217a3429f839e613c32680c Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 20:11:33 +0100 Subject: [PATCH 02/11] Add failing test for the issue --- src/config-utils.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/config-utils.test.ts b/src/config-utils.test.ts index 5cc62334bb..45d5ecfe07 100644 --- a/src/config-utils.test.ts +++ b/src/config-utils.test.ts @@ -2598,3 +2598,25 @@ test.serial("loadUserConfig - loads remote configuration files", async (t) => { ); }); }); + +test.serial( + "loadUserConfig - loads remote configuration files (new format, partial)", + async (t) => { + await withTmpDir(async (tmpDir) => { + const getRemoteConfig = sinon.stub(file, "getRemoteConfig").resolves({}); + + const remoteAddress = "repo:file"; + await callee(configUtils.loadUserConfig) + .withArgs(remoteAddress, tmpDir, SAMPLE_DOTCOM_API_DETAILS, tmpDir) + .passes(t.deepEqual, {}); + + t.true( + getRemoteConfig.calledOnceWithExactly( + sinon.match.any, + remoteAddress, + SAMPLE_DOTCOM_API_DETAILS, + ), + ); + }); + }, +); From 9832a9df15b41f760adc9acdf759224fb71a71ad Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 20:41:00 +0100 Subject: [PATCH 03/11] Refactor `parseNewRemoteFileAddress` out of `parseRemoteFileAddress` --- lib/entry-points.js | 41 ++++++++++++++++--------- src/config/remote-file.ts | 64 +++++++++++++++++++++++++++------------ 2 files changed, 71 insertions(+), 34 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 14c662839a..e68efe85ce 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -148301,6 +148301,25 @@ function parseOldRemoteFileAddress(input) { ref: pieces.groups.ref.trim() }); } +function parseNewRemoteFileAddress(env, configFile) { + const format = new RegExp( + "^((?[^:@/]+)/)?(?[^:@/]+)(@(?[^:]+))?(:(?.+))?$" + ); + const pieces = format.exec(configFile.trim()); + const repo = pieces?.groups?.repo?.trim(); + if (!pieces?.groups || !repo || repo.length === 0) { + return new Failure(void 0); + } + const owner = pieces.groups.owner?.trim(); + const path29 = pieces.groups.path?.trim(); + const ref = pieces.groups.ref?.trim(); + return new Success({ + owner: owner || getDefaultOwner(env), + repo, + path: path29 || DEFAULT_CONFIG_FILE_NAME, + ref: ref || DEFAULT_CONFIG_FILE_REF + }); +} async function parseRemoteFileAddress(actionState, configFile) { const oldFormatAddressResult = parseOldRemoteFileAddress(configFile); if (oldFormatAddressResult.isSuccess()) { @@ -148314,30 +148333,22 @@ async function parseRemoteFileAddress(actionState, configFile) { getConfigFileRepoOldFormatInvalidMessage(configFile) ); } - const format = new RegExp( - "^((?[^:@/]+)/)?(?[^:@/]+)(@(?[^:]+))?(:(?.+))?$" + const newFormatAddressResult = parseNewRemoteFileAddress( + actionState.env, + configFile ); - const pieces = format.exec(configFile.trim()); - const repo = pieces?.groups?.repo?.trim(); - if (!pieces?.groups || !repo || repo.length === 0) { + if (newFormatAddressResult.isFailure()) { throw new ConfigurationError( getConfigFileRepoFormatInvalidMessage(configFile) ); } - const owner = pieces.groups.owner?.trim(); - const path29 = pieces.groups.path?.trim(); - const ref = pieces.groups.ref?.trim(); - if (path29?.startsWith("/")) { + const address = newFormatAddressResult.value; + if (address.path.startsWith("/")) { throw new ConfigurationError( `The path component of '${configFile}' cannot be an absolute path.` ); } - return { - owner: owner || getDefaultOwner(actionState.env), - repo, - path: path29 || DEFAULT_CONFIG_FILE_NAME, - ref: ref || DEFAULT_CONFIG_FILE_REF - }; + return address; } // src/config/file.ts diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts index 15b34af2b6..897b1e910d 100644 --- a/src/config/remote-file.ts +++ b/src/config/remote-file.ts @@ -1,5 +1,5 @@ import { ActionState } from "../action-common"; -import { Env, ActionsEnvVars } from "../environment"; +import { ActionsEnvVars, ReadOnlyEnv } from "../environment"; import * as errorMessages from "../error-messages"; import { Feature } from "../feature-flags"; import { ConfigurationError, Failure, Result, Success } from "../util"; @@ -23,7 +23,7 @@ export const DEFAULT_CONFIG_FILE_NAME = ".github/codeql-action.yaml"; export const DEFAULT_CONFIG_FILE_REF = "main"; /** Extracts the owner from the `GITHUB_REPOSITORY` environment variable. */ -function getDefaultOwner(env: Env): string { +function getDefaultOwner(env: ReadOnlyEnv): string { const currentRepoNwo = env.getRequired(ActionsEnvVars.GITHUB_REPOSITORY); const nwoParts = currentRepoNwo.split("/"); @@ -70,6 +70,42 @@ function parseOldRemoteFileAddress( }); } +/** + * Attempts to parse `input` as a `RemoteFileAddress` using the new format. + * + * @param env The read-only environment to obtain the owner name from if needed. + * @param configFile The input to try and parse. + * @returns A `RemoteFileAddress` value if successful or `undefined` otherwise. + */ +export function parseNewRemoteFileAddress( + env: ReadOnlyEnv, + configFile: string, +): Result { + // retrieve the various parts of the config location, and ensure they're present + const format = new RegExp( + "^((?[^:@/]+)/)?(?[^:@/]+)(@(?[^:]+))?(:(?.+))?$", + ); + const pieces = format.exec(configFile.trim()); + + const repo: string | undefined = pieces?.groups?.repo?.trim(); + + // Check that the regular expression matched and that we have at least the repo name. + if (!pieces?.groups || !repo || repo.length === 0) { + return new Failure(undefined); + } + + const owner: string | undefined = pieces.groups.owner?.trim(); + const path: string | undefined = pieces.groups.path?.trim(); + const ref: string | undefined = pieces.groups.ref?.trim(); + + return new Success({ + owner: owner || getDefaultOwner(env), + repo, + path: path || DEFAULT_CONFIG_FILE_NAME, + ref: ref || DEFAULT_CONFIG_FILE_REF, + }); +} + /** * Attempts to parse `configFile` into an array of `RemoteFileAddress` components. * @@ -101,15 +137,12 @@ export async function parseRemoteFileAddress( } // retrieve the various parts of the config location, and ensure they're present - const format = new RegExp( - "^((?[^:@/]+)/)?(?[^:@/]+)(@(?[^:]+))?(:(?.+))?$", + const newFormatAddressResult = parseNewRemoteFileAddress( + actionState.env, + configFile, ); - const pieces = format.exec(configFile.trim()); - const repo: string | undefined = pieces?.groups?.repo?.trim(); - - // Check that the regular expression matched and that we have at least the repo name. - if (!pieces?.groups || !repo || repo.length === 0) { + if (newFormatAddressResult.isFailure()) { // 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. @@ -118,21 +151,14 @@ export async function parseRemoteFileAddress( ); } - const owner: string | undefined = pieces.groups.owner?.trim(); - const path: string | undefined = pieces.groups.path?.trim(); - const ref: string | undefined = pieces.groups.ref?.trim(); + const address = newFormatAddressResult.value; // Ensure that the path is a relative path. - if (path?.startsWith("/")) { + if (address.path.startsWith("/")) { throw new ConfigurationError( `The path component of '${configFile}' cannot be an absolute path.`, ); } - return { - owner: owner || getDefaultOwner(actionState.env), - repo, - path: path || DEFAULT_CONFIG_FILE_NAME, - ref: ref || DEFAULT_CONFIG_FILE_REF, - }; + return address; } From 6d3bde3729af0fdd2e66a01a9f4f7dcd08aa21d3 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 22:24:05 +0100 Subject: [PATCH 04/11] Split up and document `isLocal` --- lib/entry-points.js | 10 ++++++++-- src/config-utils.ts | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index e68efe85ce..9fa4a1cd3b 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -149712,11 +149712,17 @@ async function initConfig(actionState, inputs) { await setCppTrapCachingEnvironmentVariables(config, logger); return config; } +function isRelativePath(configPath) { + return configPath.startsWith("./"); +} +function containsAtRef(configPath) { + return configPath.includes("@"); +} function isLocal(configPath) { - if (configPath.indexOf("./") === 0) { + if (isRelativePath(configPath)) { return true; } - return configPath.indexOf("@") === -1; + return !containsAtRef(configPath); } function getLocalConfig(logger, configFile, validateConfig) { if (!fs9.existsSync(configFile)) { diff --git a/src/config-utils.ts b/src/config-utils.ts index b14fd248ae..737bea8bd4 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -1277,13 +1277,43 @@ export async function initConfig( return config; } +/** + * Determines if `configPath` is explicitly local. That is, it starts with "./". + * A configuration file path that starts with "./" is always treated as a local path. + * + * @param configPath The path to test. + */ +function isRelativePath(configPath: string): boolean { + return configPath.startsWith("./"); +} + +/** + * Determines if `configPath` contains a '@' character. + * + * @param configPath The path to test. + */ +function containsAtRef(configPath: string): boolean { + return configPath.includes("@"); +} + +/** + * Determines if `configPath` refers to a local configuration file. + * This assumes the `OLD_REMOTE_ADDRESS_FORMAT` which must contain a '@' + * character for remote addresses. + * + * @param configPath The path to test. + * @returns True if it is local, or false otherwise. + */ function isLocal(configPath: string): boolean { - // If the path starts with ./, look locally - if (configPath.indexOf("./") === 0) { + // If the path starts with "./", it is explicitly local. + // This allows local paths that would otherwise contain '@' + // to be used with a "./" prefix. + if (isRelativePath(configPath)) { return true; } - return configPath.indexOf("@") === -1; + // Otherwise, the path is also local if it does not contain '@'. + return !containsAtRef(configPath); } export function getLocalConfig( From 001d4ae61066c890bbf948c15693b673540d4f98 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 15 Jul 2026 15:56:14 +0100 Subject: [PATCH 05/11] Move the local path prefix into a constant --- lib/entry-points.js | 3 ++- src/config-utils.ts | 12 ++++++------ src/config/file.ts | 5 +++++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 9fa4a1cd3b..7ff75dcc3b 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -148352,6 +148352,7 @@ async function parseRemoteFileAddress(actionState, configFile) { } // src/config/file.ts +var LOCAL_PATH_PREFIX = "./"; async function getConfigFileInput({ logger, actions, @@ -149713,7 +149714,7 @@ async function initConfig(actionState, inputs) { return config; } function isRelativePath(configPath) { - return configPath.startsWith("./"); + return configPath.startsWith(LOCAL_PATH_PREFIX); } function containsAtRef(configPath) { return configPath.includes("@"); diff --git a/src/config-utils.ts b/src/config-utils.ts index 737bea8bd4..2a6678bf50 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -31,7 +31,7 @@ import { parseUserConfig, UserConfig, } from "./config/db-config"; -import { getRemoteConfig } from "./config/file"; +import { getRemoteConfig, LOCAL_PATH_PREFIX } from "./config/file"; import { parseRegistries, type RegistryConfigNoCredentials, @@ -1278,13 +1278,13 @@ export async function initConfig( } /** - * Determines if `configPath` is explicitly local. That is, it starts with "./". - * A configuration file path that starts with "./" is always treated as a local path. + * Determines if `configPath` is explicitly local. That is, it starts with `LOCAL_PATH_PREFIX`. + * A configuration file path that starts with `LOCAL_PATH_PREFIX` is always treated as a local path. * * @param configPath The path to test. */ function isRelativePath(configPath: string): boolean { - return configPath.startsWith("./"); + return configPath.startsWith(LOCAL_PATH_PREFIX); } /** @@ -1305,9 +1305,9 @@ function containsAtRef(configPath: string): boolean { * @returns True if it is local, or false otherwise. */ function isLocal(configPath: string): boolean { - // If the path starts with "./", it is explicitly local. + // If the path starts with `LOCAL_PATH_PREFIX`, it is explicitly local. // This allows local paths that would otherwise contain '@' - // to be used with a "./" prefix. + // to be used with a `LOCAL_PATH_PREFIX` prefix. if (isRelativePath(configPath)) { return true; } diff --git a/src/config/file.ts b/src/config/file.ts index 5ff2dee082..06c31eb972 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -11,6 +11,11 @@ import { ConfigurationError } from "../util"; import { parseUserConfig, UserConfig } from "./db-config"; import { parseRemoteFileAddress } from "./remote-file"; +/** + * The prefix that can be specified to indicate that a path should be treated as a local file address. + */ +export const LOCAL_PATH_PREFIX = "./"; + /** * Gets the value that is configured for the configuration file, if any. */ From 57ca205769879647af63f038507ba185f2761e03 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 15 Jul 2026 15:58:24 +0100 Subject: [PATCH 06/11] Add `AssertableTarget` interface --- src/testing-utils.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/testing-utils.ts b/src/testing-utils.ts index e5b622c256..5ed19b435c 100644 --- a/src/testing-utils.ts +++ b/src/testing-utils.ts @@ -362,11 +362,30 @@ export interface PassedAssertion { assertionResult: T; } +/** + * A more minimal, exported interface for `CallableEnvBuilder`. This makes it easier to + * define helper functions in tests which expect a value of a compatible type. + */ +export interface AssertableTarget { + passes( + assertion: (val: Awaited, ...assertionArgs: AArgs) => AResult, + ...assertionArgs: AArgs + ): Promise>; + + throws( + t: ExecutionContext, + expectations?: ThrowsExpectation, + ): Promise>; +} + class CallableEnvBuilder< - Args extends readonly any[], - R, - Fs extends ReadonlyArray, -> extends BaseEnvBuilder { + Args extends readonly any[], + R, + Fs extends ReadonlyArray, + > + extends BaseEnvBuilder + implements AssertableTarget +{ private args: Args; constructor( From 23339952d6559fa1d6f6548a1fe538e78b65c1e4 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 15 Jul 2026 15:59:23 +0100 Subject: [PATCH 07/11] Add expliciit prefix for remote file addresses --- lib/entry-points.js | 10 ++++ src/config-utils.test.ts | 105 +++++++++++++++++++++++++++++++++++---- src/config-utils.ts | 31 ++++++++++-- src/config/file.ts | 8 +++ 4 files changed, 142 insertions(+), 12 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 7ff75dcc3b..82d07df59d 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -148353,6 +148353,7 @@ async function parseRemoteFileAddress(actionState, configFile) { // src/config/file.ts var LOCAL_PATH_PREFIX = "./"; +var REMOTE_PATH_PREFIX = "::"; async function getConfigFileInput({ logger, actions, @@ -149254,6 +149255,9 @@ async function loadUserConfig(actionState, configFile, workspacePath, apiDetails ); return getLocalConfig(actionState.logger, configFile, validateConfig); } else { + if (isRemotePath(configFile)) { + configFile = configFile.substring(REMOTE_PATH_PREFIX.length); + } return await getRemoteConfig(actionState, configFile, apiDetails); } } @@ -149716,6 +149720,9 @@ async function initConfig(actionState, inputs) { function isRelativePath(configPath) { return configPath.startsWith(LOCAL_PATH_PREFIX); } +function isRemotePath(configPath) { + return configPath.startsWith(REMOTE_PATH_PREFIX); +} function containsAtRef(configPath) { return configPath.includes("@"); } @@ -149723,6 +149730,9 @@ function isLocal(configPath) { if (isRelativePath(configPath)) { return true; } + if (isRemotePath(configPath)) { + return false; + } return !containsAtRef(configPath); } function getLocalConfig(logger, configFile, validateConfig) { diff --git a/src/config-utils.test.ts b/src/config-utils.test.ts index 45d5ecfe07..4a6fa739e2 100644 --- a/src/config-utils.test.ts +++ b/src/config-utils.test.ts @@ -41,6 +41,7 @@ import { initAllState, callee, SAMPLE_DOTCOM_API_DETAILS, + AssertableTarget, } from "./testing-utils"; import { GitHubVariant, @@ -2605,18 +2606,104 @@ test.serial( await withTmpDir(async (tmpDir) => { const getRemoteConfig = sinon.stub(file, "getRemoteConfig").resolves({}); - const remoteAddress = "repo:file"; - await callee(configUtils.loadUserConfig) - .withArgs(remoteAddress, tmpDir, SAMPLE_DOTCOM_API_DETAILS, tmpDir) - .passes(t.deepEqual, {}); + // Construct the basic test target. + const target = callee(configUtils.loadUserConfig) + .withDefaultActionsEnv() + .withFeatures([Feature.NewRemoteFileAddresses]); + + // Utility function to assert that `targetWithArgs` has identified + // the input as a remote file address. + const checkIsRemote = + (address: string) => + async (targetWithArgs: AssertableTarget) => { + // We have stubbed `getRemoteConfig` to resolve to `{}`, so we + // expect that result. + await targetWithArgs.passes(t.deepEqual, {}); + + // And `getRemoteConfig` should have been called exactly once. + t.is(getRemoteConfig.callCount, 1); + + // Get the arguments for the call and check that there were three. + // We don't care about the first, but check that the other two + // match our expectations. We break it down like this to get + // more useful test output. + const args = getRemoteConfig.getCalls()[0].args; + t.is(args.length, 3); + t.deepEqual(args[1], address); + t.deepEqual(args[2], SAMPLE_DOTCOM_API_DETAILS); + }; - t.true( - getRemoteConfig.calledOnceWithExactly( - sinon.match.any, - remoteAddress, + // Utility function to assert that `targetWithArgs` has not identified + // the input as a remote file address. + const checkIsNotRemote = async ( + targetWithArgs: AssertableTarget, + ) => { + // We expect `loadUserConfig` to have thrown if it thinks the path is local, + // since the inputs we provide aren't for files that exist. + await targetWithArgs.throws(t); + + // Additionally, we expect that `getRemoteConfig` wasn't called. + t.is(getRemoteConfig.callCount, 0); + }; + + // Utility function to add the explicit `REMOTE_PATH_PREFIX` to the input. + const withExplicitPrefix = (str: string) => + `${file.REMOTE_PATH_PREFIX}${str}`; + + // Utility to set up a call to `loadUserConfig` with the provided `address` + // and pass it to `assertion`. + const testTargetWith = async ( + address: string, + assertion: ( + targetWithArgs: AssertableTarget>, + ) => Promise, + ) => { + // Reset the stub's history since we re-use it. + getRemoteConfig.resetHistory(); + + // Log the input we are testing so that, in the event of a failure, + // it is easier to see which input was responsible. + t.log(`testTargetWith("${address}")`); + + // Prepare the test call to `loadUserConfig`. + const targetWithArgs = target.withArgs( + address, + tmpDir, SAMPLE_DOTCOM_API_DETAILS, - ), + tmpDir, + ); + + // Pass it to the provided assertion function. + await assertion(targetWithArgs); + }; + + // Since this input contains an '@' character, it is treated as a remote path + // by the old logic even without the explicit prefix. + const remoteWithoutPrefix = "repo@main"; + await testTargetWith( + remoteWithoutPrefix, + checkIsRemote(remoteWithoutPrefix), + ); + await testTargetWith( + withExplicitPrefix(remoteWithoutPrefix), + checkIsRemote(remoteWithoutPrefix), ); + // It is only treated as a local path with the corresponding prefix. + await testTargetWith(`./${remoteWithoutPrefix}`, checkIsNotRemote); + + // The following test inputs are examples of ambiguous paths. They could refer to + // valid local or remote paths. For each, we check that they are treated as remote + // paths if the explicit remote file prefix is used and as local paths otherwise. + const testInputs = ["repo:file", "input", "../input"]; + + for (const testInput of testInputs) { + for (const addPrefix of [true, false]) { + await testTargetWith( + addPrefix ? withExplicitPrefix(testInput) : testInput, + addPrefix ? checkIsRemote(testInput) : checkIsNotRemote, + ); + } + } }); }, ); diff --git a/src/config-utils.ts b/src/config-utils.ts index 2a6678bf50..8a37c09fe0 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -31,7 +31,11 @@ import { parseUserConfig, UserConfig, } from "./config/db-config"; -import { getRemoteConfig, LOCAL_PATH_PREFIX } from "./config/file"; +import { + getRemoteConfig, + LOCAL_PATH_PREFIX, + REMOTE_PATH_PREFIX, +} from "./config/file"; import { parseRegistries, type RegistryConfigNoCredentials, @@ -501,6 +505,12 @@ export async function loadUserConfig( ); return getLocalConfig(actionState.logger, configFile, validateConfig); } else { + // Drop the explicit prefix if it is present. Since `REMOTE_PATH_PREFIX` is chosen + // to not conflict with permissable characters in "owner" or "repo" components, + // this does not risk removing valid parts of either component by accident. + if (isRemotePath(configFile)) { + configFile = configFile.substring(REMOTE_PATH_PREFIX.length); + } return await getRemoteConfig(actionState, configFile, apiDetails); } } @@ -1287,6 +1297,16 @@ function isRelativePath(configPath: string): boolean { return configPath.startsWith(LOCAL_PATH_PREFIX); } +/** + * Determines if `configPath` starts with the prefix used to explicitly mark a path + * as a remote path (`REMOTE_PATH_PREFIX`). + * + * @param configPath The path to test. + */ +function isRemotePath(configPath: string): boolean { + return configPath.startsWith(REMOTE_PATH_PREFIX); +} + /** * Determines if `configPath` contains a '@' character. * @@ -1298,8 +1318,6 @@ function containsAtRef(configPath: string): boolean { /** * Determines if `configPath` refers to a local configuration file. - * This assumes the `OLD_REMOTE_ADDRESS_FORMAT` which must contain a '@' - * character for remote addresses. * * @param configPath The path to test. * @returns True if it is local, or false otherwise. @@ -1311,8 +1329,15 @@ function isLocal(configPath: string): boolean { if (isRelativePath(configPath)) { return true; } + // If the path starts with `REMOTE_PATH_PREFIX`, it is explicitly remote. + // This allows users to resolve ambiguity by specifying `REMOTE_PATH_PREFIX`. + if (isRemotePath(configPath)) { + return false; + } // Otherwise, the path is also local if it does not contain '@'. + // This assumes the `OLD_REMOTE_ADDRESS_FORMAT` which must contain a '@' + // character for remote addresses. return !containsAtRef(configPath); } diff --git a/src/config/file.ts b/src/config/file.ts index 06c31eb972..0cbcaeb083 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -16,6 +16,14 @@ import { parseRemoteFileAddress } from "./remote-file"; */ export const LOCAL_PATH_PREFIX = "./"; +/** + * The prefix that can be specified to indicate that a path should be treated as a remote file address. + * The new remote file address format must start with either an owner or repository name. Both + * are restricted to ASCII characters, '.', and '-'. The prefix chosen here does not interfere with + * those and is _unlikely_ (but not impossible) to appear in a local file path. + */ +export const REMOTE_PATH_PREFIX = "::"; + /** * Gets the value that is configured for the configuration file, if any. */ From abce6483eafec5ab8768d6b1180e7a74a56a96e7 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 15 Jul 2026 16:11:50 +0100 Subject: [PATCH 08/11] Fix typo in comment --- src/config-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config-utils.ts b/src/config-utils.ts index 8a37c09fe0..e6e673bf31 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -506,7 +506,7 @@ export async function loadUserConfig( return getLocalConfig(actionState.logger, configFile, validateConfig); } else { // Drop the explicit prefix if it is present. Since `REMOTE_PATH_PREFIX` is chosen - // to not conflict with permissable characters in "owner" or "repo" components, + // to not conflict with permissible characters in "owner" or "repo" components, // this does not risk removing valid parts of either component by accident. if (isRemotePath(configFile)) { configFile = configFile.substring(REMOTE_PATH_PREFIX.length); From decb87044a6bc1e5f8f54902b93be11b04fd6005 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 15 Jul 2026 16:13:02 +0100 Subject: [PATCH 09/11] Rename helper functions --- lib/entry-points.js | 10 +++++----- src/config-utils.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 82d07df59d..a1dd753da9 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -149255,7 +149255,7 @@ async function loadUserConfig(actionState, configFile, workspacePath, apiDetails ); return getLocalConfig(actionState.logger, configFile, validateConfig); } else { - if (isRemotePath(configFile)) { + if (isExplicitRemotePath(configFile)) { configFile = configFile.substring(REMOTE_PATH_PREFIX.length); } return await getRemoteConfig(actionState, configFile, apiDetails); @@ -149717,20 +149717,20 @@ async function initConfig(actionState, inputs) { await setCppTrapCachingEnvironmentVariables(config, logger); return config; } -function isRelativePath(configPath) { +function isExplicitLocalPath(configPath) { return configPath.startsWith(LOCAL_PATH_PREFIX); } -function isRemotePath(configPath) { +function isExplicitRemotePath(configPath) { return configPath.startsWith(REMOTE_PATH_PREFIX); } function containsAtRef(configPath) { return configPath.includes("@"); } function isLocal(configPath) { - if (isRelativePath(configPath)) { + if (isExplicitLocalPath(configPath)) { return true; } - if (isRemotePath(configPath)) { + if (isExplicitRemotePath(configPath)) { return false; } return !containsAtRef(configPath); diff --git a/src/config-utils.ts b/src/config-utils.ts index e6e673bf31..040cdc7060 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -508,7 +508,7 @@ export async function loadUserConfig( // Drop the explicit prefix if it is present. Since `REMOTE_PATH_PREFIX` is chosen // to not conflict with permissible characters in "owner" or "repo" components, // this does not risk removing valid parts of either component by accident. - if (isRemotePath(configFile)) { + if (isExplicitRemotePath(configFile)) { configFile = configFile.substring(REMOTE_PATH_PREFIX.length); } return await getRemoteConfig(actionState, configFile, apiDetails); @@ -1293,7 +1293,7 @@ export async function initConfig( * * @param configPath The path to test. */ -function isRelativePath(configPath: string): boolean { +function isExplicitLocalPath(configPath: string): boolean { return configPath.startsWith(LOCAL_PATH_PREFIX); } @@ -1303,7 +1303,7 @@ function isRelativePath(configPath: string): boolean { * * @param configPath The path to test. */ -function isRemotePath(configPath: string): boolean { +function isExplicitRemotePath(configPath: string): boolean { return configPath.startsWith(REMOTE_PATH_PREFIX); } @@ -1326,12 +1326,12 @@ function isLocal(configPath: string): boolean { // If the path starts with `LOCAL_PATH_PREFIX`, it is explicitly local. // This allows local paths that would otherwise contain '@' // to be used with a `LOCAL_PATH_PREFIX` prefix. - if (isRelativePath(configPath)) { + if (isExplicitLocalPath(configPath)) { return true; } // If the path starts with `REMOTE_PATH_PREFIX`, it is explicitly remote. // This allows users to resolve ambiguity by specifying `REMOTE_PATH_PREFIX`. - if (isRemotePath(configPath)) { + if (isExplicitRemotePath(configPath)) { return false; } From 247ce88c8fd049a82810fe495ecf2edd0426c103 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 15 Jul 2026 16:14:55 +0100 Subject: [PATCH 10/11] Gate changes behind new format FF --- lib/entry-points.js | 11 +++++++---- src/config-utils.ts | 12 ++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index a1dd753da9..ed518130c3 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -149241,7 +149241,10 @@ async function downloadCacheWithTime(codeQL, languages, logger) { return { trapCaches, trapCacheDownloadTime }; } async function loadUserConfig(actionState, configFile, workspacePath, apiDetails, tempDir) { - if (isLocal(configFile)) { + const allowNewFormat = await actionState.features.getValue( + "new_remote_file_addresses" /* NewRemoteFileAddresses */ + ); + if (isLocal(configFile, allowNewFormat)) { if (configFile !== userConfigFromActionPath(tempDir)) { configFile = path10.resolve(workspacePath, configFile); if (!(configFile + path10.sep).startsWith(workspacePath + path10.sep)) { @@ -149255,7 +149258,7 @@ async function loadUserConfig(actionState, configFile, workspacePath, apiDetails ); return getLocalConfig(actionState.logger, configFile, validateConfig); } else { - if (isExplicitRemotePath(configFile)) { + if (allowNewFormat && isExplicitRemotePath(configFile)) { configFile = configFile.substring(REMOTE_PATH_PREFIX.length); } return await getRemoteConfig(actionState, configFile, apiDetails); @@ -149726,11 +149729,11 @@ function isExplicitRemotePath(configPath) { function containsAtRef(configPath) { return configPath.includes("@"); } -function isLocal(configPath) { +function isLocal(configPath, allowNewFormat) { if (isExplicitLocalPath(configPath)) { return true; } - if (isExplicitRemotePath(configPath)) { + if (allowNewFormat && isExplicitRemotePath(configPath)) { return false; } return !containsAtRef(configPath); diff --git a/src/config-utils.ts b/src/config-utils.ts index 040cdc7060..948494f531 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -489,7 +489,11 @@ export async function loadUserConfig( apiDetails: api.GitHubApiCombinedDetails, tempDir: string, ): Promise { - if (isLocal(configFile)) { + const allowNewFormat = await actionState.features.getValue( + Feature.NewRemoteFileAddresses, + ); + + if (isLocal(configFile, allowNewFormat)) { if (configFile !== userConfigFromActionPath(tempDir)) { // If the config file is not generated by the Action, it should be relative to the workspace. configFile = path.resolve(workspacePath, configFile); @@ -508,7 +512,7 @@ export async function loadUserConfig( // Drop the explicit prefix if it is present. Since `REMOTE_PATH_PREFIX` is chosen // to not conflict with permissible characters in "owner" or "repo" components, // this does not risk removing valid parts of either component by accident. - if (isExplicitRemotePath(configFile)) { + if (allowNewFormat && isExplicitRemotePath(configFile)) { configFile = configFile.substring(REMOTE_PATH_PREFIX.length); } return await getRemoteConfig(actionState, configFile, apiDetails); @@ -1322,7 +1326,7 @@ function containsAtRef(configPath: string): boolean { * @param configPath The path to test. * @returns True if it is local, or false otherwise. */ -function isLocal(configPath: string): boolean { +function isLocal(configPath: string, allowNewFormat: boolean): boolean { // If the path starts with `LOCAL_PATH_PREFIX`, it is explicitly local. // This allows local paths that would otherwise contain '@' // to be used with a `LOCAL_PATH_PREFIX` prefix. @@ -1331,7 +1335,7 @@ function isLocal(configPath: string): boolean { } // If the path starts with `REMOTE_PATH_PREFIX`, it is explicitly remote. // This allows users to resolve ambiguity by specifying `REMOTE_PATH_PREFIX`. - if (isExplicitRemotePath(configPath)) { + if (allowNewFormat && isExplicitRemotePath(configPath)) { return false; } From 3492b7e9ab96e28b1d8b971345d30e929c6f8fee Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Thu, 16 Jul 2026 12:43:48 +0100 Subject: [PATCH 11/11] Change `REMOTE_PATH_PREFIX` to `remote=` --- lib/entry-points.js | 2 +- src/config/file.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index a1c2543ea0..fca6ae2f04 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -148363,7 +148363,7 @@ async function parseRemoteFileAddress(actionState, configFile) { // src/config/file.ts var LOCAL_PATH_PREFIX = "./"; -var REMOTE_PATH_PREFIX = "::"; +var REMOTE_PATH_PREFIX = "remote="; async function getConfigFileInput({ logger, actions, diff --git a/src/config/file.ts b/src/config/file.ts index 0cbcaeb083..ee6050cf47 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -20,9 +20,9 @@ export const LOCAL_PATH_PREFIX = "./"; * The prefix that can be specified to indicate that a path should be treated as a remote file address. * The new remote file address format must start with either an owner or repository name. Both * are restricted to ASCII characters, '.', and '-'. The prefix chosen here does not interfere with - * those and is _unlikely_ (but not impossible) to appear in a local file path. + * those (since it contains an `=`) and is _unlikely_ (but not impossible) to appear in a local file path. */ -export const REMOTE_PATH_PREFIX = "::"; +export const REMOTE_PATH_PREFIX = "remote="; /** * Gets the value that is configured for the configuration file, if any.