From e8f5d72ffe558347bb4c438f2046945a4fedfeea Mon Sep 17 00:00:00 2001 From: Henry Mercer Date: Fri, 25 Sep 2026 17:41:13 +0100 Subject: [PATCH] Read the CLI version of a release from its assets Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- lib/entry-points.js | 73 +++++++++++++++++++------------------- src/codeql-release.test.ts | 30 ++++++++++++++++ src/codeql-release.ts | 49 +++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 36 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index c703349ed..908c89dfa 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -26953,7 +26953,7 @@ var require_parse2 = __commonJS({ "node_modules/semver/functions/parse.js"(exports2, module2) { "use strict"; var SemVer = require_semver(); - var parse3 = (version, options, throwErrors = false) => { + var parse4 = (version, options, throwErrors = false) => { if (version instanceof SemVer) { return version; } @@ -26966,7 +26966,7 @@ var require_parse2 = __commonJS({ throw er; } }; - module2.exports = parse3; + module2.exports = parse4; } }); @@ -26974,9 +26974,9 @@ var require_parse2 = __commonJS({ var require_valid = __commonJS({ "node_modules/semver/functions/valid.js"(exports2, module2) { "use strict"; - var parse3 = require_parse2(); + var parse4 = require_parse2(); var valid4 = (version, options) => { - const v = parse3(version, options); + const v = parse4(version, options); return v ? v.version : null; }; module2.exports = valid4; @@ -26987,9 +26987,9 @@ var require_valid = __commonJS({ var require_clean = __commonJS({ "node_modules/semver/functions/clean.js"(exports2, module2) { "use strict"; - var parse3 = require_parse2(); + var parse4 = require_parse2(); var clean3 = (version, options) => { - const s = parse3(version.trim().replace(/^[=v]+/, ""), options); + const s = parse4(version.trim().replace(/^[=v]+/, ""), options); return s ? s.version : null; }; module2.exports = clean3; @@ -27024,10 +27024,10 @@ var require_inc = __commonJS({ var require_diff = __commonJS({ "node_modules/semver/functions/diff.js"(exports2, module2) { "use strict"; - var parse3 = require_parse2(); + var parse4 = require_parse2(); var diff = (version1, version2) => { - const v1 = parse3(version1, null, true); - const v2 = parse3(version2, null, true); + const v1 = parse4(version1, null, true); + const v2 = parse4(version2, null, true); const comparison = v1.compare(v2); if (comparison === 0) { return null; @@ -27098,9 +27098,9 @@ var require_patch = __commonJS({ var require_prerelease = __commonJS({ "node_modules/semver/functions/prerelease.js"(exports2, module2) { "use strict"; - var parse3 = require_parse2(); + var parse4 = require_parse2(); var prerelease = (version, options) => { - const parsed = parse3(version, options); + const parsed = parse4(version, options); return parsed && parsed.prerelease.length ? parsed.prerelease : null; }; module2.exports = prerelease; @@ -27286,7 +27286,7 @@ var require_coerce = __commonJS({ "node_modules/semver/functions/coerce.js"(exports2, module2) { "use strict"; var SemVer = require_semver(); - var parse3 = require_parse2(); + var parse4 = require_parse2(); var { safeRe: re, t } = require_re(); var coerce3 = (version, options) => { if (version instanceof SemVer) { @@ -27321,7 +27321,7 @@ var require_coerce = __commonJS({ const patch = match2[4] || "0"; const prerelease = options.includePrerelease && match2[5] ? `-${match2[5]}` : ""; const build2 = options.includePrerelease && match2[6] ? `+${match2[6]}` : ""; - return parse3(`${major}.${minor}.${patch}${prerelease}${build2}`, options); + return parse4(`${major}.${minor}.${patch}${prerelease}${build2}`, options); }; module2.exports = coerce3; } @@ -27331,7 +27331,7 @@ var require_coerce = __commonJS({ var require_truncate = __commonJS({ "node_modules/semver/functions/truncate.js"(exports2, module2) { "use strict"; - var parse3 = require_parse2(); + var parse4 = require_parse2(); var constants = require_constants6(); var SemVer = require_semver(); var truncate = (version, truncation, options) => { @@ -27343,7 +27343,7 @@ var require_truncate = __commonJS({ }; var cloneInputVersion = (version, options) => { const versionStringToParse = version instanceof SemVer ? version.version : version; - return parse3(versionStringToParse, options); + return parse4(versionStringToParse, options); }; var doTruncation = (version, truncation) => { if (isPrerelease(truncation)) { @@ -28387,7 +28387,7 @@ var require_semver2 = __commonJS({ var constants = require_constants6(); var SemVer = require_semver(); var identifiers = require_identifiers(); - var parse3 = require_parse2(); + var parse4 = require_parse2(); var valid4 = require_valid(); var clean3 = require_clean(); var inc = require_inc(); @@ -28426,7 +28426,7 @@ var require_semver2 = __commonJS({ var simplifyRange = require_simplify(); var subset = require_subset(); module2.exports = { - parse: parse3, + parse: parse4, valid: valid4, clean: clean3, inc, @@ -32089,9 +32089,9 @@ var require_minimatch = __commonJS({ throw new TypeError("pattern is too long"); } }; - Minimatch2.prototype.parse = parse3; + Minimatch2.prototype.parse = parse4; var SUBPARSE = {}; - function parse3(pattern, isSub) { + function parse4(pattern, isSub) { assertValidPattern2(pattern); var options = this.options; if (pattern === "**") { @@ -33541,8 +33541,8 @@ var require_semver3 = __commonJS({ } } var i; - exports2.parse = parse3; - function parse3(version, options) { + exports2.parse = parse4; + function parse4(version, options) { if (!options || typeof options !== "object") { options = { loose: !!options, @@ -33570,12 +33570,12 @@ var require_semver3 = __commonJS({ } exports2.valid = valid4; function valid4(version, options) { - var v = parse3(version, options); + var v = parse4(version, options); return v ? v.version : null; } exports2.clean = clean3; function clean3(version, options) { - var s = parse3(version.trim().replace(/^[=v]+/, ""), options); + var s = parse4(version.trim().replace(/^[=v]+/, ""), options); return s ? s.version : null; } exports2.SemVer = SemVer; @@ -33811,8 +33811,8 @@ var require_semver3 = __commonJS({ if (eq(version1, version2)) { return null; } else { - var v1 = parse3(version1); - var v2 = parse3(version2); + var v1 = parse4(version1); + var v2 = parse4(version2); var prefix = ""; if (v1.prerelease.length || v2.prerelease.length) { prefix = "pre"; @@ -34518,7 +34518,7 @@ var require_semver3 = __commonJS({ } exports2.prerelease = prerelease; function prerelease(version, options) { - var parsed = parse3(version, options); + var parsed = parse4(version, options); return parsed && parsed.prerelease.length ? parsed.prerelease : null; } exports2.intersects = intersects; @@ -34555,7 +34555,7 @@ var require_semver3 = __commonJS({ if (match2 === null) { return null; } - return parse3(match2[2] + "." + (match2[3] || "0") + "." + (match2[4] || "0"), options); + return parse4(match2[2] + "." + (match2[3] || "0") + "." + (match2[4] || "0"), options); } } }); @@ -37255,7 +37255,7 @@ var require_ms = __commonJS({ options = options || {}; var type = typeof val; if (type === "string" && val.length > 0) { - return parse3(val); + return parse4(val); } else if (type === "number" && isFinite(val)) { return options.long ? fmtLong(val) : fmtShort(val); } @@ -37263,7 +37263,7 @@ var require_ms = __commonJS({ "val is not a non-empty string or a valid number. val=" + JSON.stringify(val) ); }; - function parse3(str) { + function parse4(str) { str = String(str); if (str.length > 100) { return; @@ -43199,7 +43199,7 @@ var require_deserializationPolicy = __commonJS({ return result; } async function deserializeResponseBody(jsonContentTypes, xmlContentTypes, response, options, parseXML) { - const parsedResponse = await parse3(jsonContentTypes, xmlContentTypes, response, options, parseXML); + const parsedResponse = await parse4(jsonContentTypes, xmlContentTypes, response, options, parseXML); if (!shouldDeserializeResponse(parsedResponse)) { return parsedResponse; } @@ -43300,7 +43300,7 @@ var require_deserializationPolicy = __commonJS({ } return { error: error3, shouldReturnResponse: false }; } - async function parse3(jsonContentTypes, xmlContentTypes, operationResponse, opts, parseXML) { + async function parse4(jsonContentTypes, xmlContentTypes, operationResponse, opts, parseXML) { if (!operationResponse.request.streamResponseStatusCodes?.has(operationResponse.status) && operationResponse.bodyAsText) { const text = operationResponse.bodyAsText; const contentType = operationResponse.headers.get("Content-Type") || ""; @@ -112628,7 +112628,7 @@ var require_dist_node2 = __commonJS({ return template.replace(/\/$/, ""); } } - function parse3(options) { + function parse4(options) { let method = options.method.toUpperCase(); let url2 = (options.url || "/").replace(/:([a-z]\w+)/g, "{$1}"); let headers = Object.assign({}, options.headers); @@ -112692,7 +112692,7 @@ var require_dist_node2 = __commonJS({ ); } function endpointWithDefaults2(defaults3, route, options) { - return parse3(merge2(defaults3, route, options)); + return parse4(merge2(defaults3, route, options)); } function withDefaults4(oldDefaults, newDefaults) { const DEFAULTS22 = merge2(oldDefaults, newDefaults); @@ -112701,7 +112701,7 @@ var require_dist_node2 = __commonJS({ DEFAULTS: DEFAULTS22, defaults: withDefaults4.bind(null, DEFAULTS22), merge: merge2.bind(null, DEFAULTS22), - parse: parse3 + parse: parse4 }); } var endpoint2 = withDefaults4(null, DEFAULTS2); @@ -116933,7 +116933,7 @@ var require_binary = __commonJS({ }); return stream2; }; - exports2.parse = function parse3(buffer) { + exports2.parse = function parse4(buffer) { var self2 = words(function(bytes, cb) { return function(name) { if (offset + bytes <= buffer.length) { @@ -151368,7 +151368,8 @@ async function getRelease({ apiClient }, reference) { }); return { url: getReleasePageURL(reference), - getAssetURL: (name) => release2.assets.find((asset) => asset.name === name)?.url + getAssetURL: (name) => release2.assets.find((asset) => asset.name === name)?.url, + assetNames: release2.assets.map((asset) => asset.name) }; } function getPublicRelease(reference) { diff --git a/src/codeql-release.test.ts b/src/codeql-release.test.ts index 12d7b0a09..e62d8e5b2 100644 --- a/src/codeql-release.test.ts +++ b/src/codeql-release.test.ts @@ -5,6 +5,7 @@ import { BundleSelectionOptions, getPublicRelease, getRelease, + getReleaseCliVersion, parseCodeQLReleaseUrl, selectBundle, } from "./codeql-release"; @@ -290,3 +291,32 @@ test("parseCodeQLReleaseUrl excludes archives, REST references and untrusted URL ); } }); + +test("getRelease returns the names of the release's assets", async (t) => { + const assetNames = [COMBINED, "cli-version-2.27.2.txt"]; + const fixture = releaseFixture({ assetNames }); + const release = await getRelease(fixture.state, REFERENCE); + t.deepEqual(release.assetNames, assetNames); +}); + +test("getReleaseCliVersion prefers an unambiguous marker asset to the tag", (t) => { + for (const [tagName, markers, cliVersion] of [ + [TAG, [], "2.27.1"], + [TAG, ["invalid"], "2.27.1"], + [TAG, ["2.27.2"], "2.27.2"], + [TAG, ["2.27.1", "2.28.0"], undefined], + ["codeql-bundle-20260101", [], undefined], + ["run-123", [], undefined], + ["run-123", ["2.27.2+202601011200"], "2.27.2+202601011200"], + ] as const) { + t.is( + getReleaseCliVersion( + tagName, + [COMBINED, ...markers.map((version) => `cli-version-${version}.txt`)], + getRecordingLogger([], { logToConsole: false }), + ), + cliVersion, + `${tagName} ${markers.join(",")}`, + ); + } +}); diff --git a/src/codeql-release.ts b/src/codeql-release.ts index 67344d3f1..4a7c97ee0 100644 --- a/src/codeql-release.ts +++ b/src/codeql-release.ts @@ -4,6 +4,7 @@ import { ActionState } from "./action-common"; import type { GitHubApiDetails } from "./api-client"; import { CodeQLBundle, getCodeQLBundleName } from "./codeql-bundle"; import { CODEQL_VERSION_ZSTD_BUNDLE } from "./feature-flags"; +import { Logger } from "./logging"; import { getPerLanguageBundleLanguage, logMissingPerLanguageBundle, @@ -26,6 +27,8 @@ export interface CodeQLRelease { url: string; /** Returns the download URL for an asset, or `undefined` if the release doesn't have it. */ getAssetURL(name: string): string | undefined; + /** Names of the release's assets, if we looked up the release. */ + assetNames?: string[]; } /** A release requested by URL, on this GitHub instance or on GitHub.com. */ @@ -86,6 +89,51 @@ export function parseCodeQLReleaseUrl( }; } +function parseCliVersion(value: string): string | undefined { + const parsed = semver.parse(value); + if (parsed === null) { + return undefined; + } + // semver's normalized version omits build metadata, which distinguishes nightly builds. + return ( + parsed.version + (parsed.build.length ? `+${parsed.build.join(".")}` : "") + ); +} + +/** + * Returns the CLI version from the release's `cli-version-.txt` asset if it has one, and + * otherwise the version in the tag. Returns `undefined` if the release has conflicting markers. + */ +export function getReleaseCliVersion( + tagName: string, + assetNames: string[], + logger: Logger, +): string | undefined { + const versions = new Set(); + for (const name of assetNames) { + const match = name.match(/^cli-version-(.+)\.txt$/); + if (match === null) { + continue; + } + const version = parseCliVersion(match[1]); + if (version !== undefined) { + versions.add(version); + } else { + logger.debug(`Ignoring invalid CLI version marker ${name}.`); + } + } + if (versions.size > 1) { + logger.warning( + `Release ${tagName} has conflicting CLI version markers. Using a combined CodeQL bundle.`, + ); + return undefined; + } + if (versions.size === 1) { + return versions.values().next().value; + } + return parseCliVersion(tagName.replace(/^codeql-bundle-/, "")); +} + /** * Encodes a tag for use in a URL path. Slashes stay as path separators, as in GitHub's release URLs * for tags like `build/123`. @@ -121,6 +169,7 @@ export async function getRelease( url: getReleasePageURL(reference), getAssetURL: (name) => release.assets.find((asset) => asset.name === name)?.url, + assetNames: release.assets.map((asset) => asset.name), }; }