import * as fs from "fs"; import * as path from "path"; import * as actionsCache from "@actions/cache"; import { type ActionState } from "./action-common"; import * as actionsUtil from "./actions-util"; import { type CodeQL } from "./codeql"; import { type Config } from "./config-utils"; import * as gitUtils from "./git-utils"; import { Language } from "./languages"; import { Logger } from "./logging"; import { getEnv, tryGetFolderBytes, waitForResultWithTimeLimit } from "./util"; // This constant should be bumped if we make a breaking change // to how the CodeQL Action stores or retrieves the TRAP cache, // and will invalidate previous caches. We don't need to bump // this for CLI/extractor changes, since the CLI version also // goes into the cache key. const CACHE_VERSION = 1; const CODEQL_TRAP_CACHE_PREFIX = "codeql-trap"; // This constant sets the minimum size in megabytes of a TRAP // cache for us to consider it worth uploading. const MINIMUM_CACHE_MB_TO_UPLOAD = 10; // The maximum number of milliseconds to wait for TRAP cache // uploads or downloads to complete before continuing. Note // this timeout is per operation, so will be run as many // times as there are languages with TRAP caching enabled. const MAX_CACHE_OPERATION_MS = 120_000; // Two minutes /** * Download TRAP caches from the Actions cache. * @param codeql The CodeQL instance to use. * @param languages The languages being analyzed. * @param logger A logger to record some informational messages to. * @param repositoryRoot The path at which the repository is checked out at. * @returns A partial map from languages to TRAP cache paths on disk, with * languages for which we shouldn't use TRAP caching omitted. */ export async function downloadTrapCaches( codeql: CodeQL, languages: Language[], logger: Logger, repositoryRoot: string | undefined, ): Promise<{ [language: string]: string }> { const result: { [language: string]: string } = {}; const languagesSupportingCaching = await getLanguagesSupportingCaching( codeql, languages, logger, ); logger.info( `Found ${languagesSupportingCaching.length} languages that support TRAP caching`, ); if (languagesSupportingCaching.length === 0) return result; const cachesDir = path.join( actionsUtil.getTemporaryDirectory(), "trapCaches", ); for (const language of languagesSupportingCaching) { const cacheDir = path.join(cachesDir, language); fs.mkdirSync(cacheDir, { recursive: true }); result[language] = cacheDir; } if (await gitUtils.isAnalyzingDefaultBranch(getEnv(), repositoryRoot)) { logger.info( "Analyzing default branch. Skipping downloading of TRAP caches.", ); return result; } let baseSha = "unknown"; const eventPath = process.env.GITHUB_EVENT_PATH; if ( actionsUtil.getWorkflowEventName() === "pull_request" && eventPath !== undefined ) { const event = JSON.parse(fs.readFileSync(path.resolve(eventPath), "utf-8")); baseSha = event.pull_request?.base?.sha || baseSha; } for (const language of languages) { const cacheDir = result[language]; if (cacheDir === undefined) continue; // The SHA from the base of the PR is the most similar commit we might have a cache for const preferredKey = await cacheKey(codeql, language, baseSha); logger.info( `Looking in Actions cache for TRAP cache with key ${preferredKey}`, ); const found = await waitForResultWithTimeLimit( MAX_CACHE_OPERATION_MS, actionsCache.restoreCache([cacheDir], preferredKey, [ preferredKey, // Fall back to any cache with the right key prefix await cachePrefix(codeql, language), ]), () => { logger.info( `Timed out downloading cache for ${language}, will continue without it`, ); }, ); if (found === undefined) { // We didn't find a TRAP cache in the Actions cache, so the directory on disk is // still just an empty directory. There's no reason to tell the extractor to use it, // so let's unset the entry in the map so we don't set any extractor options. logger.info(`No TRAP cache found in Actions cache for ${language}`); delete result[language]; } } return result; } /** * Possibly upload TRAP caches to the Actions cache. * @param codeql The CodeQL instance to use. * @param config The configuration for this workflow. * @param logger A logger to record some informational messages to. * @returns Whether the TRAP caches were uploaded. */ export async function uploadTrapCaches( codeql: CodeQL, config: Config, logger: Logger, ): Promise { // Only upload caches from the default branch if ( !(await gitUtils.isAnalyzingDefaultBranch(getEnv(), config.repositoryRoot)) ) { return false; } for (const language of config.languages) { const cacheDir = config.trapCaches[language]; if (cacheDir === undefined) continue; const trapFolderSize = await tryGetFolderBytes(cacheDir, logger); if (trapFolderSize === undefined) { logger.info( `Skipping upload of TRAP cache for ${language} as we couldn't determine its size`, ); continue; } if (trapFolderSize < MINIMUM_CACHE_MB_TO_UPLOAD * 1_048_576) { logger.info( `Skipping upload of TRAP cache for ${language} as it is too small`, ); continue; } const key = await cacheKey( codeql, language, process.env.GITHUB_SHA || "unknown", ); logger.info(`Uploading TRAP cache to Actions cache with key ${key}`); await waitForResultWithTimeLimit( MAX_CACHE_OPERATION_MS, actionsCache.saveCache([cacheDir], key), () => { logger.info( `Timed out waiting for TRAP cache for ${language} to upload, will continue without uploading`, ); }, ); } return true; } /** * Warns if the `CODEQL_ACTION_CLEANUP_TRAP_CACHES` environment variable is set to a non-empty * value. That variable controlled the removed TRAP cache cleanup feature, so setting it has no * effect. */ export function warnIfTrapCacheCleanupEnvVarIsSet({ env, logger, }: ActionState<["Logger", "ReadOnlyEnv"]>): void { if (env.getOptional("CODEQL_ACTION_CLEANUP_TRAP_CACHES") !== undefined) { logger.warning( "TRAP cache cleanup has been removed, so the CODEQL_ACTION_CLEANUP_TRAP_CACHES environment " + "variable no longer has any effect and can be removed from your workflow. If you used TRAP " + "cache cleanup to limit Actions cache usage, we recommend disabling TRAP caching instead by " + "passing the `trap-caching: false` input to the `init` Action.", ); } } export async function getLanguagesSupportingCaching( codeql: CodeQL, languages: Language[], logger: Logger, ): Promise { const result: Language[] = []; const resolveResult = await codeql.resolveLanguages(); outer: for (const lang of languages) { const extractorsForLanguage = resolveResult.extractors[lang]; if (extractorsForLanguage === undefined) { logger.info( `${lang} does not support TRAP caching (couldn't find an extractor)`, ); continue; } if (extractorsForLanguage.length !== 1) { logger.info( `${lang} does not support TRAP caching (found multiple extractors)`, ); continue; } const extractor = extractorsForLanguage[0]; const trapCacheOptions = extractor.extractor_options?.trap?.properties?.cache?.properties; if (trapCacheOptions === undefined) { logger.info( `${lang} does not support TRAP caching (missing option group)`, ); continue; } for (const requiredOpt of ["dir", "bound", "write"]) { if (!(requiredOpt in trapCacheOptions)) { logger.info( `${lang} does not support TRAP caching (missing ${requiredOpt} option)`, ); continue outer; } } result.push(lang); } return result; } async function cacheKey( codeql: CodeQL, language: Language, baseSha: string, ): Promise { return `${await cachePrefix(codeql, language)}${baseSha}`; } async function cachePrefix( codeql: CodeQL, language: Language, ): Promise { return `${CODEQL_TRAP_CACHE_PREFIX}-${CACHE_VERSION}-${ (await codeql.getVersion()).version }-${language}-`; }