Merge pull request #4124 from github/henrymercer/toolcache-bundle-cleanup

Delete unused CodeQL bundles from the toolcache before downloading
This commit is contained in:
Henry Mercer
2026-09-08 17:04:25 +00:00
committed by GitHub
13 changed files with 1367 additions and 394 deletions

View File

@@ -191,5 +191,6 @@ jobs:
exit 1 exit 1
fi fi
env: env:
CODEQL_ACTION_CLEANUP_TOOLCACHE_BUNDLES: true
CODEQL_ACTION_RESOLVE_SUPPORTED_LANGUAGES_USING_CLI: true CODEQL_ACTION_RESOLVE_SUPPORTED_LANGUAGES_USING_CLI: true
CODEQL_ACTION_TEST_MODE: true CODEQL_ACTION_TEST_MODE: true

View File

@@ -4,6 +4,7 @@ See the [releases page](https://github.com/github/codeql-action/releases) for th
## [UNRELEASED] ## [UNRELEASED]
- On GitHub-hosted runners, the CodeQL Action now deletes unused CodeQL bundles from the toolcache before downloading a different bundle, which frees up disk space for the analysis. We expect to roll this change out to everyone in September. [#4124](https://github.com/github/codeql-action/pull/4124)
- The CodeQL Action now supports CodeQL releases that are compatible with Linux Arm64 and downloads the native `linux-arm64` CodeQL bundle when available. [#4072](https://github.com/github/codeql-action/pull/4072) - The CodeQL Action now supports CodeQL releases that are compatible with Linux Arm64 and downloads the native `linux-arm64` CodeQL bundle when available. [#4072](https://github.com/github/codeql-action/pull/4072)
## 4.37.9 - 26 Aug 2026 ## 4.37.9 - 26 Aug 2026

890
lib/entry-points.js generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@ operatingSystems:
- stable-v2.21.4 - stable-v2.21.4
- stable-v2.22.4 - stable-v2.22.4
env: env:
CODEQL_ACTION_CLEANUP_TOOLCACHE_BUNDLES: true
CODEQL_ACTION_RESOLVE_SUPPORTED_LANGUAGES_USING_CLI: true CODEQL_ACTION_RESOLVE_SUPPORTED_LANGUAGES_USING_CLI: true
installGo: true installGo: true
installDotNet: true installDotNet: true

View File

@@ -283,6 +283,19 @@ export function isSelfHostedRunner(env: Env = getEnv()) {
return env.getOptional(ActionsEnvVars.RUNNER_ENVIRONMENT) === "self-hosted"; return env.getOptional(ActionsEnvVars.RUNNER_ENVIRONMENT) === "self-hosted";
} }
/**
* Whether the job is running on a runner that GitHub hosts, and whose toolcache is therefore thrown
* away once the job has finished.
*
* Unlike `looksLikeHostedRunner`, this is based on what the service reports for the job rather than
* on how the runner's filesystem happens to be laid out, so it does not match self-hosted runners
* that are configured to resemble hosted ones, such as those that mount a persistent volume at
* `/opt/hostedtoolcache`.
*/
export function isGitHubHostedRunner(env: Env = getEnv()) {
return env.getOptional(ActionsEnvVars.RUNNER_ENVIRONMENT) === "github-hosted";
}
/** Determines whether the workflow trigger is `dynamic`. */ /** Determines whether the workflow trigger is `dynamic`. */
export function isDynamicWorkflow(env: Env = getEnv()): boolean { export function isDynamicWorkflow(env: Env = getEnv()): boolean {
return getWorkflowEventName(env) === "dynamic"; return getWorkflowEventName(env) === "dynamic";

View File

@@ -5,7 +5,7 @@ import * as core from "@actions/core";
import { getOptionalInput, isDefaultSetup } from "./actions-util"; import { getOptionalInput, isDefaultSetup } from "./actions-util";
import { EnvVar } from "./environment"; import { EnvVar } from "./environment";
import { Logger } from "./logging"; import { Logger } from "./logging";
import { isHostedRunner, tryGetFolderBytes } from "./util"; import { looksLikeHostedRunner, tryGetFolderBytes } from "./util";
/** /**
* Returns the total size of all the specified paths. * Returns the total size of all the specified paths.
@@ -109,7 +109,7 @@ export function getDependencyCachingEnabled(): CachingKind {
if (dependencyCaching !== undefined) return getCachingKind(dependencyCaching); if (dependencyCaching !== undefined) return getCachingKind(dependencyCaching);
// On self-hosted runners which may have dependencies installed centrally, disable caching by default // On self-hosted runners which may have dependencies installed centrally, disable caching by default
if (!isHostedRunner()) return CachingKind.None; if (!looksLikeHostedRunner()) return CachingKind.None;
// Disable in advanced workflows by default. // Disable in advanced workflows by default.
if (!isDefaultSetup()) return CachingKind.None; if (!isDefaultSetup()) return CachingKind.None;

View File

@@ -90,9 +90,8 @@ import {
Result, Result,
Success, Success,
Failure, Failure,
isHostedRunner, looksLikeHostedRunner,
} from "./util"; } from "./util";
export { type Config } from "./config/action-config"; export { type Config } from "./config/action-config";
/** /**
@@ -938,7 +937,7 @@ export async function isTrapCachingEnabled(
if (trapCaching !== undefined) return trapCaching === "true"; if (trapCaching !== undefined) return trapCaching === "true";
// On self-hosted runners which may have slow network access, disable TRAP caching by default. // On self-hosted runners which may have slow network access, disable TRAP caching by default.
if (!isHostedRunner()) return false; if (!looksLikeHostedRunner()) return false;
// If overlay analysis is enabled, then disable TRAP caching since overlay analysis supersedes it. // If overlay analysis is enabled, then disable TRAP caching since overlay analysis supersedes it.
// This change is gated behind a feature flag. // This change is gated behind a feature flag.

View File

@@ -63,6 +63,12 @@ export enum EnvVar {
/** Whether the CodeQL Action has already warned the user about low disk space. */ /** Whether the CodeQL Action has already warned the user about low disk space. */
HAS_WARNED_ABOUT_DISK_SPACE = "CODEQL_ACTION_HAS_WARNED_ABOUT_DISK_SPACE", HAS_WARNED_ABOUT_DISK_SPACE = "CODEQL_ACTION_HAS_WARNED_ABOUT_DISK_SPACE",
/**
* Whether a step in this job has already set up CodeQL. Steps that run afterwards may be holding
* a path into the toolcache, so we must not delete anything from it.
*/
HAS_SET_UP_CODEQL = "CODEQL_ACTION_HAS_SET_UP_CODEQL",
/** Whether the `setup-codeql` action has been run. */ /** Whether the `setup-codeql` action has been run. */
SETUP_CODEQL_ACTION_HAS_RUN = "CODEQL_ACTION_SETUP_CODEQL_HAS_RUN", SETUP_CODEQL_ACTION_HAS_RUN = "CODEQL_ACTION_SETUP_CODEQL_HAS_RUN",

View File

@@ -74,6 +74,11 @@ export enum Feature {
AllowMergeConfigFiles = "allow_merge_config_files", AllowMergeConfigFiles = "allow_merge_config_files",
/** Controls whether we allow multiple values for the `analysis-kinds` input. */ /** Controls whether we allow multiple values for the `analysis-kinds` input. */
AllowMultipleAnalysisKinds = "allow_multiple_analysis_kinds", AllowMultipleAnalysisKinds = "allow_multiple_analysis_kinds",
/**
* Controls whether we delete CodeQL bundles that we are not going to use from the toolcache
* before downloading a different bundle, in order to reclaim disk space.
*/
CleanupToolcacheBundles = "cleanup_toolcache_bundles",
CleanupTrapCaches = "cleanup_trap_caches", CleanupTrapCaches = "cleanup_trap_caches",
/** Whether to allow the `config-file` input to be specified via a repository property. */ /** Whether to allow the `config-file` input to be specified via a repository property. */
ConfigFileRepositoryProperty = "config_file_repository_property", ConfigFileRepositoryProperty = "config_file_repository_property",
@@ -211,6 +216,11 @@ export const featureConfig = {
envVar: "CODEQL_ACTION_ALLOW_MULTIPLE_ANALYSIS_KINDS", envVar: "CODEQL_ACTION_ALLOW_MULTIPLE_ANALYSIS_KINDS",
minimumVersion: undefined, minimumVersion: undefined,
}, },
[Feature.CleanupToolcacheBundles]: {
defaultValue: false,
envVar: "CODEQL_ACTION_CLEANUP_TOOLCACHE_BUNDLES",
minimumVersion: undefined,
},
[Feature.CleanupTrapCaches]: { [Feature.CleanupTrapCaches]: {
defaultValue: false, defaultValue: false,
envVar: "CODEQL_ACTION_CLEANUP_TRAP_CACHES", envVar: "CODEQL_ACTION_CLEANUP_TRAP_CACHES",

View File

@@ -1,3 +1,5 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path"; import * as path from "path";
import * as github from "@actions/github"; import * as github from "@actions/github";
@@ -7,7 +9,8 @@ import * as sinon from "sinon";
import * as actionsUtil from "./actions-util"; import * as actionsUtil from "./actions-util";
import * as api from "./api-client"; import * as api from "./api-client";
import { EnvVar } from "./environment"; import * as diagnostics from "./diagnostics";
import { ActionsEnvVars, EnvVar, ReadOnlyEnv } from "./environment";
import { Feature } from "./feature-flags"; import { Feature } from "./feature-flags";
import { getRunnerLogger } from "./logging"; import { getRunnerLogger } from "./logging";
import { getCacheRestoreKeyPrefix } from "./overlay/caching"; import { getCacheRestoreKeyPrefix } from "./overlay/caching";
@@ -27,6 +30,7 @@ import {
setupActionsVars, setupActionsVars,
setupTests, setupTests,
} from "./testing-utils"; } from "./testing-utils";
import * as toolsDownload from "./tools-download";
import { import {
getErrorMessage, getErrorMessage,
GitHubVariant, GitHubVariant,
@@ -939,3 +943,616 @@ test.serial(
]); ]);
}, },
); );
/** The CLI version that the toolcache cleanup tests download. */
const CLEANUP_CLI_VERSION = "2.21.0";
/** The bundle version that the toolcache cleanup tests download. */
const CLEANUP_BUNDLE_VERSION = "20240101";
/** A version of the CodeQL tools that is already in the toolcache but that we are not going to use. */
const CLEANUP_STALE_VERSION = "2.20.0";
/** Creates a directory in the toolcache that looks like a tool that `tool-cache` has cached. */
function createToolcacheEntry(
toolcacheRoot: string,
tool: string,
version: string,
): string {
const versionDirectory = path.join(toolcacheRoot, tool, version);
const archDirectory = path.join(versionDirectory, os.arch());
fs.mkdirSync(archDirectory, { recursive: true });
fs.writeFileSync(path.join(archDirectory, "contents"), "x".repeat(1024));
fs.writeFileSync(`${archDirectory}.complete`, "");
return versionDirectory;
}
/**
* Stubs out the download and the diagnostic sink, then downloads the CodeQL tools.
*
* @returns the extraction directory and the toolcache cleanup diagnostic, if emitted.
*/
async function runDownloadCodeQL(
toolcacheRoot: string,
features: Feature[],
bundleVersion: string | undefined,
): Promise<{
codeqlFolder: string;
cleanupDiagnostic: toolsDownload.ToolcacheCleanupResult | undefined;
}> {
sinon
.stub(toolsDownload, "downloadAndExtract")
.callsFake(async (_url, _compressionMethod, dest) => {
// The real implementation creates the destination directory, which matters here because the
// cleanup deletes it first and `writeToolcacheMarkerFile` writes into its parent afterwards.
fs.mkdirSync(dest, { recursive: true });
return { totalDurationMs: 1 };
});
const addDiagnostic = sinon.stub(diagnostics, "addNoLanguageDiagnostic");
const { codeqlFolder } = await setupCodeql.downloadCodeQL(
"https://example.com/codeql-bundle.tar.gz",
"gzip",
bundleVersion,
CLEANUP_CLI_VERSION,
SAMPLE_DOTCOM_API_DETAILS,
undefined, // tarVersion
toolcacheRoot, // tempDir
createFeatures(features),
getRunnerLogger(true),
);
const diagnostic = addDiagnostic
.getCalls()
.map((call) => call.args[1])
.find((d) => d.source?.id === "codeql-action/toolcache-bundle-cleanup");
return {
codeqlFolder,
cleanupDiagnostic: diagnostic?.attributes as
| toolsDownload.ToolcacheCleanupResult
| undefined,
};
}
/**
* Sets up a toolcache containing the version of the CodeQL tools that we are about to download, a
* different version of the CodeQL tools, and an unrelated tool, then downloads the CodeQL tools.
*/
async function testToolcacheCleanup(
t: ExecutionContext<unknown>,
{
features,
runnerEnvironment,
setUp,
}: {
features: Feature[];
runnerEnvironment: string | undefined;
setUp?: () => void;
},
check: (context: {
cleanupDiagnostic: toolsDownload.ToolcacheCleanupResult | undefined;
destinationDirectory: string;
staleDirectory: string;
}) => void,
) {
await withTmpDir(async (tmpDir) => {
setupActionsVars(tmpDir, tmpDir);
if (runnerEnvironment === undefined) {
delete process.env[ActionsEnvVars.RUNNER_ENVIRONMENT];
} else {
process.env[ActionsEnvVars.RUNNER_ENVIRONMENT] = runnerEnvironment;
}
setUp?.();
// The extraction of the bundle would normally create this directory.
const destinationDirectory = createToolcacheEntry(
tmpDir,
"CodeQL",
CLEANUP_CLI_VERSION,
);
const staleDirectory = createToolcacheEntry(
tmpDir,
"CodeQL",
CLEANUP_STALE_VERSION,
);
const otherToolDirectory = createToolcacheEntry(tmpDir, "Node", "20.0.0");
const { cleanupDiagnostic } = await runDownloadCodeQL(
tmpDir,
features,
CLEANUP_BUNDLE_VERSION,
);
t.true(
fs.existsSync(otherToolDirectory),
"Should never delete other tools from the toolcache.",
);
check({ cleanupDiagnostic, destinationDirectory, staleDirectory });
});
}
test.serial(
"downloadCodeQL does not clean up the toolcache when the feature flag is disabled",
async (t) => {
await testToolcacheCleanup(
t,
{ features: [], runnerEnvironment: "github-hosted" },
({ cleanupDiagnostic, destinationDirectory, staleDirectory }) => {
t.true(fs.existsSync(staleDirectory));
t.true(fs.existsSync(destinationDirectory));
t.is(cleanupDiagnostic, undefined);
},
);
},
);
test.serial(
"downloadCodeQL does not clean up the toolcache when the runner is not GitHub-hosted",
async (t) => {
await testToolcacheCleanup(
t,
{
features: [Feature.CleanupToolcacheBundles],
runnerEnvironment: "self-hosted",
},
({ cleanupDiagnostic, destinationDirectory, staleDirectory }) => {
t.true(fs.existsSync(staleDirectory));
t.true(fs.existsSync(destinationDirectory));
t.is(cleanupDiagnostic, undefined);
},
);
},
);
test.serial(
"downloadCodeQL does not clean up the toolcache when the runner environment is unknown",
async (t) => {
// A runner that doesn't report its environment must be treated as not GitHub-hosted, since its
// toolcache may well outlive the job.
await testToolcacheCleanup(
t,
{
features: [Feature.CleanupToolcacheBundles],
runnerEnvironment: undefined,
},
({ cleanupDiagnostic, destinationDirectory, staleDirectory }) => {
t.true(fs.existsSync(staleDirectory));
t.true(fs.existsSync(destinationDirectory));
t.is(cleanupDiagnostic, undefined);
},
);
},
);
test.serial(
"downloadCodeQL deletes other CodeQL bundles from the toolcache when enabled on a GitHub-hosted runner",
async (t) => {
await testToolcacheCleanup(
t,
{
features: [Feature.CleanupToolcacheBundles],
runnerEnvironment: "github-hosted",
},
({ cleanupDiagnostic, destinationDirectory, staleDirectory }) => {
t.false(
fs.existsSync(staleDirectory),
"Should delete the version directory, including the `tool-cache` marker file it contains.",
);
t.false(
fs.existsSync(path.join(destinationDirectory, os.arch(), "contents")),
"Should also delete a partial entry for the version we are about to download, rather " +
"than extracting over it.",
);
t.deepEqual(cleanupDiagnostic, {
deletedVersions: [CLEANUP_STALE_VERSION, CLEANUP_CLI_VERSION].sort(),
failed: false,
});
},
);
},
);
test.serial(
"downloadCodeQL reports no deleted versions when the toolcache has no CodeQL bundles",
async (t) => {
await withTmpDir(async (tmpDir) => {
setupActionsVars(tmpDir, tmpDir);
process.env[ActionsEnvVars.RUNNER_ENVIRONMENT] = "github-hosted";
// A toolcache with other tools in it, but no CodeQL.
const otherToolDirectory = createToolcacheEntry(tmpDir, "Node", "20.0.0");
const { cleanupDiagnostic } = await runDownloadCodeQL(
tmpDir,
[Feature.CleanupToolcacheBundles],
CLEANUP_BUNDLE_VERSION,
);
t.true(fs.existsSync(otherToolDirectory));
t.deepEqual(cleanupDiagnostic, { deletedVersions: [], failed: false });
});
},
);
test.serial(
"downloadCodeQL continues when deleting a CodeQL bundle from the toolcache fails",
async (t) => {
await withTmpDir(async (tmpDir) => {
setupActionsVars(tmpDir, tmpDir);
process.env[ActionsEnvVars.RUNNER_ENVIRONMENT] = "github-hosted";
createToolcacheEntry(tmpDir, "CodeQL", CLEANUP_CLI_VERSION);
const staleDirectory = createToolcacheEntry(
tmpDir,
"CodeQL",
CLEANUP_STALE_VERSION,
);
const rmStub = sinon
.stub(fs.promises, "rm")
.rejects(new Error("EACCES: permission denied"));
const { cleanupDiagnostic } = await runDownloadCodeQL(
tmpDir,
[Feature.CleanupToolcacheBundles],
CLEANUP_BUNDLE_VERSION,
);
// Restore before `withTmpDir` cleans up after itself.
rmStub.restore();
t.true(fs.existsSync(staleDirectory));
t.deepEqual(
cleanupDiagnostic,
{ deletedVersions: [], failed: true },
"Should not report versions that we failed to delete.",
);
});
},
);
test.serial(
"deleteToolcacheBundles reports a failure when the toolcache location is unknown",
async (t) => {
const messages: LoggedMessage[] = [];
const result = await toolsDownload.deleteToolcacheBundles({
env: new ReadOnlyEnv({}),
logger: getRecordingLogger(messages),
});
t.deepEqual(
result,
{ deletedVersions: [], failed: true },
"Should report a failure rather than throwing, so the download can continue.",
);
checkExpectedLogMessages(t, messages, [
"Unable to determine toolcache directory: RUNNER_TOOL_CACHE environment variable must be set",
]);
},
);
test.serial(
"deleteToolcacheBundles uses the supplied environment rather than process.env",
async (t) => {
await withTmpDir(async (tmpDir) => {
const ambientRoot = path.join(tmpDir, "ambient");
const injectedRoot = path.join(tmpDir, "injected");
setupActionsVars(tmpDir, ambientRoot);
const ambientVersion = createToolcacheEntry(
ambientRoot,
"CodeQL",
CLEANUP_STALE_VERSION,
);
const injectedVersion = createToolcacheEntry(
injectedRoot,
"CodeQL",
CLEANUP_STALE_VERSION,
);
const fileEntry = path.join(injectedRoot, "CodeQL", "not-a-directory");
fs.writeFileSync(fileEntry, "keep");
const result = await toolsDownload.deleteToolcacheBundles({
env: new ReadOnlyEnv({
[ActionsEnvVars.RUNNER_TOOL_CACHE]: injectedRoot,
}),
logger: getRunnerLogger(true),
});
t.deepEqual(result, {
deletedVersions: [CLEANUP_STALE_VERSION],
failed: false,
});
t.false(fs.existsSync(injectedVersion));
t.true(fs.existsSync(ambientVersion));
t.is(fs.readFileSync(fileEntry, "utf8"), "keep");
});
},
);
test.serial(
"deleteToolcacheBundles reports a failure when the toolcache directory cannot be read",
async (t) => {
await withTmpDir(async (tmpDir) => {
const versionDirectory = createToolcacheEntry(
tmpDir,
"CodeQL",
CLEANUP_STALE_VERSION,
);
const messages: LoggedMessage[] = [];
const readdir = sinon
.stub(fs.promises, "readdir")
.rejects(new Error("permission denied"));
try {
const result = await toolsDownload.deleteToolcacheBundles({
env: new ReadOnlyEnv({
[ActionsEnvVars.RUNNER_TOOL_CACHE]: tmpDir,
}),
logger: getRecordingLogger(messages),
});
t.deepEqual(result, { deletedVersions: [], failed: true });
t.true(fs.existsSync(versionDirectory));
checkExpectedLogMessages(t, messages, [
`Failed to clean up the CodeQL toolcache at '${path.join(tmpDir, "CodeQL")}': permission denied`,
]);
} finally {
readdir.restore();
}
});
},
);
test.serial(
"downloadCodeQL does not follow a symlinked CodeQL toolcache directory",
async (t) => {
await withTmpDir(async (tmpDir) => {
const toolcacheRoot = path.join(tmpDir, "toolcache");
setupActionsVars(tmpDir, toolcacheRoot);
process.env[ActionsEnvVars.RUNNER_ENVIRONMENT] = "github-hosted";
// Somewhere the toolcache cleanup must never reach.
const outsideDirectory = path.join(tmpDir, "outside");
createToolcacheEntry(outsideDirectory, "CodeQL", CLEANUP_STALE_VERSION);
createToolcacheEntry(outsideDirectory, "CodeQL", CLEANUP_CLI_VERSION);
fs.mkdirSync(toolcacheRoot, { recursive: true });
fs.symlinkSync(
path.join(outsideDirectory, "CodeQL"),
path.join(toolcacheRoot, "CodeQL"),
);
const { cleanupDiagnostic } = await runDownloadCodeQL(
toolcacheRoot,
[Feature.CleanupToolcacheBundles],
CLEANUP_BUNDLE_VERSION,
);
t.true(
fs.existsSync(
path.join(outsideDirectory, "CodeQL", CLEANUP_STALE_VERSION),
),
"Should not delete anything through a symlinked CodeQL directory.",
);
t.deepEqual(cleanupDiagnostic, { deletedVersions: [], failed: true });
});
},
);
test.serial(
"downloadCodeQL does not clean up the toolcache once a step has already set up CodeQL",
async (t) => {
// `.github/workflows/codeql.yml` sets up CodeQL twice and then runs both returned paths. If the
// second setup downloads, it must not delete the bundle the first one handed out.
await testToolcacheCleanup(
t,
{
features: [Feature.CleanupToolcacheBundles],
runnerEnvironment: "github-hosted",
setUp: () => {
process.env[EnvVar.HAS_SET_UP_CODEQL] = "true";
},
},
({ cleanupDiagnostic, destinationDirectory, staleDirectory }) => {
t.true(fs.existsSync(staleDirectory));
t.true(fs.existsSync(destinationDirectory));
t.is(cleanupDiagnostic, undefined);
},
);
},
);
test.serial(
"setupCodeQLBundle records that this job has set up CodeQL",
async (t) => {
await withTmpDir(async (tmpDir) => {
setupActionsVars(tmpDir, tmpDir);
delete process.env[EnvVar.HAS_SET_UP_CODEQL];
sinon.stub(setupCodeql, "downloadCodeQL").resolves({
codeqlFolder: "codeql",
statusReport: { totalDurationMs: 1 },
toolsVersion: LINKED_CLI_VERSION.cliVersion,
});
await setupCodeql.setupCodeQLBundle(
"linked",
SAMPLE_DOTCOM_API_DETAILS,
tmpDir,
GitHubVariant.DOTCOM,
SAMPLE_DEFAULT_CLI_VERSION,
undefined, // rawLanguages
false, // useOverlayAwareDefaultCliVersion
createFeatures([]),
getRunnerLogger(true),
);
t.is(
process.env[EnvVar.HAS_SET_UP_CODEQL],
"true",
"A later step must be able to tell that the toolcache is in use.",
);
});
},
);
test.serial(
"downloadCodeQL cleans up the toolcache even when the download will not be cached",
async (t) => {
// A `tools` URL we can't derive a bundle version from is extracted to a temporary directory
// rather than the toolcache, but the toolcache is on the same filesystem, so emptying it still
// frees up space for the analysis.
await withTmpDir(async (tmpDir) => {
setupActionsVars(tmpDir, tmpDir);
process.env[ActionsEnvVars.RUNNER_ENVIRONMENT] = "github-hosted";
const staleDirectory = createToolcacheEntry(
tmpDir,
"CodeQL",
CLEANUP_STALE_VERSION,
);
const { codeqlFolder, cleanupDiagnostic } = await runDownloadCodeQL(
tmpDir,
[Feature.CleanupToolcacheBundles],
undefined, // bundleVersion
);
t.is(path.dirname(codeqlFolder), tmpDir);
t.not(codeqlFolder, path.join(tmpDir, "CodeQL"));
t.true(fs.existsSync(codeqlFolder));
t.false(fs.existsSync(`${codeqlFolder}.complete`));
t.false(fs.existsSync(staleDirectory));
t.deepEqual(cleanupDiagnostic, {
deletedVersions: [CLEANUP_STALE_VERSION],
failed: false,
});
});
},
);
test.serial(
"downloadCodeQL reports a failure when the toolcache cannot be inspected",
async (t) => {
await withTmpDir(async (tmpDir) => {
setupActionsVars(tmpDir, tmpDir);
process.env[ActionsEnvVars.RUNNER_ENVIRONMENT] = "github-hosted";
createToolcacheEntry(tmpDir, "CodeQL", CLEANUP_STALE_VERSION);
const lstatStub = sinon.stub(fs.promises, "lstat").rejects(
Object.assign(new Error("permission denied"), {
code: "EACCES",
}),
);
const { cleanupDiagnostic } = await runDownloadCodeQL(
tmpDir,
[Feature.CleanupToolcacheBundles],
CLEANUP_BUNDLE_VERSION,
);
lstatStub.restore();
t.deepEqual(
cleanupDiagnostic,
{ deletedVersions: [], failed: true },
"An error other than the toolcache being absent must not be reported as success.",
);
});
},
);
test.serial(
"downloadCodeQL does not clean up a toolcache on a different filesystem to the workspace",
async (t) => {
// Some runner images keep the toolcache on a different volume to the workspace, in which case
// deleting the tools frees up disk space that the analysis cannot use.
await withTmpDir(async (tmpDir) => {
setupActionsVars(tmpDir, tmpDir);
process.env[ActionsEnvVars.RUNNER_ENVIRONMENT] = "github-hosted";
const staleDirectory = createToolcacheEntry(
tmpDir,
"CodeQL",
CLEANUP_STALE_VERSION,
);
sinon
.stub(toolsDownload, "isToolcacheOnWorkspaceFilesystem")
.returns(false);
const { cleanupDiagnostic } = await runDownloadCodeQL(
tmpDir,
[Feature.CleanupToolcacheBundles],
CLEANUP_BUNDLE_VERSION,
);
t.true(fs.existsSync(staleDirectory));
t.is(cleanupDiagnostic, undefined);
});
},
);
test.serial(
"isToolcacheOnWorkspaceFilesystem assumes a different filesystem when it cannot tell",
async (t) => {
await withTmpDir(async (tmpDir) => {
const logger = getRunnerLogger(true);
setupActionsVars(tmpDir, tmpDir);
t.true(toolsDownload.isToolcacheOnWorkspaceFilesystem(logger));
// If we can't tell, we assume the toolcache is not somewhere we can reclaim space from.
process.env[ActionsEnvVars.RUNNER_TOOL_CACHE] = path.join(
tmpDir,
"does-not-exist",
);
t.false(toolsDownload.isToolcacheOnWorkspaceFilesystem(logger));
});
},
);
test.serial(
"downloadCodeQL does not delete through a symlinked version directory",
async (t) => {
await withTmpDir(async (tmpDir) => {
const toolcacheRoot = path.join(tmpDir, "toolcache");
setupActionsVars(tmpDir, toolcacheRoot);
process.env[ActionsEnvVars.RUNNER_ENVIRONMENT] = "github-hosted";
createToolcacheEntry(toolcacheRoot, "CodeQL", CLEANUP_STALE_VERSION);
// Somewhere outside the toolcache that a version directory points at.
const outsideDirectory = path.join(tmpDir, "outside");
fs.mkdirSync(outsideDirectory, { recursive: true });
fs.writeFileSync(path.join(outsideDirectory, "contents"), "x");
fs.symlinkSync(
outsideDirectory,
path.join(toolcacheRoot, "CodeQL", "9.9.9"),
);
const { cleanupDiagnostic } = await runDownloadCodeQL(
toolcacheRoot,
[Feature.CleanupToolcacheBundles],
CLEANUP_BUNDLE_VERSION,
);
t.true(
fs.existsSync(path.join(outsideDirectory, "contents")),
"Should not delete anything through a symlinked version directory.",
);
t.true(
fs
.lstatSync(path.join(toolcacheRoot, "CodeQL", "9.9.9"))
.isSymbolicLink(),
);
t.deepEqual(cleanupDiagnostic, {
deletedVersions: [CLEANUP_STALE_VERSION],
failed: false,
});
});
},
);

