diff --git a/__tests__/download/download-version.test.ts b/__tests__/download/download-version.test.ts index a513d24..49e3406 100644 --- a/__tests__/download/download-version.test.ts +++ b/__tests__/download/download-version.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import * as semver from "semver"; +import { VERSIONS_MANIFEST_URL } from "../../src/utils/constants"; const mockInfo = jest.fn(); const mockWarning = jest.fn(); @@ -36,11 +37,14 @@ const mockGetLatestVersion = jest.fn(); // biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests. const mockGetAllVersions = jest.fn(); // biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests. +const mockGetFirstMatchingVersion = jest.fn(); +// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests. const mockGetArtifact = jest.fn(); jest.unstable_mockModule("../../src/download/manifest", () => ({ getAllVersions: mockGetAllVersions, getArtifact: mockGetArtifact, + getFirstMatchingVersion: mockGetFirstMatchingVersion, getLatestVersion: mockGetLatestVersion, })); @@ -65,6 +69,7 @@ describe("download-version", () => { mockCacheDir.mockReset(); mockGetLatestVersion.mockReset(); mockGetAllVersions.mockReset(); + mockGetFirstMatchingVersion.mockReset(); mockGetArtifact.mockReset(); mockValidateChecksum.mockReset(); @@ -85,14 +90,43 @@ describe("download-version", () => { expect(mockGetLatestVersion).toHaveBeenCalledWith(undefined); }); - it("uses the default manifest to resolve available versions", async () => { - mockGetAllVersions.mockResolvedValue(["0.9.26", "0.9.25"]); + it("stops at the first matching version in the default manifest", async () => { + mockGetFirstMatchingVersion.mockImplementation( + (predicate: (version: string) => boolean) => + ["0.9.26", "0.9.25"].find(predicate), + ); const version = await resolveVersion("^0.9.0", undefined); expect(version).toBe("0.9.26"); - expect(mockGetAllVersions).toHaveBeenCalledTimes(1); - expect(mockGetAllVersions).toHaveBeenCalledWith(undefined); + expect(mockGetFirstMatchingVersion).toHaveBeenCalledTimes(1); + expect(mockGetAllVersions).not.toHaveBeenCalled(); + }); + + it("streams ranges when the default manifest URL is explicit", async () => { + mockGetFirstMatchingVersion.mockImplementation( + (predicate: (version: string) => boolean) => + ["0.9.26", "0.9.25"].find(predicate), + ); + + const version = await resolveVersion("^0.9.0", VERSIONS_MANIFEST_URL); + + expect(version).toBe("0.9.26"); + expect(mockGetFirstMatchingVersion).toHaveBeenCalledTimes(1); + expect(mockGetAllVersions).not.toHaveBeenCalled(); + }); + + it("streams PEP 440 ranges from the default manifest", async () => { + mockGetFirstMatchingVersion.mockImplementation( + (predicate: (version: string) => boolean) => + ["0.9.26", "0.9.25"].find(predicate), + ); + + const version = await resolveVersion("!=0.9.26", undefined); + + expect(version).toBe("0.9.25"); + expect(mockGetFirstMatchingVersion).toHaveBeenCalledTimes(1); + expect(mockGetAllVersions).not.toHaveBeenCalled(); }); it("treats == exact pins as explicit versions", async () => { diff --git a/__tests__/download/manifest.test.ts b/__tests__/download/manifest.test.ts index 85d6872..b269335 100644 --- a/__tests__/download/manifest.test.ts +++ b/__tests__/download/manifest.test.ts @@ -17,6 +17,7 @@ const { fetchManifest, getAllVersions, getArtifact, + getFirstMatchingVersion, getLatestVersion, parseManifest, } = await import("../../src/download/manifest"); @@ -33,6 +34,7 @@ function createMockResponse( data: string, ) { return { + body: null, ok, status, statusText, @@ -40,6 +42,31 @@ function createMockResponse( }; } +function createStreamingMockResponse( + chunks: string[], + cancel: () => void | Promise = () => {}, + close = false, +) { + const encoder = new TextEncoder(); + return { + body: new ReadableStream({ + cancel, + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + if (close) { + controller.close(); + } + }, + }), + ok: true, + status: 200, + statusText: "OK", + text: async () => chunks.join(""), + }; +} + describe("manifest", () => { beforeEach(() => { clearManifestCache(); @@ -105,6 +132,75 @@ describe("manifest", () => { getLatestVersion("https://example.com/custom.ndjson"), ).resolves.toBe("0.9.26"); }); + + it("stops reading the default manifest after the first record", async () => { + const [latestVersion] = sampleManifestResponse.split("\n"); + const cancel = jest.fn(); + mockFetch.mockResolvedValue( + createStreamingMockResponse( + [ + latestVersion.slice(0, 100), + `${latestVersion.slice(100)}\n`, + "invalid trailing data\n", + ], + cancel, + ), + ); + + await expect(getLatestVersion()).resolves.toBe("0.9.26"); + await expect( + getArtifact("0.9.26", "aarch64", "apple-darwin"), + ).resolves.toBeDefined(); + + expect(cancel).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("does not fail when canceling the remaining response fails", async () => { + const [latestVersion] = sampleManifestResponse.split("\n"); + mockFetch.mockResolvedValue( + createStreamingMockResponse([`${latestVersion}\n`], () => + Promise.reject(new Error("cancel failed")), + ), + ); + + await expect(getLatestVersion()).resolves.toBe("0.9.26"); + }); + }); + + describe("getFirstMatchingVersion", () => { + it("stops at the first matching record", async () => { + const cancel = jest.fn(); + mockFetch.mockResolvedValue( + createStreamingMockResponse( + [`${sampleManifestResponse}\n`, "invalid trailing data\n"], + cancel, + ), + ); + + await expect( + getFirstMatchingVersion((version) => version === "0.9.25"), + ).resolves.toBe("0.9.25"); + + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it("caches a fully consumed response stream", async () => { + mockFetch.mockResolvedValue( + createStreamingMockResponse( + [`${sampleManifestResponse}\n`], + undefined, + true, + ), + ); + + await expect( + getFirstMatchingVersion((version) => version === "0.0.1"), + ).resolves.toBeUndefined(); + await expect(getLatestVersion()).resolves.toBe("0.9.26"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); }); describe("getArtifact", () => { @@ -168,6 +264,60 @@ describe("manifest", () => { expect(artifact).toBeUndefined(); }); + + it("does not cache records from a failed stream read", async () => { + const [latestVersion] = sampleManifestResponse.split("\n"); + mockFetch + .mockResolvedValueOnce( + createStreamingMockResponse([ + `${latestVersion}\n`, + "invalid manifest record\n", + ]), + ) + .mockResolvedValueOnce( + createMockResponse(true, 200, "OK", sampleManifestResponse), + ); + + await expect( + getArtifact("0.0.1", "aarch64", "apple-darwin"), + ).rejects.toThrow("Failed to parse manifest data"); + await expect(getLatestVersion()).resolves.toBe("0.9.26"); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("does not cache records when the response stream fails", async () => { + const [latestVersion] = sampleManifestResponse.split("\n"); + const encoder = new TextEncoder(); + let sentVersion = false; + const body = new ReadableStream({ + pull(controller) { + if (!sentVersion) { + sentVersion = true; + controller.enqueue(encoder.encode(`${latestVersion}\n`)); + return; + } + controller.error(new Error("response stream failed")); + }, + }); + mockFetch + .mockResolvedValueOnce({ + body, + ok: true, + status: 200, + statusText: "OK", + }) + .mockResolvedValueOnce( + createMockResponse(true, 200, "OK", sampleManifestResponse), + ); + + await expect( + getArtifact("0.0.1", "aarch64", "apple-darwin"), + ).rejects.toThrow("response stream failed"); + await expect(getLatestVersion()).resolves.toBe("0.9.26"); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); }); describe("parseManifest", () => { diff --git a/dist/setup/index.cjs b/dist/setup/index.cjs index 8121bf1..fff7ac4 100644 --- a/dist/setup/index.cjs +++ b/dist/setup/index.cjs @@ -9161,7 +9161,7 @@ var require_readable = __commonJS({ "node_modules/@actions/http-client/node_modules/undici/lib/api/readable.js"(exports2, module2) { "use strict"; var assert4 = require("node:assert"); - var { Readable: Readable5 } = require("node:stream"); + var { Readable: Readable6 } = require("node:stream"); var { RequestAbortedError, NotSupportedError, InvalidArgumentError, AbortError: AbortError3 } = require_errors(); var util7 = require_util(); var { ReadableStreamFrom } = require_util(); @@ -9173,7 +9173,7 @@ var require_readable = __commonJS({ var kContentLength = /* @__PURE__ */ Symbol("kContentLength"); var noop = () => { }; - var BodyReadable = class extends Readable5 { + var BodyReadable = class extends Readable6 { constructor({ resume, abort, @@ -9515,7 +9515,7 @@ var require_api_request = __commonJS({ "node_modules/@actions/http-client/node_modules/undici/lib/api/api-request.js"(exports2, module2) { "use strict"; var assert4 = require("node:assert"); - var { Readable: Readable5 } = require_readable(); + var { Readable: Readable6 } = require_readable(); var { InvalidArgumentError, RequestAbortedError } = require_errors(); var util7 = require_util(); var { getResolveErrorBodyCallback } = require_util3(); @@ -9610,7 +9610,7 @@ var require_api_request = __commonJS({ const parsedHeaders = responseHeaders === "raw" ? util7.parseHeaders(rawHeaders) : headers; const contentType2 = parsedHeaders["content-type"]; const contentLength2 = parsedHeaders["content-length"]; - const res = new Readable5({ + const res = new Readable6({ resume, abort, contentType: contentType2, @@ -9925,7 +9925,7 @@ var require_api_pipeline = __commonJS({ "node_modules/@actions/http-client/node_modules/undici/lib/api/api-pipeline.js"(exports2, module2) { "use strict"; var { - Readable: Readable5, + Readable: Readable6, Duplex, PassThrough } = require("node:stream"); @@ -9939,7 +9939,7 @@ var require_api_pipeline = __commonJS({ var { addSignal, removeSignal } = require_abort_signal(); var assert4 = require("node:assert"); var kResume = /* @__PURE__ */ Symbol("resume"); - var PipelineRequest = class extends Readable5 { + var PipelineRequest = class extends Readable6 { constructor() { super({ autoDestroy: true }); this[kResume] = null; @@ -9956,7 +9956,7 @@ var require_api_pipeline = __commonJS({ callback(err); } }; - var PipelineResponse = class extends Readable5 { + var PipelineResponse = class extends Readable6 { constructor(resume) { super({ autoDestroy: true }); this[kResume] = resume; @@ -13281,7 +13281,7 @@ var require_fetch = __commonJS({ subresourceSet } = require_constants3(); var EE = require("node:events"); - var { Readable: Readable5, pipeline: pipeline4, finished } = require("node:stream"); + var { Readable: Readable6, pipeline: pipeline4, finished } = require("node:stream"); var { addAbortListener, isErrored, isReadable, bufferToLowerCasedHeaderName } = require_util(); var { dataURLProcessor, serializeAMimeType, minimizeSupportedMimeType } = require_data_url(); var { getGlobalDispatcher } = require_global2(); @@ -14182,7 +14182,7 @@ var require_fetch = __commonJS({ headersList.append(bufferToLowerCasedHeaderName(rawHeaders[i]), rawHeaders[i + 1].toString("latin1"), true); } location = headersList.get("location", true); - this.body = new Readable5({ read: resume }); + this.body = new Readable6({ read: resume }); const decoders = []; const willFollow = location && request.redirect === "follow" && redirectStatusSet.has(status); if (request.method !== "HEAD" && request.method !== "CONNECT" && !nullBodyStatus.includes(status) && !willFollow) { @@ -20742,7 +20742,7 @@ var require_satisfies = __commonJS({ "node_modules/@actions/cache/node_modules/semver/functions/satisfies.js"(exports2, module2) { "use strict"; var Range = require_range(); - var satisfies4 = (version3, range2, options) => { + var satisfies5 = (version3, range2, options) => { try { range2 = new Range(range2, options); } catch (er) { @@ -20750,7 +20750,7 @@ var require_satisfies = __commonJS({ } return range2.test(version3); }; - module2.exports = satisfies4; + module2.exports = satisfies5; } }); @@ -20886,14 +20886,14 @@ var require_valid2 = __commonJS({ "node_modules/@actions/cache/node_modules/semver/ranges/valid.js"(exports2, module2) { "use strict"; var Range = require_range(); - var validRange2 = (range2, options) => { + var validRange3 = (range2, options) => { try { return new Range(range2, options).range || "*"; } catch (er) { return null; } }; - module2.exports = validRange2; + module2.exports = validRange3; } }); @@ -20905,7 +20905,7 @@ var require_outside = __commonJS({ var Comparator = require_comparator(); var { ANY } = Comparator; var Range = require_range(); - var satisfies4 = require_satisfies(); + var satisfies5 = require_satisfies(); var gt3 = require_gt(); var lt2 = require_lt(); var lte = require_lte(); @@ -20932,7 +20932,7 @@ var require_outside = __commonJS({ default: throw new TypeError('Must provide a hilo val of "<" or ">"'); } - if (satisfies4(version3, range2, options)) { + if (satisfies5(version3, range2, options)) { return false; } for (let i = 0; i < range2.set.length; ++i) { @@ -21004,7 +21004,7 @@ var require_intersects = __commonJS({ var require_simplify = __commonJS({ "node_modules/@actions/cache/node_modules/semver/ranges/simplify.js"(exports2, module2) { "use strict"; - var satisfies4 = require_satisfies(); + var satisfies5 = require_satisfies(); var compare2 = require_compare(); module2.exports = (versions, range2, options) => { const set = []; @@ -21012,7 +21012,7 @@ var require_simplify = __commonJS({ let prev = null; const v = versions.sort((a, b) => compare2(a, b, options)); for (const version3 of v) { - const included = satisfies4(version3, range2, options); + const included = satisfies5(version3, range2, options); if (included) { prev = version3; if (!first) { @@ -21057,7 +21057,7 @@ var require_subset = __commonJS({ var Range = require_range(); var Comparator = require_comparator(); var { ANY } = Comparator; - var satisfies4 = require_satisfies(); + var satisfies5 = require_satisfies(); var compare2 = require_compare(); var subset = (sub, dom, options = {}) => { if (sub === dom) { @@ -21126,14 +21126,14 @@ var require_subset = __commonJS({ } } for (const eq2 of eqSet) { - if (gt3 && !satisfies4(eq2, String(gt3), options)) { + if (gt3 && !satisfies5(eq2, String(gt3), options)) { return null; } - if (lt2 && !satisfies4(eq2, String(lt2), options)) { + if (lt2 && !satisfies5(eq2, String(lt2), options)) { return null; } for (const c of dom) { - if (!satisfies4(eq2, String(c), options)) { + if (!satisfies5(eq2, String(c), options)) { return false; } } @@ -21160,7 +21160,7 @@ var require_subset = __commonJS({ if (higher === c && higher !== gt3) { return false; } - } else if (gt3.operator === ">=" && !satisfies4(gt3.semver, String(c), options)) { + } else if (gt3.operator === ">=" && !satisfies5(gt3.semver, String(c), options)) { return false; } } @@ -21175,7 +21175,7 @@ var require_subset = __commonJS({ if (lower === c && lower !== lt2) { return false; } - } else if (lt2.operator === "<=" && !satisfies4(lt2.semver, String(c), options)) { + } else if (lt2.operator === "<=" && !satisfies5(lt2.semver, String(c), options)) { return false; } } @@ -21245,12 +21245,12 @@ var require_semver2 = __commonJS({ var coerce = require_coerce(); var Comparator = require_comparator(); var Range = require_range(); - var satisfies4 = require_satisfies(); + var satisfies5 = require_satisfies(); var toComparators = require_to_comparators(); var maxSatisfying3 = require_max_satisfying(); var minSatisfying4 = require_min_satisfying(); var minVersion = require_min_version(); - var validRange2 = require_valid2(); + var validRange3 = require_valid2(); var outside = require_outside(); var gtr = require_gtr(); var ltr = require_ltr(); @@ -21283,12 +21283,12 @@ var require_semver2 = __commonJS({ coerce, Comparator, Range, - satisfies: satisfies4, + satisfies: satisfies5, toComparators, maxSatisfying: maxSatisfying3, minSatisfying: minSatisfying4, minVersion, - validRange: validRange2, + validRange: validRange3, outside, gtr, ltr, @@ -28352,7 +28352,7 @@ var require_satisfies2 = __commonJS({ "node_modules/@actions/tool-cache/node_modules/semver/functions/satisfies.js"(exports2, module2) { "use strict"; var Range = require_range2(); - var satisfies4 = (version3, range2, options) => { + var satisfies5 = (version3, range2, options) => { try { range2 = new Range(range2, options); } catch (er) { @@ -28360,7 +28360,7 @@ var require_satisfies2 = __commonJS({ } return range2.test(version3); }; - module2.exports = satisfies4; + module2.exports = satisfies5; } }); @@ -28496,14 +28496,14 @@ var require_valid4 = __commonJS({ "node_modules/@actions/tool-cache/node_modules/semver/ranges/valid.js"(exports2, module2) { "use strict"; var Range = require_range2(); - var validRange2 = (range2, options) => { + var validRange3 = (range2, options) => { try { return new Range(range2, options).range || "*"; } catch (er) { return null; } }; - module2.exports = validRange2; + module2.exports = validRange3; } }); @@ -28515,7 +28515,7 @@ var require_outside2 = __commonJS({ var Comparator = require_comparator2(); var { ANY } = Comparator; var Range = require_range2(); - var satisfies4 = require_satisfies2(); + var satisfies5 = require_satisfies2(); var gt3 = require_gt2(); var lt2 = require_lt2(); var lte = require_lte2(); @@ -28542,7 +28542,7 @@ var require_outside2 = __commonJS({ default: throw new TypeError('Must provide a hilo val of "<" or ">"'); } - if (satisfies4(version3, range2, options)) { + if (satisfies5(version3, range2, options)) { return false; } for (let i = 0; i < range2.set.length; ++i) { @@ -28614,7 +28614,7 @@ var require_intersects2 = __commonJS({ var require_simplify2 = __commonJS({ "node_modules/@actions/tool-cache/node_modules/semver/ranges/simplify.js"(exports2, module2) { "use strict"; - var satisfies4 = require_satisfies2(); + var satisfies5 = require_satisfies2(); var compare2 = require_compare2(); module2.exports = (versions, range2, options) => { const set = []; @@ -28622,7 +28622,7 @@ var require_simplify2 = __commonJS({ let prev = null; const v = versions.sort((a, b) => compare2(a, b, options)); for (const version3 of v) { - const included = satisfies4(version3, range2, options); + const included = satisfies5(version3, range2, options); if (included) { prev = version3; if (!first) { @@ -28667,7 +28667,7 @@ var require_subset2 = __commonJS({ var Range = require_range2(); var Comparator = require_comparator2(); var { ANY } = Comparator; - var satisfies4 = require_satisfies2(); + var satisfies5 = require_satisfies2(); var compare2 = require_compare2(); var subset = (sub, dom, options = {}) => { if (sub === dom) { @@ -28736,14 +28736,14 @@ var require_subset2 = __commonJS({ } } for (const eq2 of eqSet) { - if (gt3 && !satisfies4(eq2, String(gt3), options)) { + if (gt3 && !satisfies5(eq2, String(gt3), options)) { return null; } - if (lt2 && !satisfies4(eq2, String(lt2), options)) { + if (lt2 && !satisfies5(eq2, String(lt2), options)) { return null; } for (const c of dom) { - if (!satisfies4(eq2, String(c), options)) { + if (!satisfies5(eq2, String(c), options)) { return false; } } @@ -28770,7 +28770,7 @@ var require_subset2 = __commonJS({ if (higher === c && higher !== gt3) { return false; } - } else if (gt3.operator === ">=" && !satisfies4(gt3.semver, String(c), options)) { + } else if (gt3.operator === ">=" && !satisfies5(gt3.semver, String(c), options)) { return false; } } @@ -28785,7 +28785,7 @@ var require_subset2 = __commonJS({ if (lower === c && lower !== lt2) { return false; } - } else if (lt2.operator === "<=" && !satisfies4(lt2.semver, String(c), options)) { + } else if (lt2.operator === "<=" && !satisfies5(lt2.semver, String(c), options)) { return false; } } @@ -28855,12 +28855,12 @@ var require_semver4 = __commonJS({ var coerce = require_coerce2(); var Comparator = require_comparator2(); var Range = require_range2(); - var satisfies4 = require_satisfies2(); + var satisfies5 = require_satisfies2(); var toComparators = require_to_comparators2(); var maxSatisfying3 = require_max_satisfying2(); var minSatisfying4 = require_min_satisfying2(); var minVersion = require_min_version2(); - var validRange2 = require_valid4(); + var validRange3 = require_valid4(); var outside = require_outside2(); var gtr = require_gtr2(); var ltr = require_ltr2(); @@ -28893,12 +28893,12 @@ var require_semver4 = __commonJS({ coerce, Comparator, Range, - satisfies: satisfies4, + satisfies: satisfies5, toComparators, maxSatisfying: maxSatisfying3, minSatisfying: minSatisfying4, minVersion, - validRange: validRange2, + validRange: validRange3, outside, gtr, ltr, @@ -40422,7 +40422,7 @@ var require_readable2 = __commonJS({ "use strict"; var assert4 = require("node:assert"); var { addAbortListener } = require("node:events"); - var { Readable: Readable5 } = require("node:stream"); + var { Readable: Readable6 } = require("node:stream"); var { RequestAbortedError, NotSupportedError, InvalidArgumentError, AbortError: AbortError3 } = require_errors2(); var util7 = require_util9(); var { ReadableStreamFrom } = require_util9(); @@ -40436,7 +40436,7 @@ var require_readable2 = __commonJS({ var kBytesRead = /* @__PURE__ */ Symbol("kBytesRead"); var noop = () => { }; - var BodyReadable = class extends Readable5 { + var BodyReadable = class extends Readable6 { /** * @param {object} opts * @param {(this: Readable, size: number) => void} opts.resume @@ -40825,7 +40825,7 @@ var require_api_request2 = __commonJS({ "use strict"; var assert4 = require("node:assert"); var { AsyncResource } = require("node:async_hooks"); - var { Readable: Readable5 } = require_readable2(); + var { Readable: Readable6 } = require_readable2(); var { InvalidArgumentError, RequestAbortedError } = require_errors2(); var util7 = require_util9(); function noop() { @@ -40909,7 +40909,7 @@ var require_api_request2 = __commonJS({ const parsedHeaders = headers; const contentType2 = parsedHeaders?.["content-type"]; const contentLength2 = parsedHeaders?.["content-length"]; - const res = new Readable5({ + const res = new Readable6({ resume: () => controller.resume(), abort: (reason) => controller.abort(reason), contentType: contentType2, @@ -41285,7 +41285,7 @@ var require_api_pipeline2 = __commonJS({ "node_modules/undici/lib/api/api-pipeline.js"(exports2, module2) { "use strict"; var { - Readable: Readable5, + Readable: Readable6, Duplex, PassThrough } = require("node:stream"); @@ -41302,7 +41302,7 @@ var require_api_pipeline2 = __commonJS({ function noop() { } var kResume = /* @__PURE__ */ Symbol("resume"); - var PipelineRequest = class extends Readable5 { + var PipelineRequest = class extends Readable6 { constructor() { super({ autoDestroy: true }); this[kResume] = null; @@ -41320,7 +41320,7 @@ var require_api_pipeline2 = __commonJS({ callback(err); } }; - var PipelineResponse = class extends Readable5 { + var PipelineResponse = class extends Readable6 { constructor(resume) { super({ autoDestroy: true }); this[kResume] = resume; @@ -45937,7 +45937,7 @@ var require_cache3 = __commonJS({ "node_modules/undici/lib/interceptor/cache.js"(exports2, module2) { "use strict"; var assert4 = require("node:assert"); - var { Readable: Readable5 } = require("node:stream"); + var { Readable: Readable6 } = require("node:stream"); var util7 = require_util9(); var CacheHandler = require_cache_handler(); var MemoryCacheStore = require_memory_cache_store(); @@ -46033,7 +46033,7 @@ var require_cache3 = __commonJS({ return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)); } function sendCachedValue(handler, opts, result, age, context3, isStale2) { - const stream4 = util7.isStream(result.body) ? result.body : Readable5.from(result.body ?? []); + const stream4 = util7.isStream(result.body) ? result.body : Readable6.from(result.body ?? []); assert4(!stream4.destroyed, "stream should not be destroyed"); assert4(!stream4.readableDidRead, "stream should not be readableDidRead"); const controller = { @@ -49156,7 +49156,7 @@ var require_fetch2 = __commonJS({ subresourceSet } = require_constants10(); var EE = require("node:events"); - var { Readable: Readable5, pipeline: pipeline4, finished, isErrored, isReadable } = require("node:stream"); + var { Readable: Readable6, pipeline: pipeline4, finished, isErrored, isReadable } = require("node:stream"); var { addAbortListener, bufferToLowerCasedHeaderName } = require_util9(); var { dataURLProcessor, serializeAMimeType, minimizeSupportedMimeType } = require_data_url2(); var { getGlobalDispatcher } = require_global4(); @@ -50118,7 +50118,7 @@ var require_fetch2 = __commonJS({ const headersList = new HeadersList(); appendHeadersListFromResponseHeaders(headersList, headers, rawHeaders); const location = headersList.get("location", true); - this.body = new Readable5({ read: () => controller.resume() }); + this.body = new Readable6({ read: () => controller.resume() }); const willFollow = location && request.redirect === "follow" && redirectStatusSet.has(status); const decoders = []; if (request.method !== "HEAD" && request.method !== "CONNECT" && !nullBodyStatus.includes(status) && !willFollow) { @@ -55017,13 +55017,13 @@ var require_semver5 = __commonJS({ return true; } rangeTmp = new Range(comp26.value, options); - return satisfies4(this.value, rangeTmp, options); + return satisfies5(this.value, rangeTmp, options); } else if (comp26.operator === "") { if (comp26.value === "") { return true; } rangeTmp = new Range(this.value, options); - return satisfies4(comp26.semver, rangeTmp, options); + return satisfies5(comp26.semver, rangeTmp, options); } var sameDirectionIncreasing = (this.operator === ">=" || this.operator === ">") && (comp26.operator === ">=" || comp26.operator === ">"); var sameDirectionDecreasing = (this.operator === "<=" || this.operator === "<") && (comp26.operator === "<=" || comp26.operator === "<"); @@ -55350,8 +55350,8 @@ var require_semver5 = __commonJS({ } return true; } - exports2.satisfies = satisfies4; - function satisfies4(version3, range2, options) { + exports2.satisfies = satisfies5; + function satisfies5(version3, range2, options) { try { range2 = new Range(range2, options); } catch (er) { @@ -55442,8 +55442,8 @@ var require_semver5 = __commonJS({ } return null; } - exports2.validRange = validRange2; - function validRange2(range2, options) { + exports2.validRange = validRange3; + function validRange3(range2, options) { try { return new Range(range2, options).range || "*"; } catch (er) { @@ -55481,7 +55481,7 @@ var require_semver5 = __commonJS({ default: throw new TypeError('Must provide a hilo val of "<" or ">"'); } - if (satisfies4(version3, range2, options)) { + if (satisfies5(version3, range2, options)) { return false; } for (var i2 = 0; i2 < range2.set.length; ++i2) { @@ -96116,6 +96116,10 @@ async function validateFileCheckSum(filePath, expected) { }); } +// src/download/manifest.ts +var import_node_readline = require("node:readline"); +var import_node_stream5 = require("node:stream"); + // src/utils/fetch.ts var import_undici2 = __toESM(require_undici2(), 1); function getProxyAgent() { @@ -96169,21 +96173,15 @@ function formatVariants(entries) { // src/download/manifest.ts var cachedManifestData = /* @__PURE__ */ new Map(); async function fetchManifest(manifestUrl = VERSIONS_MANIFEST_URL) { - const cachedVersions = cachedManifestData.get(manifestUrl); - if (cachedVersions !== void 0) { + const cachedManifest = cachedManifestData.get(manifestUrl); + if (cachedManifest?.complete === true) { debug(`Using cached manifest data from ${manifestUrl}`); - return cachedVersions; - } - info2(`Fetching manifest data from ${manifestUrl} ...`); - const response = await fetch(manifestUrl, {}); - if (!response.ok) { - throw new Error( - `Failed to fetch manifest data: ${response.status} ${response.statusText}` - ); + return cachedManifest.versions; } + const response = await fetchManifestResponse(manifestUrl); const body2 = await response.text(); const versions = parseManifest(body2, manifestUrl); - cachedManifestData.set(manifestUrl, versions); + cachedManifestData.set(manifestUrl, { complete: true, versions }); return versions; } function parseManifest(data, sourceDescription) { @@ -96191,31 +96189,14 @@ function parseManifest(data, sourceDescription) { if (trimmed === "") { throw new Error(`Manifest at ${sourceDescription} is empty.`); } - if (trimmed.startsWith("[")) { - throw new Error( - `Legacy JSON array manifests are no longer supported in ${sourceDescription}. Use the astral-sh/versions manifest format instead.` - ); - } + rejectLegacyManifest(trimmed, sourceDescription); const versions = []; for (const [index, line] of data.split("\n").entries()) { const record = line.trim(); if (record === "") { continue; } - let parsed; - try { - parsed = JSON.parse(record); - } catch (error2) { - throw new Error( - `Failed to parse manifest data from ${sourceDescription} at line ${index + 1}: ${error2.message}` - ); - } - if (!isManifestVersion(parsed)) { - throw new Error( - `Invalid manifest record in ${sourceDescription} at line ${index + 1}.` - ); - } - versions.push(parsed); + versions.push(parseManifestRecord(record, sourceDescription, index + 1)); } if (versions.length === 0) { throw new Error(`No manifest data found in ${sourceDescription}.`); @@ -96223,13 +96204,16 @@ function parseManifest(data, sourceDescription) { return versions; } async function getLatestVersion(manifestUrl = VERSIONS_MANIFEST_URL) { - const latestVersion = (await fetchManifest(manifestUrl))[0]?.version; + const latestVersion = manifestUrl === VERSIONS_MANIFEST_URL ? (await findManifestVersion(() => true))?.version : (await fetchManifest(manifestUrl))[0]?.version; if (latestVersion === void 0) { throw new Error("No versions found in manifest data"); } debug(`Latest version from manifest: ${latestVersion}`); return latestVersion; } +async function getFirstMatchingVersion(predicate) { + return (await findManifestVersion((versionData) => predicate(versionData.version)))?.version; +} async function getAllVersions(manifestUrl = VERSIONS_MANIFEST_URL) { info2( `Getting available versions from ${manifestSource(manifestUrl)} ...` @@ -96238,8 +96222,7 @@ async function getAllVersions(manifestUrl = VERSIONS_MANIFEST_URL) { return versions.map((versionData) => versionData.version); } async function getArtifact(version3, arch3, platform2, manifestUrl = VERSIONS_MANIFEST_URL) { - const versions = await fetchManifest(manifestUrl); - const versionData = versions.find( + const versionData = manifestUrl === VERSIONS_MANIFEST_URL ? await findManifestVersion((candidate) => candidate.version === version3) : (await fetchManifest(manifestUrl)).find( (candidate) => candidate.version === version3 ); if (!versionData) { @@ -96266,12 +96249,103 @@ async function getArtifact(version3, arch3, platform2, manifestUrl = VERSIONS_MA downloadUrl: artifact.url }; } +async function fetchManifestResponse(manifestUrl) { + info2(`Fetching manifest data from ${manifestUrl} ...`); + const response = await fetch(manifestUrl, {}); + if (!response.ok) { + throw new Error( + `Failed to fetch manifest data: ${response.status} ${response.statusText}` + ); + } + return response; +} +async function findManifestVersion(predicate) { + const cachedManifest = cachedManifestData.get(VERSIONS_MANIFEST_URL); + const cachedVersion = cachedManifest?.versions.find(predicate); + if (cachedVersion !== void 0 || cachedManifest?.complete === true) { + return cachedVersion; + } + const response = await fetchManifestResponse(VERSIONS_MANIFEST_URL); + if (response.body === null) { + const versions2 = parseManifest( + await response.text(), + VERSIONS_MANIFEST_URL + ); + cachedManifestData.set(VERSIONS_MANIFEST_URL, { + complete: true, + versions: versions2 + }); + return versions2.find(predicate); + } + const input = import_node_stream5.Readable.fromWeb(response.body); + const lines = (0, import_node_readline.createInterface)({ crlfDelay: Number.POSITIVE_INFINITY, input }); + const versions = []; + let complete = false; + let lineNumber = 0; + let matchedVersion; + try { + for await (const line of lines) { + lineNumber += 1; + const record = line.trim(); + if (record === "") { + continue; + } + if (versions.length === 0) { + rejectLegacyManifest(record, VERSIONS_MANIFEST_URL); + } + const versionData = parseManifestRecord( + record, + VERSIONS_MANIFEST_URL, + lineNumber + ); + versions.push(versionData); + if (predicate(versionData)) { + matchedVersion = versionData; + break; + } + } + complete = matchedVersion === void 0; + } finally { + lines.close(); + if (!complete) { + input.destroy(); + } + } + if (versions.length === 0) { + throw new Error(`Manifest at ${VERSIONS_MANIFEST_URL} is empty.`); + } + cachedManifestData.set(VERSIONS_MANIFEST_URL, { complete, versions }); + return matchedVersion; +} function manifestSource(manifestUrl) { if (manifestUrl === VERSIONS_MANIFEST_URL) { return VERSIONS_MANIFEST_URL; } return `manifest-file ${manifestUrl}`; } +function parseManifestRecord(record, sourceDescription, lineNumber) { + let parsed; + try { + parsed = JSON.parse(record); + } catch (error2) { + throw new Error( + `Failed to parse manifest data from ${sourceDescription} at line ${lineNumber}: ${error2.message}` + ); + } + if (!isManifestVersion(parsed)) { + throw new Error( + `Invalid manifest record in ${sourceDescription} at line ${lineNumber}.` + ); + } + return parsed; +} +function rejectLegacyManifest(data, sourceDescription) { + if (data.startsWith("[")) { + throw new Error( + `Legacy JSON array manifests are no longer supported in ${sourceDescription}. Use the astral-sh/versions manifest format instead.` + ); + } +} function isManifestVersion(value) { if (!isRecord(value)) { return false; @@ -97735,9 +97809,19 @@ var RangeVersionResolver = class { if (context3.parsedSpecifier.kind !== "range") { return void 0; } - const availableVersions = await getAllVersions(context3.manifestUrl); - debug(`Available versions: ${availableVersions}`); - const resolvedVersion = context3.resolutionStrategy === "lowest" ? minSatisfying3(availableVersions, context3.parsedSpecifier.normalized) : maxSatisfying2(availableVersions, context3.parsedSpecifier.normalized); + let resolvedVersion; + if (context3.resolutionStrategy === "highest" && (context3.manifestUrl === void 0 || context3.manifestUrl === VERSIONS_MANIFEST_URL)) { + resolvedVersion = await findHighestSatisfyingVersion( + context3.parsedSpecifier.normalized + ); + } else { + const availableVersions = await getAllVersions(context3.manifestUrl); + debug(`Available versions: ${availableVersions}`); + resolvedVersion = context3.resolutionStrategy === "lowest" ? minSatisfying3(availableVersions, context3.parsedSpecifier.normalized) : maxSatisfying2( + availableVersions, + context3.parsedSpecifier.normalized + ); + } if (resolvedVersion === void 0) { throw new Error(`No version found for ${context3.parsedSpecifier.raw}`); } @@ -97774,6 +97858,29 @@ async function resolveVersion(versionInput, manifestUrl, resolutionStrategy = "h } throw new Error(`No version found for ${versionInput}`); } +async function findHighestSatisfyingVersion(versionSpecifier) { + const semverRange = semver4.validRange(versionSpecifier); + if (semverRange !== null) { + const semverMatch = await getFirstMatchingVersion( + (version3) => semver4.satisfies(version3, semverRange) + ); + if (semverMatch !== void 0) { + debug( + `Found a version that satisfies the semver range: ${semverMatch}` + ); + return semverMatch; + } + } + const pep440Match = await getFirstMatchingVersion( + (version3) => satisfies3(version3, versionSpecifier) + ); + if (pep440Match !== void 0) { + debug( + `Found a version that satisfies the pep440 specifier: ${pep440Match}` + ); + } + return pep440Match; +} function maxSatisfying2(versions, version3) { const maxSemver = evaluateVersions(versions, version3); if (maxSemver !== "") { diff --git a/dist/update-known-checksums/index.cjs b/dist/update-known-checksums/index.cjs index 540683a..2c0bca1 100644 --- a/dist/update-known-checksums/index.cjs +++ b/dist/update-known-checksums/index.cjs @@ -9157,7 +9157,7 @@ var require_readable = __commonJS({ "node_modules/@actions/http-client/node_modules/undici/lib/api/readable.js"(exports2, module2) { "use strict"; var assert = require("node:assert"); - var { Readable } = require("node:stream"); + var { Readable: Readable2 } = require("node:stream"); var { RequestAbortedError, NotSupportedError, InvalidArgumentError, AbortError } = require_errors(); var util = require_util(); var { ReadableStreamFrom } = require_util(); @@ -9169,7 +9169,7 @@ var require_readable = __commonJS({ var kContentLength = /* @__PURE__ */ Symbol("kContentLength"); var noop = () => { }; - var BodyReadable = class extends Readable { + var BodyReadable = class extends Readable2 { constructor({ resume, abort, @@ -9511,7 +9511,7 @@ var require_api_request = __commonJS({ "node_modules/@actions/http-client/node_modules/undici/lib/api/api-request.js"(exports2, module2) { "use strict"; var assert = require("node:assert"); - var { Readable } = require_readable(); + var { Readable: Readable2 } = require_readable(); var { InvalidArgumentError, RequestAbortedError } = require_errors(); var util = require_util(); var { getResolveErrorBodyCallback } = require_util3(); @@ -9606,7 +9606,7 @@ var require_api_request = __commonJS({ const parsedHeaders = responseHeaders === "raw" ? util.parseHeaders(rawHeaders) : headers; const contentType = parsedHeaders["content-type"]; const contentLength = parsedHeaders["content-length"]; - const res = new Readable({ + const res = new Readable2({ resume, abort, contentType, @@ -9921,7 +9921,7 @@ var require_api_pipeline = __commonJS({ "node_modules/@actions/http-client/node_modules/undici/lib/api/api-pipeline.js"(exports2, module2) { "use strict"; var { - Readable, + Readable: Readable2, Duplex, PassThrough } = require("node:stream"); @@ -9935,7 +9935,7 @@ var require_api_pipeline = __commonJS({ var { addSignal, removeSignal } = require_abort_signal(); var assert = require("node:assert"); var kResume = /* @__PURE__ */ Symbol("resume"); - var PipelineRequest = class extends Readable { + var PipelineRequest = class extends Readable2 { constructor() { super({ autoDestroy: true }); this[kResume] = null; @@ -9952,7 +9952,7 @@ var require_api_pipeline = __commonJS({ callback(err); } }; - var PipelineResponse = class extends Readable { + var PipelineResponse = class extends Readable2 { constructor(resume) { super({ autoDestroy: true }); this[kResume] = resume; @@ -13277,7 +13277,7 @@ var require_fetch = __commonJS({ subresourceSet } = require_constants3(); var EE = require("node:events"); - var { Readable, pipeline, finished } = require("node:stream"); + var { Readable: Readable2, pipeline, finished } = require("node:stream"); var { addAbortListener, isErrored, isReadable, bufferToLowerCasedHeaderName } = require_util(); var { dataURLProcessor, serializeAMimeType, minimizeSupportedMimeType } = require_data_url(); var { getGlobalDispatcher } = require_global2(); @@ -14178,7 +14178,7 @@ var require_fetch = __commonJS({ headersList.append(bufferToLowerCasedHeaderName(rawHeaders[i]), rawHeaders[i + 1].toString("latin1"), true); } location = headersList.get("location", true); - this.body = new Readable({ read: resume }); + this.body = new Readable2({ read: resume }); const decoders = []; const willFollow = location && request.redirect === "follow" && redirectStatusSet.has(status); if (request.method !== "HEAD" && request.method !== "CONNECT" && !nullBodyStatus.includes(status) && !willFollow) { @@ -31244,7 +31244,7 @@ var require_readable2 = __commonJS({ "use strict"; var assert = require("node:assert"); var { addAbortListener } = require("node:events"); - var { Readable } = require("node:stream"); + var { Readable: Readable2 } = require("node:stream"); var { RequestAbortedError, NotSupportedError, InvalidArgumentError, AbortError } = require_errors2(); var util = require_util9(); var { ReadableStreamFrom } = require_util9(); @@ -31258,7 +31258,7 @@ var require_readable2 = __commonJS({ var kBytesRead = /* @__PURE__ */ Symbol("kBytesRead"); var noop = () => { }; - var BodyReadable = class extends Readable { + var BodyReadable = class extends Readable2 { /** * @param {object} opts * @param {(this: Readable, size: number) => void} opts.resume @@ -31647,7 +31647,7 @@ var require_api_request2 = __commonJS({ "use strict"; var assert = require("node:assert"); var { AsyncResource } = require("node:async_hooks"); - var { Readable } = require_readable2(); + var { Readable: Readable2 } = require_readable2(); var { InvalidArgumentError, RequestAbortedError } = require_errors2(); var util = require_util9(); function noop() { @@ -31731,7 +31731,7 @@ var require_api_request2 = __commonJS({ const parsedHeaders = headers; const contentType = parsedHeaders?.["content-type"]; const contentLength = parsedHeaders?.["content-length"]; - const res = new Readable({ + const res = new Readable2({ resume: () => controller.resume(), abort: (reason) => controller.abort(reason), contentType, @@ -32107,7 +32107,7 @@ var require_api_pipeline2 = __commonJS({ "node_modules/undici/lib/api/api-pipeline.js"(exports2, module2) { "use strict"; var { - Readable, + Readable: Readable2, Duplex, PassThrough } = require("node:stream"); @@ -32124,7 +32124,7 @@ var require_api_pipeline2 = __commonJS({ function noop() { } var kResume = /* @__PURE__ */ Symbol("resume"); - var PipelineRequest = class extends Readable { + var PipelineRequest = class extends Readable2 { constructor() { super({ autoDestroy: true }); this[kResume] = null; @@ -32142,7 +32142,7 @@ var require_api_pipeline2 = __commonJS({ callback(err); } }; - var PipelineResponse = class extends Readable { + var PipelineResponse = class extends Readable2 { constructor(resume) { super({ autoDestroy: true }); this[kResume] = resume; @@ -36759,7 +36759,7 @@ var require_cache3 = __commonJS({ "node_modules/undici/lib/interceptor/cache.js"(exports2, module2) { "use strict"; var assert = require("node:assert"); - var { Readable } = require("node:stream"); + var { Readable: Readable2 } = require("node:stream"); var util = require_util9(); var CacheHandler = require_cache_handler(); var MemoryCacheStore = require_memory_cache_store(); @@ -36855,7 +36855,7 @@ var require_cache3 = __commonJS({ return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)); } function sendCachedValue(handler, opts, result, age, context, isStale2) { - const stream = util.isStream(result.body) ? result.body : Readable.from(result.body ?? []); + const stream = util.isStream(result.body) ? result.body : Readable2.from(result.body ?? []); assert(!stream.destroyed, "stream should not be destroyed"); assert(!stream.readableDidRead, "stream should not be readableDidRead"); const controller = { @@ -39978,7 +39978,7 @@ var require_fetch2 = __commonJS({ subresourceSet } = require_constants8(); var EE = require("node:events"); - var { Readable, pipeline, finished, isErrored, isReadable } = require("node:stream"); + var { Readable: Readable2, pipeline, finished, isErrored, isReadable } = require("node:stream"); var { addAbortListener, bufferToLowerCasedHeaderName } = require_util9(); var { dataURLProcessor, serializeAMimeType, minimizeSupportedMimeType } = require_data_url2(); var { getGlobalDispatcher } = require_global4(); @@ -40940,7 +40940,7 @@ var require_fetch2 = __commonJS({ const headersList = new HeadersList(); appendHeadersListFromResponseHeaders(headersList, headers, rawHeaders); const location = headersList.get("location", true); - this.body = new Readable({ read: () => controller.resume() }); + this.body = new Readable2({ read: () => controller.resume() }); const willFollow = location && request.redirect === "follow" && redirectStatusSet.has(status); const decoders = []; if (request.method !== "HEAD" && request.method !== "CONNECT" && !nullBodyStatus.includes(status) && !willFollow) { @@ -50701,6 +50701,10 @@ async function updateChecksums(filePath, checksumEntries) { await import_node_fs.promises.writeFile(filePath, content); } +// src/download/manifest.ts +var import_node_readline = require("node:readline"); +var import_node_stream = require("node:stream"); + // src/utils/constants.ts var VERSIONS_MANIFEST_URL = "https://raw.githubusercontent.com/astral-sh/versions/main/v1/uv.ndjson"; @@ -50745,21 +50749,15 @@ function info2(msg) { // src/download/manifest.ts var cachedManifestData = /* @__PURE__ */ new Map(); async function fetchManifest(manifestUrl = VERSIONS_MANIFEST_URL) { - const cachedVersions = cachedManifestData.get(manifestUrl); - if (cachedVersions !== void 0) { + const cachedManifest = cachedManifestData.get(manifestUrl); + if (cachedManifest?.complete === true) { debug(`Using cached manifest data from ${manifestUrl}`); - return cachedVersions; - } - info2(`Fetching manifest data from ${manifestUrl} ...`); - const response = await fetch(manifestUrl, {}); - if (!response.ok) { - throw new Error( - `Failed to fetch manifest data: ${response.status} ${response.statusText}` - ); + return cachedManifest.versions; } + const response = await fetchManifestResponse(manifestUrl); const body = await response.text(); const versions = parseManifest(body, manifestUrl); - cachedManifestData.set(manifestUrl, versions); + cachedManifestData.set(manifestUrl, { complete: true, versions }); return versions; } function parseManifest(data, sourceDescription) { @@ -50767,31 +50765,14 @@ function parseManifest(data, sourceDescription) { if (trimmed === "") { throw new Error(`Manifest at ${sourceDescription} is empty.`); } - if (trimmed.startsWith("[")) { - throw new Error( - `Legacy JSON array manifests are no longer supported in ${sourceDescription}. Use the astral-sh/versions manifest format instead.` - ); - } + rejectLegacyManifest(trimmed, sourceDescription); const versions = []; for (const [index, line] of data.split("\n").entries()) { const record = line.trim(); if (record === "") { continue; } - let parsed; - try { - parsed = JSON.parse(record); - } catch (error2) { - throw new Error( - `Failed to parse manifest data from ${sourceDescription} at line ${index + 1}: ${error2.message}` - ); - } - if (!isManifestVersion(parsed)) { - throw new Error( - `Invalid manifest record in ${sourceDescription} at line ${index + 1}.` - ); - } - versions.push(parsed); + versions.push(parseManifestRecord(record, sourceDescription, index + 1)); } if (versions.length === 0) { throw new Error(`No manifest data found in ${sourceDescription}.`); @@ -50799,13 +50780,104 @@ function parseManifest(data, sourceDescription) { return versions; } async function getLatestVersion(manifestUrl = VERSIONS_MANIFEST_URL) { - const latestVersion = (await fetchManifest(manifestUrl))[0]?.version; + const latestVersion = manifestUrl === VERSIONS_MANIFEST_URL ? (await findManifestVersion(() => true))?.version : (await fetchManifest(manifestUrl))[0]?.version; if (latestVersion === void 0) { throw new Error("No versions found in manifest data"); } debug(`Latest version from manifest: ${latestVersion}`); return latestVersion; } +async function fetchManifestResponse(manifestUrl) { + info2(`Fetching manifest data from ${manifestUrl} ...`); + const response = await fetch(manifestUrl, {}); + if (!response.ok) { + throw new Error( + `Failed to fetch manifest data: ${response.status} ${response.statusText}` + ); + } + return response; +} +async function findManifestVersion(predicate) { + const cachedManifest = cachedManifestData.get(VERSIONS_MANIFEST_URL); + const cachedVersion = cachedManifest?.versions.find(predicate); + if (cachedVersion !== void 0 || cachedManifest?.complete === true) { + return cachedVersion; + } + const response = await fetchManifestResponse(VERSIONS_MANIFEST_URL); + if (response.body === null) { + const versions2 = parseManifest( + await response.text(), + VERSIONS_MANIFEST_URL + ); + cachedManifestData.set(VERSIONS_MANIFEST_URL, { + complete: true, + versions: versions2 + }); + return versions2.find(predicate); + } + const input = import_node_stream.Readable.fromWeb(response.body); + const lines = (0, import_node_readline.createInterface)({ crlfDelay: Number.POSITIVE_INFINITY, input }); + const versions = []; + let complete = false; + let lineNumber = 0; + let matchedVersion; + try { + for await (const line of lines) { + lineNumber += 1; + const record = line.trim(); + if (record === "") { + continue; + } + if (versions.length === 0) { + rejectLegacyManifest(record, VERSIONS_MANIFEST_URL); + } + const versionData = parseManifestRecord( + record, + VERSIONS_MANIFEST_URL, + lineNumber + ); + versions.push(versionData); + if (predicate(versionData)) { + matchedVersion = versionData; + break; + } + } + complete = matchedVersion === void 0; + } finally { + lines.close(); + if (!complete) { + input.destroy(); + } + } + if (versions.length === 0) { + throw new Error(`Manifest at ${VERSIONS_MANIFEST_URL} is empty.`); + } + cachedManifestData.set(VERSIONS_MANIFEST_URL, { complete, versions }); + return matchedVersion; +} +function parseManifestRecord(record, sourceDescription, lineNumber) { + let parsed; + try { + parsed = JSON.parse(record); + } catch (error2) { + throw new Error( + `Failed to parse manifest data from ${sourceDescription} at line ${lineNumber}: ${error2.message}` + ); + } + if (!isManifestVersion(parsed)) { + throw new Error( + `Invalid manifest record in ${sourceDescription} at line ${lineNumber}.` + ); + } + return parsed; +} +function rejectLegacyManifest(data, sourceDescription) { + if (data.startsWith("[")) { + throw new Error( + `Legacy JSON array manifests are no longer supported in ${sourceDescription}. Use the astral-sh/versions manifest format instead.` + ); + } +} function isManifestVersion(value) { if (!isRecord(value)) { return false; diff --git a/src/download/manifest.ts b/src/download/manifest.ts index ab41342..b26bf82 100644 --- a/src/download/manifest.ts +++ b/src/download/manifest.ts @@ -1,3 +1,5 @@ +import { createInterface } from "node:readline"; +import { Readable } from "node:stream"; import * as core from "@actions/core"; import { VERSIONS_MANIFEST_URL } from "../utils/constants"; import { fetch } from "../utils/fetch"; @@ -23,28 +25,26 @@ export interface ArtifactResult { downloadUrl: string; } -const cachedManifestData = new Map(); +interface CachedManifest { + complete: boolean; + versions: ManifestVersion[]; +} + +const cachedManifestData = new Map(); export async function fetchManifest( manifestUrl: string = VERSIONS_MANIFEST_URL, ): Promise { - const cachedVersions = cachedManifestData.get(manifestUrl); - if (cachedVersions !== undefined) { + const cachedManifest = cachedManifestData.get(manifestUrl); + if (cachedManifest?.complete === true) { core.debug(`Using cached manifest data from ${manifestUrl}`); - return cachedVersions; - } - - log.info(`Fetching manifest data from ${manifestUrl} ...`); - const response = await fetch(manifestUrl, {}); - if (!response.ok) { - throw new Error( - `Failed to fetch manifest data: ${response.status} ${response.statusText}`, - ); + return cachedManifest.versions; } + const response = await fetchManifestResponse(manifestUrl); const body = await response.text(); const versions = parseManifest(body, manifestUrl); - cachedManifestData.set(manifestUrl, versions); + cachedManifestData.set(manifestUrl, { complete: true, versions }); return versions; } @@ -57,11 +57,7 @@ export function parseManifest( throw new Error(`Manifest at ${sourceDescription} is empty.`); } - if (trimmed.startsWith("[")) { - throw new Error( - `Legacy JSON array manifests are no longer supported in ${sourceDescription}. Use the astral-sh/versions manifest format instead.`, - ); - } + rejectLegacyManifest(trimmed, sourceDescription); const versions: ManifestVersion[] = []; @@ -71,22 +67,7 @@ export function parseManifest( continue; } - let parsed: unknown; - try { - parsed = JSON.parse(record); - } catch (error) { - throw new Error( - `Failed to parse manifest data from ${sourceDescription} at line ${index + 1}: ${(error as Error).message}`, - ); - } - - if (!isManifestVersion(parsed)) { - throw new Error( - `Invalid manifest record in ${sourceDescription} at line ${index + 1}.`, - ); - } - - versions.push(parsed); + versions.push(parseManifestRecord(record, sourceDescription, index + 1)); } if (versions.length === 0) { @@ -99,7 +80,10 @@ export function parseManifest( export async function getLatestVersion( manifestUrl: string = VERSIONS_MANIFEST_URL, ): Promise { - const latestVersion = (await fetchManifest(manifestUrl))[0]?.version; + const latestVersion = + manifestUrl === VERSIONS_MANIFEST_URL + ? (await findManifestVersion(() => true))?.version + : (await fetchManifest(manifestUrl))[0]?.version; if (latestVersion === undefined) { throw new Error("No versions found in manifest data"); @@ -109,6 +93,16 @@ export async function getLatestVersion( return latestVersion; } +// The default manifest is guaranteed to be ordered newest-first: +// https://github.com/astral-sh/versions#format +export async function getFirstMatchingVersion( + predicate: (version: string) => boolean, +): Promise { + return ( + await findManifestVersion((versionData) => predicate(versionData.version)) + )?.version; +} + export async function getAllVersions( manifestUrl: string = VERSIONS_MANIFEST_URL, ): Promise { @@ -125,10 +119,12 @@ export async function getArtifact( platform: string, manifestUrl: string = VERSIONS_MANIFEST_URL, ): Promise { - const versions = await fetchManifest(manifestUrl); - const versionData = versions.find( - (candidate) => candidate.version === version, - ); + const versionData = + manifestUrl === VERSIONS_MANIFEST_URL + ? await findManifestVersion((candidate) => candidate.version === version) + : (await fetchManifest(manifestUrl)).find( + (candidate) => candidate.version === version, + ); if (!versionData) { core.debug(`Version ${version} not found in manifest ${manifestUrl}`); return undefined; @@ -169,6 +165,87 @@ export function clearManifestCache(manifestUrl?: string): void { cachedManifestData.delete(manifestUrl); } +async function fetchManifestResponse(manifestUrl: string) { + log.info(`Fetching manifest data from ${manifestUrl} ...`); + const response = await fetch(manifestUrl, {}); + if (!response.ok) { + throw new Error( + `Failed to fetch manifest data: ${response.status} ${response.statusText}`, + ); + } + + return response; +} + +async function findManifestVersion( + predicate: (versionData: ManifestVersion) => boolean, +): Promise { + const cachedManifest = cachedManifestData.get(VERSIONS_MANIFEST_URL); + const cachedVersion = cachedManifest?.versions.find(predicate); + if (cachedVersion !== undefined || cachedManifest?.complete === true) { + return cachedVersion; + } + + const response = await fetchManifestResponse(VERSIONS_MANIFEST_URL); + if (response.body === null) { + const versions = parseManifest( + await response.text(), + VERSIONS_MANIFEST_URL, + ); + cachedManifestData.set(VERSIONS_MANIFEST_URL, { + complete: true, + versions, + }); + return versions.find(predicate); + } + + const input = Readable.fromWeb(response.body); + const lines = createInterface({ crlfDelay: Number.POSITIVE_INFINITY, input }); + const versions: ManifestVersion[] = []; + let complete = false; + let lineNumber = 0; + let matchedVersion: ManifestVersion | undefined; + + try { + for await (const line of lines) { + lineNumber += 1; + const record = line.trim(); + if (record === "") { + continue; + } + + if (versions.length === 0) { + rejectLegacyManifest(record, VERSIONS_MANIFEST_URL); + } + + const versionData = parseManifestRecord( + record, + VERSIONS_MANIFEST_URL, + lineNumber, + ); + versions.push(versionData); + if (predicate(versionData)) { + matchedVersion = versionData; + break; + } + } + + complete = matchedVersion === undefined; + } finally { + lines.close(); + if (!complete) { + input.destroy(); + } + } + + if (versions.length === 0) { + throw new Error(`Manifest at ${VERSIONS_MANIFEST_URL} is empty.`); + } + + cachedManifestData.set(VERSIONS_MANIFEST_URL, { complete, versions }); + return matchedVersion; +} + function manifestSource(manifestUrl: string): string { if (manifestUrl === VERSIONS_MANIFEST_URL) { return VERSIONS_MANIFEST_URL; @@ -177,6 +254,37 @@ function manifestSource(manifestUrl: string): string { return `manifest-file ${manifestUrl}`; } +function parseManifestRecord( + record: string, + sourceDescription: string, + lineNumber: number, +): ManifestVersion { + let parsed: unknown; + try { + parsed = JSON.parse(record); + } catch (error) { + throw new Error( + `Failed to parse manifest data from ${sourceDescription} at line ${lineNumber}: ${(error as Error).message}`, + ); + } + + if (!isManifestVersion(parsed)) { + throw new Error( + `Invalid manifest record in ${sourceDescription} at line ${lineNumber}.`, + ); + } + + return parsed; +} + +function rejectLegacyManifest(data: string, sourceDescription: string): void { + if (data.startsWith("[")) { + throw new Error( + `Legacy JSON array manifests are no longer supported in ${sourceDescription}. Use the astral-sh/versions manifest format instead.`, + ); + } +} + function isManifestVersion(value: unknown): value is ManifestVersion { if (!isRecord(value)) { return false; diff --git a/src/version/resolve.ts b/src/version/resolve.ts index fcc82b1..610af42 100644 --- a/src/version/resolve.ts +++ b/src/version/resolve.ts @@ -2,7 +2,12 @@ import * as core from "@actions/core"; import * as tc from "@actions/tool-cache"; import * as pep440 from "@renovatebot/pep440"; import * as semver from "semver"; -import { getAllVersions, getLatestVersion } from "../download/manifest"; +import { + getAllVersions, + getFirstMatchingVersion, + getLatestVersion, +} from "../download/manifest"; +import { VERSIONS_MANIFEST_URL } from "../utils/constants"; import type { ResolutionStrategy } from "../utils/inputs"; import * as log from "../utils/logging"; import { @@ -82,13 +87,26 @@ class RangeVersionResolver implements ConcreteVersionResolver { return undefined; } - const availableVersions = await getAllVersions(context.manifestUrl); - core.debug(`Available versions: ${availableVersions}`); - - const resolvedVersion = - context.resolutionStrategy === "lowest" - ? minSatisfying(availableVersions, context.parsedSpecifier.normalized) - : maxSatisfying(availableVersions, context.parsedSpecifier.normalized); + let resolvedVersion: string | undefined; + if ( + context.resolutionStrategy === "highest" && + (context.manifestUrl === undefined || + context.manifestUrl === VERSIONS_MANIFEST_URL) + ) { + resolvedVersion = await findHighestSatisfyingVersion( + context.parsedSpecifier.normalized, + ); + } else { + const availableVersions = await getAllVersions(context.manifestUrl); + core.debug(`Available versions: ${availableVersions}`); + resolvedVersion = + context.resolutionStrategy === "lowest" + ? minSatisfying(availableVersions, context.parsedSpecifier.normalized) + : maxSatisfying( + availableVersions, + context.parsedSpecifier.normalized, + ); + } if (resolvedVersion === undefined) { throw new Error(`No version found for ${context.parsedSpecifier.raw}`); @@ -141,6 +159,35 @@ export async function resolveVersion( throw new Error(`No version found for ${versionInput}`); } +async function findHighestSatisfyingVersion( + versionSpecifier: string, +): Promise { + // This fast path assumes uv releases are semver-monotonic: newer manifest + // entries always have higher versions, with no lower-version backports. + const semverRange = semver.validRange(versionSpecifier); + if (semverRange !== null) { + const semverMatch = await getFirstMatchingVersion((version) => + semver.satisfies(version, semverRange), + ); + if (semverMatch !== undefined) { + core.debug( + `Found a version that satisfies the semver range: ${semverMatch}`, + ); + return semverMatch; + } + } + + const pep440Match = await getFirstMatchingVersion((version) => + pep440.satisfies(version, versionSpecifier), + ); + if (pep440Match !== undefined) { + core.debug( + `Found a version that satisfies the pep440 specifier: ${pep440Match}`, + ); + } + return pep440Match; +} + function maxSatisfying( versions: string[], version: string,