From fdf46cd70c0f59d79af585d5c2bf06844e0470ee Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 28 Jul 2026 12:32:58 +0100 Subject: [PATCH 1/4] Add `ScopedConfigurations` feature --- 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 46c44a8183..a985386085 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -146993,6 +146993,11 @@ var featureConfig = { envVar: "CODEQL_ACTION_PROXY_API_REQUESTS", minimumVersion: void 0 }, + ["scoped_configurations" /* ScopedConfigurations */]: { + defaultValue: false, + envVar: "CODEQL_ACTION_SCOPED_CONFIGURATIONS", + minimumVersion: void 0 + }, ["skip_file_coverage_on_prs" /* SkipFileCoverageOnPrs */]: { defaultValue: false, envVar: "CODEQL_ACTION_SKIP_FILE_COVERAGE_ON_PRS", diff --git a/src/feature-flags.ts b/src/feature-flags.ts index b3107af962..3984829a2d 100644 --- a/src/feature-flags.ts +++ b/src/feature-flags.ts @@ -137,6 +137,8 @@ export enum Feature { QaTelemetryEnabled = "qa_telemetry_enabled", /** Routes (some) API requests through the registry proxy. */ ProxyApiRequests = "proxy_api_requests", + /** Allows sections specific to analysis kinds in configuration files. */ + ScopedConfigurations = "scoped_configurations", /** Note that this currently only disables baseline file coverage information. */ SkipFileCoverageOnPrs = "skip_file_coverage_on_prs", StartProxyUseFeaturesRelease = "start_proxy_use_features_release", @@ -385,6 +387,11 @@ export const featureConfig = { envVar: "CODEQL_ACTION_PROXY_API_REQUESTS", minimumVersion: undefined, }, + [Feature.ScopedConfigurations]: { + defaultValue: false, + envVar: "CODEQL_ACTION_SCOPED_CONFIGURATIONS", + minimumVersion: undefined, + }, [Feature.SkipFileCoverageOnPrs]: { defaultValue: false, envVar: "CODEQL_ACTION_SKIP_FILE_COVERAGE_ON_PRS", From 391a05d414ff6272c1649a0c0c98be2b814cf390 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 28 Jul 2026 12:36:18 +0100 Subject: [PATCH 2/4] Reshuffle `loadUserConfig` slightly --- lib/entry-points.js | 6 ++++-- src/config-utils.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index a985386085..2d0f687d61 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -149392,6 +149392,7 @@ async function downloadCacheWithTime(codeQL, languages, logger) { return { trapCaches, trapCacheDownloadTime }; } async function loadUserConfig(actionState, configFile, workspacePath, apiDetails, tempDir) { + let config; if (isLocal(configFile)) { if (configFile !== userConfigFromActionPath(tempDir)) { configFile = path10.resolve(workspacePath, configFile); @@ -149404,13 +149405,14 @@ async function loadUserConfig(actionState, configFile, workspacePath, apiDetails const validateConfig = await actionState.features.getValue( "validate_db_config" /* ValidateDbConfig */ ); - return getLocalConfig(actionState.logger, configFile, validateConfig); + config = getLocalConfig(actionState.logger, configFile, validateConfig); } else { if (isExplicitRemotePath(configFile)) { configFile = configFile.substring(REMOTE_PATH_PREFIX.length); } - return await getRemoteConfig(actionState, configFile, apiDetails); + config = await getRemoteConfig(actionState, configFile, apiDetails); } + return config; } var OVERLAY_ANALYSIS_FEATURES = { cpp: "overlay_analysis_cpp" /* OverlayAnalysisCpp */, diff --git a/src/config-utils.ts b/src/config-utils.ts index b5a880ba7b..3653a8b96f 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -489,6 +489,8 @@ export async function loadUserConfig( apiDetails: api.GitHubApiCombinedDetails, tempDir: string, ): Promise { + let config: UserConfig; + if (isLocal(configFile)) { if (configFile !== userConfigFromActionPath(tempDir)) { // If the config file is not generated by the Action, it should be relative to the workspace. @@ -503,7 +505,7 @@ export async function loadUserConfig( const validateConfig = await actionState.features.getValue( Feature.ValidateDbConfig, ); - return getLocalConfig(actionState.logger, configFile, validateConfig); + config = getLocalConfig(actionState.logger, configFile, validateConfig); } else { // 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, @@ -511,8 +513,10 @@ export async function loadUserConfig( if (isExplicitRemotePath(configFile)) { configFile = configFile.substring(REMOTE_PATH_PREFIX.length); } - return await getRemoteConfig(actionState, configFile, apiDetails); + config = await getRemoteConfig(actionState, configFile, apiDetails); } + + return config; } /** From f638e8b5fb639c51718ff42a323160b4266493a8 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 28 Jul 2026 13:35:33 +0100 Subject: [PATCH 3/4] Add `applyAnalysisKindConfig` --- src/config/db-config.test.ts | 112 +++++++++++++++++++++++++++++++++++ src/config/db-config.ts | 63 ++++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/src/config/db-config.test.ts b/src/config/db-config.test.ts index 63d9d2ffec..18f64d71e7 100644 --- a/src/config/db-config.test.ts +++ b/src/config/db-config.test.ts @@ -1,5 +1,6 @@ import test, { ExecutionContext } from "ava"; +import { AnalysisKind } from "../analyses"; import { RepositoryProperties } from "../feature-flags/properties"; import { BuiltInLanguage, Language } from "../languages"; import { getRunnerLogger } from "../logging"; @@ -14,6 +15,117 @@ import { ConfigurationError, prettyPrintPack } from "../util"; import * as dbConfig from "./db-config"; +function makeUserConfigWithActionExtensions(options?: { + analysisConfig?: dbConfig.UserConfig; + analysisKinds?: AnalysisKind[]; + baseConfig?: dbConfig.UserConfig; +}) { + const testConfig = { ...options?.baseConfig }; + + const allAnalysisKinds = Object.values(AnalysisKind); + const analysisKinds = options?.analysisKinds ?? allAnalysisKinds; + for (const analysisKind of analysisKinds) { + testConfig[analysisKind] = { ...options?.analysisConfig }; + } + + return testConfig as dbConfig.UserConfigWithActionExtensions; +} + +test("hasAnalysisKindKey - finds `AnalysisKind` sections", async (t) => { + const testConfig = makeUserConfigWithActionExtensions(); + + for (const analysisKind of Object.values(AnalysisKind)) { + // Check that it finds the `analysisKind`-specific section among multiple. + t.true(dbConfig.hasAnalysisKindKey(analysisKind, testConfig)); + // And for just the `analysisKind`-specific section. + t.true(dbConfig.hasAnalysisKindKey(analysisKind, { [analysisKind]: {} })); + } +}); + +test("hasAnalysisKindKey - returns false if there is no relevant section", async (t) => { + const testConfig = makeUserConfigWithActionExtensions(); + + for (const analysisKind of Object.values(AnalysisKind)) { + // Clone the `testConfig` and remove the `analysisKind`-specific key. + const testConfigClone = { ...testConfig }; + delete testConfigClone[analysisKind]; + + // Check that it doesn't find an `analysisKind`-specific section. + t.false(dbConfig.hasAnalysisKindKey(analysisKind, testConfigClone)); + } +}); + +test("applyAnalysisKindConfig - applies analysis-specific settings", async (t) => { + for (const analysisKind of Object.values(AnalysisKind)) { + const testConfig = makeUserConfigWithActionExtensions({ + analysisKinds: [analysisKind], + analysisConfig: { + "paths-ignore": [], + }, + baseConfig: { + "paths-ignore": ["a", "b", "c"], + "threat-models": ["local"], + }, + }); + const clonedTestConfig = { ...testConfig }; + + const result = dbConfig.applyAnalysisKindConfig(analysisKind, testConfig); + + // The `analysisKind`-specific section should have been removed. + // `threat-models` should be unchanged from the `baseConfig`. + // `paths-ignore` should have been overwritten with the `analysisKind`-specific config. + t.deepEqual(result, { + "threat-models": ["local"], + "paths-ignore": [], + }); + + // The input `testConfig` should not have changed. + t.deepEqual(testConfig, clonedTestConfig); + } +}); + +test("applyAnalysisKindConfig - removes all `AnalysisKind`-specific keys", async (t) => { + for (const analysisKind of Object.values(AnalysisKind)) { + const testConfig = makeUserConfigWithActionExtensions({ + analysisConfig: { + "paths-ignore": ["a", "b", "c"], + }, + baseConfig: {}, + }); + + const result = dbConfig.applyAnalysisKindConfig(analysisKind, testConfig); + + // All `AnalysisKind`-specific sections should have been removed. + // `paths-ignore` should have been overwritten with the analysis-specific config. + t.deepEqual(result, { + "paths-ignore": ["a", "b", "c"], + }); + } +}); + +test("applyAnalysisKindConfig - returns the `baseConfig` if there is no `analysisKind`-specific key", async (t) => { + const baseConfig = { + "disable-default-queries": true, + "paths-ignore": ["a", "b", "c"], + queries: [{ name: "foo", uses: "something" }], + packs: { test: ["bar"] }, + "default-setup": { org: { "model-packs": ["pack"] } }, + } satisfies dbConfig.UserConfig; + + const testConfig = makeUserConfigWithActionExtensions({ + analysisKinds: [], + baseConfig, + }); + + const result = dbConfig.applyAnalysisKindConfig( + AnalysisKind.CodeScanning, + testConfig, + ); + + // The result should be the same as the `baseConfig`. + t.deepEqual(result, baseConfig); +}); + /** * Test macro for ensuring the packs block is valid */ diff --git a/src/config/db-config.ts b/src/config/db-config.ts index 7b5bdbd8ce..402a8376fb 100644 --- a/src/config/db-config.ts +++ b/src/config/db-config.ts @@ -4,6 +4,7 @@ import * as yaml from "js-yaml"; import * as jsonschema from "jsonschema"; import * as semver from "semver"; +import { AnalysisKind } from "../analyses"; import { addNoLanguageDiagnostic, makeTelemetryDiagnostic, @@ -77,6 +78,68 @@ export interface UserConfig { "default-setup"?: DefaultSetupConfig; } +/** Represents a `UserConfig` with CodeQL Action extensions. */ +export type UserConfigWithActionExtensions = { + /** + * We add a key for each analysis kind that may contain options + * specific to that kind of analysis. + */ + [T in keyof typeof AnalysisKind as (typeof AnalysisKind)[T]]?: UserConfig; +} & UserConfig; + +/** + * Determines whether `config` contains an `analysisKind`-specific section. + * + * @param analysisKind The analysis section to check for. + * @param config The base `UserConfig` value. + */ +export function hasAnalysisKindKey( + analysisKind: AnalysisKind, + config: UserConfig, +): config is UserConfigWithActionExtensions { + return analysisKind in config && typeof config[analysisKind] === "object"; +} + +/** + * Clones `config` and removes any `AnalysisKind`-specific sections. + */ +export function removeAnalysisKindConfigs(config: UserConfig): UserConfig { + // Clone the configuration and remove any `AnalysisKind`-specific sections. + const configWithoutScopes = { ...config }; + for (const kind of Object.values(AnalysisKind)) { + delete configWithoutScopes[kind]; + } + return configWithoutScopes; +} + +/** + * Applies an `analysisKind`-specific configuration, if any, to the resulting configuration + * and removes all `AnalysisKind`-specific configuration sections. + * + * @param analysisKind The `AnalysisKind` to apply specific configuration options for. + * @param config The loaded configuration, possibly including `analysisKind`-specific settings. + */ +export function applyAnalysisKindConfig( + analysisKind: AnalysisKind, + config: UserConfig, +): UserConfig { + // Clone the configuration and remove any `AnalysisKind`-specific sections. + const configWithoutScopes = removeAnalysisKindConfigs(config); + + // If there is no `analysisKind`-specific section, return the input configuration + // without any `AnalysisKind`-specific sections. + if (!hasAnalysisKindKey(analysisKind, config)) { + return configWithoutScopes; + } + + // Otherwise, extract the `analysisKind`-specific configuration from the input `config`. + const analysisKindSpecificConfig = config[analysisKind]; + + // Return the base configuration (without any `AnalysisKind`-specific sections) and + // override the top-level properties with the `analysisKind`-specific settings. + return { ...configWithoutScopes, ...analysisKindSpecificConfig }; +} + /** A subset of the `UserConfig` schema that is used by Default Setup. */ const DEFAULT_SETUP_CONFIG_SCHEMA = { "threat-models": json.optional(json.array(json.string)), From af8f50bf949a12a1dc818772a6764e22fed722e3 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 28 Jul 2026 13:52:29 +0100 Subject: [PATCH 4/4] Use `applyAnalysisKindConfig` in `loadUserConfig` --- lib/entry-points.js | 34 +++++++++++++++++++-- src/config-utils.test.ts | 66 ++++++++++++++++++++++++++++++++++++++-- src/config-utils.ts | 26 +++++++++++++++- 3 files changed, 121 insertions(+), 5 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 2d0f687d61..bb4b1669e7 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -148115,6 +148115,24 @@ var ORG_SCHEMA = { var DEFAULT_SETUP_SCHEMA = { org: optional(object(ORG_SCHEMA)) }; +function hasAnalysisKindKey(analysisKind, config) { + return analysisKind in config && typeof config[analysisKind] === "object"; +} +function removeAnalysisKindConfigs(config) { + const configWithoutScopes = { ...config }; + for (const kind of Object.values(AnalysisKind)) { + delete configWithoutScopes[kind]; + } + return configWithoutScopes; +} +function applyAnalysisKindConfig(analysisKind, config) { + const configWithoutScopes = removeAnalysisKindConfigs(config); + if (!hasAnalysisKindKey(analysisKind, config)) { + return configWithoutScopes; + } + const analysisKindSpecificConfig = config[analysisKind]; + return { ...configWithoutScopes, ...analysisKindSpecificConfig }; +} var DEFAULT_SETUP_CONFIG_SCHEMA = { "threat-models": optional(array(string)), "default-setup": optional( @@ -149391,7 +149409,7 @@ async function downloadCacheWithTime(codeQL, languages, logger) { const trapCacheDownloadTime = import_perf_hooks.performance.now() - start; return { trapCaches, trapCacheDownloadTime }; } -async function loadUserConfig(actionState, configFile, workspacePath, apiDetails, tempDir) { +async function loadUserConfig(actionState, analysisKinds, configFile, workspacePath, apiDetails, tempDir) { let config; if (isLocal(configFile)) { if (configFile !== userConfigFromActionPath(tempDir)) { @@ -149412,7 +149430,17 @@ async function loadUserConfig(actionState, configFile, workspacePath, apiDetails } config = await getRemoteConfig(actionState, configFile, apiDetails); } - return config; + const allowScopedConfig = await actionState.features.getValue( + "scoped_configurations" /* ScopedConfigurations */ + ); + if (allowScopedConfig && analysisKinds.length === 1) { + return applyAnalysisKindConfig(analysisKinds[0], config); + } else if (allowScopedConfig) { + actionState.logger.info( + `Ignoring '${"scoped_configurations" /* ScopedConfigurations */}' feature, because multiple analysis kinds are enabled.` + ); + } + return removeAnalysisKindConfigs(config); } var OVERLAY_ANALYSIS_FEATURES = { cpp: "overlay_analysis_cpp" /* OverlayAnalysisCpp */, @@ -149713,6 +149741,7 @@ async function determineUserConfig(action, tempDir, inputs) { ); const fromConfigFile = await loadUserConfig( action, + inputs.analysisKinds, inputs.configFile, inputs.workspacePath, inputs.apiDetails, @@ -149749,6 +149778,7 @@ async function determineUserConfig(action, tempDir, inputs) { action.logger.debug(`Using configuration file: ${inputs.configFile}`); return await loadUserConfig( action, + inputs.analysisKinds, inputs.configFile, inputs.workspacePath, inputs.apiDetails, diff --git a/src/config-utils.test.ts b/src/config-utils.test.ts index 84c709e72a..597ed3b970 100644 --- a/src/config-utils.test.ts +++ b/src/config-utils.test.ts @@ -12,7 +12,7 @@ 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 { UserConfig, UserConfigWithActionExtensions } from "./config/db-config"; import * as file from "./config/file"; import * as configUtils from "./config-utils"; import * as errorMessages from "./error-messages"; @@ -2544,6 +2544,7 @@ test("loadUserConfig - loads local configuration files", async (t) => { ) => configUtils.loadUserConfig( actionState, + [AnalysisKind.CodeScanning], filePath, workspaceDir, SAMPLE_DOTCOM_API_DETAILS, @@ -2587,7 +2588,13 @@ test.serial("loadUserConfig - loads remote configuration files", async (t) => { const remoteAddress = "owner/repo/file@ref"; await callee(configUtils.loadUserConfig) - .withArgs(remoteAddress, tmpDir, SAMPLE_DOTCOM_API_DETAILS, tmpDir) + .withArgs( + [AnalysisKind.CodeScanning], + remoteAddress, + tmpDir, + SAMPLE_DOTCOM_API_DETAILS, + tmpDir, + ) .passes(t.deepEqual, {}); t.true( @@ -2665,6 +2672,7 @@ test.serial( // Prepare the test call to `loadUserConfig`. const targetWithArgs = target.withArgs( + [AnalysisKind.CodeScanning], address, tmpDir, SAMPLE_DOTCOM_API_DETAILS, @@ -2705,3 +2713,57 @@ test.serial( }); }, ); + +test("loadUserConfig - applies analysis-specific settings", async (t) => { + await withTmpDir(async (workspaceDir) => { + await withTmpDir(async (tmpDir) => { + // Construct the test target. + const loadUserConfig = ( + actionState: ActionState<["Logger", "Env", "FeatureFlags"]>, + filePath: string, + analysisKind: AnalysisKind, + ) => + configUtils.loadUserConfig( + actionState, + [analysisKind], + filePath, + workspaceDir, + SAMPLE_DOTCOM_API_DETAILS, + tmpDir, + ); + const target = callee(loadUserConfig); + + for (const analysisKind of Object.values(AnalysisKind)) { + const analysisSpecificConfig: UserConfig = { + paths: ["custom"], + queries: [{ uses: "foo" }], + }; + const baseConfig: UserConfig = { "threat-models": ["remote"] }; + + // Write a local configuration file with an `analysisKind`-specific section. + const configPath = path.join(workspaceDir, "codeql.yml"); + fs.writeFileSync( + configPath, + JSON.stringify({ + ...baseConfig, + [analysisKind]: analysisSpecificConfig, + } satisfies UserConfigWithActionExtensions), + "utf8", + ); + + // The resulting configuration should be the `baseConfig` with the `analysisSpecificConfig` + // merged into it if the FF is enabled. + await target + .withArgs(configPath, analysisKind) + .withFeatures([Feature.ScopedConfigurations]) + .passes(t.deepEqual, { ...baseConfig, ...analysisSpecificConfig }); + + // Without the FF, it should be the `baseConfig`. This also validates that the + // `AnalysisKind`-specific sections are removed even if the FF is off. + await target + .withArgs(configPath, analysisKind) + .passes(t.deepEqual, baseConfig); + } + }); + }); +}); diff --git a/src/config-utils.ts b/src/config-utils.ts index 3653a8b96f..9e9accdb3a 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -24,11 +24,13 @@ import { getCachingKind } from "./caching-utils"; import { type CodeQL } from "./codeql"; import { type Config } from "./config/action-config"; import { + applyAnalysisKindConfig, calculateAugmentation, ExcludeQueryFilter, generateCodeScanningConfig, mergeDefaultSetupAndUserConfigs, parseUserConfig, + removeAnalysisKindConfigs, UserConfig, } from "./config/db-config"; import { @@ -484,6 +486,7 @@ async function downloadCacheWithTime( */ export async function loadUserConfig( actionState: ActionState<["Logger", "Env", "FeatureFlags"]>, + analysisKinds: AnalysisKind[], configFile: string, workspacePath: string, apiDetails: api.GitHubApiCombinedDetails, @@ -516,7 +519,26 @@ export async function loadUserConfig( config = await getRemoteConfig(actionState, configFile, apiDetails); } - return config; + // If the `ScopedConfigurations` feature is enabled, we allow options specific to the + // current `AnalysisKind` to be applied on top of a base configuration. + const allowScopedConfig = await actionState.features.getValue( + Feature.ScopedConfigurations, + ); + + if (allowScopedConfig && analysisKinds.length === 1) { + // Apply the `analysisKind`-specific configuration. + return applyAnalysisKindConfig(analysisKinds[0], config); + } else if (allowScopedConfig) { + actionState.logger.info( + `Ignoring '${Feature.ScopedConfigurations}' feature, because multiple analysis kinds are enabled.`, + ); + } + + // Remove `AnalysisKind`-specific sections from `config` even if the FF is not enabled, + // because the CLI will warn about unknown keys in the configuration. This is to guard + // against the case where we ship the feature, enable the FF, start using the new keys, + // and then need to roll the FF back. + return removeAnalysisKindConfigs(config); } /** @@ -1075,6 +1097,7 @@ export async function determineUserConfig( ); const fromConfigFile = await loadUserConfig( action, + inputs.analysisKinds, inputs.configFile, inputs.workspacePath, inputs.apiDetails, @@ -1122,6 +1145,7 @@ export async function determineUserConfig( action.logger.debug(`Using configuration file: ${inputs.configFile}`); return await loadUserConfig( action, + inputs.analysisKinds, inputs.configFile, inputs.workspacePath, inputs.apiDetails,