Compare commits

...

4 Commits

Author SHA1 Message Date
Michael B. Gale
af8f50bf94 Use applyAnalysisKindConfig in loadUserConfig 2026-07-28 13:52:29 +01:00
Michael B. Gale
f638e8b5fb Add applyAnalysisKindConfig 2026-07-28 13:49:56 +01:00
Michael B. Gale
391a05d414 Reshuffle loadUserConfig slightly 2026-07-28 12:46:49 +01:00
Michael B. Gale
fdf46cd70c Add ScopedConfigurations feature 2026-07-28 12:46:47 +01:00
6 changed files with 316 additions and 7 deletions

43
lib/entry-points.js generated
View File

@@ -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",
@@ -148110,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(
@@ -149386,7 +149409,8 @@ 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)) {
configFile = path10.resolve(workspacePath, configFile);
@@ -149399,13 +149423,24 @@ 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);
}
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 */,
@@ -149706,6 +149741,7 @@ async function determineUserConfig(action, tempDir, inputs) {
);
const fromConfigFile = await loadUserConfig(
action,
inputs.analysisKinds,
inputs.configFile,
inputs.workspacePath,
inputs.apiDetails,
@@ -149742,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,

View File

@@ -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);
}
});
});
});

View File

@@ -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,11 +486,14 @@ async function downloadCacheWithTime(
*/
export async function loadUserConfig(
actionState: ActionState<["Logger", "Env", "FeatureFlags"]>,
analysisKinds: AnalysisKind[],
configFile: string,
workspacePath: string,
apiDetails: api.GitHubApiCombinedDetails,
tempDir: string,
): Promise<UserConfig> {
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 +508,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 +516,29 @@ 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);
}
// 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);
}
/**
@@ -1071,6 +1097,7 @@ export async function determineUserConfig(
);
const fromConfigFile = await loadUserConfig(
action,
inputs.analysisKinds,
inputs.configFile,
inputs.workspacePath,
inputs.apiDetails,
@@ -1118,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,

View File

@@ -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
*/

View File

@@ -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)),

View File

@@ -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",