From 8c514cbb0028033ee704cc048f7ae69337e90bbf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:06:21 +0000 Subject: [PATCH 1/9] Support bare CodeQL CLI version numbers in the tools input Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- CHANGELOG.md | 2 +- init/action.yml | 3 + lib/entry-points.js | 11 ++- setup-codeql/action.yml | 3 + src/setup-codeql.test.ts | 145 +++++++++++++++++++++++++++++++++++++++ src/setup-codeql.ts | 37 +++++++++- 6 files changed, 196 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21e812c9f8..6922414dba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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. ## 4.37.5 - 03 Aug 2026 diff --git a/init/action.yml b/init/action.yml index 1b64e8d2a3..70639e8eff 100644 --- a/init/action.yml +++ b/init/action.yml @@ -10,6 +10,9 @@ 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 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 cdd0db217d..d281ef4152 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151045,6 +151045,9 @@ function convertToSemVer(version, logger) { } return s; } +function tryGetCliVersionFromToolsInput(toolsInput) { + return semver9.valid(toolsInput) ?? void 0; +} async function findOverridingToolsInCache(humanReadableVersion, logger) { const candidates = toolcache3.findAllVersions("CodeQL").filter(isGoodVersion).map((version) => ({ folder: toolcache3.find("CodeQL", version), @@ -151149,7 +151152,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) && !toolsInput.startsWith("http") && tryGetCliVersionFromToolsInput(toolsInput) === void 0) { logger.info(`Using CodeQL CLI from local path ${toolsInput}`); const compressionMethod2 = inferCompressionMethod(toolsInput); if (compressionMethod2 === void 0) { @@ -151243,6 +151246,12 @@ 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) { tagName = tryGetTagNameFromUrl(toolsInput, logger); url2 = toolsInput; diff --git a/setup-codeql/action.yml b/setup-codeql/action.yml index 8d13eeaff0..d5f57793de 100644 --- a/setup-codeql/action.yml +++ b/setup-codeql/action.yml @@ -10,6 +10,9 @@ 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 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/setup-codeql.test.ts b/src/setup-codeql.test.ts index 219e39984c..2f435695d7 100644 --- a/src/setup-codeql.test.ts +++ b/src/setup-codeql.test.ts @@ -181,6 +181,151 @@ 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"); + } + }); + }, +); + 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..c8104ccb86 100644 --- a/src/setup-codeql.ts +++ b/src/setup-codeql.ts @@ -209,6 +209,22 @@ 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. + */ +function tryGetCliVersionFromToolsInput( + toolsInput: string, +): string | undefined { + return semver.valid(toolsInput) ?? undefined; +} + export type CodeQLToolsSource = | { codeqlTarPath: string; @@ -418,13 +434,15 @@ 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, then we assume it is a local + // path and use the CLI from there. // TODO: This appears to misclassify filenames that happen to start with `http` as URLs. if ( toolsInput && !isReservedToolsValue(toolsInput) && - !toolsInput.startsWith("http") + !toolsInput.startsWith("http") && + tryGetCliVersionFromToolsInput(toolsInput) === undefined ) { logger.info(`Using CodeQL CLI from local path ${toolsInput}`); const compressionMethod = tar.inferCompressionMethod(toolsInput); @@ -569,6 +587,19 @@ 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) { // If a tools URL was provided, then use that. tagName = tryGetTagNameFromUrl(toolsInput, logger); From 16c4f3ecbb34b02a6d19ab985a7c6083d3695a46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:32:53 +0000 Subject: [PATCH 2/9] Add latest-N offset and semver range support for tools input Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- CHANGELOG.md | 1 + init/action.yml | 5 ++ lib/entry-points.js | 112 +++++++++++++++++++++----- setup-codeql/action.yml | 5 ++ src/setup-codeql.test.ts | 168 +++++++++++++++++++++++++++++++++++++++ src/setup-codeql.ts | 157 +++++++++++++++++++++++++++++++++++- 6 files changed, 426 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6922414dba..9f9083e326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ See the [releases page](https://github.com/github/codeql-action/releases) for th ## [UNRELEASED] - 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. ## 4.37.5 - 03 Aug 2026 diff --git a/init/action.yml b/init/action.yml index 70639e8eff..85946ad58f 100644 --- a/init/action.yml +++ b/init/action.yml @@ -13,6 +13,11 @@ inputs: - 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 `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 d281ef4152..be522e5001 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, @@ -33337,8 +33337,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; @@ -33832,8 +33832,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 { @@ -33915,8 +33915,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) { @@ -150930,6 +150930,9 @@ 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 CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE = 100; +var CODEQL_BUNDLE_RELEASE_LIST_MAX_PAGES = 20; function getCodeQLBundleExtension(compressionMethod) { switch (compressionMethod) { case "gzip": @@ -151048,6 +151051,50 @@ function convertToSemVer(version, logger) { 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; +} +async function getSortedStableCliVersions(logger) { + const [owner, repo] = CODEQL_DEFAULT_ACTION_REPOSITORY.split("/"); + const versions = /* @__PURE__ */ new Set(); + try { + for (let page = 1; page <= CODEQL_BUNDLE_RELEASE_LIST_MAX_PAGES; page++) { + const response = await getApiClient().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) { + 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 findOverridingToolsInCache(humanReadableVersion, logger) { const candidates = toolcache3.findAllVersions("CodeQL").filter(isGoodVersion).map((version) => ({ folder: toolcache3.find("CodeQL", version), @@ -151152,7 +151199,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") && tryGetCliVersionFromToolsInput(toolsInput) === void 0) { + if (toolsInput && !isReservedToolsValue(toolsInput) && !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) { @@ -151252,6 +151299,33 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO 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(logger); + if (offset >= sortedVersions.length) { + throw new ConfigurationError( + `'tools: ${toolsInput}' was requested, but only ${sortedVersions.length} stable CodeQL CLI release(s) could be found.` + ); + } + 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(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.` + ); + } + 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 d5f57793de..1e3bdbe735 100644 --- a/setup-codeql/action.yml +++ b/setup-codeql/action.yml @@ -13,6 +13,11 @@ inputs: - 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 `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/setup-codeql.test.ts b/src/setup-codeql.test.ts index 2f435695d7..38a1a5b933 100644 --- a/src/setup-codeql.test.ts +++ b/src/setup-codeql.test.ts @@ -28,6 +28,7 @@ import { setupTests, } from "./testing-utils"; import { + ConfigurationError, getErrorMessage, GitHubVariant, initializeEnvironment, @@ -326,6 +327,173 @@ test.serial( }, ); +/** + * 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() { + 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: STABLE_BUNDLE_RELEASES_TEST_SET, + } as any); + sinon.stub(api, "getApiClient").value(() => client); +} + +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); + 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 }, + ); + }); + }, +); + +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); + 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 }, + ); + }); + }, +); + 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 c8104ccb86..99c490fbaa 100644 --- a/src/setup-codeql.ts +++ b/src/setup-codeql.ts @@ -53,6 +53,20 @@ 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; + +/** 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 { @@ -225,6 +239,88 @@ function tryGetCliVersionFromToolsInput( 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. + */ +function tryGetCliVersionRangeFromToolsInput( + toolsInput: string, +): string | undefined { + if (!toolsInput.includes(".") || semver.valid(toolsInput) !== null) { + return undefined; + } + return semver.validRange(toolsInput) !== null ? toolsInput : undefined; +} + +/** + * 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). + * + * 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. + */ +async function getSortedStableCliVersions(logger: Logger): Promise { + const [owner, repo] = CODEQL_DEFAULT_ACTION_REPOSITORY.split("/"); + const versions = new Set(); + + try { + for (let page = 1; page <= CODEQL_BUNDLE_RELEASE_LIST_MAX_PAGES; page++) { + const response = await api.getApiClient().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) { + 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); +} + export type CodeQLToolsSource = | { codeqlTarPath: string; @@ -435,14 +531,17 @@ export async function getCodeQLSource( logger: Logger, ): Promise { // 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, then we assume it is a local - // path and use the CLI from there. + // to point to a URL, and it isn't a bare CodeQL CLI version number, a `latest-` version + // offset, or a semantic version range, then we assume it is a local path and use the CLI from + // there. // TODO: This appears to misclassify filenames that happen to start with `http` as URLs. if ( toolsInput && !isReservedToolsValue(toolsInput) && !toolsInput.startsWith("http") && - tryGetCliVersionFromToolsInput(toolsInput) === undefined + tryGetCliVersionFromToolsInput(toolsInput) === undefined && + tryGetLatestOffsetFromToolsInput(toolsInput) === undefined && + tryGetCliVersionRangeFromToolsInput(toolsInput) === undefined ) { logger.info(`Using CodeQL CLI from local path ${toolsInput}`); const compressionMethod = tar.inferCompressionMethod(toolsInput); @@ -600,6 +699,58 @@ export async function getCodeQLSource( 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(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.", + ); + } + + 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(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.", + ); + } + + 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); From eccc8886e0daeeae2363c5bd2721bf3e2e34302e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:45:49 +0000 Subject: [PATCH 3/9] Clarify tools input resolution order and add URL disambiguation tests Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- src/setup-codeql.test.ts | 55 ++++++++++++++++++++++++++++++++++++++++ src/setup-codeql.ts | 37 ++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/setup-codeql.test.ts b/src/setup-codeql.test.ts index 38a1a5b933..2d21ae6283 100644 --- a/src/setup-codeql.test.ts +++ b/src/setup-codeql.test.ts @@ -118,6 +118,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", diff --git a/src/setup-codeql.ts b/src/setup-codeql.ts index 99c490fbaa..a3611160cb 100644 --- a/src/setup-codeql.ts +++ b/src/setup-codeql.ts @@ -232,6 +232,11 @@ export function convertToSemVer(version: string, logger: Logger): string { * 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, @@ -263,6 +268,11 @@ function tryGetLatestOffsetFromToolsInput( * 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, @@ -507,6 +517,30 @@ 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. A URL, i.e. a string starting with `http`: the CodeQL Bundle downloaded from that URL. + * 4. A bare CLI version number, e.g. `2.19.0` or `v2.19.0`: the CodeQL Bundle release + * containing that CLI version (see `tryGetCliVersionFromToolsInput`). + * 5. `latest-`, e.g. `latest-1` or `LATEST-2`: the stable CLI release `N` positions before + * the most recent one (see `tryGetLatestOffsetFromToolsInput`). + * 6. 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`). + * 7. Anything else: a local path to a CodeQL Bundle tarball. + * + * Categories 4-6 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 3) always + * contains `://`. So even though the checks for categories 4-6 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. @@ -533,7 +567,8 @@ export async function getCodeQLSource( // 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, or a semantic version range, then we assume it is a local path and use the CLI from - // there. + // 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 && From 18e73d32e1349ce3e8140b9cef25b003a27b6619 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:50:25 +0000 Subject: [PATCH 4/9] Include available version range in latest-N/semver-range error messages Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- lib/entry-points.js | 16 ++++++++++-- src/setup-codeql.test.ts | 53 ++++++++++++++++++++++++++++++++++++++-- src/setup-codeql.ts | 34 ++++++++++++++++++++++++-- 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index be522e5001..45a78d6187 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151095,6 +151095,14 @@ async function getSortedStableCliVersions(logger) { } return [...versions].sort(semver9.rcompare); } +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 +151312,9 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO const sortedVersions = await getSortedStableCliVersions(logger); if (offset >= sortedVersions.length) { throw new ConfigurationError( - `'tools: ${toolsInput}' was requested, but only ${sortedVersions.length} stable CodeQL CLI release(s) could be found.` + `'tools: ${toolsInput}' was requested, but only ${sortedVersions.length} stable CodeQL CLI release(s) could be found.${describeAvailableCliVersionRange( + sortedVersions + )}` ); } cliVersion2 = sortedVersions[offset]; @@ -151318,7 +151328,9 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO 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.` + `'tools: ${toolsInput}' was requested, but no stable CodeQL CLI release satisfying that version range could be found.${describeAvailableCliVersionRange( + sortedVersions + )}` ); } cliVersion2 = resolvedVersion; diff --git a/src/setup-codeql.test.ts b/src/setup-codeql.test.ts index 2d21ae6283..bed605eef1 100644 --- a/src/setup-codeql.test.ts +++ b/src/setup-codeql.test.ts @@ -463,7 +463,7 @@ test.serial( await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); - await t.throwsAsync( + const error = await t.throwsAsync( async () => await setupCodeql.getCodeQLSource( "latest-99", @@ -478,6 +478,13 @@ test.serial( ), { 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.", + ), + ); }); }, ); @@ -530,7 +537,7 @@ test.serial( await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); - await t.throwsAsync( + const error = await t.throwsAsync( async () => await setupCodeql.getCodeQLSource( "9.x", @@ -545,6 +552,48 @@ test.serial( ), { 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.", + ), + ); }); }, ); diff --git a/src/setup-codeql.ts b/src/setup-codeql.ts index a3611160cb..4f14505ad0 100644 --- a/src/setup-codeql.ts +++ b/src/setup-codeql.ts @@ -331,6 +331,32 @@ async function getSortedStableCliVersions(logger: Logger): Promise { return [...versions].sort(semver.rcompare); } +/** + * 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; @@ -748,7 +774,9 @@ export async function getCodeQLSource( if (offset >= sortedVersions.length) { throw new util.ConfigurationError( `'tools: ${toolsInput}' was requested, but only ${sortedVersions.length} stable CodeQL ` + - "CLI release(s) could be found.", + `CLI release(s) could be found.${describeAvailableCliVersionRange( + sortedVersions, + )}`, ); } @@ -775,7 +803,9 @@ export async function getCodeQLSource( if (!resolvedVersion) { throw new util.ConfigurationError( `'tools: ${toolsInput}' was requested, but no stable CodeQL CLI release satisfying that ` + - "version range could be found.", + `version range could be found.${describeAvailableCliVersionRange( + sortedVersions, + )}`, ); } From ada6eaf086f2a0f5a24c2b7a782f19204231a588 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:57:33 +0000 Subject: [PATCH 5/9] Add latest-prerelease and nightly-until tools input forms Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- CHANGELOG.md | 1 + init/action.yml | 7 + lib/entry-points.js | 56 ++++++- setup-codeql/action.yml | 7 + src/setup-codeql.test.ts | 330 ++++++++++++++++++++++++++++++++++++++- src/setup-codeql.ts | 177 +++++++++++++++++++-- 6 files changed, 559 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f9083e326..52d6122ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ See the [releases page](https://github.com/github/codeql-action/releases) for th - 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` and `nightly-until-stable` (for example `nightly-until2.24.0`), which resolve to the most recent CodeQL Bundle release satisfying that version threshold, or fall back to the latest nightly version of the CodeQL tools if no such release exists. ## 4.37.5 - 03 Aug 2026 diff --git a/init/action.yml b/init/action.yml index 85946ad58f..ba969c5c5c 100644 --- a/init/action.yml +++ b/init/action.yml @@ -18,6 +18,13 @@ inputs: 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` or `nightly-until-stable`, for example + `nightly-until2.24.0` or `NIGHTLY-UNTIL-STABLE2.24.0` (matching is case insensitive), + which uses the most recent CodeQL Bundle release satisfying that version threshold, + considering GitHub prereleases too unless `-stable` is specified, or falls back to the + latest nightly version of the CodeQL tools if no such release exists, 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 45a78d6187..dd434efc7e 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -150931,6 +150931,8 @@ 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_TOOLS_INPUT_REGEX = /^nightly-until(-stable)?(.+)$/i; var CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE = 100; var CODEQL_BUNDLE_RELEASE_LIST_MAX_PAGES = 20; function getCodeQLBundleExtension(compressionMethod) { @@ -151061,7 +151063,23 @@ function tryGetCliVersionRangeFromToolsInput(toolsInput) { } return semver9.validRange(toolsInput) !== null ? toolsInput : void 0; } -async function getSortedStableCliVersions(logger) { +function isLatestPrereleaseToolsInput(toolsInput) { + return toolsInput.toLowerCase() === LATEST_PRERELEASE_TOOLS_INPUT; +} +function tryGetNightlyUntilToolsInput(toolsInput) { + const match2 = toolsInput.match(NIGHTLY_UNTIL_TOOLS_INPUT_REGEX); + return match2 ? { rawThreshold: match2[2], stableOnly: match2[1] !== void 0 } : 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(logger, includePrereleases) { const [owner, repo] = CODEQL_DEFAULT_ACTION_REPOSITORY.split("/"); const versions = /* @__PURE__ */ new Set(); try { @@ -151073,7 +151091,7 @@ async function getSortedStableCliVersions(logger) { page }); for (const release2 of response.data) { - if (release2.draft || release2.prerelease) { + if (release2.draft || release2.prerelease && !includePrereleases) { continue; } const bundleVersion2 = tryGetBundleVersionFromTagName( @@ -151095,6 +151113,12 @@ async function getSortedStableCliVersions(logger) { } return [...versions].sort(semver9.rcompare); } +async function getSortedStableCliVersions(logger) { + return getSortedCliVersions(logger, false); +} +async function getSortedCliVersionsIncludingPrereleases(logger) { + return getSortedCliVersions(logger, true); +} function describeAvailableCliVersionRange(sortedVersions) { if (sortedVersions.length === 0) { return ""; @@ -151207,7 +151231,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") && tryGetCliVersionFromToolsInput(toolsInput) === void 0 && tryGetLatestOffsetFromToolsInput(toolsInput) === void 0 && tryGetCliVersionRangeFromToolsInput(toolsInput) === void 0) { + if (toolsInput && !isReservedToolsValue(toolsInput) && !isLatestPrereleaseToolsInput(toolsInput) && 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) { @@ -151256,6 +151280,32 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO ); } toolsInput = await getNightlyToolsUrl(logger); + } else if (toolsInput !== void 0 && isLatestPrereleaseToolsInput(toolsInput)) { + const sortedVersions = await getSortedCliVersionsIncludingPrereleases(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, stableOnly } = tryGetNightlyUntilToolsInput(toolsInput); + const threshold = parseNightlyUntilThreshold(toolsInput, rawThreshold); + const sortedVersions = stableOnly ? await getSortedStableCliVersions(logger) : await getSortedCliVersionsIncludingPrereleases(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${stableOnly ? " stable" : ""} CodeQL CLI release satisfies the version threshold of ${threshold}.` + ); + toolsInput = await getNightlyToolsUrl(logger); + } } const forceShippedTools = toolsInput && CODEQL_BUNDLE_VERSION_ALIAS.includes(toolsInput); if (forceShippedTools) { diff --git a/setup-codeql/action.yml b/setup-codeql/action.yml index 1e3bdbe735..496c80185d 100644 --- a/setup-codeql/action.yml +++ b/setup-codeql/action.yml @@ -18,6 +18,13 @@ inputs: 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` or `nightly-until-stable`, for example + `nightly-until2.24.0` or `NIGHTLY-UNTIL-STABLE2.24.0` (matching is case insensitive), + which uses the most recent CodeQL Bundle release satisfying that version threshold, + considering GitHub prereleases too unless `-stable` is specified, or falls back to the + latest nightly version of the CodeQL tools if no such release exists, 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/setup-codeql.test.ts b/src/setup-codeql.test.ts index bed605eef1..74770fd13e 100644 --- a/src/setup-codeql.test.ts +++ b/src/setup-codeql.test.ts @@ -405,14 +405,34 @@ const STABLE_BUNDLE_RELEASES_TEST_SET = [ { tag_name: "codeql-bundle-v2.24.0", prerelease: false, draft: false }, ]; -function mockListStableCodeQLBundleReleases() { +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: STABLE_BUNDLE_RELEASES_TEST_SET, + 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` or `nightly-until-stable` 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); } const LATEST_OFFSET_TOOLS_INPUT_TEST_CASES = [ @@ -598,6 +618,312 @@ test.serial( }, ); +/** + * A set of CodeQL bundle releases that includes GitHub prereleases, used to test resolution of + * the `latest-prerelease` and `nightly-until`/`nightly-until-stable` 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-until2.26.0", + releases: STABLE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.26.0", + }, + { + name: "the newest release comfortably exceeds the threshold", + toolsInput: "nightly-until2.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-until2.25.4", + releases: STABLE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.26.0", + }, + { + name: "matching is case insensitive", + toolsInput: "NIGHTLY-UNTIL2.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-until2.20.0", + releases: PRERELEASE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.27.0", + }, + { + name: "nightly-until-stable: threshold exactly matches the newest stable release", + toolsInput: "nightly-until-stable2.25.3", + releases: STABLE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.25.3", + }, + { + name: "nightly-until-stable: the newest stable release comfortably exceeds the threshold", + toolsInput: "nightly-until-stable2.20.0", + releases: STABLE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.25.3", + }, + { + name: "nightly-until-stable: matching is case insensitive", + toolsInput: "NIGHTLY-UNTIL-STABLE2.25.3", + releases: STABLE_BUNDLE_RELEASES_TEST_SET, + expectedCliVersion: "2.25.3", + }, +] 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); + }); + }, + ); +} + +const NIGHTLY_UNTIL_FALLBACK_TOOLS_INPUT_TEST_CASES = [ + { + name: "no release, stable or prerelease, satisfies the threshold", + toolsInput: "nightly-until9.0.0", + }, + { + name: "only a prerelease, not a stable release, would satisfy the threshold", + toolsInput: "nightly-until-stable2.25.4", + }, +] 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-untilbogus", expectedRawThreshold: "bogus" }, + { + toolsInput: "nightly-until-stablebogus", + 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`, + ), + ); + }); + }, + ); +} + 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 4f14505ad0..898ce46f9a 100644 --- a/src/setup-codeql.ts +++ b/src/setup-codeql.ts @@ -56,6 +56,18 @@ 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` and `nightly-until-stable` forms of the `tools` + * input, for example `nightly-until2.24.0` or `NIGHTLY-UNTIL-STABLE2.24.0` (matching is case + * insensitive). Capture group 1 is `-stable` if the `-stable` variant was used, or `undefined` + * otherwise. Capture group 2 is the raw version threshold, which this regular expression does not + * validate: see `parseNightlyUntilThreshold`. + */ +const NIGHTLY_UNTIL_TOOLS_INPUT_REGEX = /^nightly-until(-stable)?(.+)$/i; + /** Number of releases requested per page when listing CodeQL bundle releases. */ const CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE = 100; @@ -284,15 +296,67 @@ function tryGetCliVersionRangeFromToolsInput( } /** - * 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). + * 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. This is the only `tools` input form 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` or `nightly-until-stable` + * syntax, for example `nightly-until2.24.0` or `NIGHTLY-UNTIL-STABLE2.24.0` (matching is case + * insensitive), returns the raw version threshold (not yet validated as a semantic version: see + * `parseNightlyUntilThreshold`) and whether only stable releases should be considered when + * resolving that threshold. Otherwise returns `undefined`. + */ +function tryGetNightlyUntilToolsInput( + toolsInput: string, +): { rawThreshold: string; stableOnly: boolean } | undefined { + const match = toolsInput.match(NIGHTLY_UNTIL_TOOLS_INPUT_REGEX); + return match + ? { rawThreshold: match[2], stableOnly: match[1] !== undefined } + : undefined; +} + +/** + * Validates that `rawThreshold`, extracted from the `nightly-until` or + * `nightly-until-stable` form of the `tools` input by `tryGetNightlyUntilToolsInput`, 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. */ -async function getSortedStableCliVersions(logger: Logger): Promise { +async function getSortedCliVersions( + logger: Logger, + includePrereleases: boolean, +): Promise { const [owner, repo] = CODEQL_DEFAULT_ACTION_REPOSITORY.split("/"); const versions = new Set(); @@ -306,7 +370,7 @@ async function getSortedStableCliVersions(logger: Logger): Promise { }); for (const release of response.data) { - if (release.draft || release.prerelease) { + if (release.draft || (release.prerelease && !includePrereleases)) { continue; } const bundleVersion = tryGetBundleVersionFromTagName( @@ -331,6 +395,31 @@ async function getSortedStableCliVersions(logger: Logger): Promise { 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(logger: Logger): Promise { + return getSortedCliVersions(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( + logger: Logger, +): Promise { + return getSortedCliVersions(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 @@ -550,19 +639,26 @@ async function resolveDefaultCliVersion( * 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. A URL, i.e. a string starting with `http`: the CodeQL Bundle downloaded from that URL. - * 4. A bare CLI version number, e.g. `2.19.0` or `v2.19.0`: the CodeQL Bundle release + * 3. `latest-prerelease`: the newest semantically versioned CodeQL bundle release, whether it + * is a stable release or a GitHub prerelease (see `isLatestPrereleaseToolsInput`). This is + * the only category that may resolve to a prerelease. + * 4. `nightly-until` or `nightly-until-stable`, e.g. `nightly-until2.24.0` or + * `nightly-until-stable2.24.0`: the newest release at or above that version threshold, among + * stable releases and, unless `-stable` is used, GitHub prereleases too; or the latest + * nightly bundle if no such release exists (see `tryGetNightlyUntilToolsInput`). + * 5. A URL, i.e. a string starting with `http`: the CodeQL Bundle downloaded from that URL. + * 6. A bare CLI version number, e.g. `2.19.0` or `v2.19.0`: the CodeQL Bundle release * containing that CLI version (see `tryGetCliVersionFromToolsInput`). - * 5. `latest-`, e.g. `latest-1` or `LATEST-2`: the stable CLI release `N` positions before + * 7. `latest-`, e.g. `latest-1` or `LATEST-2`: the stable CLI release `N` positions before * the most recent one (see `tryGetLatestOffsetFromToolsInput`). - * 6. A semantic version range, e.g. `2.24.x`, `2.x`, `~2.24.0`, or `^2.24.0`: the newest stable + * 8. 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`). - * 7. Anything else: a local path to a CodeQL Bundle tarball. + * 9. Anything else: a local path to a CodeQL Bundle tarball. * - * Categories 4-6 are all detected using the `semver` package, which only matches a bare version + * Categories 6-8 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 3) always - * contains `://`. So even though the checks for categories 4-6 don't explicitly exclude URLs, + * never permits a `:` or `/` character in a version or a range, whereas a URL (category 5) always + * contains `://`. So even though the checks for categories 6-8 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. @@ -592,13 +688,16 @@ export async function getCodeQLSource( ): Promise { // 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, or a semantic version range, then we assume it is a local path and use the CLI from + // offset, a semantic version range, `latest-prerelease`, or `nightly-until`/ + // `nightly-until-stable`, 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) && + !isLatestPrereleaseToolsInput(toolsInput) && + tryGetNightlyUntilToolsInput(toolsInput) === undefined && !toolsInput.startsWith("http") && tryGetCliVersionFromToolsInput(toolsInput) === undefined && tryGetLatestOffsetFromToolsInput(toolsInput) === undefined && @@ -673,6 +772,56 @@ export async function getCodeQLSource( ); } 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. This is the + // only `tools` input form that may resolve to a prerelease. + const sortedVersions = + await getSortedCliVersionsIncludingPrereleases(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` or `nightly-until-stable` syntax was used: use the + // newest release at or above the given version threshold, among stable releases and, unless + // `-stable` was specified, GitHub prereleases too. If no release satisfies the threshold, + // fall back to the latest nightly bundle. + const { rawThreshold, stableOnly } = + tryGetNightlyUntilToolsInput(toolsInput)!; + const threshold = parseNightlyUntilThreshold(toolsInput, rawThreshold); + const sortedVersions = stableOnly + ? await getSortedStableCliVersions(logger) + : await getSortedCliVersionsIncludingPrereleases(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${stableOnly ? " stable" : ""} CodeQL CLI release satisfies the version ` + + `threshold of ${threshold}.`, + ); + toolsInput = await getNightlyToolsUrl(logger); + } } /** From 06da64295bbd57b6ee6597907b2f4249c7784000 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:46:11 +0000 Subject: [PATCH 6/9] Require a separator before the version in nightly-until forms; fix prerelease docs Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- CHANGELOG.md | 2 +- init/action.yml | 4 ++-- lib/entry-points.js | 2 +- setup-codeql/action.yml | 4 ++-- src/setup-codeql.test.ts | 28 +++++++++++----------- src/setup-codeql.ts | 51 +++++++++++++++++++++------------------- 6 files changed, 47 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52d6122ce1..782f82cfb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ See the [releases page](https://github.com/github/codeql-action/releases) for th - 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` and `nightly-until-stable` (for example `nightly-until2.24.0`), which resolve to the most recent CodeQL Bundle release satisfying that version threshold, or fall back to the latest nightly version of the CodeQL tools if no such release exists. +- 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-` and `nightly-until-stable-` (for example `nightly-until-2.24.0`), which resolve to the most recent CodeQL Bundle release satisfying that version threshold, considering GitHub prereleases too unless `-stable` is specified, or fall back to the latest nightly version of the CodeQL tools if no such release exists. ## 4.37.5 - 03 Aug 2026 diff --git a/init/action.yml b/init/action.yml index ba969c5c5c..8b41e4ef4f 100644 --- a/init/action.yml +++ b/init/action.yml @@ -20,8 +20,8 @@ inputs: 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` or `nightly-until-stable`, for example - `nightly-until2.24.0` or `NIGHTLY-UNTIL-STABLE2.24.0` (matching is case insensitive), + - `nightly-until-` or `nightly-until-stable-`, for example + `nightly-until-2.24.0` or `NIGHTLY-UNTIL-STABLE-2.24.0` (matching is case insensitive), which uses the most recent CodeQL Bundle release satisfying that version threshold, considering GitHub prereleases too unless `-stable` is specified, or falls back to the latest nightly version of the CodeQL tools if no such release exists, or diff --git a/lib/entry-points.js b/lib/entry-points.js index dd434efc7e..20de1168b5 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -150932,7 +150932,7 @@ 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_TOOLS_INPUT_REGEX = /^nightly-until(-stable)?(.+)$/i; +var NIGHTLY_UNTIL_TOOLS_INPUT_REGEX = /^nightly-until(-stable)?-(.+)$/i; var CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE = 100; var CODEQL_BUNDLE_RELEASE_LIST_MAX_PAGES = 20; function getCodeQLBundleExtension(compressionMethod) { diff --git a/setup-codeql/action.yml b/setup-codeql/action.yml index 496c80185d..b2d4f7f965 100644 --- a/setup-codeql/action.yml +++ b/setup-codeql/action.yml @@ -20,8 +20,8 @@ inputs: 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` or `nightly-until-stable`, for example - `nightly-until2.24.0` or `NIGHTLY-UNTIL-STABLE2.24.0` (matching is case insensitive), + - `nightly-until-` or `nightly-until-stable-`, for example + `nightly-until-2.24.0` or `NIGHTLY-UNTIL-STABLE-2.24.0` (matching is case insensitive), which uses the most recent CodeQL Bundle release satisfying that version threshold, considering GitHub prereleases too unless `-stable` is specified, or falls back to the latest nightly version of the CodeQL tools if no such release exists, or diff --git a/src/setup-codeql.test.ts b/src/setup-codeql.test.ts index 74770fd13e..1df9270bac 100644 --- a/src/setup-codeql.test.ts +++ b/src/setup-codeql.test.ts @@ -421,7 +421,7 @@ function mockListStableCodeQLBundleReleases( /** * 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` or `nightly-until-stable` version + * release satisfies a `nightly-until-` or `nightly-until-stable-` version * threshold. */ function mockListCodeQLBundleReleasesWithNightlyFallback( @@ -620,7 +620,7 @@ test.serial( /** * A set of CodeQL bundle releases that includes GitHub prereleases, used to test resolution of - * the `latest-prerelease` and `nightly-until`/`nightly-until-stable` forms of + * the `latest-prerelease` and `nightly-until-`/`nightly-until-stable-` 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. @@ -728,49 +728,49 @@ test.serial( const NIGHTLY_UNTIL_RESOLVES_TOOLS_INPUT_TEST_CASES = [ { name: "threshold exactly matches the newest release, which is a prerelease", - toolsInput: "nightly-until2.26.0", + 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-until2.20.0", + 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-until2.25.4", + toolsInput: "nightly-until-2.25.4", releases: STABLE_BUNDLE_RELEASES_TEST_SET, expectedCliVersion: "2.26.0", }, { name: "matching is case insensitive", - toolsInput: "NIGHTLY-UNTIL2.25.4", + 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-until2.20.0", + toolsInput: "nightly-until-2.20.0", releases: PRERELEASE_BUNDLE_RELEASES_TEST_SET, expectedCliVersion: "2.27.0", }, { name: "nightly-until-stable: threshold exactly matches the newest stable release", - toolsInput: "nightly-until-stable2.25.3", + toolsInput: "nightly-until-stable-2.25.3", releases: STABLE_BUNDLE_RELEASES_TEST_SET, expectedCliVersion: "2.25.3", }, { name: "nightly-until-stable: the newest stable release comfortably exceeds the threshold", - toolsInput: "nightly-until-stable2.20.0", + toolsInput: "nightly-until-stable-2.20.0", releases: STABLE_BUNDLE_RELEASES_TEST_SET, expectedCliVersion: "2.25.3", }, { name: "nightly-until-stable: matching is case insensitive", - toolsInput: "NIGHTLY-UNTIL-STABLE2.25.3", + toolsInput: "NIGHTLY-UNTIL-STABLE-2.25.3", releases: STABLE_BUNDLE_RELEASES_TEST_SET, expectedCliVersion: "2.25.3", }, @@ -814,11 +814,11 @@ for (const { const NIGHTLY_UNTIL_FALLBACK_TOOLS_INPUT_TEST_CASES = [ { name: "no release, stable or prerelease, satisfies the threshold", - toolsInput: "nightly-until9.0.0", + toolsInput: "nightly-until-9.0.0", }, { name: "only a prerelease, not a stable release, would satisfy the threshold", - toolsInput: "nightly-until-stable2.25.4", + toolsInput: "nightly-until-stable-2.25.4", }, ] as const; @@ -881,9 +881,9 @@ for (const { } const MALFORMED_NIGHTLY_UNTIL_THRESHOLD_TOOLS_INPUT_TEST_CASES = [ - { toolsInput: "nightly-untilbogus", expectedRawThreshold: "bogus" }, + { toolsInput: "nightly-until-bogus", expectedRawThreshold: "bogus" }, { - toolsInput: "nightly-until-stablebogus", + toolsInput: "nightly-until-stable-bogus", expectedRawThreshold: "bogus", }, ] as const; diff --git a/src/setup-codeql.ts b/src/setup-codeql.ts index 898ce46f9a..ffc96a850e 100644 --- a/src/setup-codeql.ts +++ b/src/setup-codeql.ts @@ -60,13 +60,13 @@ const LATEST_OFFSET_TOOLS_INPUT_REGEX = /^latest-(\d+)$/i; const LATEST_PRERELEASE_TOOLS_INPUT = "latest-prerelease"; /** - * Matches the `nightly-until` and `nightly-until-stable` forms of the `tools` - * input, for example `nightly-until2.24.0` or `NIGHTLY-UNTIL-STABLE2.24.0` (matching is case - * insensitive). Capture group 1 is `-stable` if the `-stable` variant was used, or `undefined` - * otherwise. Capture group 2 is the raw version threshold, which this regular expression does not - * validate: see `parseNightlyUntilThreshold`. + * Matches the `nightly-until-` and `nightly-until-stable-` forms of the + * `tools` input, for example `nightly-until-2.24.0` or `NIGHTLY-UNTIL-STABLE-2.24.0` (matching is + * case insensitive). Capture group 1 is `-stable` if the `-stable` variant was used, or + * `undefined` otherwise. Capture group 2 is the raw version threshold, which this regular + * expression does not validate: see `parseNightlyUntilThreshold`. */ -const NIGHTLY_UNTIL_TOOLS_INPUT_REGEX = /^nightly-until(-stable)?(.+)$/i; +const NIGHTLY_UNTIL_TOOLS_INPUT_REGEX = /^nightly-until(-stable)?-(.+)$/i; /** Number of releases requested per page when listing CodeQL bundle releases. */ const CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE = 100; @@ -300,16 +300,17 @@ function tryGetCliVersionRangeFromToolsInput( * `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. This is the only `tools` input form that may - * resolve to a prerelease: every other form only ever considers stable releases. + * it is a stable release or a GitHub prerelease. Along with the non-`-stable` form of + * `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` or `nightly-until-stable` - * syntax, for example `nightly-until2.24.0` or `NIGHTLY-UNTIL-STABLE2.24.0` (matching is case + * If the `tools` input matches the `nightly-until-` or `nightly-until-stable-` + * syntax, for example `nightly-until-2.24.0` or `NIGHTLY-UNTIL-STABLE-2.24.0` (matching is case * insensitive), returns the raw version threshold (not yet validated as a semantic version: see * `parseNightlyUntilThreshold`) and whether only stable releases should be considered when * resolving that threshold. Otherwise returns `undefined`. @@ -324,8 +325,8 @@ function tryGetNightlyUntilToolsInput( } /** - * Validates that `rawThreshold`, extracted from the `nightly-until` or - * `nightly-until-stable` form of the `tools` input by `tryGetNightlyUntilToolsInput`, is + * Validates that `rawThreshold`, extracted from the `nightly-until-` or + * `nightly-until-stable-` form of the `tools` input by `tryGetNightlyUntilToolsInput`, 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. @@ -410,7 +411,7 @@ async function getSortedStableCliVersions(logger: Logger): Promise { * 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 + * `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. */ @@ -640,12 +641,13 @@ async function resolveDefaultCliVersion( * `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`). This is - * the only category that may resolve to a prerelease. - * 4. `nightly-until` or `nightly-until-stable`, e.g. `nightly-until2.24.0` or - * `nightly-until-stable2.24.0`: the newest release at or above that version threshold, among - * stable releases and, unless `-stable` is used, GitHub prereleases too; or the latest - * nightly bundle if no such release exists (see `tryGetNightlyUntilToolsInput`). + * is a stable release or a GitHub prerelease (see `isLatestPrereleaseToolsInput`). + * 4. `nightly-until-` or `nightly-until-stable-`, e.g. `nightly-until-2.24.0` + * or `nightly-until-stable-2.24.0`: the newest release at or above that version threshold, + * among stable releases and, unless `-stable` is used, GitHub prereleases too; or the latest + * nightly bundle if no such release exists (see `tryGetNightlyUntilToolsInput`). Along with + * `latest-prerelease`, the non-`-stable` form of this category is the only other one that + * may resolve to a prerelease. * 5. A URL, i.e. a string starting with `http`: the CodeQL Bundle downloaded from that URL. * 6. A bare CLI version number, e.g. `2.19.0` or `v2.19.0`: the CodeQL Bundle release * containing that CLI version (see `tryGetCliVersionFromToolsInput`). @@ -688,8 +690,8 @@ export async function getCodeQLSource( ): Promise { // 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`, or `nightly-until`/ - // `nightly-until-stable`, then we assume it is a local path and use the CLI from + // offset, a semantic version range, `latest-prerelease`, or `nightly-until-`/ + // `nightly-until-stable-`, 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. @@ -777,8 +779,9 @@ export async function getCodeQLSource( 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. This is the - // only `tools` input form that may resolve to a prerelease. + // CodeQL bundle release, whether it is a stable release or a GitHub prerelease. Along with + // the non-`-stable` form of `nightly-until-`, this is one of the two `tools` input + // forms that may resolve to a prerelease. const sortedVersions = await getSortedCliVersionsIncludingPrereleases(logger); @@ -797,7 +800,7 @@ export async function getCodeQLSource( toolsInput !== undefined && tryGetNightlyUntilToolsInput(toolsInput) !== undefined ) { - // The `nightly-until` or `nightly-until-stable` syntax was used: use the + // The `nightly-until-` or `nightly-until-stable-` syntax was used: use the // newest release at or above the given version threshold, among stable releases and, unless // `-stable` was specified, GitHub prereleases too. If no release satisfies the threshold, // fall back to the latest nightly bundle. From 8f22549aa9c8505791f7a43300281318430e5406 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:07:08 +0000 Subject: [PATCH 7/9] Fix GHES multi-host resolution bug in getSortedCliVersions Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- lib/entry-points.js | 42 +++++++++++++---- src/api-client.test.ts | 24 ++++++++++ src/api-client.ts | 33 ++++++++++++++ src/setup-codeql.test.ts | 98 ++++++++++++++++++++++++++++++++++++++++ src/setup-codeql.ts | 40 ++++++++++++---- src/testing-utils.ts | 6 +++ src/util.ts | 5 ++ 7 files changed, 228 insertions(+), 20 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 20de1168b5..d98d50854a 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -144772,6 +144772,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; @@ -145980,6 +145981,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.`); @@ -151079,12 +151097,13 @@ function parseNightlyUntilThreshold(toolsInput, rawThreshold) { } return threshold; } -async function getSortedCliVersions(logger, includePrereleases) { +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 getApiClient().rest.repos.listReleases({ + const response = await apiClient.rest.repos.listReleases({ owner, repo, per_page: CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE, @@ -151113,11 +151132,11 @@ async function getSortedCliVersions(logger, includePrereleases) { } return [...versions].sort(semver9.rcompare); } -async function getSortedStableCliVersions(logger) { - return getSortedCliVersions(logger, false); +async function getSortedStableCliVersions(variant, logger) { + return getSortedCliVersions(variant, logger, false); } -async function getSortedCliVersionsIncludingPrereleases(logger) { - return getSortedCliVersions(logger, true); +async function getSortedCliVersionsIncludingPrereleases(variant, logger) { + return getSortedCliVersions(variant, logger, true); } function describeAvailableCliVersionRange(sortedVersions) { if (sortedVersions.length === 0) { @@ -151281,7 +151300,10 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO } toolsInput = await getNightlyToolsUrl(logger); } else if (toolsInput !== void 0 && isLatestPrereleaseToolsInput(toolsInput)) { - const sortedVersions = await getSortedCliVersionsIncludingPrereleases(logger); + 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.` @@ -151294,7 +151316,7 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO } else if (toolsInput !== void 0 && tryGetNightlyUntilToolsInput(toolsInput) !== void 0) { const { rawThreshold, stableOnly } = tryGetNightlyUntilToolsInput(toolsInput); const threshold = parseNightlyUntilThreshold(toolsInput, rawThreshold); - const sortedVersions = stableOnly ? await getSortedStableCliVersions(logger) : await getSortedCliVersionsIncludingPrereleases(logger); + const sortedVersions = stableOnly ? await getSortedStableCliVersions(variant, logger) : 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}.` @@ -151359,7 +151381,7 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO ); } else if (toolsInput !== void 0 && tryGetLatestOffsetFromToolsInput(toolsInput) !== void 0) { const offset = tryGetLatestOffsetFromToolsInput(toolsInput); - const sortedVersions = await getSortedStableCliVersions(logger); + 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( @@ -151374,7 +151396,7 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO ); } else if (toolsInput !== void 0 && tryGetCliVersionRangeFromToolsInput(toolsInput) !== void 0) { const range2 = tryGetCliVersionRangeFromToolsInput(toolsInput); - const sortedVersions = await getSortedStableCliVersions(logger); + const sortedVersions = await getSortedStableCliVersions(variant, logger); const resolvedVersion = semver9.maxSatisfying(sortedVersions, range2); if (!resolvedVersion) { throw new ConfigurationError( 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 1df9270bac..2043d6a48b 100644 --- a/src/setup-codeql.test.ts +++ b/src/setup-codeql.test.ts @@ -18,6 +18,7 @@ import { LoggedMessage, SAMPLE_DEFAULT_CLI_VERSION, SAMPLE_DOTCOM_API_DETAILS, + SAMPLE_GHES_API_DETAILS, checkExpectedLogMessages, createFeatures, createTestConfig, @@ -435,6 +436,47 @@ function mockListCodeQLBundleReleasesWithNightlyFallback( } 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" }, @@ -924,6 +966,62 @@ for (const { ); } +/** + * `latest-`, SemVer ranges, `latest-prerelease`, and `nightly-until-`/ + * `nightly-until-stable-` 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 ffc96a850e..dc2f3000e5 100644 --- a/src/setup-codeql.ts +++ b/src/setup-codeql.ts @@ -353,17 +353,31 @@ function parseNightlyUntilThreshold( * 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 api.getApiClient().rest.repos.listReleases({ + const response = await apiClient.rest.repos.listReleases({ owner, repo, per_page: CODEQL_BUNDLE_RELEASE_LIST_PAGE_SIZE, @@ -401,8 +415,11 @@ async function getSortedCliVersions( * published to the canonical CodeQL Action repository, sorted in descending semantic-version * order (newest first). See `getSortedCliVersions` for details. */ -async function getSortedStableCliVersions(logger: Logger): Promise { - return getSortedCliVersions(logger, false); +async function getSortedStableCliVersions( + variant: util.GitHubVariant, + logger: Logger, +): Promise { + return getSortedCliVersions(variant, logger, false); } /** @@ -416,9 +433,10 @@ async function getSortedStableCliVersions(logger: Logger): Promise { * prerelease. */ async function getSortedCliVersionsIncludingPrereleases( + variant: util.GitHubVariant, logger: Logger, ): Promise { - return getSortedCliVersions(logger, true); + return getSortedCliVersions(variant, logger, true); } /** @@ -782,8 +800,10 @@ export async function getCodeQLSource( // CodeQL bundle release, whether it is a stable release or a GitHub prerelease. Along with // the non-`-stable` form of `nightly-until-`, this is one of the two `tools` input // forms that may resolve to a prerelease. - const sortedVersions = - await getSortedCliVersionsIncludingPrereleases(logger); + const sortedVersions = await getSortedCliVersionsIncludingPrereleases( + variant, + logger, + ); if (sortedVersions.length === 0) { throw new util.ConfigurationError( @@ -808,8 +828,8 @@ export async function getCodeQLSource( tryGetNightlyUntilToolsInput(toolsInput)!; const threshold = parseNightlyUntilThreshold(toolsInput, rawThreshold); const sortedVersions = stableOnly - ? await getSortedStableCliVersions(logger) - : await getSortedCliVersionsIncludingPrereleases(logger); + ? await getSortedStableCliVersions(variant, logger) + : await getSortedCliVersionsIncludingPrereleases(variant, logger); if (sortedVersions.length > 0 && semver.gte(sortedVersions[0], threshold)) { logger.info( @@ -921,7 +941,7 @@ export async function getCodeQLSource( // 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(logger); + const sortedVersions = await getSortedStableCliVersions(variant, logger); if (offset >= sortedVersions.length) { throw new util.ConfigurationError( @@ -949,7 +969,7 @@ export async function getCodeQLSource( // 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(logger); + const sortedVersions = await getSortedStableCliVersions(variant, logger); const resolvedVersion = semver.maxSatisfying(sortedVersions, range); if (!resolvedVersion) { 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. */ From 71844e5a46c84d8d3abf0d0d2f28c12f3e32ab0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:50:11 +0000 Subject: [PATCH 8/9] Replace nightly-until-stable- with nightly-until-default- Compares the threshold directly against the Action's known default CLI version instead of querying the release list, per feedback. Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- CHANGELOG.md | 3 +- init/action.yml | 13 +-- lib/entry-points.js | 38 +++++++-- setup-codeql/action.yml | 13 +-- src/setup-codeql.test.ts | 168 ++++++++++++++++++++++++++++++++------- src/setup-codeql.ts | 164 +++++++++++++++++++++++++------------- 6 files changed, 298 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 782f82cfb9..da656c7be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ See the [releases page](https://github.com/github/codeql-action/releases) for th - 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-` and `nightly-until-stable-` (for example `nightly-until-2.24.0`), which resolve to the most recent CodeQL Bundle release satisfying that version threshold, considering GitHub prereleases too unless `-stable` is specified, or fall back to the latest nightly version of the CodeQL tools if no such release exists. +- 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.5 - 03 Aug 2026 diff --git a/init/action.yml b/init/action.yml index 8b41e4ef4f..cbb3f4f530 100644 --- a/init/action.yml +++ b/init/action.yml @@ -20,11 +20,14 @@ inputs: 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-` or `nightly-until-stable-`, for example - `nightly-until-2.24.0` or `NIGHTLY-UNTIL-STABLE-2.24.0` (matching is case insensitive), - which uses the most recent CodeQL Bundle release satisfying that version threshold, - considering GitHub prereleases too unless `-stable` is specified, or falls back to the - latest nightly version of the CodeQL tools if no such release exists, 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 d98d50854a..a43d50dc1a 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -150950,7 +150950,8 @@ 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_TOOLS_INPUT_REGEX = /^nightly-until(-stable)?-(.+)$/i; +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) { @@ -151085,8 +151086,15 @@ 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 ? { rawThreshold: match2[2], stableOnly: match2[1] !== void 0 } : void 0; + 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); @@ -151250,7 +151258,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) && !isLatestPrereleaseToolsInput(toolsInput) && tryGetNightlyUntilToolsInput(toolsInput) === void 0 && !toolsInput.startsWith("http") && tryGetCliVersionFromToolsInput(toolsInput) === void 0 && tryGetLatestOffsetFromToolsInput(toolsInput) === void 0 && tryGetCliVersionRangeFromToolsInput(toolsInput) === void 0) { + 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) { @@ -151299,6 +151307,21 @@ 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 defaultVersion = defaultCliVersion.enabledVersions[0].cliVersion; + if (semver9.gte(defaultVersion, threshold)) { + logger.info( + `'tools: ${toolsInput}' was requested, so using the default CodeQL version, since the default CodeQL version ${defaultVersion} satisfies the version threshold of ${threshold}.` + ); + toolsInput = void 0; + } else { + logger.info( + `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since the default CodeQL version ${defaultVersion} does not satisfy the version threshold of ${threshold}.` + ); + toolsInput = await getNightlyToolsUrl(logger); + } } else if (toolsInput !== void 0 && isLatestPrereleaseToolsInput(toolsInput)) { const sortedVersions = await getSortedCliVersionsIncludingPrereleases( variant, @@ -151314,9 +151337,12 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO ); toolsInput = sortedVersions[0]; } else if (toolsInput !== void 0 && tryGetNightlyUntilToolsInput(toolsInput) !== void 0) { - const { rawThreshold, stableOnly } = tryGetNightlyUntilToolsInput(toolsInput); + const rawThreshold = tryGetNightlyUntilToolsInput(toolsInput); const threshold = parseNightlyUntilThreshold(toolsInput, rawThreshold); - const sortedVersions = stableOnly ? await getSortedStableCliVersions(variant, logger) : await getSortedCliVersionsIncludingPrereleases(variant, logger); + 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}.` @@ -151324,7 +151350,7 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO toolsInput = sortedVersions[0]; } else { logger.info( - `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since no eligible${stableOnly ? " stable" : ""} CodeQL CLI release satisfies the version threshold of ${threshold}.` + `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); } diff --git a/setup-codeql/action.yml b/setup-codeql/action.yml index b2d4f7f965..208a014f06 100644 --- a/setup-codeql/action.yml +++ b/setup-codeql/action.yml @@ -20,11 +20,14 @@ inputs: 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-` or `nightly-until-stable-`, for example - `nightly-until-2.24.0` or `NIGHTLY-UNTIL-STABLE-2.24.0` (matching is case insensitive), - which uses the most recent CodeQL Bundle release satisfying that version threshold, - considering GitHub prereleases too unless `-stable` is specified, or falls back to the - latest nightly version of the CodeQL tools if no such release exists, 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/setup-codeql.test.ts b/src/setup-codeql.test.ts index 2043d6a48b..9cdab392cf 100644 --- a/src/setup-codeql.test.ts +++ b/src/setup-codeql.test.ts @@ -422,8 +422,7 @@ function mockListStableCodeQLBundleReleases( /** * 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-` or `nightly-until-stable-` version - * threshold. + * release satisfies a `nightly-until-` version threshold. */ function mockListCodeQLBundleReleasesWithNightlyFallback( releases: unknown[], @@ -662,9 +661,9 @@ test.serial( /** * A set of CodeQL bundle releases that includes GitHub prereleases, used to test resolution of - * the `latest-prerelease` and `nightly-until-`/`nightly-until-stable-` 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 + * 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 = [ @@ -798,24 +797,6 @@ const NIGHTLY_UNTIL_RESOLVES_TOOLS_INPUT_TEST_CASES = [ releases: PRERELEASE_BUNDLE_RELEASES_TEST_SET, expectedCliVersion: "2.27.0", }, - { - name: "nightly-until-stable: threshold exactly matches the newest stable release", - toolsInput: "nightly-until-stable-2.25.3", - releases: STABLE_BUNDLE_RELEASES_TEST_SET, - expectedCliVersion: "2.25.3", - }, - { - name: "nightly-until-stable: the newest stable release comfortably exceeds the threshold", - toolsInput: "nightly-until-stable-2.20.0", - releases: STABLE_BUNDLE_RELEASES_TEST_SET, - expectedCliVersion: "2.25.3", - }, - { - name: "nightly-until-stable: matching is case insensitive", - toolsInput: "NIGHTLY-UNTIL-STABLE-2.25.3", - releases: STABLE_BUNDLE_RELEASES_TEST_SET, - expectedCliVersion: "2.25.3", - }, ] as const; for (const { @@ -853,15 +834,142 @@ for (const { ); } +/** + * `nightly-until-default-` compares the given version threshold directly against + * `SAMPLE_DEFAULT_CLI_VERSION`'s CLI version, `2.20.0`, without ever listing CodeQL bundle + * releases. When the default 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 NIGHTLY_UNTIL_FALLBACK_TOOLS_INPUT_TEST_CASES = [ { name: "no release, stable or prerelease, satisfies the threshold", toolsInput: "nightly-until-9.0.0", }, - { - name: "only a prerelease, not a stable release, would satisfy the threshold", - toolsInput: "nightly-until-stable-2.25.4", - }, ] as const; for (const { @@ -925,7 +1033,7 @@ for (const { const MALFORMED_NIGHTLY_UNTIL_THRESHOLD_TOOLS_INPUT_TEST_CASES = [ { toolsInput: "nightly-until-bogus", expectedRawThreshold: "bogus" }, { - toolsInput: "nightly-until-stable-bogus", + toolsInput: "nightly-until-default-bogus", expectedRawThreshold: "bogus", }, ] as const; @@ -967,8 +1075,8 @@ for (const { } /** - * `latest-`, SemVer ranges, `latest-prerelease`, and `nightly-until-`/ - * `nightly-until-stable-` all need to look up the canonical CodeQL Action repository's + * `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 diff --git a/src/setup-codeql.ts b/src/setup-codeql.ts index dc2f3000e5..09223c058f 100644 --- a/src/setup-codeql.ts +++ b/src/setup-codeql.ts @@ -60,13 +60,21 @@ const LATEST_OFFSET_TOOLS_INPUT_REGEX = /^latest-(\d+)$/i; const LATEST_PRERELEASE_TOOLS_INPUT = "latest-prerelease"; /** - * Matches the `nightly-until-` and `nightly-until-stable-` forms of the - * `tools` input, for example `nightly-until-2.24.0` or `NIGHTLY-UNTIL-STABLE-2.24.0` (matching is - * case insensitive). Capture group 1 is `-stable` if the `-stable` variant was used, or - * `undefined` otherwise. Capture group 2 is the raw version threshold, which this regular - * expression does not validate: see `parseNightlyUntilThreshold`. + * 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_TOOLS_INPUT_REGEX = /^nightly-until(-stable)?-(.+)$/i; +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; @@ -300,36 +308,55 @@ function tryGetCliVersionRangeFromToolsInput( * `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 the non-`-stable` form of - * `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. + * 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-` or `nightly-until-stable-` - * syntax, for example `nightly-until-2.24.0` or `NIGHTLY-UNTIL-STABLE-2.24.0` (matching is case + * 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`) and whether only stable releases should be considered when - * resolving that threshold. Otherwise returns `undefined`. + * `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 tryGetNightlyUntilToolsInput( +function tryGetNightlyUntilDefaultToolsInput( toolsInput: string, -): { rawThreshold: string; stableOnly: boolean } | undefined { - const match = toolsInput.match(NIGHTLY_UNTIL_TOOLS_INPUT_REGEX); - return match - ? { rawThreshold: match[2], stableOnly: match[1] !== undefined } - : undefined; +): 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-stable-` form of the `tools` input by `tryGetNightlyUntilToolsInput`, 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. + * `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, @@ -660,25 +687,28 @@ async function resolveDefaultCliVersion( * 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-` or `nightly-until-stable-`, e.g. `nightly-until-2.24.0` - * or `nightly-until-stable-2.24.0`: the newest release at or above that version threshold, - * among stable releases and, unless `-stable` is used, GitHub prereleases too; or the latest - * nightly bundle if no such release exists (see `tryGetNightlyUntilToolsInput`). Along with - * `latest-prerelease`, the non-`-stable` form of this category is the only other one that - * may resolve to a prerelease. - * 5. A URL, i.e. a string starting with `http`: the CodeQL Bundle downloaded from that URL. - * 6. A bare CLI version number, e.g. `2.19.0` or `v2.19.0`: the CodeQL Bundle release + * 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`). - * 7. `latest-`, e.g. `latest-1` or `LATEST-2`: the stable CLI release `N` positions before + * 8. `latest-`, e.g. `latest-1` or `LATEST-2`: the stable CLI release `N` positions before * the most recent one (see `tryGetLatestOffsetFromToolsInput`). - * 8. A semantic version range, e.g. `2.24.x`, `2.x`, `~2.24.0`, or `^2.24.0`: the newest stable + * 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`). - * 9. Anything else: a local path to a CodeQL Bundle tarball. + * 10. Anything else: a local path to a CodeQL Bundle tarball. * - * Categories 6-8 are all detected using the `semver` package, which only matches a bare version + * 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 5) always - * contains `://`. So even though the checks for categories 6-8 don't explicitly exclude URLs, + * 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. @@ -708,15 +738,16 @@ export async function getCodeQLSource( ): Promise { // 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`, or `nightly-until-`/ - // `nightly-until-stable-`, 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. + // 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) && !isLatestPrereleaseToolsInput(toolsInput) && + tryGetNightlyUntilDefaultToolsInput(toolsInput) === undefined && tryGetNightlyUntilToolsInput(toolsInput) === undefined && !toolsInput.startsWith("http") && tryGetCliVersionFromToolsInput(toolsInput) === undefined && @@ -792,14 +823,41 @@ 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 + // directly against this CodeQL Action's already-known default CLI version, without querying + // the CodeQL bundle release history. If the default CLI version is at or above the + // threshold, resume normal default CLI version selection, exactly as if `tools` had not been + // specified at all. Otherwise, fall back to the latest nightly bundle. + const rawThreshold = tryGetNightlyUntilDefaultToolsInput(toolsInput)!; + const threshold = parseNightlyUntilThreshold(toolsInput, rawThreshold); + const defaultVersion = defaultCliVersion.enabledVersions[0].cliVersion; + + if (semver.gte(defaultVersion, threshold)) { + logger.info( + `'tools: ${toolsInput}' was requested, so using the default CodeQL version, since the ` + + `default CodeQL version ${defaultVersion} satisfies the version threshold of ${threshold}.`, + ); + toolsInput = undefined; + } else { + logger.info( + `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since the ` + + `default CodeQL version ${defaultVersion} 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 - // the non-`-stable` form of `nightly-until-`, this is one of the two `tools` input - // forms that may resolve to a prerelease. + // `nightly-until-`, this is one of the two `tools` input forms that may resolve to a + // prerelease. const sortedVersions = await getSortedCliVersionsIncludingPrereleases( variant, logger, @@ -820,16 +878,15 @@ export async function getCodeQLSource( toolsInput !== undefined && tryGetNightlyUntilToolsInput(toolsInput) !== undefined ) { - // The `nightly-until-` or `nightly-until-stable-` syntax was used: use the - // newest release at or above the given version threshold, among stable releases and, unless - // `-stable` was specified, GitHub prereleases too. If no release satisfies the threshold, - // fall back to the latest nightly bundle. - const { rawThreshold, stableOnly } = - tryGetNightlyUntilToolsInput(toolsInput)!; + // 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 = stableOnly - ? await getSortedStableCliVersions(variant, logger) - : await getSortedCliVersionsIncludingPrereleases(variant, logger); + const sortedVersions = await getSortedCliVersionsIncludingPrereleases( + variant, + logger, + ); if (sortedVersions.length > 0 && semver.gte(sortedVersions[0], threshold)) { logger.info( @@ -840,8 +897,7 @@ export async function getCodeQLSource( } else { logger.info( `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since no ` + - `eligible${stableOnly ? " stable" : ""} CodeQL CLI release satisfies the version ` + - `threshold of ${threshold}.`, + `eligible CodeQL CLI release satisfies the version threshold of ${threshold}.`, ); toolsInput = await getNightlyToolsUrl(logger); } From 9892b527887a74ed04a81f8b2d328f248f294f39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:27:15 +0000 Subject: [PATCH 9/9] Add tests for nightly-until-default overlay-aware and GHES override scenarios Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- lib/entry-points.js | 21 +++- src/setup-codeql.test.ts | 265 ++++++++++++++++++++++++++++++++++++++- src/setup-codeql.ts | 44 +++++-- 3 files changed, 311 insertions(+), 19 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index a43d50dc1a..29a390740c 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151310,15 +151310,26 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO } else if (toolsInput !== void 0 && tryGetNightlyUntilDefaultToolsInput(toolsInput) !== void 0) { const rawThreshold = tryGetNightlyUntilDefaultToolsInput(toolsInput); const threshold = parseNightlyUntilThreshold(toolsInput, rawThreshold); - const defaultVersion = defaultCliVersion.enabledVersions[0].cliVersion; - if (semver9.gte(defaultVersion, threshold)) { + 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 default CodeQL version ${defaultVersion} satisfies the version threshold of ${threshold}.` + `'tools: ${toolsInput}' was requested, so using the default CodeQL version, since the resolved default CodeQL version ${resolvedVersion} satisfies the version threshold of ${threshold}.` ); - toolsInput = void 0; + return defaultSource; } else { logger.info( - `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since the default CodeQL version ${defaultVersion} does not satisfy the version threshold of ${threshold}.` + `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); } diff --git a/src/setup-codeql.test.ts b/src/setup-codeql.test.ts index 9cdab392cf..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"; @@ -835,10 +836,12 @@ for (const { } /** - * `nightly-until-default-` compares the given version threshold directly against - * `SAMPLE_DEFAULT_CLI_VERSION`'s CLI version, `2.20.0`, without ever listing CodeQL bundle - * releases. When the default version is at or above the threshold, resolution should proceed - * exactly as if `tools` were not specified at all. + * `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 = [ { @@ -965,6 +968,260 @@ test.serial( }, ); +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", diff --git a/src/setup-codeql.ts b/src/setup-codeql.ts index 09223c058f..278e7fcae8 100644 --- a/src/setup-codeql.ts +++ b/src/setup-codeql.ts @@ -828,25 +828,49 @@ export async function getCodeQLSource( tryGetNightlyUntilDefaultToolsInput(toolsInput) !== undefined ) { // The `nightly-until-default-` syntax was used: compare the given version threshold - // directly against this CodeQL Action's already-known default CLI version, without querying - // the CodeQL bundle release history. If the default CLI version is at or above the - // threshold, resume normal default CLI version selection, exactly as if `tools` had not been - // specified at all. Otherwise, fall back to the latest nightly bundle. + // 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 defaultVersion = defaultCliVersion.enabledVersions[0].cliVersion; - if (semver.gte(defaultVersion, threshold)) { + 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 ` + - `default CodeQL version ${defaultVersion} satisfies the version threshold of ${threshold}.`, + `resolved default CodeQL version ${resolvedVersion} satisfies the version threshold ` + + `of ${threshold}.`, ); - toolsInput = undefined; + return defaultSource; } else { logger.info( `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}', since the ` + - `default CodeQL version ${defaultVersion} does not satisfy the version threshold of ` + - `${threshold}.`, + `resolved default CodeQL version${ + resolvedVersion !== undefined ? ` ${resolvedVersion}` : "" + } does not satisfy the version threshold of ${threshold}.`, ); toolsInput = await getNightlyToolsUrl(logger); }