diff --git a/CHANGELOG.md b/CHANGELOG.md index bd770ab5f4..1405a1ee55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,10 @@ See the [releases page](https://github.com/github/codeql-action/releases) for th ## [UNRELEASED] -No user facing changes. +- The `tools` input for the `init` and `setup-codeql` Actions now accepts a bare CodeQL CLI version number, for example `2.19.0` or `v2.19.0`. The Action will automatically download the CodeQL Bundle release asset that matches the runner's operating system and architecture, preferring the smaller `zstd`-compressed bundle where it is supported. +- The `tools` input also now accepts a CodeQL CLI version range, for example `2.24.x`, `2.x`, `~2.24.0`, or `^2.24.0`, as well as `latest-` (for example `latest-1`), which resolves to the CodeQL Bundle release `N` stable releases before the most recent one. +- The `tools` input also now accepts `latest-prerelease`, which resolves to the most recent CodeQL Bundle release, whether it is a stable release or a GitHub prerelease, as well as `nightly-until-` (for example `nightly-until-2.24.0`), which resolves to the most recent CodeQL Bundle release satisfying that version threshold, considering GitHub prereleases too, or falls back to the latest nightly version of the CodeQL tools if no such release exists. +- The `tools` input also now accepts `nightly-until-default-` (for example `nightly-until-default-2.24.0`), which uses the default CodeQL CLI version for this environment if it satisfies that version threshold, or falls back to the latest nightly version of the CodeQL tools otherwise. ## 4.37.6 - 04 Aug 2026 diff --git a/init/action.yml b/init/action.yml index 1b64e8d2a3..cbb3f4f530 100644 --- a/init/action.yml +++ b/init/action.yml @@ -10,6 +10,24 @@ inputs: - A local path to a CodeQL Bundle tarball, or - The URL of a CodeQL Bundle tarball GitHub release asset, or + - A CodeQL CLI version number, for example `2.19.0` or `v2.19.0`, in which case the CodeQL + Bundle containing that version will be downloaded, selecting the archive format and + release asset that matches the runner's operating system and architecture, or + - A CodeQL CLI version range, for example `2.24.x`, `2.x`, `~2.24.0`, or `^2.24.0`, in + which case the most recent stable CodeQL Bundle release satisfying that range will be + downloaded, or + - `latest-`, for example `latest-1` or `LATEST-2` (matching is case insensitive), which + uses the CodeQL Bundle release `N` stable releases before the most recent one, or + - A special value `latest-prerelease` (matching is case insensitive) which uses the most + recent CodeQL Bundle release, whether it is a stable release or a GitHub prerelease, or + - `nightly-until-`, for example `nightly-until-2.24.0` or + `NIGHTLY-UNTIL-2.24.0` (matching is case insensitive), which uses the most recent CodeQL + Bundle release satisfying that version threshold, considering GitHub prereleases too, or + falls back to the latest nightly version of the CodeQL tools if no such release exists, or + - `nightly-until-default-`, for example `nightly-until-default-2.24.0` (matching + is case insensitive), which uses the default CodeQL CLI version if it satisfies that + version threshold, or falls back to the latest nightly version of the CodeQL tools + otherwise, or - A special value `linked` which uses the version of the CodeQL tools that the Action has been bundled with. - A special value `nightly` which uses the latest nightly version of the diff --git a/lib/entry-points.js b/lib/entry-points.js index dd77444e02..c9dffe7609 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -26679,8 +26679,8 @@ var require_rcompare = __commonJS({ "node_modules/semver/functions/rcompare.js"(exports2, module2) { "use strict"; var compare3 = require_compare(); - var rcompare3 = (a, b, loose) => compare3(b, a, loose); - module2.exports = rcompare3; + var rcompare4 = (a, b, loose) => compare3(b, a, loose); + module2.exports = rcompare4; } }); @@ -27494,7 +27494,7 @@ var require_max_satisfying = __commonJS({ "use strict"; var SemVer = require_semver(); var Range2 = require_range(); - var maxSatisfying = (versions, range2, options) => { + var maxSatisfying2 = (versions, range2, options) => { let max = null; let maxSV = null; let rangeObj = null; @@ -27513,7 +27513,7 @@ var require_max_satisfying = __commonJS({ }); return max; }; - module2.exports = maxSatisfying; + module2.exports = maxSatisfying2; } }); @@ -27610,14 +27610,14 @@ var require_valid2 = __commonJS({ "node_modules/semver/ranges/valid.js"(exports2, module2) { "use strict"; var Range2 = require_range(); - var validRange = (range2, options) => { + var validRange2 = (range2, options) => { try { return new Range2(range2, options).range || "*"; } catch (er) { return null; } }; - module2.exports = validRange; + module2.exports = validRange2; } }); @@ -27954,7 +27954,7 @@ var require_semver2 = __commonJS({ var patch = require_patch(); var prerelease = require_prerelease(); var compare3 = require_compare(); - var rcompare3 = require_rcompare(); + var rcompare4 = require_rcompare(); var compareLoose = require_compare_loose(); var compareBuild = require_compare_build(); var sort = require_sort(); @@ -27972,10 +27972,10 @@ var require_semver2 = __commonJS({ var Range2 = require_range(); var satisfies2 = require_satisfies(); var toComparators = require_to_comparators(); - var maxSatisfying = require_max_satisfying(); + var maxSatisfying2 = require_max_satisfying(); var minSatisfying = require_min_satisfying(); var minVersion = require_min_version(); - var validRange = require_valid2(); + var validRange2 = require_valid2(); var outside = require_outside(); var gtr = require_gtr(); var ltr = require_ltr(); @@ -27993,7 +27993,7 @@ var require_semver2 = __commonJS({ patch, prerelease, compare: compare3, - rcompare: rcompare3, + rcompare: rcompare4, compareLoose, compareBuild, sort, @@ -28011,10 +28011,10 @@ var require_semver2 = __commonJS({ Range: Range2, satisfies: satisfies2, toComparators, - maxSatisfying, + maxSatisfying: maxSatisfying2, minSatisfying, minVersion, - validRange, + validRange: validRange2, outside, gtr, ltr, @@ -33426,8 +33426,8 @@ var require_semver3 = __commonJS({ var versionB = new SemVer(b, loose); return versionA.compare(versionB) || versionA.compareBuild(versionB); } - exports2.rcompare = rcompare3; - function rcompare3(a, b, loose) { + exports2.rcompare = rcompare4; + function rcompare4(a, b, loose) { return compare3(b, a, loose); } exports2.sort = sort; @@ -33921,8 +33921,8 @@ var require_semver3 = __commonJS({ } return range2.test(version); } - exports2.maxSatisfying = maxSatisfying; - function maxSatisfying(versions, range2, options) { + exports2.maxSatisfying = maxSatisfying2; + function maxSatisfying2(versions, range2, options) { var max = null; var maxSV = null; try { @@ -34004,8 +34004,8 @@ var require_semver3 = __commonJS({ } return null; } - exports2.validRange = validRange; - function validRange(range2, options) { + exports2.validRange = validRange2; + function validRange2(range2, options) { try { return new Range2(range2, options).range || "*"; } catch (er) { @@ -144927,6 +144927,7 @@ function checkSchema(schema, obj, options = {}, path29 = "") { var BASE_DATABASE_OIDS_FILE_NAME = "base-database-oids.json"; var BROKEN_VERSIONS = ["0.0.0-20211207"]; var GITHUB_DOTCOM_URL = "https://github.com"; +var GITHUB_DOTCOM_API_URL = "https://api.github.com"; var DEFAULT_DEBUG_ARTIFACT_NAME = "debug-artifacts"; var DEFAULT_DEBUG_DATABASE_NAME = "db"; var DEFAULT_RESERVED_RAM_SCALING_FACTOR = 0.05; @@ -146135,6 +146136,23 @@ function getApiClient(env = getEnv()) { function getApiClientWithExternalAuth(apiDetails, proxy) { return createApiClientWithDetails(apiDetails, { allowExternal: true, proxy }); } +function getUnauthenticatedApiClientForDotcom(proxy) { + const retryingOctokit = githubUtils.GitHub.plugin(retry); + return new retryingOctokit({ + baseUrl: GITHUB_DOTCOM_API_URL, + userAgent: `CodeQL-Action/${getActionVersion()}`, + log: { + debug: core5.debug, + info: core5.info, + warn: core5.warning, + error: core5.error + }, + request: makeProxyRequestOptions(proxy), + retry: { + doNotRetry: DO_NOT_RETRY_STATUSES + } + }); +} function getAuthorizationHeaderFor(logger, apiDetails, url2) { if (url2.startsWith(`${apiDetails.url}/`) || apiDetails.apiURL && url2.startsWith(`${apiDetails.apiURL}/`)) { logger.debug(`Providing an authorization token.`); @@ -151085,6 +151103,12 @@ var CODEQL_NIGHTLIES_REPOSITORY_NAME = "codeql-cli-nightlies"; var CODEQL_BUNDLE_VERSION_ALIAS = ["linked", "latest"]; var CODEQL_NIGHTLY_TOOLS_INPUTS = ["nightly", "nightly-latest"]; var CODEQL_TOOLCACHE_INPUT = "toolcache"; +var LATEST_OFFSET_TOOLS_INPUT_REGEX = /^latest-(\d+)$/i; +var LATEST_PRERELEASE_TOOLS_INPUT = "latest-prerelease"; +var NIGHTLY_UNTIL_DEFAULT_TOOLS_INPUT_REGEX = /^nightly-until-default-(.+)$/i; +var NIGHTLY_UNTIL_TOOLS_INPUT_REGEX = /^nightly-until-(.+)$/i; +var CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE = 100; +var CODEQL_BUNDLE_RELEASE_LIST_MAX_PAGES = 20; function getCodeQLBundleExtension(compressionMethod) { switch (compressionMethod) { case "gzip": @@ -151200,6 +151224,91 @@ function convertToSemVer(version, logger) { } return s; } +function tryGetCliVersionFromToolsInput(toolsInput) { + return semver9.valid(toolsInput) ?? void 0; +} +function tryGetLatestOffsetFromToolsInput(toolsInput) { + const match2 = toolsInput.match(LATEST_OFFSET_TOOLS_INPUT_REGEX); + return match2 ? parseInt(match2[1], 10) : void 0; +} +function tryGetCliVersionRangeFromToolsInput(toolsInput) { + if (!toolsInput.includes(".") || semver9.valid(toolsInput) !== null) { + return void 0; + } + return semver9.validRange(toolsInput) !== null ? toolsInput : void 0; +} +function isLatestPrereleaseToolsInput(toolsInput) { + return toolsInput.toLowerCase() === LATEST_PRERELEASE_TOOLS_INPUT; +} +function tryGetNightlyUntilToolsInput(toolsInput) { + if (tryGetNightlyUntilDefaultToolsInput(toolsInput) !== void 0) { + return void 0; + } + const match2 = toolsInput.match(NIGHTLY_UNTIL_TOOLS_INPUT_REGEX); + return match2 ? match2[1] : void 0; +} +function tryGetNightlyUntilDefaultToolsInput(toolsInput) { + const match2 = toolsInput.match(NIGHTLY_UNTIL_DEFAULT_TOOLS_INPUT_REGEX); + return match2 ? match2[1] : void 0; +} +function parseNightlyUntilThreshold(toolsInput, rawThreshold) { + const threshold = semver9.valid(rawThreshold); + if (!threshold) { + throw new ConfigurationError( + `'tools: ${toolsInput}' was requested, but '${rawThreshold}' is not a valid semantic version. The version threshold must be a semantic version, for example '2.24.0'.` + ); + } + return threshold; +} +async function getSortedCliVersions(variant, logger, includePrereleases) { + const [owner, repo] = CODEQL_DEFAULT_ACTION_REPOSITORY.split("/"); + const versions = /* @__PURE__ */ new Set(); + const apiClient = variant === "GitHub.com" /* DOTCOM */ ? getApiClient() : getUnauthenticatedApiClientForDotcom(); + try { + for (let page = 1; page <= CODEQL_BUNDLE_RELEASE_LIST_MAX_PAGES; page++) { + const response = await apiClient.rest.repos.listReleases({ + owner, + repo, + per_page: CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE, + page + }); + for (const release2 of response.data) { + if (release2.draft || release2.prerelease && !includePrereleases) { + continue; + } + const bundleVersion2 = tryGetBundleVersionFromTagName( + release2.tag_name, + logger + ); + if (bundleVersion2 && semver9.valid(bundleVersion2)) { + versions.add(semver9.clean(bundleVersion2)); + } + } + if (response.data.length < CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE) { + break; + } + } + } catch (e) { + throw new ConfigurationError( + `Failed to list CodeQL bundle releases in ${CODEQL_DEFAULT_ACTION_REPOSITORY}: ${getErrorMessage(e)}` + ); + } + return [...versions].sort(semver9.rcompare); +} +async function getSortedStableCliVersions(variant, logger) { + return getSortedCliVersions(variant, logger, false); +} +async function getSortedCliVersionsIncludingPrereleases(variant, logger) { + return getSortedCliVersions(variant, logger, true); +} +function describeAvailableCliVersionRange(sortedVersions) { + if (sortedVersions.length === 0) { + return ""; + } + const newest = sortedVersions[0]; + const oldest = sortedVersions[sortedVersions.length - 1]; + return newest === oldest ? ` The only available stable CodeQL CLI release is ${newest}.` : ` Available stable CodeQL CLI releases range from ${oldest} to ${newest}.`; +} async function findOverridingToolsInCache(humanReadableVersion, logger) { const candidates = toolcache3.findAllVersions("CodeQL").filter(isGoodVersion).map((version) => ({ folder: toolcache3.find("CodeQL", version), @@ -151304,7 +151413,7 @@ async function resolveDefaultCliVersion(defaultCliVersion, rawLanguages, useOver return defaultCliVersion.enabledVersions[0]; } async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useOverlayAwareDefaultCliVersion, apiDetails, variant, tarSupportsZstd, features, logger) { - if (toolsInput && !isReservedToolsValue(toolsInput) && !toolsInput.startsWith("http")) { + if (toolsInput && !isReservedToolsValue(toolsInput) && !isLatestPrereleaseToolsInput(toolsInput) && tryGetNightlyUntilDefaultToolsInput(toolsInput) === void 0 && tryGetNightlyUntilToolsInput(toolsInput) === void 0 && !toolsInput.startsWith("http") && tryGetCliVersionFromToolsInput(toolsInput) === void 0 && tryGetLatestOffsetFromToolsInput(toolsInput) === void 0 && tryGetCliVersionRangeFromToolsInput(toolsInput) === void 0) { logger.info(`Using CodeQL CLI from local path ${toolsInput}`); const compressionMethod2 = inferCompressionMethod(toolsInput); if (compressionMethod2 === void 0) { @@ -151353,6 +151462,64 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO ); } toolsInput = await getNightlyToolsUrl(logger); + } else if (toolsInput !== void 0 && tryGetNightlyUntilDefaultToolsInput(toolsInput) !== void 0) { + const rawThreshold = tryGetNightlyUntilDefaultToolsInput(toolsInput); + const threshold = parseNightlyUntilThreshold(toolsInput, rawThreshold); + const defaultSource = await getCodeQLSource( + void 0, + defaultCliVersion, + rawLanguages, + useOverlayAwareDefaultCliVersion, + apiDetails, + variant, + tarSupportsZstd, + features, + logger + ); + const resolvedVersion = "cliVersion" in defaultSource ? defaultSource.cliVersion : defaultSource.toolsVersion; + if (resolvedVersion !== void 0 && semver9.valid(resolvedVersion) !== null && semver9.gte(resolvedVersion, threshold)) { + logger.info( + `'tools: ${toolsInput}' was requested, so using the default CodeQL version, since the resolved default CodeQL version ${resolvedVersion} satisfies the version threshold of ${threshold}.` + ); + return defaultSource; + } else { + logger.info( + `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since the resolved default CodeQL version${resolvedVersion !== void 0 ? ` ${resolvedVersion}` : ""} does not satisfy the version threshold of ${threshold}.` + ); + toolsInput = await getNightlyToolsUrl(logger); + } + } else if (toolsInput !== void 0 && isLatestPrereleaseToolsInput(toolsInput)) { + const sortedVersions = await getSortedCliVersionsIncludingPrereleases( + variant, + logger + ); + if (sortedVersions.length === 0) { + throw new ConfigurationError( + `'tools: ${toolsInput}' was requested, but no CodeQL CLI releases could be found.` + ); + } + logger.info( + `'tools: ${toolsInput}' was requested, so using CodeQL version ${sortedVersions[0]}, the most recent CodeQL CLI release, which may be a prerelease.` + ); + toolsInput = sortedVersions[0]; + } else if (toolsInput !== void 0 && tryGetNightlyUntilToolsInput(toolsInput) !== void 0) { + const rawThreshold = tryGetNightlyUntilToolsInput(toolsInput); + const threshold = parseNightlyUntilThreshold(toolsInput, rawThreshold); + const sortedVersions = await getSortedCliVersionsIncludingPrereleases( + variant, + logger + ); + if (sortedVersions.length > 0 && semver9.gte(sortedVersions[0], threshold)) { + logger.info( + `'tools: ${toolsInput}' was requested, so using CodeQL version ${sortedVersions[0]}, since it satisfies the version threshold of ${threshold}.` + ); + toolsInput = sortedVersions[0]; + } else { + logger.info( + `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since no eligible CodeQL CLI release satisfies the version threshold of ${threshold}.` + ); + toolsInput = await getNightlyToolsUrl(logger); + } } const forceShippedTools = toolsInput && CODEQL_BUNDLE_VERSION_ALIAS.includes(toolsInput); if (forceShippedTools) { @@ -151398,6 +151565,43 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO cliVersion2 = version.cliVersion; tagName = version.tagName; } + } else if (toolsInput !== void 0 && tryGetCliVersionFromToolsInput(toolsInput) !== void 0) { + cliVersion2 = tryGetCliVersionFromToolsInput(toolsInput); + tagName = `codeql-bundle-v${cliVersion2}`; + logger.info( + `'tools: ${toolsInput}' was requested, so using CodeQL version ${cliVersion2}.` + ); + } else if (toolsInput !== void 0 && tryGetLatestOffsetFromToolsInput(toolsInput) !== void 0) { + const offset = tryGetLatestOffsetFromToolsInput(toolsInput); + const sortedVersions = await getSortedStableCliVersions(variant, logger); + if (offset >= sortedVersions.length) { + throw new ConfigurationError( + `'tools: ${toolsInput}' was requested, but only ${sortedVersions.length} stable CodeQL CLI release(s) could be found.${describeAvailableCliVersionRange( + sortedVersions + )}` + ); + } + cliVersion2 = sortedVersions[offset]; + tagName = `codeql-bundle-v${cliVersion2}`; + logger.info( + `'tools: ${toolsInput}' was requested, so using CodeQL version ${cliVersion2}, which is ${offset === 0 ? "the most recent stable CodeQL CLI release." : `${offset} stable CodeQL CLI release(s) before the most recent one.`}` + ); + } else if (toolsInput !== void 0 && tryGetCliVersionRangeFromToolsInput(toolsInput) !== void 0) { + const range2 = tryGetCliVersionRangeFromToolsInput(toolsInput); + const sortedVersions = await getSortedStableCliVersions(variant, logger); + const resolvedVersion = semver9.maxSatisfying(sortedVersions, range2); + if (!resolvedVersion) { + throw new ConfigurationError( + `'tools: ${toolsInput}' was requested, but no stable CodeQL CLI release satisfying that version range could be found.${describeAvailableCliVersionRange( + sortedVersions + )}` + ); + } + cliVersion2 = resolvedVersion; + tagName = `codeql-bundle-v${cliVersion2}`; + logger.info( + `'tools: ${toolsInput}' was requested, so using CodeQL version ${cliVersion2}, the most recent stable CodeQL CLI release satisfying that version range.` + ); } else if (toolsInput !== void 0) { tagName = tryGetTagNameFromUrl(toolsInput, logger); url2 = toolsInput; diff --git a/setup-codeql/action.yml b/setup-codeql/action.yml index 8d13eeaff0..208a014f06 100644 --- a/setup-codeql/action.yml +++ b/setup-codeql/action.yml @@ -10,6 +10,24 @@ inputs: - A local path to a CodeQL Bundle tarball, or - The URL of a CodeQL Bundle tarball GitHub release asset, or + - A CodeQL CLI version number, for example `2.19.0` or `v2.19.0`, in which case the CodeQL + Bundle containing that version will be downloaded, selecting the archive format and + release asset that matches the runner's operating system and architecture, or + - A CodeQL CLI version range, for example `2.24.x`, `2.x`, `~2.24.0`, or `^2.24.0`, in + which case the most recent stable CodeQL Bundle release satisfying that range will be + downloaded, or + - `latest-`, for example `latest-1` or `LATEST-2` (matching is case insensitive), which + uses the CodeQL Bundle release `N` stable releases before the most recent one, or + - A special value `latest-prerelease` (matching is case insensitive) which uses the most + recent CodeQL Bundle release, whether it is a stable release or a GitHub prerelease, or + - `nightly-until-`, for example `nightly-until-2.24.0` or + `NIGHTLY-UNTIL-2.24.0` (matching is case insensitive), which uses the most recent CodeQL + Bundle release satisfying that version threshold, considering GitHub prereleases too, or + falls back to the latest nightly version of the CodeQL tools if no such release exists, or + - `nightly-until-default-`, for example `nightly-until-default-2.24.0` (matching + is case insensitive), which uses the default CodeQL CLI version if it satisfies that + version threshold, or falls back to the latest nightly version of the CodeQL tools + otherwise, or - A special value `linked` which uses the version of the CodeQL tools that the Action has been bundled with. - A special value `nightly` which uses the latest nightly version of the diff --git a/src/api-client.test.ts b/src/api-client.test.ts index ae8c6269b1..e36d2a215a 100644 --- a/src/api-client.test.ts +++ b/src/api-client.test.ts @@ -46,6 +46,30 @@ test.serial("getApiClient", async (t) => { ); }); +test.serial("getUnauthenticatedApiClientForDotcom", async (t) => { + const pluginStub: sinon.SinonStub = sinon.stub(githubUtils.GitHub, "plugin"); + const githubStub: sinon.SinonStub = sinon.stub(); + pluginStub.returns(githubStub); + + const apiClient = api.getUnauthenticatedApiClientForDotcom(); + t.truthy(apiClient); + + t.true(githubStub.calledOnce); + // No `auth` should be set: this client must be unauthenticated, and it must always target + // GitHub.com's API, regardless of which GitHub instance the Action itself is running on. + t.assert( + githubStub.calledOnceWithExactly({ + baseUrl: "https://api.github.com", + log: sinon.match.any, + userAgent: `CodeQL-Action/${actionsUtil.getActionVersion()}`, + request: sinon.match.any, + retry: { + doNotRetry: DO_NOT_RETRY_STATUSES, + }, + }), + ); +}); + function mockGetMetaVersionHeader( versionHeader: string | undefined, ): sinon.SinonStub { diff --git a/src/api-client.ts b/src/api-client.ts index ba800a2587..94c428031a 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -27,6 +27,7 @@ import { asHTTPError, ConfigurationError, getRequiredEnvParam, + GITHUB_DOTCOM_API_URL, GITHUB_DOTCOM_URL, GitHubVariant, GitHubVersion, @@ -181,6 +182,38 @@ export function getApiClientWithExternalAuth( return createApiClientWithDetails(apiDetails, { allowExternal: true, proxy }); } +/** + * Gets an unauthenticated API client scoped to GitHub.com, for use when working with resources + * that only ever exist on GitHub.com -- such as the canonical CodeQL Action repository's release + * history -- regardless of which GitHub instance this Action itself is running on. + * + * This must be used instead of `getApiClient`/`getApiClientWithExternalAuth` whenever the current + * GitHub instance is not GitHub.com (for example GitHub Enterprise Server or GHEC with data + * residency), since in that case the current instance's authentication token cannot authenticate + * to GitHub.com. Sending it there anyway would both fail and needlessly expose that token to a + * different host. Reading public data, such as the release history of a public repository, does + * not require authentication. + */ +export function getUnauthenticatedApiClientForDotcom( + proxy?: ProxyAgent, +): ApiClient { + const retryingOctokit = githubUtils.GitHub.plugin(retry.retry); + return new retryingOctokit({ + baseUrl: GITHUB_DOTCOM_API_URL, + userAgent: `CodeQL-Action/${getActionVersion()}`, + log: { + debug: core.debug, + info: core.info, + warn: core.warning, + error: core.error, + }, + request: makeProxyRequestOptions(proxy), + retry: { + doNotRetry: DO_NOT_RETRY_STATUSES, + }, + }); +} + /** * Gets a value for the `Authorization` header for a request to `url`; or `undefined` if the * `Authorization` header should not be set for `url`. diff --git a/src/setup-codeql.test.ts b/src/setup-codeql.test.ts index 219e39984c..7a4f0d0577 100644 --- a/src/setup-codeql.test.ts +++ b/src/setup-codeql.test.ts @@ -1,3 +1,4 @@ +import * as fs from "fs"; import * as path from "path"; import * as github from "@actions/github"; @@ -18,6 +19,7 @@ import { LoggedMessage, SAMPLE_DEFAULT_CLI_VERSION, SAMPLE_DOTCOM_API_DETAILS, + SAMPLE_GHES_API_DETAILS, checkExpectedLogMessages, createFeatures, createTestConfig, @@ -28,6 +30,7 @@ import { setupTests, } from "./testing-utils"; import { + ConfigurationError, getErrorMessage, GitHubVariant, initializeEnvironment, @@ -117,6 +120,61 @@ test.serial( }, ); +/** + * A URL should never be misidentified as a bare CLI version number or a version range, even when + * it contains version-like path segments, since neither `semver.valid` nor `semver.validRange` + * can ever match a string containing `://`. + */ +const URL_NOT_MISTAKEN_FOR_VERSION_TEST_CASES = [ + { + name: "a bundle URL without a recognizable bundle tag", + toolsInput: "https://example.com/assets/codeql-bundle-linux64.tar.gz", + }, + { + name: "a bundle URL containing a bare-version-like path segment", + toolsInput: + "https://example.com/download/2.19.0/codeql-bundle-linux64.tar.gz", + }, + { + name: "a bundle URL containing a version-range-like path segment", + toolsInput: + "https://example.com/download/2.24.x/codeql-bundle-linux64.tar.gz", + }, +] as const; + +for (const { name, toolsInput } of URL_NOT_MISTAKEN_FOR_VERSION_TEST_CASES) { + test.serial( + `getCodeQLSource resolves ${name} as a URL, not a version or range`, + async (t) => { + const features = createFeatures([]); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + toolsInput, + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + getRunnerLogger(true), + ); + + t.is(source.sourceType, "download"); + if (source.sourceType === "download") { + t.is(source.codeqlURL, toolsInput); + t.is(source.compressionMethod, "gzip"); + } + // Neither a bare CLI version number nor a version range was detected: the bundle tag + // could not be determined from the URL, so no CLI version is known. + t.is(source["cliVersion"], undefined); + }); + }, + ); +} + const LINKED_BUNDLE_TEST_CASES = [ { platform: "linux", @@ -181,6 +239,1154 @@ for (const { ); } +const CLI_VERSION_TOOLS_INPUT_TEST_CASES = [ + { + toolsInput: "2.20.1", + platform: "linux", + tarSupportsZstd: true, + expectedBundleName: "codeql-bundle-linux64.tar.zst", + expectedCompressionMethod: "zstd", + }, + { + toolsInput: "v2.20.1", + platform: "darwin", + tarSupportsZstd: true, + expectedBundleName: "codeql-bundle-osx64.tar.zst", + expectedCompressionMethod: "zstd", + }, + { + toolsInput: "v2.20.1", + platform: "win32", + tarSupportsZstd: true, + expectedBundleName: "codeql-bundle-win64.tar.gz", + expectedCompressionMethod: "gzip", + }, + { + toolsInput: "2.20.1", + platform: "linux", + tarSupportsZstd: false, + expectedBundleName: "codeql-bundle-linux64.tar.gz", + expectedCompressionMethod: "gzip", + }, + { + // CodeQL versions older than 2.19.0 don't have zstd bundles, so gzip should be selected + // even though the runner supports zstd. + toolsInput: "v2.18.4", + platform: "linux", + tarSupportsZstd: true, + expectedBundleName: "codeql-bundle-linux64.tar.gz", + expectedCompressionMethod: "gzip", + }, +] as const; + +for (const { + toolsInput, + platform, + tarSupportsZstd, + expectedBundleName, + expectedCompressionMethod, +} of CLI_VERSION_TOOLS_INPUT_TEST_CASES) { + test.serial( + `getCodeQLSource selects ${expectedBundleName} for 'tools: ${toolsInput}'`, + async (t) => { + const features = createFeatures([]); + sinon.stub(process, "platform").value(platform); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + toolsInput, + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + tarSupportsZstd, + features, + getRunnerLogger(true), + ); + + const expectedCliVersion = toolsInput.replace(/^v/, ""); + t.is(source.toolsVersion, expectedCliVersion); + t.is(source["cliVersion"], expectedCliVersion); + t.is(source.sourceType, "download"); + if (source.sourceType === "download") { + t.is(source.compressionMethod, expectedCompressionMethod); + t.true(source.codeqlURL.endsWith(`/${expectedBundleName}`)); + t.true( + source.codeqlURL.includes(`/codeql-bundle-v${expectedCliVersion}/`), + ); + } + }); + }, + ); +} + +test.serial( + "getCodeQLSource logs a message when given a bare CLI version number", + async (t) => { + const loggedMessages: LoggedMessage[] = []; + const logger = getRecordingLogger(loggedMessages); + const features = createFeatures([]); + + sinon.stub(process, "platform").value("linux"); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + "v2.20.1", + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + true, + features, + logger, + ); + + t.is(source.sourceType, "download"); + t.is(source.toolsVersion, "2.20.1"); + + checkExpectedLogMessages(t, loggedMessages, [ + "'tools: v2.20.1' was requested, so using CodeQL version 2.20.1.", + ]); + }); + }, +); + +test.serial( + "getCodeQLSource still resolves a local tarball path when the path looks like a version-ish string", + async (t) => { + const features = createFeatures([]); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const localPath = "/path/to/codeql-bundle-2.20.1.tar.gz"; + const source = await setupCodeql.getCodeQLSource( + localPath, + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + getRunnerLogger(true), + ); + + t.is(source.sourceType, "local"); + if (source.sourceType === "local") { + t.is(source.codeqlTarPath, localPath); + t.is(source.compressionMethod, "gzip"); + } + }); + }, +); + +/** + * A representative set of CodeQL bundle releases, as well as some non-bundle releases and + * non-stable bundle releases, used to test resolution of the `latest-` and semantic version + * range forms of the `tools` input. + * + * The stable bundle releases are deliberately non-contiguous: `2.25.2` and `2.24.1` do not exist, + * so that tests can confirm that `latest-` and version ranges are resolved against the actual + * release history, rather than by decrementing the patch version of the most recent release. + */ +const STABLE_BUNDLE_RELEASES_TEST_SET = [ + // A release of the Action itself, which does not represent a CodeQL bundle and should be + // ignored. + { tag_name: "v4.30.0", prerelease: false, draft: false }, + // A prerelease CodeQL bundle, which should be ignored. + { tag_name: "codeql-bundle-v2.26.0", prerelease: true, draft: false }, + // A draft CodeQL bundle, which should be ignored. + { tag_name: "codeql-bundle-v2.23.9", prerelease: false, draft: true }, + { tag_name: "codeql-bundle-v2.25.3", prerelease: false, draft: false }, + { tag_name: "codeql-bundle-v2.25.1", prerelease: false, draft: false }, + { tag_name: "codeql-bundle-v2.24.2", prerelease: false, draft: false }, + { tag_name: "codeql-bundle-v2.24.0", prerelease: false, draft: false }, +]; + +function mockListStableCodeQLBundleReleases( + releases: unknown[] = STABLE_BUNDLE_RELEASES_TEST_SET, +) { + const client = github.getOctokit("123"); + const listReleases = sinon.stub(client.rest.repos, "listReleases"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + listReleases.resolves({ + data: releases, + } as any); + sinon.stub(api, "getApiClient").value(() => client); + return listReleases; +} + +/** + * As `mockListStableCodeQLBundleReleases`, but additionally mocks the CodeQL nightlies + * repository's release list, so that fallback to the latest nightly bundle can be tested when no + * release satisfies a `nightly-until-` version threshold. + */ +function mockListCodeQLBundleReleasesWithNightlyFallback( + releases: unknown[], + nightlyTagName: string, +) { + const listReleases = mockListStableCodeQLBundleReleases(releases); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + listReleases.withArgs(sinon.match({ owner: "dsp-testing" })).resolves({ + data: [{ tag_name: nightlyTagName }], + } as any); +} + +/** + * As `mockListStableCodeQLBundleReleases`, but for use when `getCodeQLSource` is called with a + * non-dotcom `variant` (for example `GitHubVariant.GHES` or `GitHubVariant.GHEC_DR`). The + * canonical CodeQL Action repository's release history only ever exists on GitHub.com, so in that + * case we must mock the unauthenticated GitHub.com client (`getUnauthenticatedApiClientForDotcom`) + * rather than the current instance's client (`getApiClient`). + * + * The current instance's client is also mocked here, but to return a different set of bogus + * releases and to fail to find the bundle by tag, so that tests using this helper fail if + * `getSortedCliVersions` regresses to (incorrectly) querying the current GitHub instance, or if + * `getCodeQLBundleDownloadURL`'s unrelated, pre-existing attempt to look up the bundle on the + * current instance is not handled gracefully. + */ +function mockListStableCodeQLBundleReleasesForNonDotcomVariant( + releases: unknown[] = STABLE_BUNDLE_RELEASES_TEST_SET, +) { + const dotcomClient = github.getOctokit("123"); + const listReleases = sinon.stub(dotcomClient.rest.repos, "listReleases"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + listReleases.resolves({ + data: releases, + } as any); + sinon + .stub(api, "getUnauthenticatedApiClientForDotcom") + .value(() => dotcomClient); + + const currentInstanceClient = github.getOctokit("456"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + sinon.stub(currentInstanceClient.rest.repos, "listReleases").resolves({ + data: [ + { tag_name: "codeql-bundle-v9.9.9", prerelease: false, draft: false }, + ], + } as any); + sinon + .stub(currentInstanceClient.rest.repos, "getReleaseByTag") + .rejects(new Error("Not Found")); + sinon.stub(api, "getApiClient").value(() => currentInstanceClient); + + return listReleases; +} + +const LATEST_OFFSET_TOOLS_INPUT_TEST_CASES = [ + { toolsInput: "latest-0", expectedCliVersion: "2.25.3" }, + { toolsInput: "latest-1", expectedCliVersion: "2.25.1" }, + { toolsInput: "LATEST-2", expectedCliVersion: "2.24.2" }, + { toolsInput: "latest-3", expectedCliVersion: "2.24.0" }, +] as const; + +for (const { + toolsInput, + expectedCliVersion, +} of LATEST_OFFSET_TOOLS_INPUT_TEST_CASES) { + test.serial( + `getCodeQLSource resolves 'tools: ${toolsInput}' to CodeQL version ${expectedCliVersion}`, + async (t) => { + const features = createFeatures([]); + sinon.stub(process, "platform").value("linux"); + mockListStableCodeQLBundleReleases(); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + toolsInput, + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + getRunnerLogger(true), + ); + + t.is(source.sourceType, "download"); + t.is(source.toolsVersion, expectedCliVersion); + t.is(source["cliVersion"], expectedCliVersion); + }); + }, + ); +} + +test.serial( + "getCodeQLSource throws when 'latest-' requests more stable releases than exist", + async (t) => { + const features = createFeatures([]); + mockListStableCodeQLBundleReleases(); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const error = await t.throwsAsync( + async () => + await setupCodeql.getCodeQLSource( + "latest-99", + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + getRunnerLogger(true), + ), + { instanceOf: ConfigurationError }, + ); + // The error should mention the oldest and newest available releases, to help distinguish + // a request that is out of range from other configuration mistakes. + t.true( + error.message.includes( + "Available stable CodeQL CLI releases range from 2.24.0 to 2.25.3.", + ), + ); + }); + }, +); + +const CLI_VERSION_RANGE_TOOLS_INPUT_TEST_CASES = [ + { toolsInput: "2.24.x", expectedCliVersion: "2.24.2" }, + { toolsInput: "2.x", expectedCliVersion: "2.25.3" }, + { toolsInput: "~2.24.0", expectedCliVersion: "2.24.2" }, + { toolsInput: "^2.24.0", expectedCliVersion: "2.25.3" }, +] as const; + +for (const { + toolsInput, + expectedCliVersion, +} of CLI_VERSION_RANGE_TOOLS_INPUT_TEST_CASES) { + test.serial( + `getCodeQLSource resolves 'tools: ${toolsInput}' to CodeQL version ${expectedCliVersion}`, + async (t) => { + const features = createFeatures([]); + sinon.stub(process, "platform").value("linux"); + mockListStableCodeQLBundleReleases(); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + toolsInput, + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + getRunnerLogger(true), + ); + + t.is(source.sourceType, "download"); + t.is(source.toolsVersion, expectedCliVersion); + t.is(source["cliVersion"], expectedCliVersion); + }); + }, + ); +} + +test.serial( + "getCodeQLSource throws when no stable release satisfies the requested version range", + async (t) => { + const features = createFeatures([]); + mockListStableCodeQLBundleReleases(); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const error = await t.throwsAsync( + async () => + await setupCodeql.getCodeQLSource( + "9.x", + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + getRunnerLogger(true), + ), + { instanceOf: ConfigurationError }, + ); + t.true( + error.message.includes( + "Available stable CodeQL CLI releases range from 2.24.0 to 2.25.3.", + ), + ); + }); + }, +); + +test.serial( + "getCodeQLSource throws a helpful error when a version range is older than any available release", + async (t) => { + const features = createFeatures([]); + mockListStableCodeQLBundleReleases(); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + // The oldest release in STABLE_BUNDLE_RELEASES_TEST_SET is 2.24.0, so a range entirely + // below that, such as this one modeled on a real user request for CodeQL 1.28.x, can never + // be satisfied. The error should clearly state the oldest available release, since older + // CodeQL bundles are tagged with a date, e.g. `codeql-bundle-20211208`, rather than a + // semantic version, and so are invisible to SemVer-based `tools` inputs. + const error = await t.throwsAsync( + async () => + await setupCodeql.getCodeQLSource( + "1.28.0 - 1.28.9", + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + getRunnerLogger(true), + ), + { instanceOf: ConfigurationError }, + ); + t.true( + error.message.includes( + "Available stable CodeQL CLI releases range from 2.24.0 to 2.25.3.", + ), + ); + }); + }, +); + +/** + * A set of CodeQL bundle releases that includes GitHub prereleases, used to test resolution of + * the `latest-prerelease` and `nightly-until-` forms of the `tools` input in the case + * where the newest release overall is a stable release, even though prereleases exist. + * `STABLE_BUNDLE_RELEASES_TEST_SET` above covers the opposite case, where the + * newest release overall is a prerelease. + */ +const PRERELEASE_BUNDLE_RELEASES_TEST_SET = [ + // A draft CodeQL bundle newer than every other release here, which should be ignored: if drafts + // were not correctly excluded, this would incorrectly be selected as the newest release. + { tag_name: "codeql-bundle-v2.28.0", prerelease: false, draft: true }, + // An old-style, date-tagged bundle, which has no semantic version and should be ignored. + { tag_name: "codeql-bundle-20211208", prerelease: false, draft: false }, + { tag_name: "codeql-bundle-v2.27.0", prerelease: false, draft: false }, + { tag_name: "codeql-bundle-v2.26.3", prerelease: true, draft: false }, + { tag_name: "codeql-bundle-v2.26.2", prerelease: false, draft: false }, + { tag_name: "codeql-bundle-v2.25.0", prerelease: true, draft: false }, + { tag_name: "codeql-bundle-v2.24.0", prerelease: false, draft: false }, +]; + +const LATEST_PRERELEASE_TOOLS_INPUT_TEST_CASES = [ + { + name: "the newest release is a prerelease", + toolsInput: "latest-prerelease", + releases: STABLE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.26.0", + }, + { + name: "the newest release is stable, even though prereleases exist", + toolsInput: "LATEST-PRERELEASE", + releases: PRERELEASE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.27.0", + }, +] as const; + +for (const { + name, + toolsInput, + releases, + expectedCliVersion, +} of LATEST_PRERELEASE_TOOLS_INPUT_TEST_CASES) { + test.serial( + `getCodeQLSource resolves 'tools: ${toolsInput}' to CodeQL version ${expectedCliVersion} when ${name}`, + async (t) => { + const features = createFeatures([]); + sinon.stub(process, "platform").value("linux"); + mockListStableCodeQLBundleReleases(releases); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + toolsInput, + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + getRunnerLogger(true), + ); + + t.is(source.sourceType, "download"); + t.is(source.toolsVersion, expectedCliVersion); + t.is(source["cliVersion"], expectedCliVersion); + }); + }, + ); +} + +test.serial( + "getCodeQLSource throws when 'latest-prerelease' is requested but no CodeQL CLI releases can be found", + async (t) => { + const features = createFeatures([]); + mockListStableCodeQLBundleReleases([ + // A release of the Action itself, not a CodeQL bundle. + { tag_name: "v4.30.0", prerelease: false, draft: false }, + // A draft CodeQL bundle, which should be ignored, leaving no eligible release. + { tag_name: "codeql-bundle-v9.9.9", prerelease: false, draft: true }, + ]); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const error = await t.throwsAsync( + async () => + await setupCodeql.getCodeQLSource( + "latest-prerelease", + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + getRunnerLogger(true), + ), + { instanceOf: ConfigurationError }, + ); + t.true( + error.message.includes( + "'tools: latest-prerelease' was requested, but no CodeQL CLI releases could be found.", + ), + ); + }); + }, +); + +const NIGHTLY_UNTIL_RESOLVES_TOOLS_INPUT_TEST_CASES = [ + { + name: "threshold exactly matches the newest release, which is a prerelease", + toolsInput: "nightly-until-2.26.0", + releases: STABLE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.26.0", + }, + { + name: "the newest release comfortably exceeds the threshold", + toolsInput: "nightly-until-2.20.0", + releases: STABLE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.26.0", + }, + { + name: "a prerelease is selected since it is the newest release satisfying the threshold, even though no stable release would satisfy it", + toolsInput: "nightly-until-2.25.4", + releases: STABLE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.26.0", + }, + { + name: "matching is case insensitive", + toolsInput: "NIGHTLY-UNTIL-2.25.4", + releases: STABLE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.26.0", + }, + { + name: "drafts and date-tagged releases are excluded even when they would otherwise be the newest", + toolsInput: "nightly-until-2.20.0", + releases: PRERELEASE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.27.0", + }, +] as const; + +for (const { + name, + toolsInput, + releases, + expectedCliVersion, +} of NIGHTLY_UNTIL_RESOLVES_TOOLS_INPUT_TEST_CASES) { + test.serial( + `getCodeQLSource resolves 'tools: ${toolsInput}' to CodeQL version ${expectedCliVersion}: ${name}`, + async (t) => { + const features = createFeatures([]); + sinon.stub(process, "platform").value("linux"); + mockListStableCodeQLBundleReleases(releases); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + toolsInput, + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + getRunnerLogger(true), + ); + + t.is(source.sourceType, "download"); + t.is(source.toolsVersion, expectedCliVersion); + t.is(source["cliVersion"], expectedCliVersion); + }); + }, + ); +} + +/** + * `nightly-until-default-` compares the given version threshold against the CLI version + * that would actually be used if `tools` had not been specified at all, i.e. after normal + * (possibly overlay-aware) default CLI version selection and any applicable overrides, without + * ever listing CodeQL bundle releases. In these test cases, that resolved version is + * `SAMPLE_DEFAULT_CLI_VERSION`'s CLI version, `2.20.0`. When the resolved version is at or above + * the threshold, resolution should proceed exactly as if `tools` were not specified at all. + */ +const NIGHTLY_UNTIL_DEFAULT_RESUMES_DEFAULT_TOOLS_INPUT_TEST_CASES = [ + { + name: "the default CLI version exactly matches the threshold", + toolsInput: "nightly-until-default-2.20.0", + }, + { + name: "the default CLI version exceeds the threshold", + toolsInput: "nightly-until-default-2.19.0", + }, + { + name: "matching is case insensitive", + toolsInput: "NIGHTLY-UNTIL-DEFAULT-2.20.0", + }, +] as const; + +for (const { + name, + toolsInput, +} of NIGHTLY_UNTIL_DEFAULT_RESUMES_DEFAULT_TOOLS_INPUT_TEST_CASES) { + test.serial( + `getCodeQLSource resumes normal default CLI version selection for 'tools: ${toolsInput}', since ${name}`, + async (t) => { + const loggedMessages: LoggedMessage[] = []; + const logger = getRecordingLogger(loggedMessages); + const features = createFeatures([]); + + // No release-list API request should be made, so if one is attempted, the test will fail. + const client = github.getOctokit("123"); + const listReleases = sinon.stub(client.rest.repos, "listReleases"); + sinon.stub(api, "getApiClient").value(() => client); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + toolsInput, + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + logger, + ); + + t.is( + source.toolsVersion, + SAMPLE_DEFAULT_CLI_VERSION.enabledVersions[0].cliVersion, + ); + t.true(listReleases.notCalled); + checkExpectedLogMessages(t, loggedMessages, [ + `'tools: ${toolsInput}' was requested, so using the default CodeQL version`, + ]); + }); + }, + ); +} + +test.serial( + "getCodeQLSource falls back to the latest nightly bundle for 'tools: nightly-until-default-', since the default CLI version does not satisfy the threshold", + async (t) => { + const loggedMessages: LoggedMessage[] = []; + const logger = getRecordingLogger(loggedMessages); + const features = createFeatures([]); + + const expectedDate = "30260213"; + const expectedTag = `codeql-bundle-${expectedDate}`; + + // Ensure that we consistently select "zstd" for the test. + sinon.stub(process, "platform").value("linux"); + sinon.stub(tar, "isZstdAvailable").resolves({ + available: true, + foundZstdBinary: true, + }); + + // No release-list API request for the canonical CodeQL Action repository should be made; + // only the nightly repository's release list should be queried, via the same authenticated + // client used elsewhere in this file. + const client = github.getOctokit("123"); + const listReleases = sinon.stub(client.rest.repos, "listReleases"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + listReleases.resolves({ + data: [{ tag_name: expectedTag }], + } as any); + sinon.stub(api, "getApiClient").value(() => client); + + const toolsInput = "nightly-until-default-2.21.0"; + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + toolsInput, + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + logger, + ); + + const expectedVersion = `0.0.0-${expectedDate}`; + const expectedURL = `https://github.com/dsp-testing/codeql-cli-nightlies/releases/download/${expectedTag}/${setupCodeql.getCodeQLBundleName("zstd")}`; + t.deepEqual(source, { + bundleVersion: expectedDate, + cliVersion: undefined, + codeqlURL: expectedURL, + compressionMethod: "zstd", + sourceType: "download", + toolsVersion: expectedVersion, + } satisfies setupCodeql.CodeQLToolsSource); + + t.true( + listReleases.neverCalledWith( + sinon.match({ owner: "github", repo: "codeql-action" }), + ), + ); + checkExpectedLogMessages(t, loggedMessages, [ + `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since the`, + ]); + }); + }, +); + +const nightlyUntilDefaultOverlayEnabledVersions = { + enabledVersions: [ + { cliVersion: "2.20.2", tagName: "codeql-bundle-v2.20.2" }, + { cliVersion: "2.20.1", tagName: "codeql-bundle-v2.20.1" }, + { cliVersion: "2.20.0", tagName: "codeql-bundle-v2.20.0" }, + ], + toolsFeatureFlagsValid: true, +}; + +async function stubOverlayBaseCacheForNightlyUntilDefaultTests( + cliVersion: string, +) { + sinon.stub(api, "getAutomationID").resolves("test/"); + sinon.stub(api, "listActionsCaches").resolves([ + { + key: await fakeOverlayBaseCacheKey("javascript", cliVersion, "abc-1-1"), + }, + ]); + process.env[EnvVar.CODE_SCANNING_REF] = "refs/heads/feature-branch"; + process.env[EnvVar.CODE_SCANNING_BASE_BRANCH] = "main"; +} + +test.serial( + "getCodeQLSource stays on nightly for 'tools: nightly-until-default-' when the " + + "newest enabled default satisfies the threshold, but overlay-aware selection resolves to " + + "an older version that does not", + async (t) => { + const loggedMessages: LoggedMessage[] = []; + const logger = getRecordingLogger(loggedMessages); + const features = createFeatures([ + Feature.OverlayAnalysisMatchCodeqlVersion, + ]); + + const expectedDate = "30260213"; + const expectedTag = `codeql-bundle-${expectedDate}`; + + sinon.stub(process, "platform").value("linux"); + sinon.stub(tar, "isZstdAvailable").resolves({ + available: true, + foundZstdBinary: true, + }); + + const client = github.getOctokit("123"); + const listReleases = sinon.stub(client.rest.repos, "listReleases"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + listReleases.resolves({ + data: [{ tag_name: expectedTag }], + } as any); + sinon.stub(api, "getApiClient").value(() => client); + + // The overlay-base cache only has an entry for 2.20.1, which is below the 2.20.2 threshold, + // even though the newest enabled default version, 2.20.2, satisfies it. + await stubOverlayBaseCacheForNightlyUntilDefaultTests("2.20.1"); + sinon + .stub(toolcache, "find") + .withArgs("CodeQL", "2.20.1") + .returns("/path/to/codeql-2.20.1"); + + const toolsInput = "nightly-until-default-2.20.2"; + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + toolsInput, + nightlyUntilDefaultOverlayEnabledVersions, + ["javascript"], + true, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + logger, + ); + + t.is(source.sourceType, "download"); + t.true( + listReleases.neverCalledWith( + sinon.match({ owner: "github", repo: "codeql-action" }), + ), + ); + checkExpectedLogMessages(t, loggedMessages, [ + `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since the ` + + "resolved default CodeQL version 2.20.1 does not satisfy the version threshold", + ]); + }); + }, +); + +test.serial( + "getCodeQLSource resumes normal default CLI version selection for 'tools: " + + "nightly-until-default-' when overlay-aware selection resolves to a version that " + + "satisfies the threshold", + async (t) => { + const loggedMessages: LoggedMessage[] = []; + const logger = getRecordingLogger(loggedMessages); + const features = createFeatures([ + Feature.OverlayAnalysisMatchCodeqlVersion, + ]); + + const client = github.getOctokit("123"); + const listReleases = sinon.stub(client.rest.repos, "listReleases"); + sinon.stub(api, "getApiClient").value(() => client); + + await stubOverlayBaseCacheForNightlyUntilDefaultTests("2.20.1"); + sinon + .stub(toolcache, "find") + .withArgs("CodeQL", "2.20.1") + .returns("/path/to/codeql-2.20.1"); + + const toolsInput = "nightly-until-default-2.20.1"; + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + toolsInput, + nightlyUntilDefaultOverlayEnabledVersions, + ["javascript"], + true, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + logger, + ); + + t.is(source.sourceType, "toolcache"); + t.is(source.toolsVersion, "2.20.1"); + t.true(listReleases.notCalled); + checkExpectedLogMessages(t, loggedMessages, [ + `'tools: ${toolsInput}' was requested, so using the default CodeQL version, since the ` + + "resolved default CodeQL version 2.20.1 satisfies the version threshold", + ]); + }); + }, +); + +test.serial( + "getCodeQLSource stays on nightly for 'tools: nightly-until-default-' on GHES when " + + "the newest enabled default satisfies the threshold, but a pinned toolcache override " + + "resolves to an older version that does not", + async (t) => { + const loggedMessages: LoggedMessage[] = []; + const logger = getRecordingLogger(loggedMessages); + const features = createFeatures([]); + + const expectedDate = "30260213"; + const expectedTag = `codeql-bundle-${expectedDate}`; + + sinon.stub(process, "platform").value("linux"); + sinon.stub(tar, "isZstdAvailable").resolves({ + available: true, + foundZstdBinary: true, + }); + + const client = github.getOctokit("123"); + const listReleases = sinon.stub(client.rest.repos, "listReleases"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + listReleases.resolves({ + data: [{ tag_name: expectedTag }], + } as any); + sinon.stub(api, "getApiClient").value(() => client); + + const toolsInput = "nightly-until-default-2.20.2"; + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + + // Nothing in the toolcache matches the resolved default version 2.20.2, but a pinned + // (overriding) version 2.20.1 is present, which is below the threshold. + const pinnedFolder = path.join(tmpDir, "pinned-codeql-2.20.1"); + fs.mkdirSync(pinnedFolder, { recursive: true }); + fs.writeFileSync(path.join(pinnedFolder, "pinned-version"), ""); + sinon.stub(toolcache, "findAllVersions").returns(["2.20.1"]); + sinon + .stub(toolcache, "find") + .withArgs("CodeQL", "2.20.1") + .returns(pinnedFolder); + + const source = await setupCodeql.getCodeQLSource( + toolsInput, + nightlyUntilDefaultOverlayEnabledVersions, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_GHES_API_DETAILS, + GitHubVariant.GHES, + false, + features, + logger, + ); + + t.is(source.sourceType, "download"); + t.true( + listReleases.neverCalledWith( + sinon.match({ owner: "github", repo: "codeql-action" }), + ), + ); + checkExpectedLogMessages(t, loggedMessages, [ + `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since the ` + + "resolved default CodeQL version 2.20.1 does not satisfy the version threshold", + ]); + }); + }, +); + +test.serial( + "getCodeQLSource resumes normal default CLI version selection for 'tools: " + + "nightly-until-default-' on GHES when a pinned toolcache override resolves to a " + + "version that satisfies the threshold", + async (t) => { + const loggedMessages: LoggedMessage[] = []; + const logger = getRecordingLogger(loggedMessages); + const features = createFeatures([]); + + const client = github.getOctokit("123"); + const listReleases = sinon.stub(client.rest.repos, "listReleases"); + sinon.stub(api, "getApiClient").value(() => client); + + const toolsInput = "nightly-until-default-2.20.1"; + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + + const pinnedFolder = path.join(tmpDir, "pinned-codeql-2.20.1"); + fs.mkdirSync(pinnedFolder, { recursive: true }); + fs.writeFileSync(path.join(pinnedFolder, "pinned-version"), ""); + sinon.stub(toolcache, "findAllVersions").returns(["2.20.1"]); + sinon + .stub(toolcache, "find") + .withArgs("CodeQL", "2.20.1") + .returns(pinnedFolder); + + const source = await setupCodeql.getCodeQLSource( + toolsInput, + nightlyUntilDefaultOverlayEnabledVersions, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_GHES_API_DETAILS, + GitHubVariant.GHES, + false, + features, + logger, + ); + + t.is(source.sourceType, "toolcache"); + t.is(source.toolsVersion, "2.20.1"); + t.true(listReleases.notCalled); + checkExpectedLogMessages(t, loggedMessages, [ + `'tools: ${toolsInput}' was requested, so using the default CodeQL version, since the ` + + "resolved default CodeQL version 2.20.1 satisfies the version threshold", + ]); + }); + }, +); + +const NIGHTLY_UNTIL_FALLBACK_TOOLS_INPUT_TEST_CASES = [ + { + name: "no release, stable or prerelease, satisfies the threshold", + toolsInput: "nightly-until-9.0.0", + }, +] as const; + +for (const { + name, + toolsInput, +} of NIGHTLY_UNTIL_FALLBACK_TOOLS_INPUT_TEST_CASES) { + test.serial( + `getCodeQLSource falls back to the latest nightly bundle for 'tools: ${toolsInput}', since ${name}`, + async (t) => { + const loggedMessages: LoggedMessage[] = []; + const logger = getRecordingLogger(loggedMessages); + const features = createFeatures([]); + + const expectedDate = "30260213"; + const expectedTag = `codeql-bundle-${expectedDate}`; + + // Ensure that we consistently select "zstd" for the test. + sinon.stub(process, "platform").value("linux"); + sinon.stub(tar, "isZstdAvailable").resolves({ + available: true, + foundZstdBinary: true, + }); + mockListCodeQLBundleReleasesWithNightlyFallback( + STABLE_BUNDLE_RELEASES_TEST_SET, + expectedTag, + ); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + toolsInput, + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + logger, + ); + + const expectedVersion = `0.0.0-${expectedDate}`; + const expectedURL = `https://github.com/dsp-testing/codeql-cli-nightlies/releases/download/${expectedTag}/${setupCodeql.getCodeQLBundleName("zstd")}`; + t.deepEqual(source, { + bundleVersion: expectedDate, + cliVersion: undefined, + codeqlURL: expectedURL, + compressionMethod: "zstd", + sourceType: "download", + toolsVersion: expectedVersion, + } satisfies setupCodeql.CodeQLToolsSource); + + checkExpectedLogMessages(t, loggedMessages, [ + `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since no`, + ]); + }); + }, + ); +} + +const MALFORMED_NIGHTLY_UNTIL_THRESHOLD_TOOLS_INPUT_TEST_CASES = [ + { toolsInput: "nightly-until-bogus", expectedRawThreshold: "bogus" }, + { + toolsInput: "nightly-until-default-bogus", + expectedRawThreshold: "bogus", + }, +] as const; + +for (const { + toolsInput, + expectedRawThreshold, +} of MALFORMED_NIGHTLY_UNTIL_THRESHOLD_TOOLS_INPUT_TEST_CASES) { + test.serial( + `getCodeQLSource throws a configuration error for 'tools: ${toolsInput}', which has a malformed version threshold`, + async (t) => { + const features = createFeatures([]); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const error = await t.throwsAsync( + async () => + await setupCodeql.getCodeQLSource( + toolsInput, + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + false, + features, + getRunnerLogger(true), + ), + { instanceOf: ConfigurationError }, + ); + t.true( + error.message.includes( + `'${expectedRawThreshold}' is not a valid semantic version`, + ), + ); + }); + }, + ); +} + +/** + * `latest-`, SemVer ranges, `latest-prerelease`, and `nightly-until-` all need to + * look up the canonical CodeQL Action repository's + * release history. That repository only ever exists on GitHub.com, so on a non-dotcom `variant` + * (GHES or GHEC with data residency) this lookup must be made directly, and unauthenticated, + * against GitHub.com, rather than against the current GitHub instance as for all other API + * requests. Otherwise, the request would either fail outright (since the canonical repository + * typically does not exist on the current instance), or -- if a same-named repository happens to + * exist there -- could silently resolve to the wrong CodeQL CLI version. + */ +const NON_DOTCOM_VARIANT_TOOLS_INPUT_TEST_CASES = [ + { toolsInput: "latest-1", expectedCliVersion: "2.25.1" }, + { toolsInput: "^2.24.0", expectedCliVersion: "2.25.3" }, + { toolsInput: "latest-prerelease", expectedCliVersion: "2.26.0" }, + { toolsInput: "nightly-until-2.20.0", expectedCliVersion: "2.26.0" }, +] as const; + +for (const variant of [GitHubVariant.GHES, GitHubVariant.GHEC_DR]) { + for (const { + toolsInput, + expectedCliVersion, + } of NON_DOTCOM_VARIANT_TOOLS_INPUT_TEST_CASES) { + test.serial( + `getCodeQLSource resolves 'tools: ${toolsInput}' to CodeQL version ${expectedCliVersion} ` + + `on ${variant} using GitHub.com, rather than the current instance`, + async (t) => { + const features = createFeatures([]); + sinon.stub(process, "platform").value("linux"); + mockListStableCodeQLBundleReleasesForNonDotcomVariant(); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + toolsInput, + SAMPLE_DEFAULT_CLI_VERSION, + undefined, // rawLanguages + false, // useOverlayAwareDefaultCliVersion + SAMPLE_GHES_API_DETAILS, + variant, + false, + features, + getRunnerLogger(true), + ); + + t.is(source.sourceType, "download"); + // If resolution incorrectly used the current (non-dotcom) instance's API instead of + // GitHub.com, this would instead resolve to the bogus "9.9.9" release configured by + // `mockListStableCodeQLBundleReleasesForNonDotcomVariant`. + t.is(source.toolsVersion, expectedCliVersion); + t.is(source["cliVersion"], expectedCliVersion); + }); + }, + ); + } +} + test.serial( "getCodeQLSource correctly returns bundled CLI version when tools == latest", async (t) => { diff --git a/src/setup-codeql.ts b/src/setup-codeql.ts index 8d374585aa..278e7fcae8 100644 --- a/src/setup-codeql.ts +++ b/src/setup-codeql.ts @@ -53,6 +53,40 @@ const CODEQL_BUNDLE_VERSION_ALIAS: string[] = ["linked", "latest"]; const CODEQL_NIGHTLY_TOOLS_INPUTS = ["nightly", "nightly-latest"]; const CODEQL_TOOLCACHE_INPUT = "toolcache"; +/** Matches the `latest-` form of the `tools` input, for example `latest-1` or `LATEST-2`. */ +const LATEST_OFFSET_TOOLS_INPUT_REGEX = /^latest-(\d+)$/i; + +/** Matches the `latest-prerelease` form of the `tools` input (matching is case insensitive). */ +const LATEST_PRERELEASE_TOOLS_INPUT = "latest-prerelease"; + +/** + * Matches the `nightly-until-default-` form of the `tools` input, for example + * `nightly-until-default-2.24.0` or `NIGHTLY-UNTIL-DEFAULT-2.24.0` (matching is case + * insensitive). Capture group 1 is the raw version threshold, which this regular expression does + * not validate: see `parseNightlyUntilThreshold`. This is checked in preference to + * `NIGHTLY_UNTIL_TOOLS_INPUT_REGEX`, which would otherwise also match this form. + */ +const NIGHTLY_UNTIL_DEFAULT_TOOLS_INPUT_REGEX = /^nightly-until-default-(.+)$/i; + +/** + * Matches the `nightly-until-` form of the `tools` input, for example + * `nightly-until-2.24.0` or `NIGHTLY-UNTIL-2.24.0` (matching is case insensitive). Capture group 1 + * is the raw version threshold, which this regular expression does not validate: see + * `parseNightlyUntilThreshold`. + */ +const NIGHTLY_UNTIL_TOOLS_INPUT_REGEX = /^nightly-until-(.+)$/i; + +/** Number of releases requested per page when listing CodeQL bundle releases. */ +const CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE = 100; + +/** + * Maximum number of pages of releases to fetch when listing stable CodeQL bundle releases in + * order to resolve `tools` inputs such as `latest-` or a semantic version range. This bounds + * the number of API requests made while still comfortably covering many years of CodeQL CLI + * releases. + */ +const CODEQL_BUNDLE_RELEASE_LIST_MAX_PAGES = 20; + function getCodeQLBundleExtension( compressionMethod: tar.CompressionMethod, ): string { @@ -209,6 +243,255 @@ export function convertToSemVer(version: string, logger: Logger): string { return s; } +/** + * If the `tools` input is a bare CodeQL CLI version number, for example `2.19.0` or `v2.19.0`, + * returns the version normalized to the `x.y.z` form used elsewhere in the Action to identify + * CLI versions. Otherwise returns `undefined`. + * + * This allows users to request a specific version of the CodeQL CLI without needing to know the + * name or URL of the bundle asset for their platform: the appropriate bundle for the runner's OS, + * and the best compression method it supports, are selected automatically, in the same way as + * they are for the default and `linked` versions of the tools. + * + * `semver.valid` only succeeds if the *entire* input string is a valid semantic version, and the + * semantic versioning spec never permits `:` or `/` characters in a version string. Since every + * URL contains `://`, a CodeQL Bundle URL can never be misidentified as a bare CLI version number + * by this function. + */ +function tryGetCliVersionFromToolsInput( + toolsInput: string, +): string | undefined { + return semver.valid(toolsInput) ?? undefined; +} + +/** + * If the `tools` input matches the `latest-` syntax, for example `latest-1` or `LATEST-2` + * (matching is case insensitive), returns `N`. Otherwise returns `undefined`. + * + * `latest-` refers to the stable CodeQL CLI release `N` positions before the most recent + * stable release, when all stable releases are sorted in descending version order. `N` is + * resolved against the actual release history rather than by decrementing the patch version, + * since CodeQL CLI releases can be skipped or withdrawn. + */ +function tryGetLatestOffsetFromToolsInput( + toolsInput: string, +): number | undefined { + const match = toolsInput.match(LATEST_OFFSET_TOOLS_INPUT_REGEX); + return match ? parseInt(match[1], 10) : undefined; +} + +/** + * If the `tools` input is a semantic version range, for example `2.24.x`, `2.x`, `~2.24.0`, or + * `^2.24.0`, returns that range. Bare CLI version numbers, which are handled by + * `tryGetCliVersionFromToolsInput`, are not considered ranges. Otherwise returns `undefined`. + * + * A range must contain a `.`, so that bare numbers or strings such as `2` or `20200601` are not + * misinterpreted as version ranges and are instead handled as local paths, as they were before + * version ranges were supported. + * + * As with `tryGetCliVersionFromToolsInput`, this delegates to `semver.validRange`, which also + * requires the whole input string to be a syntactically valid range and never permits `:` or `/` + * characters. A CodeQL Bundle URL, which always contains `://`, can therefore never be + * misidentified as a version range here either. + */ +function tryGetCliVersionRangeFromToolsInput( + toolsInput: string, +): string | undefined { + if (!toolsInput.includes(".") || semver.valid(toolsInput) !== null) { + return undefined; + } + return semver.validRange(toolsInput) !== null ? toolsInput : undefined; +} + +/** + * If the `tools` input is the `latest-prerelease` keyword (matching is case insensitive), returns + * `true`. Otherwise returns `false`. + * + * `latest-prerelease` refers to the newest semantically versioned CodeQL bundle release, whether + * it is a stable release or a GitHub prerelease. Along with `nightly-until-`, this is + * one of the two `tools` input forms that may resolve to a prerelease: every other form only ever + * considers stable releases. + */ +function isLatestPrereleaseToolsInput(toolsInput: string): boolean { + return toolsInput.toLowerCase() === LATEST_PRERELEASE_TOOLS_INPUT; +} + +/** + * If the `tools` input matches the `nightly-until-` syntax, for example + * `nightly-until-2.24.0` or `NIGHTLY-UNTIL-2.24.0` (matching is case insensitive), returns the raw + * version threshold (not yet validated as a semantic version: see `parseNightlyUntilThreshold`). + * Otherwise returns `undefined`. + * + * This never matches the `nightly-until-default-` form: callers must check + * `tryGetNightlyUntilDefaultToolsInput` first. + */ +function tryGetNightlyUntilToolsInput(toolsInput: string): string | undefined { + if (tryGetNightlyUntilDefaultToolsInput(toolsInput) !== undefined) { + return undefined; + } + const match = toolsInput.match(NIGHTLY_UNTIL_TOOLS_INPUT_REGEX); + return match ? match[1] : undefined; +} + +/** + * If the `tools` input matches the `nightly-until-default-` syntax, for example + * `nightly-until-default-2.24.0` or `NIGHTLY-UNTIL-DEFAULT-2.24.0` (matching is case + * insensitive), returns the raw version threshold (not yet validated as a semantic version: see + * `parseNightlyUntilThreshold`). Otherwise returns `undefined`. + * + * Unlike `nightly-until-`, this form never queries the CodeQL bundle release history: it + * compares the threshold directly against this CodeQL Action's already-known default CLI + * version, so it can never resolve to a prerelease. + */ +function tryGetNightlyUntilDefaultToolsInput( + toolsInput: string, +): string | undefined { + const match = toolsInput.match(NIGHTLY_UNTIL_DEFAULT_TOOLS_INPUT_REGEX); + return match ? match[1] : undefined; +} + +/** + * Validates that `rawThreshold`, extracted from the `nightly-until-` or + * `nightly-until-default-` form of the `tools` input by `tryGetNightlyUntilToolsInput` + * or `tryGetNightlyUntilDefaultToolsInput`, is a valid semantic version, and returns it + * normalized to the `x.y.z` form. A version threshold that isn't a valid semantic version can + * never be compared against the available releases, so this throws a `ConfigurationError` + * describing the problem. + */ +function parseNightlyUntilThreshold( + toolsInput: string, + rawThreshold: string, +): string { + const threshold = semver.valid(rawThreshold); + if (!threshold) { + throw new util.ConfigurationError( + `'tools: ${toolsInput}' was requested, but '${rawThreshold}' is not a valid semantic ` + + "version. The version threshold must be a semantic version, for example '2.24.0'.", + ); + } + return threshold; +} + +/** + * Fetches the CLI versions of CodeQL bundle releases published to the canonical CodeQL Action + * repository, sorted in descending semantic-version order (newest first). Draft releases are + * always excluded; GitHub prereleases are excluded unless `includePrereleases` is `true`. + * + * We fetch the actual release history, rather than assuming a contiguous sequence of patch + * versions, since CodeQL CLI releases can be skipped or withdrawn, so the release before the + * newest one is not necessarily the newest one with its patch version decremented by one. + * + * The canonical CodeQL Action repository, and its release history, only ever exists on + * GitHub.com, regardless of which GitHub instance this Action is running on. On GitHub.com, + * `variant` lets us reuse the same authenticated API client as the rest of the Action. On any + * other variant, such as GitHub Enterprise Server or GHEC with data residency, we instead query + * GitHub.com directly and without authentication: the current instance's token cannot + * authenticate to GitHub.com, and sending it there would both fail and needlessly expose that + * token to a different host, whereas reading a public repository's release history does not + * require authentication. + */ +async function getSortedCliVersions( + variant: util.GitHubVariant, + logger: Logger, + includePrereleases: boolean, +): Promise { + const [owner, repo] = CODEQL_DEFAULT_ACTION_REPOSITORY.split("/"); + const versions = new Set(); + const apiClient = + variant === util.GitHubVariant.DOTCOM + ? api.getApiClient() + : api.getUnauthenticatedApiClientForDotcom(); + + try { + for (let page = 1; page <= CODEQL_BUNDLE_RELEASE_LIST_MAX_PAGES; page++) { + const response = await apiClient.rest.repos.listReleases({ + owner, + repo, + per_page: CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE, + page, + }); + + for (const release of response.data) { + if (release.draft || (release.prerelease && !includePrereleases)) { + continue; + } + const bundleVersion = tryGetBundleVersionFromTagName( + release.tag_name, + logger, + ); + if (bundleVersion && semver.valid(bundleVersion)) { + versions.add(semver.clean(bundleVersion)!); + } + } + + if (response.data.length < CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE) { + break; + } + } + } catch (e) { + throw new util.ConfigurationError( + `Failed to list CodeQL bundle releases in ${CODEQL_DEFAULT_ACTION_REPOSITORY}: ${util.getErrorMessage(e)}`, + ); + } + + return [...versions].sort(semver.rcompare); +} + +/** + * Fetches the CLI versions of all stable (non-prerelease, non-draft) CodeQL bundle releases + * published to the canonical CodeQL Action repository, sorted in descending semantic-version + * order (newest first). See `getSortedCliVersions` for details. + */ +async function getSortedStableCliVersions( + variant: util.GitHubVariant, + logger: Logger, +): Promise { + return getSortedCliVersions(variant, logger, false); +} + +/** + * Fetches the CLI versions of all non-draft CodeQL bundle releases, including GitHub + * prereleases, published to the canonical CodeQL Action repository, sorted in descending + * semantic-version order (newest first). See `getSortedCliVersions` for details. + * + * This is only used to resolve `tools` input forms, such as `latest-prerelease` and + * `nightly-until-`, that explicitly opt in to considering prereleases. Every other + * `tools` input form uses `getSortedStableCliVersions` instead, so can never be resolved to a + * prerelease. + */ +async function getSortedCliVersionsIncludingPrereleases( + variant: util.GitHubVariant, + logger: Logger, +): Promise { + return getSortedCliVersions(variant, logger, true); +} + +/** + * Returns a human-readable clause describing the oldest and newest versions in + * `sortedVersions` (which must be sorted in descending version order, as returned by + * `getSortedStableCliVersions`), suitable for appending to an error message when a requested + * `latest-` offset or version range cannot be satisfied. + * + * Older CodeQL bundle releases are not tagged with a semantic version (for example + * `codeql-bundle-20211208`), so `sortedVersions` will never extend further back than the oldest + * release with a semantic version tag. Mentioning that oldest version here helps explain + * failures caused by requesting a version or range older than any semantically versioned bundle, + * without needing to special-case that situation separately from any other unsatisfiable + * request, such as one for a version newer than any release. + * + * Returns an empty string if `sortedVersions` is empty. + */ +function describeAvailableCliVersionRange(sortedVersions: string[]): string { + if (sortedVersions.length === 0) { + return ""; + } + const newest = sortedVersions[0]; + const oldest = sortedVersions[sortedVersions.length - 1]; + return newest === oldest + ? ` The only available stable CodeQL CLI release is ${newest}.` + : ` Available stable CodeQL CLI releases range from ${oldest} to ${newest}.`; +} + export type CodeQLToolsSource = | { codeqlTarPath: string; @@ -395,6 +678,41 @@ async function resolveDefaultCliVersion( * Determines where the CodeQL CLI we want to use comes from. This can be from a local file, * the Actions toolcache, or a download. * + * The `tools` input is classified into exactly one of the following mutually exclusive + * categories, listed here in order of precedence: + * + * 1. Not specified: the default CLI version for this environment. + * 2. A reserved keyword: `nightly`/`nightly-latest`, `linked`/`latest`, or `toolcache` (see + * `CODEQL_NIGHTLY_TOOLS_INPUTS`, `CODEQL_BUNDLE_VERSION_ALIAS`, and `CODEQL_TOOLCACHE_INPUT` + * below). + * 3. `latest-prerelease`: the newest semantically versioned CodeQL bundle release, whether it + * is a stable release or a GitHub prerelease (see `isLatestPrereleaseToolsInput`). + * 4. `nightly-until-default-`, e.g. `nightly-until-default-2.24.0`: if this CodeQL + * Action's default CLI version is at or above that version threshold, this is equivalent to + * the `tools` input not being specified at all (category 1 above); otherwise, the latest + * nightly bundle is used (see `tryGetNightlyUntilDefaultToolsInput`). This never queries the + * CodeQL bundle release history, and can never resolve to a prerelease. + * 5. `nightly-until-`, e.g. `nightly-until-2.24.0`: the newest release at or above that + * version threshold, among stable releases and GitHub prereleases too; or the latest nightly + * bundle if no such release exists (see `tryGetNightlyUntilToolsInput`). Along with + * `latest-prerelease`, this is the only other category that may resolve to a prerelease. + * 6. A URL, i.e. a string starting with `http`: the CodeQL Bundle downloaded from that URL. + * 7. A bare CLI version number, e.g. `2.19.0` or `v2.19.0`: the CodeQL Bundle release + * containing that CLI version (see `tryGetCliVersionFromToolsInput`). + * 8. `latest-`, e.g. `latest-1` or `LATEST-2`: the stable CLI release `N` positions before + * the most recent one (see `tryGetLatestOffsetFromToolsInput`). + * 9. A semantic version range, e.g. `2.24.x`, `2.x`, `~2.24.0`, or `^2.24.0`: the newest stable + * CLI release satisfying that range (see `tryGetCliVersionRangeFromToolsInput`). + * 10. Anything else: a local path to a CodeQL Bundle tarball. + * + * Categories 7-9 are all detected using the `semver` package, which only matches a bare version + * or a range if the *entire* input string conforms to the semantic versioning spec. That spec + * never permits a `:` or `/` character in a version or a range, whereas a URL (category 6) always + * contains `://`. So even though the checks for categories 7-9 don't explicitly exclude URLs, + * they can never match one: a value such as `http://example.com/codeql-bundle-linux64.tar.gz` is + * always resolved as a URL, never as a bare version like `2.19.0` or a range like `2.24.x`, no + * matter what version-like path segments or filenames it contains. + * * @param toolsInput The argument provided for the `tools` input, if any. * @param defaultCliVersion The default CLI version that's linked to the CodeQL Action. * @param rawLanguages Raw set of languages. @@ -418,13 +736,23 @@ export async function getCodeQLSource( features: FeatureEnablement, logger: Logger, ): Promise { - // If there is an explicit `tools` input, it's not one of the reserved values, and it doesn't appear - // to point to a URL, then we assume it is a local path and use the CLI from there. + // If there is an explicit `tools` input, it's not one of the reserved values, it doesn't appear + // to point to a URL, and it isn't a bare CodeQL CLI version number, a `latest-` version + // offset, a semantic version range, `latest-prerelease`, `nightly-until-default-`, or + // `nightly-until-`, then we assume it is a local path and use the CLI from there. See + // the order-of-operations note in this function's doc comment above for the full list of + // categories and why a URL is never confused with a version number or a range. // TODO: This appears to misclassify filenames that happen to start with `http` as URLs. if ( toolsInput && !isReservedToolsValue(toolsInput) && - !toolsInput.startsWith("http") + !isLatestPrereleaseToolsInput(toolsInput) && + tryGetNightlyUntilDefaultToolsInput(toolsInput) === undefined && + tryGetNightlyUntilToolsInput(toolsInput) === undefined && + !toolsInput.startsWith("http") && + tryGetCliVersionFromToolsInput(toolsInput) === undefined && + tryGetLatestOffsetFromToolsInput(toolsInput) === undefined && + tryGetCliVersionRangeFromToolsInput(toolsInput) === undefined ) { logger.info(`Using CodeQL CLI from local path ${toolsInput}`); const compressionMethod = tar.inferCompressionMethod(toolsInput); @@ -495,6 +823,108 @@ export async function getCodeQLSource( ); } toolsInput = await getNightlyToolsUrl(logger); + } else if ( + toolsInput !== undefined && + tryGetNightlyUntilDefaultToolsInput(toolsInput) !== undefined + ) { + // The `nightly-until-default-` syntax was used: compare the given version threshold + // against the CLI version that would actually be used if `tools` had not been specified at + // all, i.e. after normal default CLI version selection (which may be overlay-aware) and any + // applicable overrides (such as a pinned version found in the toolcache on Enterprise + // Server), without ever querying the CodeQL bundle release history. If that resolved version + // satisfies the threshold, we resume with that already-resolved source directly, so we never + // duplicate the default-selection or override logic below. Otherwise, we fall back to the + // latest nightly bundle. + const rawThreshold = tryGetNightlyUntilDefaultToolsInput(toolsInput)!; + const threshold = parseNightlyUntilThreshold(toolsInput, rawThreshold); + + const defaultSource = await getCodeQLSource( + undefined, + defaultCliVersion, + rawLanguages, + useOverlayAwareDefaultCliVersion, + apiDetails, + variant, + tarSupportsZstd, + features, + logger, + ); + const resolvedVersion = + "cliVersion" in defaultSource + ? defaultSource.cliVersion + : defaultSource.toolsVersion; + + if ( + resolvedVersion !== undefined && + semver.valid(resolvedVersion) !== null && + semver.gte(resolvedVersion, threshold) + ) { + logger.info( + `'tools: ${toolsInput}' was requested, so using the default CodeQL version, since the ` + + `resolved default CodeQL version ${resolvedVersion} satisfies the version threshold ` + + `of ${threshold}.`, + ); + return defaultSource; + } else { + logger.info( + `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since the ` + + `resolved default CodeQL version${ + resolvedVersion !== undefined ? ` ${resolvedVersion}` : "" + } does not satisfy the version threshold of ${threshold}.`, + ); + toolsInput = await getNightlyToolsUrl(logger); + } + } else if ( + toolsInput !== undefined && + isLatestPrereleaseToolsInput(toolsInput) + ) { + // The `latest-prerelease` syntax was used to request the newest semantically versioned + // CodeQL bundle release, whether it is a stable release or a GitHub prerelease. Along with + // `nightly-until-`, this is one of the two `tools` input forms that may resolve to a + // prerelease. + const sortedVersions = await getSortedCliVersionsIncludingPrereleases( + variant, + logger, + ); + + if (sortedVersions.length === 0) { + throw new util.ConfigurationError( + `'tools: ${toolsInput}' was requested, but no CodeQL CLI releases could be found.`, + ); + } + + logger.info( + `'tools: ${toolsInput}' was requested, so using CodeQL version ${sortedVersions[0]}, the ` + + "most recent CodeQL CLI release, which may be a prerelease.", + ); + toolsInput = sortedVersions[0]; + } else if ( + toolsInput !== undefined && + tryGetNightlyUntilToolsInput(toolsInput) !== undefined + ) { + // The `nightly-until-` syntax was used: use the newest release at or above the + // given version threshold, among stable releases and GitHub prereleases too. If no release + // satisfies the threshold, fall back to the latest nightly bundle. + const rawThreshold = tryGetNightlyUntilToolsInput(toolsInput)!; + const threshold = parseNightlyUntilThreshold(toolsInput, rawThreshold); + const sortedVersions = await getSortedCliVersionsIncludingPrereleases( + variant, + logger, + ); + + if (sortedVersions.length > 0 && semver.gte(sortedVersions[0], threshold)) { + logger.info( + `'tools: ${toolsInput}' was requested, so using CodeQL version ${sortedVersions[0]}, ` + + `since it satisfies the version threshold of ${threshold}.`, + ); + toolsInput = sortedVersions[0]; + } else { + logger.info( + `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since no ` + + `eligible CodeQL CLI release satisfies the version threshold of ${threshold}.`, + ); + toolsInput = await getNightlyToolsUrl(logger); + } } /** @@ -569,6 +999,75 @@ export async function getCodeQLSource( cliVersion = version.cliVersion; tagName = version.tagName; } + } else if ( + toolsInput !== undefined && + tryGetCliVersionFromToolsInput(toolsInput) !== undefined + ) { + // A bare CodeQL CLI version number, e.g. `2.19.0` or `v2.19.0`, was provided. Compute the tag + // name of the CodeQL bundle containing this CLI version so that the appropriate bundle for + // the current platform can be downloaded below. + cliVersion = tryGetCliVersionFromToolsInput(toolsInput); + tagName = `codeql-bundle-v${cliVersion}`; + + logger.info( + `'tools: ${toolsInput}' was requested, so using CodeQL version ${cliVersion}.`, + ); + } else if ( + toolsInput !== undefined && + tryGetLatestOffsetFromToolsInput(toolsInput) !== undefined + ) { + // The `latest-` syntax was used to request the stable CodeQL CLI release `N` positions + // before the most recent stable release. We look up the actual release history, sorted by + // version, rather than assuming a fixed decrease in patch version, since CodeQL CLI releases + // can be skipped or withdrawn. + const offset = tryGetLatestOffsetFromToolsInput(toolsInput)!; + const sortedVersions = await getSortedStableCliVersions(variant, logger); + + if (offset >= sortedVersions.length) { + throw new util.ConfigurationError( + `'tools: ${toolsInput}' was requested, but only ${sortedVersions.length} stable CodeQL ` + + `CLI release(s) could be found.${describeAvailableCliVersionRange( + sortedVersions, + )}`, + ); + } + + cliVersion = sortedVersions[offset]; + tagName = `codeql-bundle-v${cliVersion}`; + + logger.info( + `'tools: ${toolsInput}' was requested, so using CodeQL version ${cliVersion}, which is ${ + offset === 0 + ? "the most recent stable CodeQL CLI release." + : `${offset} stable CodeQL CLI release(s) before the most recent one.` + }`, + ); + } else if ( + toolsInput !== undefined && + tryGetCliVersionRangeFromToolsInput(toolsInput) !== undefined + ) { + // A semantic version range, e.g. `2.24.x` or `^2.24.0`, was used to request the most recent + // stable CodeQL CLI release that satisfies that range. + const range = tryGetCliVersionRangeFromToolsInput(toolsInput)!; + const sortedVersions = await getSortedStableCliVersions(variant, logger); + const resolvedVersion = semver.maxSatisfying(sortedVersions, range); + + if (!resolvedVersion) { + throw new util.ConfigurationError( + `'tools: ${toolsInput}' was requested, but no stable CodeQL CLI release satisfying that ` + + `version range could be found.${describeAvailableCliVersionRange( + sortedVersions, + )}`, + ); + } + + cliVersion = resolvedVersion; + tagName = `codeql-bundle-v${cliVersion}`; + + logger.info( + `'tools: ${toolsInput}' was requested, so using CodeQL version ${cliVersion}, the most ` + + "recent stable CodeQL CLI release satisfying that version range.", + ); } else if (toolsInput !== undefined) { // If a tools URL was provided, then use that. tagName = tryGetTagNameFromUrl(toolsInput, logger); diff --git a/src/testing-utils.ts b/src/testing-utils.ts index 279459275d..5e780ed48b 100644 --- a/src/testing-utils.ts +++ b/src/testing-utils.ts @@ -50,6 +50,12 @@ export const SAMPLE_DOTCOM_API_DETAILS = { apiURL: "https://api.github.com", }; +export const SAMPLE_GHES_API_DETAILS = { + auth: "token", + url: "https://ghes.example.com", + apiURL: "https://ghes.example.com/api/v3", +}; + export const LINKED_CLI_VERSION = { cliVersion: defaults.cliVersion, tagName: defaults.bundleVersion, diff --git a/src/util.ts b/src/util.ts index b7d27afae3..198827ee95 100644 --- a/src/util.ts +++ b/src/util.ts @@ -38,6 +38,11 @@ const BROKEN_VERSIONS = ["0.0.0-20211207"]; */ export const GITHUB_DOTCOM_URL = "https://github.com"; +/** + * The API URL for github.com. + */ +export const GITHUB_DOTCOM_API_URL = "https://api.github.com"; + /** * Default name of the debugging artifact. */