mirror of
https://github.com/github/codeql-action.git
synced 2026-10-03 17:41:28 +00:00
Merge pull request #4124 from github/henrymercer/toolcache-bundle-cleanup
Delete unused CodeQL bundles from the toolcache before downloading
This commit is contained in:
1
.github/workflows/__multi-language-autodetect.yml
generated
vendored
1
.github/workflows/__multi-language-autodetect.yml
generated
vendored
@@ -191,5 +191,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
CODEQL_ACTION_CLEANUP_TOOLCACHE_BUNDLES: true
|
||||
CODEQL_ACTION_RESOLVE_SUPPORTED_LANGUAGES_USING_CLI: true
|
||||
CODEQL_ACTION_TEST_MODE: true
|
||||
|
||||
@@ -4,6 +4,7 @@ See the [releases page](https://github.com/github/codeql-action/releases) for th
|
||||
|
||||
## [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)
|
||||
|
||||
## 4.37.9 - 26 Aug 2026
|
||||
|
||||
890
lib/entry-points.js
generated
890
lib/entry-points.js
generated
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ operatingSystems:
|
||||
- stable-v2.21.4
|
||||
- stable-v2.22.4
|
||||
env:
|
||||
CODEQL_ACTION_CLEANUP_TOOLCACHE_BUNDLES: true
|
||||
CODEQL_ACTION_RESOLVE_SUPPORTED_LANGUAGES_USING_CLI: true
|
||||
installGo: true
|
||||
installDotNet: true
|
||||
|
||||
@@ -283,6 +283,19 @@ export function isSelfHostedRunner(env: Env = getEnv()) {
|
||||
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`. */
|
||||
export function isDynamicWorkflow(env: Env = getEnv()): boolean {
|
||||
return getWorkflowEventName(env) === "dynamic";
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as core from "@actions/core";
|
||||
import { getOptionalInput, isDefaultSetup } from "./actions-util";
|
||||
import { EnvVar } from "./environment";
|
||||
import { Logger } from "./logging";
|
||||
import { isHostedRunner, tryGetFolderBytes } from "./util";
|
||||
import { looksLikeHostedRunner, tryGetFolderBytes } from "./util";
|
||||
|
||||
/**
|
||||
* Returns the total size of all the specified paths.
|
||||
@@ -109,7 +109,7 @@ export function getDependencyCachingEnabled(): CachingKind {
|
||||
if (dependencyCaching !== undefined) return getCachingKind(dependencyCaching);
|
||||
|
||||
// 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.
|
||||
if (!isDefaultSetup()) return CachingKind.None;
|
||||
|
||||
@@ -90,9 +90,8 @@ import {
|
||||
Result,
|
||||
Success,
|
||||
Failure,
|
||||
isHostedRunner,
|
||||
looksLikeHostedRunner,
|
||||
} from "./util";
|
||||
|
||||
export { type Config } from "./config/action-config";
|
||||
|
||||
/**
|
||||
@@ -938,7 +937,7 @@ export async function isTrapCachingEnabled(
|
||||
if (trapCaching !== undefined) return trapCaching === "true";
|
||||
|
||||
// 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.
|
||||
// This change is gated behind a feature flag.
|
||||
|
||||
@@ -63,6 +63,12 @@ export enum EnvVar {
|
||||
/** 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",
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
SETUP_CODEQL_ACTION_HAS_RUN = "CODEQL_ACTION_SETUP_CODEQL_HAS_RUN",
|
||||
|
||||
|
||||
@@ -74,6 +74,11 @@ export enum Feature {
|
||||
AllowMergeConfigFiles = "allow_merge_config_files",
|
||||
/** Controls whether we allow multiple values for the `analysis-kinds` input. */
|
||||
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",
|
||||
/** Whether to allow the `config-file` input to be specified via a repository property. */
|
||||
ConfigFileRepositoryProperty = "config_file_repository_property",
|
||||
@@ -211,6 +216,11 @@ export const featureConfig = {
|
||||
envVar: "CODEQL_ACTION_ALLOW_MULTIPLE_ANALYSIS_KINDS",
|
||||
minimumVersion: undefined,
|
||||
},
|
||||
[Feature.CleanupToolcacheBundles]: {
|
||||
defaultValue: false,
|
||||
envVar: "CODEQL_ACTION_CLEANUP_TOOLCACHE_BUNDLES",
|
||||
minimumVersion: undefined,
|
||||
},
|
||||
[Feature.CleanupTrapCaches]: {
|
||||
defaultValue: false,
|
||||
envVar: "CODEQL_ACTION_CLEANUP_TRAP_CACHES",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
|
||||
import * as github from "@actions/github";
|
||||
@@ -7,7 +9,8 @@ import * as sinon from "sinon";
|
||||
|
||||
import * as actionsUtil from "./actions-util";
|
||||
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 { getRunnerLogger } from "./logging";
|
||||
import { getCacheRestoreKeyPrefix } from "./overlay/caching";
|
||||
@@ -27,6 +30,7 @@ import {
|
||||
setupActionsVars,
|
||||
setupTests,
|
||||
} from "./testing-utils";
|
||||
import * as toolsDownload from "./tools-download";
|
||||
import {
|
||||
getErrorMessage,
|
||||
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,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,14 +2,17 @@ import * as fs from "fs";
|
||||
import { OutgoingHttpHeaders } from "http";
|
||||
import * as path from "path";
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import * as toolcache from "@actions/tool-cache";
|
||||
import { default as deepEqual } from "fast-deep-equal";
|
||||
import * as semver from "semver";
|
||||
import { v4 as uuidV4 } from "uuid";
|
||||
|
||||
import { ActionState } from "./action-common";
|
||||
import {
|
||||
isAnalyzingPullRequest,
|
||||
isDynamicWorkflow,
|
||||
isGitHubHostedRunner,
|
||||
isRunningLocalAction,
|
||||
} from "./actions-util";
|
||||
import * as api from "./api-client";
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
makeDiagnostic,
|
||||
makeTelemetryDiagnostic,
|
||||
} from "./diagnostics";
|
||||
import { EnvVar, getEnv } from "./environment";
|
||||
import {
|
||||
CODEQL_VERSION_ZSTD_BUNDLE,
|
||||
CodeQLDefaultVersionInfo,
|
||||
@@ -30,8 +34,10 @@ import { Logger } from "./logging";
|
||||
import { getCodeQlVersionsForOverlayBaseDatabases } from "./overlay/caching";
|
||||
import * as tar from "./tar";
|
||||
import {
|
||||
deleteToolcacheBundles,
|
||||
downloadAndExtract,
|
||||
getToolcacheDirectory,
|
||||
isToolcacheOnWorkspaceFilesystem,
|
||||
ToolsDownloadStatusReport,
|
||||
writeToolcacheMarkerFile,
|
||||
} from "./tools-download";
|
||||
@@ -784,6 +790,7 @@ export const downloadCodeQL = async function (
|
||||
apiDetails: api.GitHubApiDetails,
|
||||
tarVersion: tar.TarVersion | undefined,
|
||||
tempDir: string,
|
||||
features: FeatureEnablement,
|
||||
logger: Logger,
|
||||
): Promise<{
|
||||
codeqlFolder: string;
|
||||
@@ -817,6 +824,8 @@ export const downloadCodeQL = async function (
|
||||
const extractedBundlePath =
|
||||
toolcacheInfo?.path ?? getTempExtractionDir(tempDir);
|
||||
|
||||
await tryDeleteToolcacheBundles({ env: getEnv(), features, logger });
|
||||
|
||||
const statusReport = await downloadAndExtract(
|
||||
codeqlURL,
|
||||
compressionMethod,
|
||||
@@ -869,6 +878,48 @@ function getToolcacheDestinationInfo(
|
||||
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 {
|
||||
const match = url.match(/\/codeql-bundle-(.*)\//);
|
||||
if (match === null || match.length < 2) {
|
||||
@@ -978,6 +1029,7 @@ export async function setupCodeQLBundle(
|
||||
apiDetails,
|
||||
zstdAvailability.version,
|
||||
tempDir,
|
||||
features,
|
||||
logger,
|
||||
);
|
||||
toolsVersion = result.toolsVersion;
|
||||
@@ -989,6 +1041,11 @@ export async function setupCodeQLBundle(
|
||||
default:
|
||||
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 {
|
||||
codeqlFolder,
|
||||
toolsDownloadStatusReport,
|
||||
|
||||
@@ -10,6 +10,8 @@ import * as toolcache from "@actions/tool-cache";
|
||||
import { https } from "follow-redirects";
|
||||
import * as semver from "semver";
|
||||
|
||||
import { ActionState } from "./action-common";
|
||||
import { ActionsEnvVars, getEnv, ReadOnlyEnv } from "./environment";
|
||||
import { formatDuration, Logger } from "./logging";
|
||||
import * as tar from "./tar";
|
||||
import { cleanUpPath, getErrorMessage, getRequiredEnvParam } from "./util";
|
||||
@@ -197,16 +199,154 @@ async function downloadAndExtractZstdWithStreaming(
|
||||
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. */
|
||||
export function getToolcacheDirectory(version: string): string {
|
||||
return path.join(
|
||||
getRequiredEnvParam("RUNNER_TOOL_CACHE"),
|
||||
TOOLCACHE_TOOL_NAME,
|
||||
semver.clean(version) || version,
|
||||
getToolcacheToolDirectory(getEnv()),
|
||||
getToolcacheVersionDirectoryName(version),
|
||||
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(
|
||||
extractedPath: string,
|
||||
logger: Logger,
|
||||
|
||||
@@ -842,9 +842,13 @@ export async function checkForTimeout() {
|
||||
* directory with the name hostedtoolcache which is present on
|
||||
* 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 (
|
||||
// Name of the runner on hosted Windows runners
|
||||
process.env["RUNNER_NAME"]?.includes("Hosted Agent") ||
|
||||
|
||||
Reference in New Issue
Block a user