diff --git a/.github/workflows/__bundle-toolcache.yml b/.github/workflows/__bundle-toolcache.yml index d12aeb6e7..0055f9470 100644 --- a/.github/workflows/__bundle-toolcache.yml +++ b/.github/workflows/__bundle-toolcache.yml @@ -80,6 +80,7 @@ jobs: - id: init uses: ./../action/init with: + # Request multiple languages so this check uses the combined bundle. languages: javascript,python tools: ${{ steps.prepare-test.outputs.tools-url }} - uses: ./../action/analyze diff --git a/lib/entry-points.js b/lib/entry-points.js index d18a5d66a..0dc581dc5 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -151576,7 +151576,7 @@ async function getPerLanguageBundleLanguage({ compressionMethod, platform: platform2, variant, - isNightly + isLatestNightly } = options; const explain = (reason) => { logger.debug(`Not using a per-language CodeQL bundle since ${reason}.`); @@ -151595,7 +151595,7 @@ async function getPerLanguageBundleLanguage({ return explain(`'${rawLanguages[0]}' is not a known CodeQL language`); } if (compressionMethod !== "zstd") { - return explain(`the bundle would be downloaded as ${compressionMethod}`); + return explain(`the bundle would be downloaded as '${compressionMethod}'`); } if (variant !== "GitHub.com" /* DOTCOM */) { return explain(`we are running against ${variant}`); @@ -151603,13 +151603,13 @@ async function getPerLanguageBundleLanguage({ if (!isGitHubHostedRunner(env)) { return explain("the job is not running on a GitHub-hosted runner"); } - if (!isNightly) { + if (!isLatestNightly) { if (cliVersion2 === void 0) { - return explain("the CLI version of the bundle is unknown"); + return explain("the requested CLI version is unknown"); } if (!semver7.gte(cliVersion2, MIN_PER_LANGUAGE_BUNDLE_CLI_VERSION)) { return explain( - `CodeQL ${cliVersion2} is older than ${MIN_PER_LANGUAGE_BUNDLE_CLI_VERSION}, which is the first version that publishes per-language bundles` + `the requested CodeQL version ${cliVersion2} is older than ${MIN_PER_LANGUAGE_BUNDLE_CLI_VERSION}, which is the first version for which per-language bundles are published` ); } } @@ -152283,7 +152283,7 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}'.` ); } - bundle = await getNightlyBundle( + bundle = await getLatestNightlyBundle( { env: getEnv(), features, logger }, rawLanguages, variant @@ -152441,6 +152441,12 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO } let compressionMethod; if (!url2) { + const bundleTagName = tagName; + if (bundleTagName === void 0) { + throw new Error( + "Could not determine a release tag for the requested CodeQL bundle." + ); + } compressionMethod = cliVersion2 !== void 0 && await useZstdBundle(cliVersion2, tarSupportsZstd) ? "zstd" : "gzip"; const perLanguageBundleLanguage = await getPerLanguageBundleLanguage( { env: getEnv(), features, logger }, @@ -152453,24 +152459,25 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO } ); const resolveBundleURL = (language) => getCodeQLBundleDownloadURL( - tagName, + bundleTagName, apiDetails, getCodeQLBundleName(compressionMethod, language), logger ); + const combinedBundleURL = await resolveBundleURL(); if (perLanguageBundleLanguage !== void 0) { logger.info( - `Downloading the ${perLanguageBundleLanguage} CodeQL bundle, since ${perLanguageBundleLanguage} is the only language being analyzed.` + `Selected the per-language CodeQL bundle for '${perLanguageBundleLanguage}'.` ); url2 = await resolveBundleURL(perLanguageBundleLanguage); bundle = { kind: "per-language", url: url2, language: perLanguageBundleLanguage, - combinedBundleURL: await resolveBundleURL() + combinedBundleURL }; } else { - url2 = await resolveBundleURL(); + url2 = combinedBundleURL; bundle = { kind: "combined", url: url2 }; } } else { @@ -152679,7 +152686,7 @@ async function downloadCodeQLBundle(action, source, apiDetails, tarVersion, temp throw e; } logger.warning( - `No ${bundle.language} CodeQL bundle was found at ${bundle.url}, so falling back to the bundle that contains all languages. This analysis will still produce correct results, but will take longer to set up.` + `No per-language CodeQL bundle for '${bundle.language}' was found at ${bundle.url}, so falling back to the bundle that contains all languages. This analysis will still produce correct results, but will take longer to set up.` ); const result = await downloadCodeQL( { @@ -152710,7 +152717,7 @@ async function useZstdBundle(cliVersion2, tarSupportsZstd) { function getTempExtractionDir(tempDir) { return path13.join(tempDir, v4_default()); } -async function getNightlyBundle(action, rawLanguages, variant) { +async function getLatestNightlyBundle(action, rawLanguages, variant) { const { logger } = action; const zstdAvailability = await isZstdAvailable(logger); const compressionMethod = await useZstdBundle( @@ -152723,7 +152730,7 @@ async function getNightlyBundle(action, rawLanguages, variant) { compressionMethod, platform: getBundlePlatform(), variant, - isNightly: true + isLatestNightly: true }); try { const release2 = await getApiClient().rest.repos.listReleases({ diff --git a/pr-checks/checks/bundle-toolcache.yml b/pr-checks/checks/bundle-toolcache.yml index efa1a4d76..f74c6af75 100644 --- a/pr-checks/checks/bundle-toolcache.yml +++ b/pr-checks/checks/bundle-toolcache.yml @@ -30,6 +30,7 @@ steps: - id: init uses: ./../action/init with: + # Request multiple languages so this check uses the combined bundle. languages: javascript,python tools: ${{ steps.prepare-test.outputs.tools-url }} - uses: ./../action/analyze diff --git a/src/per-language-bundles.test.ts b/src/per-language-bundles.test.ts index d192d43c6..ea8315001 100644 --- a/src/per-language-bundles.test.ts +++ b/src/per-language-bundles.test.ts @@ -148,8 +148,8 @@ test("getPerLanguageBundleLanguage explains a disabled feature before checking e ); }); -test("getPerLanguageBundleLanguage skips only the release version check for nightlies", async (t) => { - const nightly = { isNightly: true, cliVersion: undefined }; +test("getPerLanguageBundleLanguage skips only the release version check for the latest nightly", async (t) => { + const nightly = { isLatestNightly: true, cliVersion: undefined }; t.is(await checkEligibility(nightly), BuiltInLanguage.java); for (const overrides of [ diff --git a/src/per-language-bundles.ts b/src/per-language-bundles.ts index fee52d220..20720636f 100644 --- a/src/per-language-bundles.ts +++ b/src/per-language-bundles.ts @@ -14,7 +14,7 @@ export const MIN_PER_LANGUAGE_BUNDLE_CLI_VERSION = "2.27.1"; const PER_LANGUAGE_BUNDLE_NAME = /^codeql-bundle-(.+)-(?:linux64|osx64|win64)\.tar\.(?:gz|zst)$/; -/** Identifies per-language tools URLs that must not populate the toolcache. */ +/** Returns the language in a per-language tools URL, or undefined for other URLs. */ export function tryGetBundleLanguageFromUrl( url: string, ): BuiltInLanguage | undefined { @@ -55,13 +55,14 @@ const PER_LANGUAGE_BUNDLE_LANGUAGES: Readonly< export interface PerLanguageBundleOptions { /** Explicit input only: autodetection needs a CLI instance. */ rawLanguages: string[] | undefined; - /** CLI version, if known. Ignored for nightly bundles. */ + /** Requested CLI version, if known. Ignored when requesting the latest nightly. */ cliVersion: string | undefined; compressionMethod: tar.CompressionMethod; /** Platform for which the bundle is requested. */ platform: BundlePlatform | undefined; variant: GitHubVariant; - isNightly?: boolean; + /** Whether the Action is selecting the latest nightly rather than a release version. */ + isLatestNightly?: boolean; } /** Returns the eligible bundle language, or undefined for the combined bundle. */ @@ -79,7 +80,7 @@ export async function getPerLanguageBundleLanguage( compressionMethod, platform, variant, - isNightly, + isLatestNightly, } = options; const explain = (reason: string) => { @@ -106,7 +107,7 @@ export async function getPerLanguageBundleLanguage( if (compressionMethod !== "zstd") { // Per-language bundles are only published as zstd archives. - return explain(`the bundle would be downloaded as ${compressionMethod}`); + return explain(`the bundle would be downloaded as '${compressionMethod}'`); } if (variant !== GitHubVariant.DOTCOM) { @@ -121,16 +122,17 @@ export async function getPerLanguageBundleLanguage( return explain("the job is not running on a GitHub-hosted runner"); } - // Nightly tags contain dates rather than comparable CLI versions. - if (!isNightly) { + // Check whether per-language bundles are published for the requested CLI version. + // Skip this for the latest nightly, whose tag contains a date rather than a CLI version. + if (!isLatestNightly) { if (cliVersion === undefined) { - return explain("the CLI version of the bundle is unknown"); + return explain("the requested CLI version is unknown"); } if (!semver.gte(cliVersion, MIN_PER_LANGUAGE_BUNDLE_CLI_VERSION)) { return explain( - `CodeQL ${cliVersion} is older than ${MIN_PER_LANGUAGE_BUNDLE_CLI_VERSION}, which is the ` + - "first version that publishes per-language bundles", + `the requested CodeQL version ${cliVersion} is older than ${MIN_PER_LANGUAGE_BUNDLE_CLI_VERSION}, which is the ` + + "first version for which per-language bundles are published", ); } } diff --git a/src/setup-codeql.test.ts b/src/setup-codeql.test.ts index 9a87a5027..3f440894d 100644 --- a/src/setup-codeql.test.ts +++ b/src/setup-codeql.test.ts @@ -58,6 +58,7 @@ function stubDownloadAndExtract() { }); } +/** Models a hosted Linux runner with zstd and the latest nightly release. */ function stubHostedNightly(tagName: string) { sinon.stub(process, "platform").value("linux"); sinon.stub(process, "arch").value("x64"); @@ -66,15 +67,25 @@ function stubHostedNightly(tagName: string) { available: true, foundZstdBinary: true, }); - const client = github.getOctokit("123", { - request: { - fetch: async () => + const fetchRelease = sinon + .stub, ReturnType>() + .rejects(new Error("Unexpected API request in nightly bundle test")); + fetchRelease + .withArgs( + "https://api.github.com/repos/dsp-testing/codeql-cli-nightlies/releases?per_page=1&page=1&prerelease=true", + sinon.match({ method: "GET" }), + ) + .callsFake( + async () => new Response(JSON.stringify([{ tag_name: tagName }]), { headers: { "content-type": "application/json" }, }), - }, + ); + const client = github.getOctokit("123", { + request: { fetch: fetchRelease }, }); sinon.stub(api, "getApiClient").value(() => client); + return fetchRelease; } test.serial("parse codeql bundle url version", (t) => { @@ -600,11 +611,11 @@ test.serial( for (const toolsInput of ["nightly", "nightly-latest"]) { test.serial( - `getCodeQLSource selects a per-language bundle for tools == ${toolsInput}`, + `getCodeQLSource selects the latest per-language nightly for tools == ${toolsInput}`, async (t) => { const expectedTag = "codeql-bundle-30260213"; const baseURL = `https://github.com/dsp-testing/codeql-cli-nightlies/releases/download/${expectedTag}`; - stubHostedNightly(expectedTag); + const latestNightlyRequest = stubHostedNightly(expectedTag); await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); @@ -633,13 +644,14 @@ for (const toolsInput of ["nightly", "nightly-latest"]) { compressionMethod: "zstd", toolsVersion: "0.0.0-30260213", } satisfies setupCodeql.CodeQLDownloadSource); + t.true(latestNightlyRequest.calledOnce); }); }, ); } test.serial( - "getCodeQLSource downloads the combined nightly bundle when not eligible", + "getCodeQLSource downloads a combined nightly bundle when per-language selection is ineligible", async (t) => { const expectedTag = "codeql-bundle-30260213"; stubHostedNightly(expectedTag); @@ -647,7 +659,9 @@ test.serial( await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); for (const { languages, features } of [ + // The per-language feature is disabled. { languages: ["java"], features: createFeatures([]) }, + // More than one language requires a combined bundle. { languages: ["java", "python"], features: createFeatures([Feature.PerLanguageBundles]), @@ -679,11 +693,11 @@ test.serial( for (const perLanguageBundles of [false, true]) { test.serial( - `getCodeQLSource uses a ${perLanguageBundles ? "per-language" : "combined"} bundle for a forced nightly`, + `getCodeQLSource uses the latest ${perLanguageBundles ? "per-language" : "combined"} bundle for a forced nightly`, async (t) => { const expectedTag = "codeql-bundle-30260213"; const baseURL = `https://github.com/dsp-testing/codeql-cli-nightlies/releases/download/${expectedTag}`; - stubHostedNightly(expectedTag); + const latestNightlyRequest = stubHostedNightly(expectedTag); await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir, { GITHUB_EVENT_NAME: "dynamic" }); @@ -704,6 +718,7 @@ for (const perLanguageBundles of [false, true]) { ); t.is(source.sourceType, "download"); + t.true(latestNightlyRequest.calledOnce); if (source.sourceType === "download") { const combinedURL = `${baseURL}/codeql-bundle-linux64.tar.zst`; t.deepEqual( @@ -723,6 +738,105 @@ for (const perLanguageBundles of [false, true]) { ); } +for (const date of ["20200101", "30260213"]) { + for (const bundle of ["combined", "per-language"] as const) { + test.serial( + `getCodeQLSource preserves an explicit ${bundle} nightly URL for ${date}`, + async (t) => { + const latestNightlyRequest = stubHostedNightly( + "codeql-bundle-30260213", + ); + const asset = + bundle === "combined" + ? "codeql-bundle-linux64.tar.zst" + : "codeql-bundle-java-linux64.tar.zst"; + const url = `https://github.com/dsp-testing/codeql-cli-nightlies/releases/download/codeql-bundle-${date}/${asset}`; + const features = createFeatures([Feature.PerLanguageBundles]); + const logger = getRecordingLogger([], { logToConsole: false }); + const error = new HTTPError("Not Found", 404); + const extractStub = sinon + .stub(toolsDownload, "downloadAndExtract") + .rejects(error); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir); + const source = await setupCodeql.getCodeQLSource( + url, + SAMPLE_DEFAULT_CLI_VERSION, + ["java"], + false, + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + true, + features, + logger, + ); + t.deepEqual(source, { + sourceType: "download", + bundle: + bundle === "combined" + ? { kind: "combined", url } + : { kind: "per-language", url, language: BuiltInLanguage.java }, + bundleVersion: date, + cliVersion: undefined, + compressionMethod: "zstd", + toolsVersion: `0.0.0-${date}`, + } satisfies setupCodeql.CodeQLDownloadSource); + + await t.throwsAsync( + setupCodeql.setupCodeQLBundle( + url, + SAMPLE_DOTCOM_API_DETAILS, + tmpDir, + GitHubVariant.DOTCOM, + SAMPLE_DEFAULT_CLI_VERSION, + ["java"], + false, + features, + logger, + ), + { is: error }, + ); + t.true(extractStub.calledOnce); + t.is(extractStub.firstCall.args[0], url); + t.true(latestNightlyRequest.notCalled); + }); + }, + ); + } +} + +test.serial( + "getCodeQLSource reports a missing release tag when a toolcache entry disappears", + async (t) => { + sinon + .stub(toolcache, "findAllVersions") + .returns([MIN_PER_LANGUAGE_BUNDLE_CLI_VERSION]); + sinon.stub(toolcache, "find").returns(""); + + await withTmpDir(async (tmpDir) => { + setupActionsVars(tmpDir, tmpDir, { GITHUB_EVENT_NAME: "dynamic" }); + await t.throwsAsync( + setupCodeql.getCodeQLSource( + "toolcache", + SAMPLE_DEFAULT_CLI_VERSION, + ["java"], + false, + SAMPLE_DOTCOM_API_DETAILS, + GitHubVariant.DOTCOM, + true, + createFeatures([]), + getRunnerLogger(true), + ), + { + message: + "Could not determine a release tag for the requested CodeQL bundle.", + }, + ); + }); + }, +); + test.serial( "getCodeQLSource correctly returns latest version from toolcache when tools == toolcache", async (t) => { @@ -1016,18 +1130,21 @@ const PER_LANGUAGE_CLI_VERSION = { ], }; -test.serial("getCodeQLBundleName names the per-language bundle", (t) => { - sinon.stub(process, "platform").value("linux"); - sinon.stub(process, "arch").value("x64"); - t.is( - setupCodeql.getCodeQLBundleName("zstd", BuiltInLanguage.java), - "codeql-bundle-java-linux64.tar.zst", - ); - t.is( - setupCodeql.getCodeQLBundleName("zstd"), - "codeql-bundle-linux64.tar.zst", - ); -}); +test.serial( + "getCodeQLBundleName returns a per-language bundle name only when a language is specified", + (t) => { + sinon.stub(process, "platform").value("linux"); + sinon.stub(process, "arch").value("x64"); + t.is( + setupCodeql.getCodeQLBundleName("zstd", BuiltInLanguage.java), + "codeql-bundle-java-linux64.tar.zst", + ); + t.is( + setupCodeql.getCodeQLBundleName("zstd"), + "codeql-bundle-linux64.tar.zst", + ); + }, +); test.serial("getCodeQLBundleName names the Swift bundle for macOS", (t) => { sinon.stub(process, "platform").value("darwin"); @@ -1313,7 +1430,7 @@ for (const bundle of ["per-language", "combined", "fallback"] as const) { bundle: { kind: "combined", url: combinedURL }, }); checkExpectedLogMessages(t, loggedMessages, [ - `No javascript CodeQL bundle was found at ${perLanguageURL}`, + `No per-language CodeQL bundle for 'javascript' was found at ${perLanguageURL}`, ]); } if (bundle === "per-language") { diff --git a/src/setup-codeql.ts b/src/setup-codeql.ts index 744f78ed4..36d10cd42 100644 --- a/src/setup-codeql.ts +++ b/src/setup-codeql.ts @@ -83,8 +83,7 @@ function getCodeQLBundleExtension( * Returns the name of the CodeQL bundle asset to download. * * @param compressionMethod The compression method of the bundle. - * @param language If provided, the name of the bundle that contains only this language, rather than - * the name of the combined bundle that contains every language. + * @param language Optional language for a per-language bundle. If omitted, returns a combined bundle name. */ export function getCodeQLBundleName( compressionMethod: tar.CompressionMethod, @@ -247,7 +246,7 @@ export interface CodeQLDownloadSource { compressionMethod: tar.CompressionMethod; /** Bundle version of the tools, if known. */ bundleVersion?: string; - /** CLI version of the tools, if known. */ + /** Requested CLI version, if known. */ cliVersion?: string; /** Resolved version for telemetry, independent of whether the bundle can be cached. */ toolsVersion: string; @@ -476,7 +475,7 @@ export async function getCodeQLSource( }; } - /** CLI version number, for example 2.12.6. */ + /** Requested CLI version number, for example 2.12.6. */ let cliVersion: string | undefined; /** Tag name of the CodeQL bundle, for example `codeql-bundle-20230120`. */ let tagName: string | undefined; @@ -530,7 +529,7 @@ export async function getCodeQLSource( `Using the latest CodeQL CLI nightly, as requested by 'tools: ${toolsInput}'.`, ); } - bundle = await getNightlyBundle( + bundle = await getLatestNightlyBundle( { env: getEnv(), features, logger }, rawLanguages, variant, @@ -758,6 +757,13 @@ export async function getCodeQLSource( let compressionMethod: tar.CompressionMethod; if (!url) { + const bundleTagName = tagName; + if (bundleTagName === undefined) { + throw new Error( + "Could not determine a release tag for the requested CodeQL bundle.", + ); + } + compressionMethod = cliVersion !== undefined && (await useZstdBundle(cliVersion, tarSupportsZstd)) @@ -775,28 +781,29 @@ export async function getCodeQLSource( }, ); + // Resolve both bundle variants against the same release and repository lookup order. const resolveBundleURL = (language?: BuiltInLanguage) => getCodeQLBundleDownloadURL( - tagName!, + bundleTagName, apiDetails, getCodeQLBundleName(compressionMethod, language), logger, ); + const combinedBundleURL = await resolveBundleURL(); if (perLanguageBundleLanguage !== undefined) { logger.info( - `Downloading the ${perLanguageBundleLanguage} CodeQL bundle, since ${perLanguageBundleLanguage} ` + - "is the only language being analyzed.", + `Selected the per-language CodeQL bundle for '${perLanguageBundleLanguage}'.`, ); url = await resolveBundleURL(perLanguageBundleLanguage); bundle = { kind: "per-language", url, language: perLanguageBundleLanguage, - combinedBundleURL: await resolveBundleURL(), + combinedBundleURL, }; } else { - url = await resolveBundleURL(); + url = combinedBundleURL; bundle = { kind: "combined", url }; } } else { @@ -928,8 +935,8 @@ export const downloadCodeQL = async function ( }; /** - * Returns the canonical toolcache directory for a resolved download, or `undefined` if its bundle - * version is unknown. + * Returns the canonical toolcache directory for a combined bundle with a known version. + * Returns undefined for per-language bundles or unknown versions. */ function getToolcacheDestination( source: CodeQLDownloadSource, @@ -1122,8 +1129,8 @@ export async function setupCodeQLBundle( /** * Performs eligible toolcache cleanup once, then downloads and extracts the resolved bundle. * - * If `source` refers to a bundle for a single language and that bundle turns out not to exist, this - * falls back to downloading the combined bundle. + * If an automatically selected per-language bundle is missing, downloads the combined bundle + * from the same release instead. Explicit bundle URLs are not substituted. * * @returns The extraction directory and download timings. */ @@ -1160,7 +1167,7 @@ export async function downloadCodeQLBundle( throw e; } logger.warning( - `No ${bundle.language} CodeQL bundle was found at ${bundle.url}, so ` + + `No per-language CodeQL bundle for '${bundle.language}' was found at ${bundle.url}, so ` + "falling back to the bundle that contains all languages. This analysis will still " + "produce correct results, but will take longer to set up.", ); @@ -1202,8 +1209,11 @@ function getTempExtractionDir(tempDir: string) { return path.join(tempDir, uuidV4()); } -/** Selects a bundle from the latest nightly, with a same-release fallback when applicable. */ -async function getNightlyBundle( +/** + * Selects a bundle from the latest nightly release, preferring a per-language bundle when eligible. + * Records the combined bundle URL from that release for use if the selected asset is missing. + */ +async function getLatestNightlyBundle( action: ActionState<["Logger", "ReadOnlyEnv", "FeatureFlags"]>, rawLanguages: string[] | undefined, variant: util.GitHubVariant, @@ -1224,7 +1234,7 @@ async function getNightlyBundle( compressionMethod, platform: getBundlePlatform(), variant, - isNightly: true, + isLatestNightly: true, }); try {