View File

@@ -2,14 +2,17 @@ import * as fs from "fs";
import { OutgoingHttpHeaders } from "http"; import { OutgoingHttpHeaders } from "http";
import * as path from "path"; import * as path from "path";
import * as core from "@actions/core";
import * as toolcache from "@actions/tool-cache"; import * as toolcache from "@actions/tool-cache";
import { default as deepEqual } from "fast-deep-equal"; import { default as deepEqual } from "fast-deep-equal";
import * as semver from "semver"; import * as semver from "semver";
import { v4 as uuidV4 } from "uuid"; import { v4 as uuidV4 } from "uuid";
import { ActionState } from "./action-common";
import { import {
isAnalyzingPullRequest, isAnalyzingPullRequest,
isDynamicWorkflow, isDynamicWorkflow,
isGitHubHostedRunner,
isRunningLocalAction, isRunningLocalAction,
} from "./actions-util"; } from "./actions-util";
import * as api from "./api-client"; import * as api from "./api-client";
@@ -19,6 +22,7 @@ import {
makeDiagnostic, makeDiagnostic,
makeTelemetryDiagnostic, makeTelemetryDiagnostic,
} from "./diagnostics"; } from "./diagnostics";
import { EnvVar, getEnv } from "./environment";
import { import {
CODEQL_VERSION_ZSTD_BUNDLE, CODEQL_VERSION_ZSTD_BUNDLE,
CodeQLDefaultVersionInfo, CodeQLDefaultVersionInfo,
@@ -30,8 +34,10 @@ import { Logger } from "./logging";
import { getCodeQlVersionsForOverlayBaseDatabases } from "./overlay/caching"; import { getCodeQlVersionsForOverlayBaseDatabases } from "./overlay/caching";
import * as tar from "./tar"; import * as tar from "./tar";
import { import {
deleteToolcacheBundles,
downloadAndExtract, downloadAndExtract,
getToolcacheDirectory, getToolcacheDirectory,
isToolcacheOnWorkspaceFilesystem,
ToolsDownloadStatusReport, ToolsDownloadStatusReport,
writeToolcacheMarkerFile, writeToolcacheMarkerFile,
} from "./tools-download"; } from "./tools-download";
@@ -784,6 +790,7 @@ export const downloadCodeQL = async function (
apiDetails: api.GitHubApiDetails, apiDetails: api.GitHubApiDetails,
tarVersion: tar.TarVersion | undefined, tarVersion: tar.TarVersion | undefined,
tempDir: string, tempDir: string,
features: FeatureEnablement,
logger: Logger, logger: Logger,
): Promise<{ ): Promise<{
codeqlFolder: string; codeqlFolder: string;
@@ -817,6 +824,8 @@ export const downloadCodeQL = async function (
const extractedBundlePath = const extractedBundlePath =
toolcacheInfo?.path ?? getTempExtractionDir(tempDir); toolcacheInfo?.path ?? getTempExtractionDir(tempDir);
await tryDeleteToolcacheBundles({ env: getEnv(), features, logger });
const statusReport = await downloadAndExtract( const statusReport = await downloadAndExtract(
codeqlURL, codeqlURL,
compressionMethod, compressionMethod,
@@ -869,6 +878,48 @@ function getToolcacheDestinationInfo(
return undefined; return undefined;
} }
/**
* Reclaims disk space by deleting the CodeQL tools from the toolcache, if enabled.
*
* On GitHub-hosted runners the toolcache shares a filesystem with the workspace, so tools left in
* the toolcache take up space that the analysis could use instead. This holds wherever we extract
* the tools we are obtaining, since the toolcache is on that filesystem either way.
*/
async function tryDeleteToolcacheBundles({
env,
features,
logger,
}: ActionState<["Logger", "ReadOnlyEnv", "FeatureFlags"]>): Promise<void> {
// A step that has already set up CodeQL may hand out a path into the toolcache that a later step
// runs, so only the first step to set it up can know that nothing else relies on the toolcache.
if (env.getOptional(EnvVar.HAS_SET_UP_CODEQL) !== undefined) {
logger.debug(
"Not deleting the CodeQL tools from the toolcache since a previous step in this job has " +
"already set up CodeQL.",
);
return;
}
if (
!isGitHubHostedRunner() ||
!isToolcacheOnWorkspaceFilesystem(logger) ||
!(await features.getValue(Feature.CleanupToolcacheBundles))
) {
return;
}
const result = await deleteToolcacheBundles({ env, logger });
addNoLanguageDiagnostic(
undefined,
makeTelemetryDiagnostic(
"codeql-action/toolcache-bundle-cleanup",
"Toolcache CodeQL bundle cleanup",
{ ...result },
),
);
}
export function getCodeQLURLVersion(url: string): string { export function getCodeQLURLVersion(url: string): string {
const match = url.match(/\/codeql-bundle-(.*)\//); const match = url.match(/\/codeql-bundle-(.*)\//);
if (match === null || match.length < 2) { if (match === null || match.length < 2) {
@@ -978,6 +1029,7 @@ export async function setupCodeQLBundle(
apiDetails, apiDetails,
zstdAvailability.version, zstdAvailability.version,
tempDir, tempDir,
features,
logger, logger,
); );
toolsVersion = result.toolsVersion; toolsVersion = result.toolsVersion;
@@ -989,6 +1041,11 @@ export async function setupCodeQLBundle(
default: default:
util.assertNever(source); util.assertNever(source);
} }
// Record that this job now has a copy of the CodeQL tools, so that a later step doesn't delete
// the toolcache out from under the path we are about to return.
core.exportVariable(EnvVar.HAS_SET_UP_CODEQL, "true");
return { return {
codeqlFolder, codeqlFolder,
toolsDownloadStatusReport, toolsDownloadStatusReport,

View File

@@ -10,6 +10,8 @@ import * as toolcache from "@actions/tool-cache";
import { https } from "follow-redirects"; import { https } from "follow-redirects";
import * as semver from "semver"; import * as semver from "semver";
import { ActionState } from "./action-common";
import { ActionsEnvVars, getEnv, ReadOnlyEnv } from "./environment";
import { formatDuration, Logger } from "./logging"; import { formatDuration, Logger } from "./logging";
import * as tar from "./tar"; import * as tar from "./tar";
import { cleanUpPath, getErrorMessage, getRequiredEnvParam } from "./util"; import { cleanUpPath, getErrorMessage, getRequiredEnvParam } from "./util";
@@ -197,16 +199,154 @@ async function downloadAndExtractZstdWithStreaming(
await tar.extractTarZst(response, dest, tarVersion, logger); await tar.extractTarZst(response, dest, tarVersion, logger);
} }
/** Gets the path to the toolcache directory that holds all versions of the CodeQL tools. */
function getToolcacheToolDirectory(env: ReadOnlyEnv): string {
return path.join(
env.getRequired(ActionsEnvVars.RUNNER_TOOL_CACHE),
TOOLCACHE_TOOL_NAME,
);
}
/** Gets the name of the toolcache directory that holds the given version of the CodeQL tools. */
function getToolcacheVersionDirectoryName(version: string): string {
return semver.clean(version) || version;
}
/** Gets the path to the toolcache directory for the specified version of the CodeQL tools. */ /** Gets the path to the toolcache directory for the specified version of the CodeQL tools. */
export function getToolcacheDirectory(version: string): string { export function getToolcacheDirectory(version: string): string {
return path.join( return path.join(
getRequiredEnvParam("RUNNER_TOOL_CACHE"), getToolcacheToolDirectory(getEnv()),
TOOLCACHE_TOOL_NAME, getToolcacheVersionDirectoryName(version),
semver.clean(version) || version,
os.arch() || "", os.arch() || "",
); );
} }
/**
* Whether the toolcache is on the same filesystem as the workspace, and so whether deleting the
* tools frees up disk space that the analysis can use.
*
* These are separate volumes on some runner images. Windows runners, for example, keep the
* toolcache on `C:` while the workspace is on `D:`.
*/
export function isToolcacheOnWorkspaceFilesystem(logger: Logger): boolean {
try {
return (
fs.statSync(getRequiredEnvParam("RUNNER_TOOL_CACHE")).dev ===
fs.statSync(getRequiredEnvParam("GITHUB_WORKSPACE")).dev
);
} catch (e) {
logger.debug(
`Could not determine whether the toolcache is on the same filesystem as the workspace: ${getErrorMessage(e)}`,
);
return false;
}
}
/** The outcome of trying to reclaim disk space by deleting the CodeQL tools from the toolcache. */
export interface ToolcacheCleanupResult {
/** The versions of the CodeQL tools that were deleted. */
deletedVersions: string[];
/**
* Whether we hit an error while trying to delete the tools. Distinguishes a toolcache that had
* nothing to reclaim from one we failed to clean up.
*/
failed: boolean;
}
/**
* Deletes every version of the CodeQL tools from the toolcache.
*
* Only safe to call when we are about to download the tools, since that means we did not resolve
* them from the toolcache and so nothing in there is in use by this job.
*
* This only ever touches the CodeQL directory of the toolcache. Cleanup errors are logged and
* returned as `failed: true` rather than thrown.
*
* @returns the versions that were deleted, and whether we hit an error while trying.
*/
export async function deleteToolcacheBundles({
env,
logger,
}: ActionState<["Logger", "ReadOnlyEnv"]>): Promise<ToolcacheCleanupResult> {
let toolDirectory: string;
try {
toolDirectory = getToolcacheToolDirectory(env);
} catch (e) {
logger.info(
`Unable to determine toolcache directory: ${getErrorMessage(e)}`,
);
return { deletedVersions: [], failed: true };
}
try {
// Refuse to follow a symlinked CodeQL directory, so that we can only ever delete paths that are
// really inside the toolcache.
if ((await fs.promises.lstat(toolDirectory)).isSymbolicLink()) {
logger.info(
`Not deleting the CodeQL tools from the toolcache since '${toolDirectory}' is a symlink.`,
);
return { deletedVersions: [], failed: true };
}
} catch (e: any) {
if (e?.code === "ENOENT") {
logger.debug(
`There are no CodeQL tools at '${toolDirectory}' to delete from the toolcache.`,
);
return { deletedVersions: [], failed: false };
}
logger.info(
`Failed to inspect the CodeQL tools at '${toolDirectory}': ${getErrorMessage(e)}`,
);
return { deletedVersions: [], failed: true };
}
try {
const entries = await fs.promises.readdir(toolDirectory, {
withFileTypes: true,
});
const deletedVersions: string[] = [];
let failed = false;
for (const entry of entries) {
// `isDirectory` is false for a symlink, so we never delete a version directory that is
// really somewhere else.
if (!entry.isDirectory()) {
logger.debug(
`Not deleting '${entry.name}' from the CodeQL toolcache since it is not a directory.`,
);
continue;
}
const versionDirectory = path.join(toolDirectory, entry.name);
try {
await fs.promises.rm(versionDirectory, {
force: true,
recursive: true,
});
deletedVersions.push(entry.name);
logger.info(
`Deleted the CodeQL tools at '${versionDirectory}' from the toolcache to free up disk space.`,
);
} catch (e) {
failed = true;
logger.info(
`Failed to delete the CodeQL tools at '${versionDirectory}' from the toolcache: ${getErrorMessage(e)}`,
);
}
}
return { deletedVersions: deletedVersions.sort(), failed };
} catch (e) {
logger.info(
`Failed to clean up the CodeQL toolcache at '${toolDirectory}': ${getErrorMessage(e)}`,
);
return { deletedVersions: [], failed: true };
}
}
export function writeToolcacheMarkerFile( export function writeToolcacheMarkerFile(
extractedPath: string, extractedPath: string,
logger: Logger, logger: Logger,

View File

@@ -842,9 +842,13 @@ export async function checkForTimeout() {
* directory with the name hostedtoolcache which is present on * directory with the name hostedtoolcache which is present on
* GitHub-hosted runners. * GitHub-hosted runners.
* *
* @returns true iff the runner is hosted by GitHub * Since this is a heuristic over how the runner happens to be named and laid out, it also matches
* self-hosted runners that are configured to resemble hosted ones. Prefer
* `isGitHubHostedRunner` when you need the answer the Actions service reports.
*
* @returns true iff the runner looks like it is hosted by GitHub
*/ */
export function isHostedRunner() { export function looksLikeHostedRunner() {
return ( return (
// Name of the runner on hosted Windows runners // Name of the runner on hosted Windows runners
process.env["RUNNER_NAME"]?.includes("Hosted Agent") || process.env["RUNNER_NAME"]?.includes("Hosted Agent") ||