From 465f39d738ba8334c12db415045d609ef6c6a1fe Mon Sep 17 00:00:00 2001 From: asdf8675309 <174058705+asdf8675309@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:26:09 -0400 Subject: [PATCH 1/4] feat: skip draft and unaffected-language analysis --- analyze/action.yml | 4 + init/action.yml | 22 ++ lib/entry-points.js | 456 +++++++++++++++++++---- src/analysis-skip.test.ts | 89 +++++ src/analysis-skip.ts | 319 ++++++++++++++++ src/analyze-action-post.ts | 6 + src/analyze-action.test.ts | 22 ++ src/analyze-action.ts | 15 + src/diff-informed-analysis-utils.test.ts | 66 ++++ src/diff-informed-analysis-utils.ts | 34 ++ src/environment.ts | 3 + src/init-action-post.ts | 6 + src/init-action.test.ts | 87 +++++ src/init-action.ts | 30 ++ 14 files changed, 1081 insertions(+), 78 deletions(-) create mode 100644 src/analysis-skip.test.ts create mode 100644 src/analysis-skip.ts create mode 100644 src/init-action.test.ts diff --git a/analyze/action.yml b/analyze/action.yml index d70401c0a4..abd104c172 100644 --- a/analyze/action.yml +++ b/analyze/action.yml @@ -93,6 +93,10 @@ outputs: description: Absolute, local path to the directory containing the generated SARIF file. sarif-id: description: The ID of the uploaded SARIF file. + analysis-skipped: + description: Whether analysis was intentionally skipped before CodeQL finalization + analysis-skip-reason: + description: Why analysis was intentionally skipped, if applicable runs: using: node24 main: "../lib/analyze-entry.js" diff --git a/init/action.yml b/init/action.yml index 7787a0a071..fcfec4aff2 100644 --- a/init/action.yml +++ b/init/action.yml @@ -29,6 +29,24 @@ inputs: For more information, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning#changing-the-languages-that-are-analyzed. required: false + skip-if-draft: + description: >- + When true and the current event confirms this is a draft pull request, skip CodeQL before + downloading tools or initializing a database. The workflow must run again on `ready_for_review` + to analyze the PR once it is marked ready. For a `dynamic` workflow, the workflow generator can + set `CODE_SCANNING_IS_DRAFT=true` to trigger the same early skip. If draft state is unavailable, + analysis continues. + required: false + default: 'false' + skip-if-no-language-changes: + description: >- + When true for a single explicitly requested built-in language, skip CodeQL before tool download + and database initialization if the complete pull-request diff contains no relevant source or + language configuration files. Unknown, incomplete, or unrecognized diffs fail open and run the + full analysis. This uses the complete language database when relevant changes exist; it does + not restrict analysis to changed files. + required: false + default: 'false' build-mode: description: >- The build mode that will be used to analyze the language. This input is only available when @@ -176,6 +194,10 @@ outputs: description: The path of the CodeQL binary used for analysis codeql-version: description: The version of the CodeQL binary used for analysis + analysis-skipped: + description: Whether analysis was intentionally skipped before CodeQL initialization + analysis-skip-reason: + description: Why analysis was intentionally skipped, if applicable runs: using: node24 main: '../lib/init-entry.js' diff --git a/lib/entry-points.js b/lib/entry-points.js index 93084ea9be..bac93ea7e5 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -3891,18 +3891,18 @@ var require_webidl = __commonJS({ webidl.errors.exception = function(message) { return new TypeError(`${message.header}: ${message.message}`); }; - webidl.errors.conversionFailed = function(context5) { - const plural = context5.types.length === 1 ? "" : " one of"; - const message = `${context5.argument} could not be converted to${plural}: ${context5.types.join(", ")}.`; + webidl.errors.conversionFailed = function(context6) { + const plural = context6.types.length === 1 ? "" : " one of"; + const message = `${context6.argument} could not be converted to${plural}: ${context6.types.join(", ")}.`; return webidl.errors.exception({ - header: context5.prefix, + header: context6.prefix, message }); }; - webidl.errors.invalidArgument = function(context5) { + webidl.errors.invalidArgument = function(context6) { return webidl.errors.exception({ - header: context5.prefix, - message: `"${context5.value}" is an invalid ${context5.type}.` + header: context6.prefix, + message: `"${context6.value}" is an invalid ${context6.type}.` }); }; webidl.brandCheck = function(V, I, opts) { @@ -10032,17 +10032,17 @@ var require_api_request = __commonJS({ } } } - onConnect(abort, context5) { + onConnect(abort, context6) { if (this.reason) { abort(this.reason); return; } assert(this.callback); this.abort = abort; - this.context = context5; + this.context = context6; } onHeaders(statusCode, rawHeaders, resume, statusMessage) { - const { callback, opaque, abort, context: context5, responseHeaders, highWaterMark } = this; + const { callback, opaque, abort, context: context6, responseHeaders, highWaterMark } = this; const headers = responseHeaders === "raw" ? util4.parseRawHeaders(rawHeaders) : util4.parseHeaders(rawHeaders); if (statusCode < 200) { if (this.onInfo) { @@ -10079,7 +10079,7 @@ var require_api_request = __commonJS({ trailers: this.trailers, opaque, body: res, - context: context5 + context: context6 }); } } @@ -10248,17 +10248,17 @@ var require_api_stream = __commonJS({ } addSignal(this, signal); } - onConnect(abort, context5) { + onConnect(abort, context6) { if (this.reason) { abort(this.reason); return; } assert(this.callback); this.abort = abort; - this.context = context5; + this.context = context6; } onHeaders(statusCode, rawHeaders, resume, statusMessage) { - const { factory, opaque, context: context5, callback, responseHeaders } = this; + const { factory, opaque, context: context6, callback, responseHeaders } = this; const headers = responseHeaders === "raw" ? util4.parseRawHeaders(rawHeaders) : util4.parseHeaders(rawHeaders); if (statusCode < 200) { if (this.onInfo) { @@ -10286,7 +10286,7 @@ var require_api_stream = __commonJS({ statusCode, headers, opaque, - context: context5 + context: context6 }); if (!res || typeof res.write !== "function" || typeof res.end !== "function" || typeof res.on !== "function") { throw new InvalidReturnValueError("expected Writable"); @@ -10478,7 +10478,7 @@ var require_api_pipeline = __commonJS({ this.res = null; addSignal(this, signal); } - onConnect(abort, context5) { + onConnect(abort, context6) { const { ret, res } = this; if (this.reason) { abort(this.reason); @@ -10487,10 +10487,10 @@ var require_api_pipeline = __commonJS({ assert(!res, "pipeline cannot be retried"); assert(!ret.destroyed); this.abort = abort; - this.context = context5; + this.context = context6; } onHeaders(statusCode, rawHeaders, resume) { - const { opaque, handler: handler2, context: context5 } = this; + const { opaque, handler: handler2, context: context6 } = this; if (statusCode < 200) { if (this.onInfo) { const headers = this.responseHeaders === "raw" ? util4.parseRawHeaders(rawHeaders) : util4.parseHeaders(rawHeaders); @@ -10508,7 +10508,7 @@ var require_api_pipeline = __commonJS({ headers, opaque, body: this.res, - context: context5 + context: context6 }); } catch (err) { this.res.on("error", util4.nop); @@ -10592,7 +10592,7 @@ var require_api_upgrade = __commonJS({ this.context = null; addSignal(this, signal); } - onConnect(abort, context5) { + onConnect(abort, context6) { if (this.reason) { abort(this.reason); return; @@ -10606,7 +10606,7 @@ var require_api_upgrade = __commonJS({ } onUpgrade(statusCode, rawHeaders, socket) { assert(statusCode === 101); - const { callback, opaque, context: context5 } = this; + const { callback, opaque, context: context6 } = this; removeSignal(this); this.callback = null; const headers = this.responseHeaders === "raw" ? util4.parseRawHeaders(rawHeaders) : util4.parseHeaders(rawHeaders); @@ -10614,7 +10614,7 @@ var require_api_upgrade = __commonJS({ headers, socket, opaque, - context: context5 + context: context6 }); } onError(err) { @@ -10683,20 +10683,20 @@ var require_api_connect = __commonJS({ this.abort = null; addSignal(this, signal); } - onConnect(abort, context5) { + onConnect(abort, context6) { if (this.reason) { abort(this.reason); return; } assert(this.callback); this.abort = abort; - this.context = context5; + this.context = context6; } onHeaders() { throw new SocketError("bad connect", null); } onUpgrade(statusCode, rawHeaders, socket) { - const { callback, opaque, context: context5 } = this; + const { callback, opaque, context: context6 } = this; removeSignal(this); this.callback = null; let headers = rawHeaders; @@ -10708,7 +10708,7 @@ var require_api_connect = __commonJS({ headers, socket, opaque, - context: context5 + context: context6 }); } onError(err) { @@ -22248,8 +22248,8 @@ function isDefined(value) { function isKeyOperator(operator) { return operator === ";" || operator === "&" || operator === "?"; } -function getValues(context5, operator, key, modifier) { - var value = context5[key], result = []; +function getValues(context6, operator, key, modifier) { + var value = context6[key], result = []; if (isDefined(value) && value !== "") { if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") { value = value.toString(); @@ -22313,7 +22313,7 @@ function parseUrl(template) { expand: expand.bind(null, template) }; } -function expand(template, context5) { +function expand(template, context6) { var operators = ["+", "#", ".", "/", ";", "?", "&"]; template = template.replace( /\{([^\{\}]+)\}|([^\{\}]+)/g, @@ -22327,7 +22327,7 @@ function expand(template, context5) { } expression.split(/,/g).forEach(function(variable) { var tmp2 = /([^:\*]*)(?::(\d+)|(\*))?/.exec(variable); - values.push(getValues(context5, operator, tmp2[1], tmp2[2] || tmp2[3])); + values.push(getValues(context6, operator, tmp2[1], tmp2[2] || tmp2[3])); }); if (operator && operator !== "+") { var separator = ","; @@ -22800,7 +22800,7 @@ var init_json_with_bigint = __esm({ try { const result = JSON.parse( "1", - (_2, __, context5) => !!context5?.source && context5.source === "1" + (_2, __, context6) => !!context6?.source && context6.source === "1" ); featureCache.set(parseFingerprint, result); return result; @@ -22809,26 +22809,26 @@ var init_json_with_bigint = __esm({ return false; } }; - convertMarkedBigIntsReviver = (key, value, context5, userReviver) => { + convertMarkedBigIntsReviver = (key, value, context6, userReviver) => { const isCustomFormatBigInt = typeof value === "string" && customFormat.test(value); if (isCustomFormatBigInt) return BigInt(value.slice(0, -1)); const isNoiseValue = typeof value === "string" && noiseValue.test(value); if (isNoiseValue) return value.slice(0, -1); const hasUserReviver = typeof userReviver === "function"; if (!hasUserReviver) return value; - return userReviver(key, value, context5); + return userReviver(key, value, context6); }; JSONParseV2 = (text, reviver) => { - return JSON.parse(text, (key, value, context5) => { + return JSON.parse(text, (key, value, context6) => { const isNumber3 = typeof value === "number"; const isOutOfBounds = value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER; const isBigNumber = isNumber3 && isOutOfBounds; - const isInt = context5 && intRegex.test(context5.source); + const isInt = context6 && intRegex.test(context6.source); const isBigInt = isBigNumber && isInt; - if (isBigInt) return BigInt(context5.source); + if (isBigInt) return BigInt(context6.source); const hasCustomReviver = typeof reviver === "function"; if (!hasCustomReviver) return value; - return reviver(key, value, context5); + return reviver(key, value, context6); }); }; MAX_INT = Number.MAX_SAFE_INTEGER.toString(); @@ -22899,7 +22899,7 @@ var init_json_with_bigint = __esm({ const serializedData = serializeBigInts(text); return originalParse( serializedData, - (key, value, context5) => convertMarkedBigIntsReviver(key, value, context5, reviver) + (key, value, context6) => convertMarkedBigIntsReviver(key, value, context6, reviver) ); } catch (error3) { if (error3 instanceof RangeError) { @@ -35312,19 +35312,19 @@ var require_logger = __commonJS({ logger: clientLogger }; } - var context5 = createLoggerContext2({ + var context6 = createLoggerContext2({ logLevelEnvVarName: "TYPESPEC_RUNTIME_LOG_LEVEL", namespace: "typeSpecRuntime" }); - var TypeSpecRuntimeLogger2 = context5.logger; + var TypeSpecRuntimeLogger2 = context6.logger; function setLogLevel2(logLevel) { - context5.setLogLevel(logLevel); + context6.setLogLevel(logLevel); } function getLogLevel2() { - return context5.getLogLevel(); + return context6.getLogLevel(); } function createClientLogger2(namespace) { - return context5.createClientLogger(namespace); + return context6.createClientLogger(namespace); } } }); @@ -40894,19 +40894,19 @@ var require_commonjs2 = __commonJS({ exports2.getLogLevel = getLogLevel2; exports2.createClientLogger = createClientLogger2; var logger_1 = require_internal(); - var context5 = (0, logger_1.createLoggerContext)({ + var context6 = (0, logger_1.createLoggerContext)({ logLevelEnvVarName: "AZURE_LOG_LEVEL", namespace: "azure" }); - exports2.AzureLogger = context5.logger; + exports2.AzureLogger = context6.logger; function setLogLevel2(level) { - context5.setLogLevel(level); + context6.setLogLevel(level); } function getLogLevel2() { - return context5.getLogLevel(); + return context6.getLogLevel(); } function createClientLogger2(namespace) { - return context5.createClientLogger(namespace); + return context6.createClientLogger(namespace); } } }); @@ -41765,14 +41765,14 @@ function __esDecorate(ctor, descriptorIn, decorators, contextIn, initializers, e var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); var _2, done = false; for (var i = decorators.length - 1; i >= 0; i--) { - var context5 = {}; - for (var p in contextIn) context5[p] = p === "access" ? {} : contextIn[p]; - for (var p in contextIn.access) context5.access[p] = contextIn.access[p]; - context5.addInitializer = function(f) { + var context6 = {}; + for (var p in contextIn) context6[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context6.access[p] = contextIn.access[p]; + context6.addInitializer = function(f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); }; - var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context5); + var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context6); if (kind === "accessor") { if (result === void 0) continue; if (result === null || typeof result !== "object") throw new TypeError("Object expected"); @@ -42719,14 +42719,14 @@ var require_tracingContext = __commonJS({ namespace: /* @__PURE__ */ Symbol.for("@azure/core-tracing namespace") }; function createTracingContext(options = {}) { - let context5 = new TracingContextImpl(options.parentContext); + let context6 = new TracingContextImpl(options.parentContext); if (options.span) { - context5 = context5.setValue(exports2.knownContextKeys.span, options.span); + context6 = context6.setValue(exports2.knownContextKeys.span, options.span); } if (options.namespace) { - context5 = context5.setValue(exports2.knownContextKeys.namespace, options.namespace); + context6 = context6.setValue(exports2.knownContextKeys.namespace, options.namespace); } - return context5; + return context6; } var TracingContextImpl = class _TracingContextImpl { _contextMap; @@ -42864,8 +42864,8 @@ var require_tracingClient = __commonJS({ span.end(); } } - function withContext(context5, callback, ...callbackArgs) { - return (0, instrumenter_js_1.getInstrumenter)().withContext(context5, callback, ...callbackArgs); + function withContext(context6, callback, ...callbackArgs) { + return (0, instrumenter_js_1.getInstrumenter)().withContext(context6, callback, ...callbackArgs); } function parseTraceparentHeader(traceparentHeader) { return (0, instrumenter_js_1.getInstrumenter)().parseTraceparentHeader(traceparentHeader); @@ -76782,10 +76782,10 @@ var BitIterator; var init_bit = __esm({ "node_modules/apache-arrow/util/bit.mjs"() { BitIterator = class { - constructor(bytes, begin, length, context5, get) { + constructor(bytes, begin, length, context6, get) { this.bytes = bytes; this.length = length; - this.context = context5; + this.context = context6; this.get = get; this.bit = begin % 8; this.byteIndex = begin >> 3; @@ -116882,8 +116882,8 @@ var require_graceful_fs = __commonJS({ } function noop3() { } - function publishQueue(context5, queue3) { - Object.defineProperty(context5, gracefulQueue, { + function publishQueue(context6, queue3) { + Object.defineProperty(context6, gracefulQueue, { get: function() { return queue3; } @@ -136490,8 +136490,8 @@ var require_dist_node2 = __commonJS({ function isKeyOperator2(operator) { return operator === ";" || operator === "&" || operator === "?"; } - function getValues2(context5, operator, key, modifier) { - var value = context5[key], result = []; + function getValues2(context6, operator, key, modifier) { + var value = context6[key], result = []; if (isDefined3(value) && value !== "") { if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { value = value.toString(); @@ -136555,7 +136555,7 @@ var require_dist_node2 = __commonJS({ expand: expand3.bind(null, template) }; } - function expand3(template, context5) { + function expand3(template, context6) { var operators = ["+", "#", ".", "/", ";", "?", "&"]; template = template.replace( /\{([^\{\}]+)\}|([^\{\}]+)/g, @@ -136569,7 +136569,7 @@ var require_dist_node2 = __commonJS({ } expression.split(/,/g).forEach(function(variable) { var tmp2 = /([^:\*]*)(?::(\d+)|(\*))?/.exec(variable); - values.push(getValues2(context5, operator, tmp2[1], tmp2[2] || tmp2[3])); + values.push(getValues2(context6, operator, tmp2[1], tmp2[2] || tmp2[3])); }); if (operator && operator !== "+") { var separator = ","; @@ -142021,7 +142021,7 @@ var require_download_artifact = __commonJS({ var promises_1 = __importDefault2(require("fs/promises")); var crypto3 = __importStar2(require("crypto")); var stream2 = __importStar2(require("stream")); - var github5 = __importStar2(require_github2()); + var github6 = __importStar2(require_github2()); var core32 = __importStar2(require_core()); var httpClient = __importStar2(require_lib()); var unzip_stream_1 = __importDefault2(require_unzip()); @@ -142109,7 +142109,7 @@ var require_download_artifact = __commonJS({ function downloadArtifactPublic(artifactId, repositoryOwner, repositoryName, token, options) { return __awaiter2(this, void 0, void 0, function* () { const downloadPath = yield resolveOrCreateDirectory(options === null || options === void 0 ? void 0 : options.path); - const api = github5.getOctokit(token); + const api = github6.getOctokit(token); let digestMismatch = false; core32.info(`Downloading artifact '${artifactId}' from '${repositoryOwner}/${repositoryName}'`); const { headers, status } = yield api.rest.actions.downloadArtifact({ @@ -142430,8 +142430,8 @@ var require_get_artifact = __commonJS({ retry: retryOpts, request: requestOpts }; - const github5 = (0, github_1.getOctokit)(token, opts, plugin_retry_1.retry, plugin_request_log_1.requestLog); - const getArtifactResp = yield github5.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts{?name}", { + const github6 = (0, github_1.getOctokit)(token, opts, plugin_retry_1.retry, plugin_request_log_1.requestLog); + const getArtifactResp = yield github6.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts{?name}", { owner: repositoryOwner, repo: repositoryName, run_id: workflowRunId, @@ -142554,9 +142554,9 @@ var require_delete_artifact = __commonJS({ retry: retryOpts, request: requestOpts }; - const github5 = (0, github_1.getOctokit)(token, opts, plugin_retry_1.retry, plugin_request_log_1.requestLog); + const github6 = (0, github_1.getOctokit)(token, opts, plugin_retry_1.retry, plugin_request_log_1.requestLog); const getArtifactResp = yield (0, get_artifact_1.getArtifactPublic)(artifactName, workflowRunId, repositoryOwner, repositoryName, token); - const deleteArtifactResp = yield github5.rest.actions.deleteArtifact({ + const deleteArtifactResp = yield github6.rest.actions.deleteArtifact({ owner: repositoryOwner, repo: repositoryName, artifact_id: getArtifactResp.artifact.id @@ -142663,9 +142663,9 @@ var require_list_artifacts = __commonJS({ retry: retryOpts, request: requestOpts }; - const github5 = (0, github_1.getOctokit)(token, opts, plugin_retry_1.retry, plugin_request_log_1.requestLog); + const github6 = (0, github_1.getOctokit)(token, opts, plugin_retry_1.retry, plugin_request_log_1.requestLog); let currentPageNumber = 1; - const { data: listArtifactResponse } = yield github5.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts", { + const { data: listArtifactResponse } = yield github6.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts", { owner: repositoryOwner, repo: repositoryName, run_id: workflowRunId, @@ -142690,7 +142690,7 @@ var require_list_artifacts = __commonJS({ currentPageNumber++; for (currentPageNumber; currentPageNumber <= numberOfPages; currentPageNumber++) { (0, core_1.debug)(`Fetching page ${currentPageNumber} of artifact list`); - const { data: listArtifactResponse2 } = yield github5.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts", { + const { data: listArtifactResponse2 } = yield github6.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts", { owner: repositoryOwner, repo: repositoryName, run_id: workflowRunId, @@ -173812,6 +173812,25 @@ Error Response: ${JSON.stringify(error3.response, null, 2)}` } } } +async function getPullRequestChangedFiles(branches, logger2) { + const fileDiffs = await getFileDiffsWithBasehead(branches, logger2); + if (fileDiffs === void 0) { + return void 0; + } + if (fileDiffs.length >= 300) { + logger2.warning( + `Cannot retrieve the full diff because there are too many (${fileDiffs.length}) changed files in the pull request.` + ); + return void 0; + } + return Array.from( + new Set( + fileDiffs.flatMap( + (fileDiff) => fileDiff.previous_filename ? [fileDiff.filename, fileDiff.previous_filename] : [fileDiff.filename] + ) + ) + ); +} function getDiffRanges(fileDiff, logger2) { if (fileDiff.patch === void 0) { if (fileDiff.changes === 0) { @@ -180594,6 +180613,19 @@ async function run({ logger: logger2, actions }) { + const analysisSkipReason = process.env["CODEQL_ACTION_ANALYSIS_SKIP_REASON" /* ANALYSIS_SKIP_REASON */]; + if (analysisSkipReason !== void 0) { + logger2.info( + `Skipping CodeQL analysis because init intentionally skipped it: ${analysisSkipReason}.` + ); + core17.setOutput("analysis-skipped", "true"); + core17.setOutput("analysis-skip-reason", analysisSkipReason); + core17.exportVariable("CODEQL_ACTION_ANALYZE_DID_COMPLETE_SUCCESSFULLY" /* ANALYZE_DID_COMPLETE_SUCCESSFULLY */, "true"); + core17.exportVariable("CODEQL_ACTION_JOB_STATUS" /* JOB_STATUS */, "JOB_STATUS_SUCCESS" /* SuccessStatus */); + return; + } + core17.setOutput("analysis-skipped", "false"); + core17.setOutput("analysis-skip-reason", ""); let uploadResults = void 0; let runStats = void 0; let config = void 0; @@ -185760,6 +185792,12 @@ async function createDatabaseBundleCli(codeql, config, language) { async function runWrapper2() { try { const logger2 = getActionsLogger(); + if (process.env["CODEQL_ACTION_ANALYSIS_SKIP_REASON" /* ANALYSIS_SKIP_REASON */] !== void 0) { + logger2.info( + `Skipping analyze post-action because analysis was skipped: ${process.env["CODEQL_ACTION_ANALYSIS_SKIP_REASON" /* ANALYSIS_SKIP_REASON */]}` + ); + return; + } restoreInputs(logger2); const gitHubVersion = await getGitHubVersion(); checkGitHubVersionInRange(gitHubVersion, logger2); @@ -185898,6 +185936,235 @@ var core22 = __toESM(require_core()); var io7 = __toESM(require_io()); var semver11 = __toESM(require_semver2()); +// src/analysis-skip.ts +var github4 = __toESM(require_github()); +var languageExtensions = { + ["actions" /* actions */]: /* @__PURE__ */ new Set(), + ["cpp" /* cpp */]: /* @__PURE__ */ new Set([ + ".c", + ".cc", + ".cpp", + ".cxx", + ".h", + ".hh", + ".hpp", + ".hxx" + ]), + ["csharp" /* csharp */]: /* @__PURE__ */ new Set([".cs"]), + ["go" /* go */]: /* @__PURE__ */ new Set([".go"]), + ["java" /* java */]: /* @__PURE__ */ new Set([".java", ".kt", ".kts"]), + ["javascript" /* javascript */]: /* @__PURE__ */ new Set([ + ".cjs", + ".html", + ".js", + ".jsx", + ".mjs", + ".ts", + ".tsx" + ]), + ["python" /* python */]: /* @__PURE__ */ new Set([".py", ".pyi"]), + ["ruby" /* ruby */]: /* @__PURE__ */ new Set([".rb", ".rake", ".gemspec", ".erb"]), + ["rust" /* rust */]: /* @__PURE__ */ new Set([".rs"]), + ["swift" /* swift */]: /* @__PURE__ */ new Set([".swift"]) +}; +var languageConfigFiles = { + ["actions" /* actions */]: /* @__PURE__ */ new Set(), + ["cpp" /* cpp */]: /* @__PURE__ */ new Set(["cmakelists.txt"]), + ["csharp" /* csharp */]: /* @__PURE__ */ new Set([ + "directory.build.props", + "directory.build.targets" + ]), + ["go" /* go */]: /* @__PURE__ */ new Set(["go.mod", "go.sum"]), + ["java" /* java */]: /* @__PURE__ */ new Set([ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "pom.xml", + "settings.gradle", + "settings.gradle.kts" + ]), + ["javascript" /* javascript */]: /* @__PURE__ */ new Set([ + ".npmrc", + "jsconfig.json", + "package-lock.json", + "package.json", + "pnpm-lock.yaml", + "tsconfig.json", + "yarn.lock" + ]), + ["python" /* python */]: /* @__PURE__ */ new Set([ + ".python-version", + "pipfile", + "pipfile.lock", + "poetry.lock", + "pyproject.toml", + "requirements.txt", + "setup.cfg", + "setup.py", + "tox.ini", + "uv.lock" + ]), + ["ruby" /* ruby */]: /* @__PURE__ */ new Set(["gemfile", "gemfile.lock"]), + ["rust" /* rust */]: /* @__PURE__ */ new Set(["cargo.lock", "cargo.toml"]), + ["swift" /* swift */]: /* @__PURE__ */ new Set(["package.swift"]) +}; +var nonCodeExtensions = /* @__PURE__ */ new Set([ + ".adoc", + ".csv", + ".css", + ".gif", + ".jpeg", + ".jpg", + ".less", + ".markdown", + ".md", + ".pdf", + ".png", + ".rst", + ".scss", + ".svg", + ".txt", + ".webp" +]); +var workflowExtensions = /* @__PURE__ */ new Set([".yml", ".yaml"]); +function shouldSkipDraftAnalysis(skipIfDraft, draftState, managedWorkflowDraft = false) { + return managedWorkflowDraft || skipIfDraft && draftState === true; +} +function shouldSkipUnchangedLanguage(enabled, languagesInput, changedFiles, buildMode = void 0) { + if (!enabled || languagesInput === void 0 || changedFiles === void 0) { + return false; + } + const languages = languagesInput.split(",").map((language2) => parseBuiltInLanguage(language2)); + if (languages.length === 0 || languages.length > 1 || languages.some((language2) => language2 === void 0)) { + return false; + } + const language = languages[0]; + if (language === void 0) { + return false; + } + return changedFiles.every((file) => { + const fileKind = classifyChangedFile(file, buildMode); + return fileKind !== void 0 && (fileKind === "non-code" || fileKind !== "global" && fileKind !== language); + }); +} +function classifyChangedFile(file, buildMode) { + const normalizedPath = file.replaceAll("\\", "/").toLowerCase(); + const basename2 = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1); + if (normalizedPath.includes("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/.github/codeql/") || normalizedPath.startsWith(".github/codeql/") || [".ql", ".qls", ".qlpack.yml"].some( + (suffix) => normalizedPath.endsWith(suffix) + )) { + return "global"; + } + for (const language of Object.values(BuiltInLanguage)) { + if (languageConfigFiles[language].has(basename2)) { + return language; + } + } + if ((normalizedPath.startsWith(".github/workflows/") || normalizedPath.includes("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/.github/workflows/")) && workflowExtensions.has(extensionOf(basename2))) { + return "actions" /* actions */; + } + if (basename2.startsWith("requirements") && (basename2.endsWith(".txt") || basename2.endsWith(".in"))) { + return "python" /* python */; + } + if (basename2.startsWith("tsconfig") && (basename2.endsWith(".json") || basename2.endsWith(".jsonc"))) { + return "javascript" /* javascript */; + } + if (basename2.endsWith(".csproj") || basename2.endsWith(".sln")) { + return "csharp" /* csharp */; + } + const extension = extensionOf(basename2); + if (extension === ".sh" || extension === ".bash") { + return buildMode === "none" ? "non-code" : void 0; + } + for (const language of Object.values(BuiltInLanguage)) { + if (languageExtensions[language].has(extension)) { + return language; + } + } + if (nonCodeExtensions.has(extension)) { + return "non-code"; + } + return void 0; +} +function extensionOf(filename) { + const dot = filename.lastIndexOf("."); + return dot > 0 ? filename.slice(dot) : ""; +} +function getDraftStateFromContext(pullRequestDraft, dynamicDraftValue) { + if (typeof pullRequestDraft === "boolean") { + return pullRequestDraft; + } + const dynamicDraft = dynamicDraftValue; + if (dynamicDraft === "true") { + return true; + } + if (dynamicDraft === "false") { + return false; + } + return void 0; +} +function getDraftState() { + return getDraftStateFromContext( + github4.context.payload.pull_request?.draft, + process.env.CODE_SCANNING_IS_DRAFT + ); +} +async function getAnalysisSkipReason(logger2) { + const skipIfDraft = getOptionalInput("skip-if-draft") === "true"; + const draftState = getDraftState(); + const managedWorkflowDraft = process.env.CODE_SCANNING_IS_DRAFT === "true"; + if (shouldSkipDraftAnalysis(skipIfDraft, draftState, managedWorkflowDraft)) { + return "the pull request is a draft"; + } + if (skipIfDraft && draftState === void 0) { + logger2.info( + "Draft status is unavailable in this workflow event; continuing with analysis." + ); + } + const skipUnchangedLanguage = getOptionalInput("skip-if-no-language-changes") === "true"; + if (!skipUnchangedLanguage) { + return void 0; + } + const languagesInput = getOptionalInput("languages"); + if (!languagesInput) { + logger2.info( + "Cannot skip an unchanged-language analysis without an explicit languages input." + ); + return void 0; + } + const branches = getPullRequestBranches(); + if (!branches) { + logger2.info( + "Cannot skip an unchanged-language analysis outside a pull request." + ); + return void 0; + } + let changedFiles; + try { + changedFiles = await getPullRequestChangedFiles(branches, logger2); + } catch (error3) { + logger2.warning( + `Unable to determine changed files; continuing with the full analysis: ${error3}` + ); + return void 0; + } + if (changedFiles === void 0) { + logger2.info( + "The complete pull-request diff is unavailable; continuing with the full analysis." + ); + return void 0; + } + if (shouldSkipUnchangedLanguage( + true, + languagesInput, + changedFiles, + getOptionalInput("build-mode") + )) { + return `the pull request changes no files for ${languagesInput}`; + } + return void 0; +} + // src/config/inputs.ts async function getToolsInput(action, repositoryProperties) { const name = "tools" /* Tools */; @@ -186351,6 +186618,33 @@ async function run3(actionState) { `The 'init' action should not be run in the same workflow as 'setup-codeql'.` ); } + const analysisSkipReason = await getAnalysisSkipReason(logger2); + if (analysisSkipReason !== void 0) { + logger2.info( + `Skipping CodeQL before tool download and database initialization: ${analysisSkipReason}.` + ); + core22.exportVariable("CODEQL_ACTION_ANALYSIS_SKIP_REASON" /* ANALYSIS_SKIP_REASON */, analysisSkipReason); + core22.exportVariable("CODEQL_ACTION_JOB_STATUS" /* JOB_STATUS */, "JOB_STATUS_SUCCESS" /* SuccessStatus */); + core22.exportVariable("CODEQL_ACTION_ANALYZE_DID_COMPLETE_SUCCESSFULLY" /* ANALYZE_DID_COMPLETE_SUCCESSFULLY */, "true"); + core22.setOutput("analysis-skipped", "true"); + core22.setOutput("analysis-skip-reason", analysisSkipReason); + await sendCompletedStatusReport2( + startedAt, + void 0, + void 0, + void 0, + void 0, + void 0, + "UNKNOWN" /* Unknown */, + "", + void 0, + void 0, + logger2 + ); + return; + } + core22.setOutput("analysis-skipped", "false"); + core22.setOutput("analysis-skip-reason", ""); toolsInput = await getToolsInput( actionStateWithFeatures, repositoryProperties @@ -186738,7 +187032,7 @@ var core23 = __toESM(require_core()); // src/init-action-post-helper.ts var fs30 = __toESM(require("fs")); var import_path8 = __toESM(require("path")); -var github4 = __toESM(require_github()); +var github5 = __toESM(require_github()); function createFailedUploadFailedSarifResult(error3) { const wrappedError = wrapError(error3); return { @@ -186903,7 +187197,7 @@ async function uploadFailureInfo(uploadAllAvailableDebugArtifacts, printDebugLog ); } if (process.env["CODEQL_ACTION_EXPECT_UPLOAD_FAILED_SARIF"] === "true") { - if (!github4.context.payload.pull_request?.head.repo.fork) { + if (!github5.context.payload.pull_request?.head.repo.fork) { await removeUploadedSarif(uploadFailedSarifResult, logger2); } else { logger2.info( @@ -187066,6 +187360,12 @@ async function removeUploadedSarif(uploadFailedSarifResult, logger2) { // src/init-action-post.ts async function run4(startedAt) { const logger2 = getActionsLogger(); + if (process.env["CODEQL_ACTION_ANALYSIS_SKIP_REASON" /* ANALYSIS_SKIP_REASON */] !== void 0) { + logger2.info( + `Skipping init post-action because analysis was skipped: ${process.env["CODEQL_ACTION_ANALYSIS_SKIP_REASON" /* ANALYSIS_SKIP_REASON */]}` + ); + return; + } let config; let uploadFailedSarifResult; let dependencyCachingUsage; diff --git a/src/analysis-skip.test.ts b/src/analysis-skip.test.ts new file mode 100644 index 0000000000..8c3cc02dbe --- /dev/null +++ b/src/analysis-skip.test.ts @@ -0,0 +1,89 @@ +import test from "ava"; + +import { + getDraftStateFromContext, + shouldSkipDraftAnalysis, + shouldSkipUnchangedLanguage, +} from "./analysis-skip"; +import { setupTests } from "./testing-utils"; + +setupTests(test); + +test("draft analysis is skipped only when explicitly enabled and confirmed", (t) => { + t.true(shouldSkipDraftAnalysis(true, true)); + t.false(shouldSkipDraftAnalysis(false, true)); + t.false(shouldSkipDraftAnalysis(true, false)); + t.false(shouldSkipDraftAnalysis(true, undefined)); + t.true(shouldSkipDraftAnalysis(false, undefined, true)); +}); + +test("draft state can be supplied by a pull_request or dynamic event", (t) => { + t.is(getDraftStateFromContext(true), true); + t.is(getDraftStateFromContext(undefined, "true"), true); + t.is(getDraftStateFromContext(undefined, "false"), false); + t.is(getDraftStateFromContext(undefined), undefined); +}); + +test("JavaScript changes do not start an unchanged Python analysis", (t) => { + const files = [".github/issue-triage/guard.mjs", "docs/setup.md"]; + + t.false(shouldSkipUnchangedLanguage(true, "javascript-typescript", files)); + t.true(shouldSkipUnchangedLanguage(true, "python", files)); +}); + +test("documentation-only changes can skip a language analysis", (t) => { + t.true( + shouldSkipUnchangedLanguage(true, "javascript", [ + "docs/guide.md", + "README.rst", + ]), + ); +}); + +test("language-specific dependency files keep their language analysis", (t) => { + t.false( + shouldSkipUnchangedLanguage(true, "javascript-typescript", [ + "package-lock.json", + ]), + ); + t.false( + shouldSkipUnchangedLanguage(true, "python", ["requirements-dev.txt"]), + ); +}); + +test("build scripts are ignored only when the workflow uses build-mode none", (t) => { + const files = ["src/guard.mjs", "scripts/test-issue-triage.sh"]; + + t.true(shouldSkipUnchangedLanguage(true, "python", files, "none")); + t.false(shouldSkipUnchangedLanguage(true, "python", files, "manual")); + t.false(shouldSkipUnchangedLanguage(true, "python", files)); +}); + +test("renames are relevant to both the old and new languages", (t) => { + t.false( + shouldSkipUnchangedLanguage(true, "python", [ + "src/new_guard.mjs", + "src/old_guard.py", + ]), + ); +}); + +test("unknown and CodeQL configuration paths fail open", (t) => { + t.false( + shouldSkipUnchangedLanguage(true, "python", ["build/custom-generator.x"]), + ); + t.false( + shouldSkipUnchangedLanguage(true, "python", [ + ".github/codeql/codeql-config.yml", + ]), + ); +}); + +test("incomplete inputs, multi-language jobs, and disabled gates fail open", (t) => { + t.false(shouldSkipUnchangedLanguage(false, "python", ["README.md"])); + t.false(shouldSkipUnchangedLanguage(true, undefined, ["README.md"])); + t.false( + shouldSkipUnchangedLanguage(true, "python,javascript", ["README.md"]), + ); + t.false(shouldSkipUnchangedLanguage(true, "python", undefined)); +}); diff --git a/src/analysis-skip.ts b/src/analysis-skip.ts new file mode 100644 index 0000000000..374a6f102c --- /dev/null +++ b/src/analysis-skip.ts @@ -0,0 +1,319 @@ +import * as github from "@actions/github"; + +import * as actionsUtil from "./actions-util"; +import { getPullRequestChangedFiles } from "./diff-informed-analysis-utils"; +import { BuiltInLanguage, parseBuiltInLanguage } from "./languages"; +import type { Logger } from "./logging"; + +type ChangedFileKind = BuiltInLanguage | "non-code" | "global"; + +const languageExtensions: Record> = { + [BuiltInLanguage.actions]: new Set(), + [BuiltInLanguage.cpp]: new Set([ + ".c", + ".cc", + ".cpp", + ".cxx", + ".h", + ".hh", + ".hpp", + ".hxx", + ]), + [BuiltInLanguage.csharp]: new Set([".cs"]), + [BuiltInLanguage.go]: new Set([".go"]), + [BuiltInLanguage.java]: new Set([".java", ".kt", ".kts"]), + [BuiltInLanguage.javascript]: new Set([ + ".cjs", + ".html", + ".js", + ".jsx", + ".mjs", + ".ts", + ".tsx", + ]), + [BuiltInLanguage.python]: new Set([".py", ".pyi"]), + [BuiltInLanguage.ruby]: new Set([".rb", ".rake", ".gemspec", ".erb"]), + [BuiltInLanguage.rust]: new Set([".rs"]), + [BuiltInLanguage.swift]: new Set([".swift"]), +}; + +const languageConfigFiles: Record> = { + [BuiltInLanguage.actions]: new Set(), + [BuiltInLanguage.cpp]: new Set(["cmakelists.txt"]), + [BuiltInLanguage.csharp]: new Set([ + "directory.build.props", + "directory.build.targets", + ]), + [BuiltInLanguage.go]: new Set(["go.mod", "go.sum"]), + [BuiltInLanguage.java]: new Set([ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "pom.xml", + "settings.gradle", + "settings.gradle.kts", + ]), + [BuiltInLanguage.javascript]: new Set([ + ".npmrc", + "jsconfig.json", + "package-lock.json", + "package.json", + "pnpm-lock.yaml", + "tsconfig.json", + "yarn.lock", + ]), + [BuiltInLanguage.python]: new Set([ + ".python-version", + "pipfile", + "pipfile.lock", + "poetry.lock", + "pyproject.toml", + "requirements.txt", + "setup.cfg", + "setup.py", + "tox.ini", + "uv.lock", + ]), + [BuiltInLanguage.ruby]: new Set(["gemfile", "gemfile.lock"]), + [BuiltInLanguage.rust]: new Set(["cargo.lock", "cargo.toml"]), + [BuiltInLanguage.swift]: new Set(["package.swift"]), +}; + +const nonCodeExtensions = new Set([ + ".adoc", + ".csv", + ".css", + ".gif", + ".jpeg", + ".jpg", + ".less", + ".markdown", + ".md", + ".pdf", + ".png", + ".rst", + ".scss", + ".svg", + ".txt", + ".webp", +]); +const workflowExtensions = new Set([".yml", ".yaml"]); + +/** + * Returns whether the supplied pull-request metadata authorizes a draft skip. + * Unknown draft state deliberately fails open. + */ +export function shouldSkipDraftAnalysis( + skipIfDraft: boolean, + draftState: boolean | undefined, + managedWorkflowDraft = false, +): boolean { + return managedWorkflowDraft || (skipIfDraft && draftState === true); +} + +/** + * Returns whether a single-language analysis can safely be skipped for a diff. + * Unknown languages, unknown files, and absent diff data all fail open. + */ +export function shouldSkipUnchangedLanguage( + enabled: boolean, + languagesInput: string | undefined, + changedFiles: readonly string[] | undefined, + buildMode: string | undefined = undefined, +): boolean { + if (!enabled || languagesInput === undefined || changedFiles === undefined) { + return false; + } + + const languages = languagesInput + .split(",") + .map((language) => parseBuiltInLanguage(language)); + if ( + languages.length === 0 || + languages.length > 1 || + languages.some((language) => language === undefined) + ) { + return false; + } + + const language = languages[0]; + if (language === undefined) { + return false; + } + + return changedFiles.every((file) => { + const fileKind = classifyChangedFile(file, buildMode); + return ( + fileKind !== undefined && + (fileKind === "non-code" || + (fileKind !== "global" && fileKind !== language)) + ); + }); +} + +function classifyChangedFile( + file: string, + buildMode: string | undefined, +): ChangedFileKind | undefined { + const normalizedPath = file.replaceAll("\\", "/").toLowerCase(); + const basename = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1); + + // Query and extractor configuration can change the meaning of every analysis. + if ( + normalizedPath.includes("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/.github/codeql/") || + normalizedPath.startsWith(".github/codeql/") || + [".ql", ".qls", ".qlpack.yml"].some((suffix) => + normalizedPath.endsWith(suffix), + ) + ) { + return "global"; + } + + for (const language of Object.values(BuiltInLanguage)) { + if (languageConfigFiles[language].has(basename)) { + return language; + } + } + + if ( + (normalizedPath.startsWith(".github/workflows/") || + normalizedPath.includes("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/.github/workflows/")) && + workflowExtensions.has(extensionOf(basename)) + ) { + return BuiltInLanguage.actions; + } + + if ( + basename.startsWith("requirements") && + (basename.endsWith(".txt") || basename.endsWith(".in")) + ) { + return BuiltInLanguage.python; + } + if ( + basename.startsWith("tsconfig") && + (basename.endsWith(".json") || basename.endsWith(".jsonc")) + ) { + return BuiltInLanguage.javascript; + } + if (basename.endsWith(".csproj") || basename.endsWith(".sln")) { + return BuiltInLanguage.csharp; + } + + const extension = extensionOf(basename); + if (extension === ".sh" || extension === ".bash") { + // Build scripts can affect generated or compiled sources. They are safe to + // ignore only when the workflow explicitly uses build-mode: none. + return buildMode === "none" ? "non-code" : undefined; + } + for (const language of Object.values(BuiltInLanguage)) { + if (languageExtensions[language].has(extension)) { + return language; + } + } + + if (nonCodeExtensions.has(extension)) { + return "non-code"; + } + + return undefined; +} + +function extensionOf(filename: string): string { + const dot = filename.lastIndexOf("."); + return dot > 0 ? filename.slice(dot) : ""; +} + +export function getDraftStateFromContext( + pullRequestDraft: unknown, + dynamicDraftValue?: unknown, +): boolean | undefined { + if (typeof pullRequestDraft === "boolean") { + return pullRequestDraft; + } + + // GitHub-managed Code Quality uses a `dynamic` event rather than + // `pull_request`; the product could pass this value when it integrates the + // opt-in input. + const dynamicDraft = dynamicDraftValue; + if (dynamicDraft === "true") { + return true; + } + if (dynamicDraft === "false") { + return false; + } + return undefined; +} + +function getDraftState(): boolean | undefined { + return getDraftStateFromContext( + github.context.payload.pull_request?.draft, + process.env.CODE_SCANNING_IS_DRAFT, + ); +} + +/** Determine whether the current run should stop before CodeQL initialization. */ +export async function getAnalysisSkipReason( + logger: Logger, +): Promise { + const skipIfDraft = actionsUtil.getOptionalInput("skip-if-draft") === "true"; + const draftState = getDraftState(); + const managedWorkflowDraft = process.env.CODE_SCANNING_IS_DRAFT === "true"; + if (shouldSkipDraftAnalysis(skipIfDraft, draftState, managedWorkflowDraft)) { + return "the pull request is a draft"; + } + if (skipIfDraft && draftState === undefined) { + logger.info( + "Draft status is unavailable in this workflow event; continuing with analysis.", + ); + } + + const skipUnchangedLanguage = + actionsUtil.getOptionalInput("skip-if-no-language-changes") === "true"; + if (!skipUnchangedLanguage) { + return undefined; + } + + const languagesInput = actionsUtil.getOptionalInput("languages"); + if (!languagesInput) { + logger.info( + "Cannot skip an unchanged-language analysis without an explicit languages input.", + ); + return undefined; + } + + const branches = actionsUtil.getPullRequestBranches(); + if (!branches) { + logger.info( + "Cannot skip an unchanged-language analysis outside a pull request.", + ); + return undefined; + } + + let changedFiles: string[] | undefined; + try { + changedFiles = await getPullRequestChangedFiles(branches, logger); + } catch (error) { + logger.warning( + `Unable to determine changed files; continuing with the full analysis: ${error}`, + ); + return undefined; + } + if (changedFiles === undefined) { + logger.info( + "The complete pull-request diff is unavailable; continuing with the full analysis.", + ); + return undefined; + } + + if ( + shouldSkipUnchangedLanguage( + true, + languagesInput, + changedFiles, + actionsUtil.getOptionalInput("build-mode"), + ) + ) { + return `the pull request changes no files for ${languagesInput}`; + } + return undefined; +} diff --git a/src/analyze-action-post.ts b/src/analyze-action-post.ts index 3f9b2fafb2..a812d62b12 100644 --- a/src/analyze-action-post.ts +++ b/src/analyze-action-post.ts @@ -26,6 +26,12 @@ export async function runWrapper() { try { const logger = getActionsLogger(); + if (process.env[EnvVar.ANALYSIS_SKIP_REASON] !== undefined) { + logger.info( + `Skipping analyze post-action because analysis was skipped: ${process.env[EnvVar.ANALYSIS_SKIP_REASON]}`, + ); + return; + } actionsUtil.restoreInputs(logger); const gitHubVersion = await getGitHubVersion(); checkGitHubVersionInRange(gitHubVersion, logger); diff --git a/src/analyze-action.test.ts b/src/analyze-action.test.ts index 923908a641..9c6d67d7c8 100644 --- a/src/analyze-action.test.ts +++ b/src/analyze-action.test.ts @@ -6,6 +6,7 @@ import * as analyze from "./analyze"; import { runWrapper } from "./analyze-action"; import * as api from "./api-client"; import * as configUtils from "./config-utils"; +import { EnvVar } from "./environment"; import * as gitUtils from "./git-utils"; import * as statusReport from "./status-report"; import { @@ -140,3 +141,24 @@ test.serial( }); }, ); + +test.serial( + "analyze action no-ops after an intentional init skip", + async (t) => { + await util.withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + process.env[EnvVar.ANALYSIS_SKIP_REASON] = "draft pull request"; + + const getConfigStub = sinon.stub(configUtils, "getConfig"); + const runFinalizeStub = sinon.stub(analyze, "runFinalize"); + const runQueriesStub = sinon.stub(analyze, "runQueries"); + + await runWrapper(); + + t.false(getConfigStub.called); + t.false(runFinalizeStub.called); + t.false(runQueriesStub.called); + t.is(process.env[EnvVar.ANALYZE_DID_COMPLETE_SUCCESSFULLY], "true"); + }); + }, +); diff --git a/src/analyze-action.ts b/src/analyze-action.ts index 7963fa52bf..54a79bb570 100644 --- a/src/analyze-action.ts +++ b/src/analyze-action.ts @@ -41,6 +41,7 @@ import { createStatusReportBase, DatabaseCreationTimings, getActionsStatus, + JobStatus, StatusReportBase, } from "./status-report"; import { @@ -217,6 +218,20 @@ async function run({ logger, actions, }: ActionState<["Base", "Logger", "Actions"]>) { + const analysisSkipReason = process.env[EnvVar.ANALYSIS_SKIP_REASON]; + if (analysisSkipReason !== undefined) { + logger.info( + `Skipping CodeQL analysis because init intentionally skipped it: ${analysisSkipReason}.`, + ); + core.setOutput("analysis-skipped", "true"); + core.setOutput("analysis-skip-reason", analysisSkipReason); + core.exportVariable(EnvVar.ANALYZE_DID_COMPLETE_SUCCESSFULLY, "true"); + core.exportVariable(EnvVar.JOB_STATUS, JobStatus.SuccessStatus); + return; + } + core.setOutput("analysis-skipped", "false"); + core.setOutput("analysis-skip-reason", ""); + // To capture errors appropriately, keep as much code within the try-catch as // possible, and only use safe functions outside. diff --git a/src/diff-informed-analysis-utils.test.ts b/src/diff-informed-analysis-utils.test.ts index e07240e322..39a8d57289 100644 --- a/src/diff-informed-analysis-utils.test.ts +++ b/src/diff-informed-analysis-utils.test.ts @@ -5,6 +5,7 @@ import * as actionsUtil from "./actions-util"; import type { PullRequestBranches } from "./actions-util"; import * as apiClient from "./api-client"; import { + getPullRequestChangedFiles, getDiffInformedAnalysisBranches, prepareDiffInformedAnalysis, exportedForTesting, @@ -177,6 +178,71 @@ testShouldPerformDiffInformedAnalysis.serial( false, ); +test.serial( + "getPullRequestChangedFiles returns both sides of renames", + async (t) => { + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const mockApiClient = { + rest: { + repos: { + compareCommitsWithBasehead: sinon.stub().resolves({ + data: { + files: [ + { + filename: "src/new_guard.mjs", + previous_filename: "src/old_guard.py", + changes: 0, + }, + ], + }, + }), + }, + }, + } as unknown as ReturnType; + sinon.stub(apiClient, "getApiClient").returns(mockApiClient); + + const files = await getPullRequestChangedFiles( + { base: "main", head: "feature" }, + getRunnerLogger(true), + ); + + t.deepEqual(files, ["src/new_guard.mjs", "src/old_guard.py"]); + }); + }, +); + +test.serial( + "getPullRequestChangedFiles fails open when the compare API truncates the diff", + async (t) => { + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const mockApiClient = { + rest: { + repos: { + compareCommitsWithBasehead: sinon.stub().resolves({ + data: { + files: Array.from({ length: 300 }, (_, index) => ({ + filename: `src/file-${index}.js`, + changes: 1, + })), + }, + }), + }, + }, + } as unknown as ReturnType; + sinon.stub(apiClient, "getApiClient").returns(mockApiClient); + + const files = await getPullRequestChangedFiles( + { base: "main", head: "feature" }, + getRunnerLogger(true), + ); + + t.is(files, undefined); + }); + }, +); + test.serial( "prepareDiffInformedAnalysis: returns false when not a pull request", async (t) => { diff --git a/src/diff-informed-analysis-utils.ts b/src/diff-informed-analysis-utils.ts index b8e9c6915d..804a289fde 100644 --- a/src/diff-informed-analysis-utils.ts +++ b/src/diff-informed-analysis-utils.ts @@ -16,6 +16,7 @@ import { getErrorMessage, GitHubVariant, satisfiesGHESVersion } from "./util"; interface FileDiff { filename: string; changes: number; + previous_filename?: string | undefined; // A patch may be absent if the file is binary, if the file diff is too large, // or if the file is unchanged. patch?: string | undefined; @@ -243,6 +244,39 @@ async function getFileDiffsWithBasehead( } } +/** + * Return changed paths for a pull request when the complete file list is + * available. The caller should perform a full analysis if this returns + * `undefined`, since the compare API may have truncated or failed to load the + * diff. + */ +export async function getPullRequestChangedFiles( + branches: PullRequestBranches, + logger: Logger, +): Promise { + const fileDiffs = await getFileDiffsWithBasehead(branches, logger); + if (fileDiffs === undefined) { + return undefined; + } + if (fileDiffs.length >= 300) { + logger.warning( + `Cannot retrieve the full diff because there are too many ` + + `(${fileDiffs.length}) changed files in the pull request.`, + ); + return undefined; + } + + return Array.from( + new Set( + fileDiffs.flatMap((fileDiff) => + fileDiff.previous_filename + ? [fileDiff.filename, fileDiff.previous_filename] + : [fileDiff.filename], + ), + ), + ); +} + function getDiffRanges( fileDiff: FileDiff, logger: Logger, diff --git a/src/environment.ts b/src/environment.ts index bf4bb4f717..aee5716fb9 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -18,6 +18,9 @@ export enum EnvVar { /** Whether the `analyze` Action completes successfully. */ ANALYZE_DID_COMPLETE_SUCCESSFULLY = "CODEQL_ACTION_ANALYZE_DID_COMPLETE_SUCCESSFULLY", + /** Why CodeQL analysis was intentionally skipped before database initialization. */ + ANALYSIS_SKIP_REASON = "CODEQL_ACTION_ANALYSIS_SKIP_REASON", + /** Whether the `autobuild` Action completes successfully. */ AUTOBUILD_DID_COMPLETE_SUCCESSFULLY = "CODEQL_ACTION_AUTOBUILD_DID_COMPLETE_SUCCESSFULLY", diff --git a/src/init-action-post.ts b/src/init-action-post.ts index a2bba6d7a4..e0d9d570be 100644 --- a/src/init-action-post.ts +++ b/src/init-action-post.ts @@ -50,6 +50,12 @@ async function run(startedAt: Date) { // possible, and only use safe functions outside. const logger = getActionsLogger(); + if (process.env[EnvVar.ANALYSIS_SKIP_REASON] !== undefined) { + logger.info( + `Skipping init post-action because analysis was skipped: ${process.env[EnvVar.ANALYSIS_SKIP_REASON]}`, + ); + return; + } let config: Config | undefined; let uploadFailedSarifResult: | initActionPostHelper.UploadFailedSarifResult diff --git a/src/init-action.test.ts b/src/init-action.test.ts new file mode 100644 index 0000000000..dd9b3d3062 --- /dev/null +++ b/src/init-action.test.ts @@ -0,0 +1,87 @@ +import * as fs from "fs"; +import * as path from "path"; + +import * as core from "@actions/core"; +import * as github from "@actions/github"; +import test from "ava"; +import * as sinon from "sinon"; + +import * as analyses from "./analyses"; +import * as apiClient from "./api-client"; +import * as configFile from "./config/file"; +import * as configInputs from "./config/inputs"; +import { EnvVar } from "./environment"; +import * as featureFlags from "./feature-flags"; +import * as init from "./init"; +import { runWrapper } from "./init-action"; +import * as statusReport from "./status-report"; +import { createFeatures, setupActionsVars, setupTests } from "./testing-utils"; +import * as util from "./util"; + +setupTests(test); + +test.serial( + "init skips before downloading tools or initializing a database for a draft", + async (t) => { + await util.withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir, { GITHUB_EVENT_NAME: "pull_request" }); + process.env["INPUT_TOKEN"] = "test-token"; + process.env["INPUT_SKIP-IF-DRAFT"] = "true"; + const githubEnvPath = path.join(tmpDir, "github-env"); + const githubOutputPath = path.join(tmpDir, "github-output"); + const githubStatePath = path.join(tmpDir, "github-state"); + process.env["GITHUB_ENV"] = githubEnvPath; + process.env["GITHUB_OUTPUT"] = githubOutputPath; + process.env["GITHUB_STATE"] = githubStatePath; + for (const file of [githubEnvPath, githubOutputPath, githubStatePath]) { + fs.writeFileSync(file, ""); + } + + const originalPayload = github.context.payload; + github.context.payload = { + pull_request: { number: 1, draft: true }, + repository: { + name: "codeql-action-test", + owner: { login: "test", type: "User" }, + }, + }; + + try { + sinon.stub(apiClient, "getGitHubVersion").resolves({ + type: util.GitHubVariant.DOTCOM, + }); + sinon.stub(featureFlags, "initFeatures").returns(createFeatures([])); + sinon + .stub(analyses, "getAnalysisKinds") + .resolves([analyses.AnalysisKind.CodeScanning]); + sinon.stub(configFile, "getConfigFileInput").resolves(undefined); + sinon.stub(statusReport, "createStatusReportBase").resolves(undefined); + sinon.stub(util, "checkDiskUsage").resolves(undefined); + sinon.stub(util, "checkForTimeout").resolves(); + + const getToolsInputStub = sinon.stub(configInputs, "getToolsInput"); + const initCodeQLStub = sinon.stub(init, "initCodeQL"); + const databaseInitStub = sinon.stub(init, "runDatabaseInitCluster"); + const outputs = new Map(); + sinon + .stub(core, "setOutput") + .callsFake((name: string, value: string) => { + outputs.set(name, value); + }); + + await runWrapper(); + + t.false(getToolsInputStub.called); + t.false(initCodeQLStub.called); + t.false(databaseInitStub.called); + t.is(outputs.get("analysis-skipped"), "true"); + t.is( + process.env[EnvVar.ANALYSIS_SKIP_REASON], + "the pull request is a draft", + ); + } finally { + github.context.payload = originalPayload; + } + }); + }, +); diff --git a/src/init-action.ts b/src/init-action.ts index 31414bd80b..fdd0433189 100644 --- a/src/init-action.ts +++ b/src/init-action.ts @@ -16,6 +16,7 @@ import { persistInputs, } from "./actions-util"; import { AnalysisKind, getAnalysisKinds } from "./analyses"; +import { getAnalysisSkipReason } from "./analysis-skip"; import { getGitHubVersion, GitHubApiCombinedDetails } from "./api-client"; import { getDependencyCachingEnabled, @@ -67,6 +68,7 @@ import { getRepositoryNwo } from "./repository"; import { ToolsSource } from "./setup-codeql"; import { ActionName, + JobStatus, InitStatusReport, InitWithConfigStatusReport, createInitWithConfigStatusReport, @@ -295,6 +297,34 @@ async function run( ); } + const analysisSkipReason = await getAnalysisSkipReason(logger); + if (analysisSkipReason !== undefined) { + logger.info( + `Skipping CodeQL before tool download and database initialization: ${analysisSkipReason}.`, + ); + core.exportVariable(EnvVar.ANALYSIS_SKIP_REASON, analysisSkipReason); + core.exportVariable(EnvVar.JOB_STATUS, JobStatus.SuccessStatus); + core.exportVariable(EnvVar.ANALYZE_DID_COMPLETE_SUCCESSFULLY, "true"); + core.setOutput("analysis-skipped", "true"); + core.setOutput("analysis-skip-reason", analysisSkipReason); + await sendCompletedStatusReport( + startedAt, + undefined, + undefined, + undefined, + undefined, + undefined, + ToolsSource.Unknown, + "", + undefined, + undefined, + logger, + ); + return; + } + core.setOutput("analysis-skipped", "false"); + core.setOutput("analysis-skip-reason", ""); + // Get the computed `tools` input. toolsInput = await getToolsInput( actionStateWithFeatures, From affb5f3799bee8ec1798850dcacb92fe9a0ee110 Mon Sep 17 00:00:00 2001 From: asdf8675309 <174058705+asdf8675309@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:49:01 -0400 Subject: [PATCH 2/4] fix: guard analysis skip edge cases --- lib/entry-points.js | 178 +++++++++++----------------- src/analysis-skip.test.ts | 46 ++++++-- src/analysis-skip.ts | 221 +++++++++++++---------------------- src/autobuild-action.test.ts | 32 +++++ src/autobuild-action.ts | 9 ++ 5 files changed, 226 insertions(+), 260 deletions(-) create mode 100644 src/autobuild-action.test.ts diff --git a/lib/entry-points.js b/lib/entry-points.js index bac93ea7e5..d80f9e2c9a 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -185866,6 +185866,14 @@ async function run2({ startedAt, logger: logger2 }) { let config; let currentLanguage; let languages; + const analysisSkipReason = process.env["CODEQL_ACTION_ANALYSIS_SKIP_REASON" /* ANALYSIS_SKIP_REASON */]; + if (analysisSkipReason !== void 0) { + logger2.info( + `Skipping autobuild because CodeQL analysis was intentionally skipped: ${analysisSkipReason}.` + ); + core20.exportVariable("CODEQL_ACTION_AUTOBUILD_DID_COMPLETE_SUCCESSFULLY" /* AUTOBUILD_DID_COMPLETE_SUCCESSFULLY */, "true"); + return; + } try { const statusReportBase = await createStatusReportBase( "autobuild" /* Autobuild */, @@ -185938,76 +185946,6 @@ var semver11 = __toESM(require_semver2()); // src/analysis-skip.ts var github4 = __toESM(require_github()); -var languageExtensions = { - ["actions" /* actions */]: /* @__PURE__ */ new Set(), - ["cpp" /* cpp */]: /* @__PURE__ */ new Set([ - ".c", - ".cc", - ".cpp", - ".cxx", - ".h", - ".hh", - ".hpp", - ".hxx" - ]), - ["csharp" /* csharp */]: /* @__PURE__ */ new Set([".cs"]), - ["go" /* go */]: /* @__PURE__ */ new Set([".go"]), - ["java" /* java */]: /* @__PURE__ */ new Set([".java", ".kt", ".kts"]), - ["javascript" /* javascript */]: /* @__PURE__ */ new Set([ - ".cjs", - ".html", - ".js", - ".jsx", - ".mjs", - ".ts", - ".tsx" - ]), - ["python" /* python */]: /* @__PURE__ */ new Set([".py", ".pyi"]), - ["ruby" /* ruby */]: /* @__PURE__ */ new Set([".rb", ".rake", ".gemspec", ".erb"]), - ["rust" /* rust */]: /* @__PURE__ */ new Set([".rs"]), - ["swift" /* swift */]: /* @__PURE__ */ new Set([".swift"]) -}; -var languageConfigFiles = { - ["actions" /* actions */]: /* @__PURE__ */ new Set(), - ["cpp" /* cpp */]: /* @__PURE__ */ new Set(["cmakelists.txt"]), - ["csharp" /* csharp */]: /* @__PURE__ */ new Set([ - "directory.build.props", - "directory.build.targets" - ]), - ["go" /* go */]: /* @__PURE__ */ new Set(["go.mod", "go.sum"]), - ["java" /* java */]: /* @__PURE__ */ new Set([ - "build.gradle", - "build.gradle.kts", - "gradle.properties", - "pom.xml", - "settings.gradle", - "settings.gradle.kts" - ]), - ["javascript" /* javascript */]: /* @__PURE__ */ new Set([ - ".npmrc", - "jsconfig.json", - "package-lock.json", - "package.json", - "pnpm-lock.yaml", - "tsconfig.json", - "yarn.lock" - ]), - ["python" /* python */]: /* @__PURE__ */ new Set([ - ".python-version", - "pipfile", - "pipfile.lock", - "poetry.lock", - "pyproject.toml", - "requirements.txt", - "setup.cfg", - "setup.py", - "tox.ini", - "uv.lock" - ]), - ["ruby" /* ruby */]: /* @__PURE__ */ new Set(["gemfile", "gemfile.lock"]), - ["rust" /* rust */]: /* @__PURE__ */ new Set(["cargo.lock", "cargo.toml"]), - ["swift" /* swift */]: /* @__PURE__ */ new Set(["package.swift"]) -}; var nonCodeExtensions = /* @__PURE__ */ new Set([ ".adoc", ".csv", @@ -186023,31 +185961,56 @@ var nonCodeExtensions = /* @__PURE__ */ new Set([ ".rst", ".scss", ".svg", - ".txt", ".webp" ]); -var workflowExtensions = /* @__PURE__ */ new Set([".yml", ".yaml"]); +var buildConfigurationFiles = /* @__PURE__ */ new Set([ + ".npmrc", + ".python-version", + "build.gradle", + "build.gradle.kts", + "cargo.lock", + "cargo.toml", + "directory.build.props", + "directory.build.targets", + "gemfile", + "gemfile.lock", + "go.mod", + "go.sum", + "gradle.properties", + "jsconfig.json", + "package-lock.json", + "package.json", + "pipfile", + "pipfile.lock", + "poetry.lock", + "pom.xml", + "pyproject.toml", + "settings.gradle", + "settings.gradle.kts", + "setup.cfg", + "setup.py", + "tox.ini", + "tsconfig.json", + "uv.lock", + "yarn.lock" +]); function shouldSkipDraftAnalysis(skipIfDraft, draftState, managedWorkflowDraft = false) { return managedWorkflowDraft || skipIfDraft && draftState === true; } -function shouldSkipUnchangedLanguage(enabled, languagesInput, changedFiles, buildMode = void 0) { +function shouldSkipUnchangedLanguage(enabled, languagesInput, changedFiles) { if (!enabled || languagesInput === void 0 || changedFiles === void 0) { return false; } - const languages = languagesInput.split(",").map((language2) => parseBuiltInLanguage(language2)); - if (languages.length === 0 || languages.length > 1 || languages.some((language2) => language2 === void 0)) { - return false; - } - const language = languages[0]; - if (language === void 0) { + const languages = languagesInput.split(",").map((language) => parseBuiltInLanguage(language)); + if (languages.length !== 1 || languages[0] === void 0) { return false; } return changedFiles.every((file) => { - const fileKind = classifyChangedFile(file, buildMode); - return fileKind !== void 0 && (fileKind === "non-code" || fileKind !== "global" && fileKind !== language); + const fileKind = classifyChangedFile(file); + return fileKind === "non-code"; }); } -function classifyChangedFile(file, buildMode) { +function classifyChangedFile(file) { const normalizedPath = file.replaceAll("\\", "/").toLowerCase(); const basename2 = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1); if (normalizedPath.includes("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/.github/codeql/") || normalizedPath.startsWith(".github/codeql/") || [".ql", ".qls", ".qlpack.yml"].some( @@ -186055,31 +186018,15 @@ function classifyChangedFile(file, buildMode) { )) { return "global"; } - for (const language of Object.values(BuiltInLanguage)) { - if (languageConfigFiles[language].has(basename2)) { - return language; - } - } - if ((normalizedPath.startsWith(".github/workflows/") || normalizedPath.includes("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/.github/workflows/")) && workflowExtensions.has(extensionOf(basename2))) { - return "actions" /* actions */; - } - if (basename2.startsWith("requirements") && (basename2.endsWith(".txt") || basename2.endsWith(".in"))) { - return "python" /* python */; - } - if (basename2.startsWith("tsconfig") && (basename2.endsWith(".json") || basename2.endsWith(".jsonc"))) { - return "javascript" /* javascript */; + if (normalizedPath.startsWith(".github/workflows/") || normalizedPath.includes("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/.github/workflows/")) { + return "global"; } - if (basename2.endsWith(".csproj") || basename2.endsWith(".sln")) { - return "csharp" /* csharp */; + if (buildConfigurationFiles.has(basename2) || basename2 === "cmakelists.txt" || basename2.endsWith(".csproj") || basename2.endsWith(".sln") || basename2.startsWith("requirements") && (basename2.endsWith(".txt") || basename2.endsWith(".in")) || basename2.startsWith("tsconfig") && (basename2.endsWith(".json") || basename2.endsWith(".jsonc"))) { + return "global"; } const extension = extensionOf(basename2); if (extension === ".sh" || extension === ".bash") { - return buildMode === "none" ? "non-code" : void 0; - } - for (const language of Object.values(BuiltInLanguage)) { - if (languageExtensions[language].has(extension)) { - return language; - } + return void 0; } if (nonCodeExtensions.has(extension)) { return "non-code"; @@ -186109,6 +186056,18 @@ function getDraftState() { process.env.CODE_SCANNING_IS_DRAFT ); } +function getPullRequestCommitShas(pullRequest) { + if (typeof pullRequest !== "object" || pullRequest === null) { + return void 0; + } + const pullRequestData = pullRequest; + const baseSha = pullRequestData.base?.sha; + const headSha = pullRequestData.head?.sha; + if (typeof baseSha !== "string" || typeof headSha !== "string") { + return void 0; + } + return { base: baseSha, head: headSha }; +} async function getAnalysisSkipReason(logger2) { const skipIfDraft = getOptionalInput("skip-if-draft") === "true"; const draftState = getDraftState(); @@ -186132,10 +186091,12 @@ async function getAnalysisSkipReason(logger2) { ); return void 0; } - const branches = getPullRequestBranches(); + const branches = getPullRequestCommitShas( + github4.context.payload.pull_request + ); if (!branches) { logger2.info( - "Cannot skip an unchanged-language analysis outside a pull request." + "Cannot skip an unchanged-language analysis without immutable pull-request commit SHAs." ); return void 0; } @@ -186154,12 +186115,7 @@ async function getAnalysisSkipReason(logger2) { ); return void 0; } - if (shouldSkipUnchangedLanguage( - true, - languagesInput, - changedFiles, - getOptionalInput("build-mode") - )) { + if (shouldSkipUnchangedLanguage(true, languagesInput, changedFiles)) { return `the pull request changes no files for ${languagesInput}`; } return void 0; diff --git a/src/analysis-skip.test.ts b/src/analysis-skip.test.ts index 8c3cc02dbe..80405f14df 100644 --- a/src/analysis-skip.test.ts +++ b/src/analysis-skip.test.ts @@ -2,6 +2,7 @@ import test from "ava"; import { getDraftStateFromContext, + getPullRequestCommitShas, shouldSkipDraftAnalysis, shouldSkipUnchangedLanguage, } from "./analysis-skip"; @@ -24,11 +25,13 @@ test("draft state can be supplied by a pull_request or dynamic event", (t) => { t.is(getDraftStateFromContext(undefined), undefined); }); -test("JavaScript changes do not start an unchanged Python analysis", (t) => { - const files = [".github/issue-triage/guard.mjs", "docs/setup.md"]; - - t.false(shouldSkipUnchangedLanguage(true, "javascript-typescript", files)); - t.true(shouldSkipUnchangedLanguage(true, "python", files)); +test("source changes in another language fail open", (t) => { + t.false( + shouldSkipUnchangedLanguage(true, "python", [ + ".github/issue-triage/guard.mjs", + "docs/setup.md", + ]), + ); }); test("documentation-only changes can skip a language analysis", (t) => { @@ -49,14 +52,35 @@ test("language-specific dependency files keep their language analysis", (t) => { t.false( shouldSkipUnchangedLanguage(true, "python", ["requirements-dev.txt"]), ); + t.false(shouldSkipUnchangedLanguage(true, "cpp", ["CMakeLists.txt"])); }); -test("build scripts are ignored only when the workflow uses build-mode none", (t) => { - const files = ["src/guard.mjs", "scripts/test-issue-triage.sh"]; +test("build scripts always keep the analysis enabled", (t) => { + t.false( + shouldSkipUnchangedLanguage(true, "python", [ + "scripts/test-issue-triage.sh", + ]), + ); +}); + +test("workflow changes are relevant to every language", (t) => { + t.false( + shouldSkipUnchangedLanguage(true, "python", [ + ".github/workflows/codeql.yml", + ]), + ); +}); - t.true(shouldSkipUnchangedLanguage(true, "python", files, "none")); - t.false(shouldSkipUnchangedLanguage(true, "python", files, "manual")); - t.false(shouldSkipUnchangedLanguage(true, "python", files)); +test("pull-request diffs use immutable event commit SHAs", (t) => { + t.deepEqual( + getPullRequestCommitShas({ + base: { sha: "base-sha" }, + head: { sha: "head-sha" }, + }), + { base: "base-sha", head: "head-sha" }, + ); + t.is(getPullRequestCommitShas({ base: { sha: "base-sha" } }), undefined); + t.is(getPullRequestCommitShas(undefined), undefined); }); test("renames are relevant to both the old and new languages", (t) => { @@ -77,6 +101,8 @@ test("unknown and CodeQL configuration paths fail open", (t) => { ".github/codeql/codeql-config.yml", ]), ); + t.false(shouldSkipUnchangedLanguage(true, "cpp", ["meson_options.txt"])); + t.false(shouldSkipUnchangedLanguage(true, "python", ["conanfile.txt"])); }); test("incomplete inputs, multi-language jobs, and disabled gates fail open", (t) => { diff --git a/src/analysis-skip.ts b/src/analysis-skip.ts index 374a6f102c..6566a5414b 100644 --- a/src/analysis-skip.ts +++ b/src/analysis-skip.ts @@ -1,83 +1,12 @@ import * as github from "@actions/github"; import * as actionsUtil from "./actions-util"; +import type { PullRequestBranches } from "./actions-util"; import { getPullRequestChangedFiles } from "./diff-informed-analysis-utils"; -import { BuiltInLanguage, parseBuiltInLanguage } from "./languages"; +import { parseBuiltInLanguage } from "./languages"; import type { Logger } from "./logging"; -type ChangedFileKind = BuiltInLanguage | "non-code" | "global"; - -const languageExtensions: Record> = { - [BuiltInLanguage.actions]: new Set(), - [BuiltInLanguage.cpp]: new Set([ - ".c", - ".cc", - ".cpp", - ".cxx", - ".h", - ".hh", - ".hpp", - ".hxx", - ]), - [BuiltInLanguage.csharp]: new Set([".cs"]), - [BuiltInLanguage.go]: new Set([".go"]), - [BuiltInLanguage.java]: new Set([".java", ".kt", ".kts"]), - [BuiltInLanguage.javascript]: new Set([ - ".cjs", - ".html", - ".js", - ".jsx", - ".mjs", - ".ts", - ".tsx", - ]), - [BuiltInLanguage.python]: new Set([".py", ".pyi"]), - [BuiltInLanguage.ruby]: new Set([".rb", ".rake", ".gemspec", ".erb"]), - [BuiltInLanguage.rust]: new Set([".rs"]), - [BuiltInLanguage.swift]: new Set([".swift"]), -}; - -const languageConfigFiles: Record> = { - [BuiltInLanguage.actions]: new Set(), - [BuiltInLanguage.cpp]: new Set(["cmakelists.txt"]), - [BuiltInLanguage.csharp]: new Set([ - "directory.build.props", - "directory.build.targets", - ]), - [BuiltInLanguage.go]: new Set(["go.mod", "go.sum"]), - [BuiltInLanguage.java]: new Set([ - "build.gradle", - "build.gradle.kts", - "gradle.properties", - "pom.xml", - "settings.gradle", - "settings.gradle.kts", - ]), - [BuiltInLanguage.javascript]: new Set([ - ".npmrc", - "jsconfig.json", - "package-lock.json", - "package.json", - "pnpm-lock.yaml", - "tsconfig.json", - "yarn.lock", - ]), - [BuiltInLanguage.python]: new Set([ - ".python-version", - "pipfile", - "pipfile.lock", - "poetry.lock", - "pyproject.toml", - "requirements.txt", - "setup.cfg", - "setup.py", - "tox.ini", - "uv.lock", - ]), - [BuiltInLanguage.ruby]: new Set(["gemfile", "gemfile.lock"]), - [BuiltInLanguage.rust]: new Set(["cargo.lock", "cargo.toml"]), - [BuiltInLanguage.swift]: new Set(["package.swift"]), -}; +type ChangedFileKind = "non-code" | "global"; const nonCodeExtensions = new Set([ ".adoc", @@ -94,11 +23,39 @@ const nonCodeExtensions = new Set([ ".rst", ".scss", ".svg", - ".txt", ".webp", ]); -const workflowExtensions = new Set([".yml", ".yaml"]); - +const buildConfigurationFiles = new Set([ + ".npmrc", + ".python-version", + "build.gradle", + "build.gradle.kts", + "cargo.lock", + "cargo.toml", + "directory.build.props", + "directory.build.targets", + "gemfile", + "gemfile.lock", + "go.mod", + "go.sum", + "gradle.properties", + "jsconfig.json", + "package-lock.json", + "package.json", + "pipfile", + "pipfile.lock", + "poetry.lock", + "pom.xml", + "pyproject.toml", + "settings.gradle", + "settings.gradle.kts", + "setup.cfg", + "setup.py", + "tox.ini", + "tsconfig.json", + "uv.lock", + "yarn.lock", +]); /** * Returns whether the supplied pull-request metadata authorizes a draft skip. * Unknown draft state deliberately fails open. @@ -119,7 +76,6 @@ export function shouldSkipUnchangedLanguage( enabled: boolean, languagesInput: string | undefined, changedFiles: readonly string[] | undefined, - buildMode: string | undefined = undefined, ): boolean { if (!enabled || languagesInput === undefined || changedFiles === undefined) { return false; @@ -128,33 +84,20 @@ export function shouldSkipUnchangedLanguage( const languages = languagesInput .split(",") .map((language) => parseBuiltInLanguage(language)); - if ( - languages.length === 0 || - languages.length > 1 || - languages.some((language) => language === undefined) - ) { - return false; - } - - const language = languages[0]; - if (language === undefined) { + if (languages.length !== 1 || languages[0] === undefined) { return false; } return changedFiles.every((file) => { - const fileKind = classifyChangedFile(file, buildMode); - return ( - fileKind !== undefined && - (fileKind === "non-code" || - (fileKind !== "global" && fileKind !== language)) - ); + const fileKind = classifyChangedFile(file); + // File extensions cannot establish whether code in another language is a + // generator or build input for this analysis. Only explicitly non-code + // paths are safe evidence that the analyzed language is unaffected. + return fileKind === "non-code"; }); } -function classifyChangedFile( - file: string, - buildMode: string | undefined, -): ChangedFileKind | undefined { +function classifyChangedFile(file: string): ChangedFileKind | undefined { const normalizedPath = file.replaceAll("\\", "/").toLowerCase(); const basename = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1); @@ -169,48 +112,34 @@ function classifyChangedFile( return "global"; } - for (const language of Object.values(BuiltInLanguage)) { - if (languageConfigFiles[language].has(basename)) { - return language; - } - } - if ( - (normalizedPath.startsWith(".github/workflows/") || - normalizedPath.includes("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/.github/workflows/")) && - workflowExtensions.has(extensionOf(basename)) + normalizedPath.startsWith(".github/workflows/") || + normalizedPath.includes("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/.github/workflows/") ) { - return BuiltInLanguage.actions; + // Workflow files can change queries, inline CodeQL config, build steps, or + // generated-source behavior for any language. + return "global"; } if ( - basename.startsWith("requirements") && - (basename.endsWith(".txt") || basename.endsWith(".in")) - ) { - return BuiltInLanguage.python; - } - if ( - basename.startsWith("tsconfig") && - (basename.endsWith(".json") || basename.endsWith(".jsonc")) + buildConfigurationFiles.has(basename) || + basename === "cmakelists.txt" || + basename.endsWith(".csproj") || + basename.endsWith(".sln") || + (basename.startsWith("requirements") && + (basename.endsWith(".txt") || basename.endsWith(".in"))) || + (basename.startsWith("tsconfig") && + (basename.endsWith(".json") || basename.endsWith(".jsonc"))) ) { - return BuiltInLanguage.javascript; - } - if (basename.endsWith(".csproj") || basename.endsWith(".sln")) { - return BuiltInLanguage.csharp; + return "global"; } + // Shell scripts may build or generate sources. The effective build mode can + // differ from its input value, so do not treat them as unrelated changes. const extension = extensionOf(basename); if (extension === ".sh" || extension === ".bash") { - // Build scripts can affect generated or compiled sources. They are safe to - // ignore only when the workflow explicitly uses build-mode: none. - return buildMode === "none" ? "non-code" : undefined; - } - for (const language of Object.values(BuiltInLanguage)) { - if (languageExtensions[language].has(extension)) { - return language; - } + return undefined; } - if (nonCodeExtensions.has(extension)) { return "non-code"; } @@ -251,6 +180,25 @@ function getDraftState(): boolean | undefined { ); } +/** Return immutable PR commit SHAs for a snapshot-specific diff comparison. */ +export function getPullRequestCommitShas( + pullRequest: unknown, +): PullRequestBranches | undefined { + if (typeof pullRequest !== "object" || pullRequest === null) { + return undefined; + } + const pullRequestData = pullRequest as { + base?: { sha?: unknown }; + head?: { sha?: unknown }; + }; + const baseSha = pullRequestData.base?.sha; + const headSha = pullRequestData.head?.sha; + if (typeof baseSha !== "string" || typeof headSha !== "string") { + return undefined; + } + return { base: baseSha, head: headSha }; +} + /** Determine whether the current run should stop before CodeQL initialization. */ export async function getAnalysisSkipReason( logger: Logger, @@ -281,10 +229,12 @@ export async function getAnalysisSkipReason( return undefined; } - const branches = actionsUtil.getPullRequestBranches(); + const branches = getPullRequestCommitShas( + github.context.payload.pull_request, + ); if (!branches) { logger.info( - "Cannot skip an unchanged-language analysis outside a pull request.", + "Cannot skip an unchanged-language analysis without immutable pull-request commit SHAs.", ); return undefined; } @@ -305,14 +255,7 @@ export async function getAnalysisSkipReason( return undefined; } - if ( - shouldSkipUnchangedLanguage( - true, - languagesInput, - changedFiles, - actionsUtil.getOptionalInput("build-mode"), - ) - ) { + if (shouldSkipUnchangedLanguage(true, languagesInput, changedFiles)) { return `the pull request changes no files for ${languagesInput}`; } return undefined; diff --git a/src/autobuild-action.test.ts b/src/autobuild-action.test.ts new file mode 100644 index 0000000000..25e30a150b --- /dev/null +++ b/src/autobuild-action.test.ts @@ -0,0 +1,32 @@ +import test from "ava"; +import * as sinon from "sinon"; + +import { runWrapper } from "./autobuild-action"; +import * as configUtils from "./config-utils"; +import { EnvVar } from "./environment"; +import { setupActionsVars, setupTests } from "./testing-utils"; +import * as util from "./util"; + +setupTests(test); + +test.serial( + "autobuild is skipped when init intentionally skips analysis", + async (t) => { + await util.withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + process.env[EnvVar.ANALYSIS_SKIP_REASON] = "draft pull request"; + + try { + const getConfigStub = sinon.stub(configUtils, "getConfig"); + + await runWrapper(); + + t.false(getConfigStub.called); + t.is(process.env[EnvVar.AUTOBUILD_DID_COMPLETE_SUCCESSFULLY], "true"); + } finally { + delete process.env[EnvVar.ANALYSIS_SKIP_REASON]; + delete process.env[EnvVar.AUTOBUILD_DID_COMPLETE_SUCCESSFULLY]; + } + }); + }, +); diff --git a/src/autobuild-action.ts b/src/autobuild-action.ts index 9fa8016578..7c25e044c8 100644 --- a/src/autobuild-action.ts +++ b/src/autobuild-action.ts @@ -75,6 +75,15 @@ async function run({ startedAt, logger }: ActionState<["Base", "Logger"]>) { let config: Config | undefined; let currentLanguage: Language | undefined; let languages: Language[] | undefined; + const analysisSkipReason = process.env[EnvVar.ANALYSIS_SKIP_REASON]; + if (analysisSkipReason !== undefined) { + logger.info( + `Skipping autobuild because CodeQL analysis was intentionally skipped: ${analysisSkipReason}.`, + ); + core.exportVariable(EnvVar.AUTOBUILD_DID_COMPLETE_SUCCESSFULLY, "true"); + return; + } + try { const statusReportBase = await createStatusReportBase( ActionName.Autobuild, From 96d12db9eca611c6bdac2e1829c9dfed5f838675 Mon Sep 17 00:00:00 2001 From: asdf8675309 <174058705+asdf8675309@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:22:57 -0400 Subject: [PATCH 3/4] fix: skip draft before status telemetry --- lib/entry-points.js | 39 +++++++++++++++++++++------------ src/analysis-skip.ts | 12 +++++++---- src/init-action.test.ts | 28 ++++++++++++++++++------ src/init-action.ts | 48 +++++++++++++++++++++++++++-------------- 4 files changed, 87 insertions(+), 40 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index d80f9e2c9a..a570cf92b6 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -186068,7 +186068,7 @@ function getPullRequestCommitShas(pullRequest) { } return { base: baseSha, head: headSha }; } -async function getAnalysisSkipReason(logger2) { +function getDraftAnalysisSkipReason(logger2) { const skipIfDraft = getOptionalInput("skip-if-draft") === "true"; const draftState = getDraftState(); const managedWorkflowDraft = process.env.CODE_SCANNING_IS_DRAFT === "true"; @@ -186080,6 +186080,9 @@ async function getAnalysisSkipReason(logger2) { "Draft status is unavailable in this workflow event; continuing with analysis." ); } + return void 0; +} +async function getAnalysisSkipReason(logger2) { const skipUnchangedLanguage = getOptionalInput("skip-if-no-language-changes") === "true"; if (!skipUnchangedLanguage) { return void 0; @@ -186511,6 +186514,16 @@ async function sendCompletedStatusReport2(startedAt, config, configFile, toolsIn await sendStatusReport({ ...initStatusReport, ...initToolsDownloadFields }); } } +function markAnalysisSkipped(reason, logger2) { + logger2.info( + `Skipping CodeQL before tool download and database initialization: ${reason}.` + ); + core22.exportVariable("CODEQL_ACTION_ANALYSIS_SKIP_REASON" /* ANALYSIS_SKIP_REASON */, reason); + core22.exportVariable("CODEQL_ACTION_JOB_STATUS" /* JOB_STATUS */, "JOB_STATUS_SUCCESS" /* SuccessStatus */); + core22.exportVariable("CODEQL_ACTION_ANALYZE_DID_COMPLETE_SUCCESSFULLY" /* ANALYZE_DID_COMPLETE_SUCCESSFULLY */, "true"); + core22.setOutput("analysis-skipped", "true"); + core22.setOutput("analysis-skip-reason", reason); +} async function run3(actionState) { const startedAt = actionState.startedAt; const logger2 = actionState.logger; @@ -186528,6 +186541,16 @@ async function run3(actionState) { try { initializeEnvironment(getActionVersion()); persistInputs(); + if (process.env["CODEQL_ACTION_SETUP_CODEQL_HAS_RUN" /* SETUP_CODEQL_ACTION_HAS_RUN */] === "true") { + throw new ConfigurationError( + `The 'init' action should not be run in the same workflow as 'setup-codeql'.` + ); + } + const draftSkipReason = getDraftAnalysisSkipReason(logger2); + if (draftSkipReason !== void 0) { + markAnalysisSkipped(draftSkipReason, logger2); + return; + } apiDetails = { auth: getRequiredInput("token"), externalRepoAuth: getOptionalInput("external-repository-token"), @@ -186569,21 +186592,9 @@ async function run3(actionState) { analysisKinds ); await sendStartingStatusReport(startedAt, { analysisKinds }, logger2); - if (process.env["CODEQL_ACTION_SETUP_CODEQL_HAS_RUN" /* SETUP_CODEQL_ACTION_HAS_RUN */] === "true") { - throw new ConfigurationError( - `The 'init' action should not be run in the same workflow as 'setup-codeql'.` - ); - } const analysisSkipReason = await getAnalysisSkipReason(logger2); if (analysisSkipReason !== void 0) { - logger2.info( - `Skipping CodeQL before tool download and database initialization: ${analysisSkipReason}.` - ); - core22.exportVariable("CODEQL_ACTION_ANALYSIS_SKIP_REASON" /* ANALYSIS_SKIP_REASON */, analysisSkipReason); - core22.exportVariable("CODEQL_ACTION_JOB_STATUS" /* JOB_STATUS */, "JOB_STATUS_SUCCESS" /* SuccessStatus */); - core22.exportVariable("CODEQL_ACTION_ANALYZE_DID_COMPLETE_SUCCESSFULLY" /* ANALYZE_DID_COMPLETE_SUCCESSFULLY */, "true"); - core22.setOutput("analysis-skipped", "true"); - core22.setOutput("analysis-skip-reason", analysisSkipReason); + markAnalysisSkipped(analysisSkipReason, logger2); await sendCompletedStatusReport2( startedAt, void 0, diff --git a/src/analysis-skip.ts b/src/analysis-skip.ts index 6566a5414b..71a9efeafe 100644 --- a/src/analysis-skip.ts +++ b/src/analysis-skip.ts @@ -199,10 +199,8 @@ export function getPullRequestCommitShas( return { base: baseSha, head: headSha }; } -/** Determine whether the current run should stop before CodeQL initialization. */ -export async function getAnalysisSkipReason( - logger: Logger, -): Promise { +/** Determine whether a confirmed draft should skip before status reporting. */ +export function getDraftAnalysisSkipReason(logger: Logger): string | undefined { const skipIfDraft = actionsUtil.getOptionalInput("skip-if-draft") === "true"; const draftState = getDraftState(); const managedWorkflowDraft = process.env.CODE_SCANNING_IS_DRAFT === "true"; @@ -214,7 +212,13 @@ export async function getAnalysisSkipReason( "Draft status is unavailable in this workflow event; continuing with analysis.", ); } + return undefined; +} +/** Determine whether the current run should stop before CodeQL initialization. */ +export async function getAnalysisSkipReason( + logger: Logger, +): Promise { const skipUnchangedLanguage = actionsUtil.getOptionalInput("skip-if-no-language-changes") === "true"; if (!skipUnchangedLanguage) { diff --git a/src/init-action.test.ts b/src/init-action.test.ts index dd9b3d3062..25dbb6ab32 100644 --- a/src/init-action.test.ts +++ b/src/init-action.test.ts @@ -47,15 +47,26 @@ test.serial( }; try { - sinon.stub(apiClient, "getGitHubVersion").resolves({ - type: util.GitHubVariant.DOTCOM, - }); - sinon.stub(featureFlags, "initFeatures").returns(createFeatures([])); + const getGitHubVersionStub = sinon + .stub(apiClient, "getGitHubVersion") + .resolves({ + type: util.GitHubVariant.DOTCOM, + }); + const initFeaturesStub = sinon + .stub(featureFlags, "initFeatures") + .returns(createFeatures([])); sinon .stub(analyses, "getAnalysisKinds") .resolves([analyses.AnalysisKind.CodeScanning]); - sinon.stub(configFile, "getConfigFileInput").resolves(undefined); - sinon.stub(statusReport, "createStatusReportBase").resolves(undefined); + const getConfigFileInputStub = sinon + .stub(configFile, "getConfigFileInput") + .resolves(undefined); + const createStatusReportBaseStub = sinon + .stub(statusReport, "createStatusReportBase") + .resolves(undefined); + const sendStatusReportStub = sinon + .stub(statusReport, "sendStatusReport") + .resolves(); sinon.stub(util, "checkDiskUsage").resolves(undefined); sinon.stub(util, "checkForTimeout").resolves(); @@ -71,6 +82,11 @@ test.serial( await runWrapper(); + t.false(getGitHubVersionStub.called); + t.false(initFeaturesStub.called); + t.false(getConfigFileInputStub.called); + t.false(createStatusReportBaseStub.called); + t.false(sendStatusReportStub.called); t.false(getToolsInputStub.called); t.false(initCodeQLStub.called); t.false(databaseInitStub.called); diff --git a/src/init-action.ts b/src/init-action.ts index fdd0433189..fdccb2b970 100644 --- a/src/init-action.ts +++ b/src/init-action.ts @@ -16,7 +16,10 @@ import { persistInputs, } from "./actions-util"; import { AnalysisKind, getAnalysisKinds } from "./analyses"; -import { getAnalysisSkipReason } from "./analysis-skip"; +import { + getAnalysisSkipReason, + getDraftAnalysisSkipReason, +} from "./analysis-skip"; import { getGitHubVersion, GitHubApiCombinedDetails } from "./api-client"; import { getDependencyCachingEnabled, @@ -201,6 +204,17 @@ async function sendCompletedStatusReport( } } +function markAnalysisSkipped(reason: string, logger: Logger) { + logger.info( + `Skipping CodeQL before tool download and database initialization: ${reason}.`, + ); + core.exportVariable(EnvVar.ANALYSIS_SKIP_REASON, reason); + core.exportVariable(EnvVar.JOB_STATUS, JobStatus.SuccessStatus); + core.exportVariable(EnvVar.ANALYZE_DID_COMPLETE_SUCCESSFULLY, "true"); + core.setOutput("analysis-skipped", "true"); + core.setOutput("analysis-skip-reason", reason); +} + async function run( actionState: ActionState<["Base", "Logger", "Env", "Actions"]>, ) { @@ -228,6 +242,22 @@ async function run( // Make inputs accessible in the `post` step. persistInputs(); + // This is a workflow configuration error independent of draft state, so + // retain the existing validation before taking the early skip path. + if (process.env[EnvVar.SETUP_CODEQL_ACTION_HAS_RUN] === "true") { + throw new ConfigurationError( + `The 'init' action should not be run in the same workflow as 'setup-codeql'.`, + ); + } + + const draftSkipReason = getDraftAnalysisSkipReason(logger); + if (draftSkipReason !== undefined) { + // Do not send start/completion status reports here. They make API calls + // before and after the skip, and a draft needs no CodeQL status payload. + markAnalysisSkipped(draftSkipReason, logger); + return; + } + apiDetails = { auth: getRequiredInput("token"), externalRepoAuth: getOptionalInput("external-repository-token"), @@ -290,23 +320,9 @@ async function run( // Send a status report indicating that an analysis is starting. await sendStartingStatusReport(startedAt, { analysisKinds }, logger); - // Throw a `ConfigurationError` if the `setup-codeql` action has been run. - if (process.env[EnvVar.SETUP_CODEQL_ACTION_HAS_RUN] === "true") { - throw new ConfigurationError( - `The 'init' action should not be run in the same workflow as 'setup-codeql'.`, - ); - } - const analysisSkipReason = await getAnalysisSkipReason(logger); if (analysisSkipReason !== undefined) { - logger.info( - `Skipping CodeQL before tool download and database initialization: ${analysisSkipReason}.`, - ); - core.exportVariable(EnvVar.ANALYSIS_SKIP_REASON, analysisSkipReason); - core.exportVariable(EnvVar.JOB_STATUS, JobStatus.SuccessStatus); - core.exportVariable(EnvVar.ANALYZE_DID_COMPLETE_SUCCESSFULLY, "true"); - core.setOutput("analysis-skipped", "true"); - core.setOutput("analysis-skip-reason", analysisSkipReason); + markAnalysisSkipped(analysisSkipReason, logger); await sendCompletedStatusReport( startedAt, undefined, From c7befb8d236128236baca444ed44cb5ec3dfab8a Mon Sep 17 00:00:00 2001 From: asdf8675309 <174058705+asdf8675309@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:37:45 -0400 Subject: [PATCH 4/4] docs: describe opt-in analysis skips --- README.md | 11 +++++++++++ unreleased-change-notes/2026-10-07-analysis-skip.md | 4 ++++ 2 files changed, 15 insertions(+) create mode 100644 unreleased-change-notes/2026-10-07-analysis-skip.md diff --git a/README.md b/README.md index 8c4fc46631..82cf0f6ef6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,17 @@ Actions with special purposes and unlikely to be used directly: All advanced setup code scanning workflows must have the `security-events: write` permission. Workflows in private repositories must additionally have the `contents: read` permission. For more information, see "[Assigning permissions to jobs](https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs)." +### Skipping analysis during pull request iteration + +The `init` action supports two opt-in inputs, both disabled by default: + +- `skip-if-draft: true` skips analysis when the pull request event confirms that the PR is a draft. Include `ready_for_review` in the workflow's `pull_request.types` so analysis runs when the PR becomes ready. Confirmed draft skips return before repository configuration lookups, status reporting, tool download, or database initialization. +- `skip-if-no-language-changes: true` skips a single explicitly selected built-in language only when a complete comparison of the PR event's immutable commit SHAs contains exclusively recognized non-code paths. Source files, workflows, build and dependency configuration, shell scripts, unknown paths, incomplete comparisons, and API errors retain full analysis. This is a conservative path heuristic; leave it disabled when documentation or other allowed non-code files are inputs to code generation. + +For example, include `types: [opened, synchronize, reopened, ready_for_review]` under `on.pull_request`, assign an `id` to the `init` step, and enable the desired inputs. The `analysis-skipped` and `analysis-skip-reason` outputs describe the decision. Guard custom build steps with `if: steps.init.outputs.analysis-skipped != 'true'`. The `autobuild`, `analyze`, and post actions handle intentional skips automatically. When analysis runs, it still uses the complete language database rather than analyzing only changed files. + +GitHub-managed workflows require integration by their workflow generator to provide the inputs and draft metadata. These inputs do not configure managed Code Quality from a repository workflow file. + ### Build Modes The CodeQL Action supports different build modes for analyzing the source code. The available build modes are: diff --git a/unreleased-change-notes/2026-10-07-analysis-skip.md b/unreleased-change-notes/2026-10-07-analysis-skip.md new file mode 100644 index 0000000000..8117ebfcb6 --- /dev/null +++ b/unreleased-change-notes/2026-10-07-analysis-skip.md @@ -0,0 +1,4 @@ +--- +category: feature +--- +- Add opt-in `skip-if-draft` and `skip-if-no-language-changes` inputs to skip unnecessary pull request analysis before CodeQL tool download and database initialization, with outputs for guarding custom build steps.