From 7a2e4bd371ece7b9d2a11b546522150a00ba048c Mon Sep 17 00:00:00 2001 From: Chiezo Date: Thu, 9 Nov 2023 15:33:02 +0900 Subject: [PATCH] fix: do not update to pre-releases (#79) --- cli.ts | 5 +-- deno.lock | 5 +++ lib/dependency.ts | 72 ++++++++++++++---------------------------- lib/dependency_test.ts | 58 ++++++++++++++++++++++++---------- lib/file_test.ts | 2 +- lib/import_map.ts | 6 ++-- lib/semver.ts | 55 ++++++++++++++++++++++++++++++++ lib/semver_test.ts | 32 +++++++++++++++++++ lib/std/semver.ts | 1 + lib/testing.ts | 5 ++- lib/types.ts | 9 ------ lib/update.ts | 2 +- lib/update_test.ts | 2 +- 13 files changed, 169 insertions(+), 85 deletions(-) create mode 100644 lib/semver.ts create mode 100644 lib/semver_test.ts create mode 100644 lib/std/semver.ts diff --git a/cli.ts b/cli.ts index 81e1817d..33edf358 100644 --- a/cli.ts +++ b/cli.ts @@ -8,7 +8,8 @@ import { URI } from "./lib/uri.ts"; import { DependencyUpdate } from "./lib/update.ts"; import { writeAll } from "./lib/file.ts"; import { GitCommitSequence } from "./lib/git.ts"; -import { Dependency, parseSemVer } from "./lib/dependency.ts"; +import { Dependency } from "./lib/dependency.ts"; +import { SemVerString } from "./lib/semver.ts"; const { gray, yellow, bold, cyan } = colors; @@ -61,7 +62,7 @@ const main = new Command() }); async function versionCommand() { - const version = parseSemVer(import.meta.url) ?? + const version = SemVerString.parse(import.meta.url) ?? await $.progress("Fetching version info").with(async () => { const latest = await Dependency.resolveLatest( Dependency.parse(new URL("https://deno.land/x/molt/cli.ts")), diff --git a/deno.lock b/deno.lock index 661dcf8c..f81fe7a8 100644 --- a/deno.lock +++ b/deno.lock @@ -160,6 +160,11 @@ "https://deno.land/std@0.205.0/path/windows/separator.ts": "ae21f27015f10510ed1ac4a0ba9c4c9c967cbdd9d9e776a3e4967553c397bd5d", "https://deno.land/std@0.205.0/path/windows/to_file_url.ts": "8e9ea9e1ff364aa06fa72999204229952d0a279dbb876b7b838b2b2fea55cce3", "https://deno.land/std@0.205.0/path/windows/to_namespaced_path.ts": "e0f4d4a5e77f28a5708c1a33ff24360f35637ba6d8f103d19661255ef7bfd50d", + "https://deno.land/std@0.205.0/semver/_shared.ts": "8547ccf91b36c30fb2a8a17d7081df13f4ae694c4aa44c39799eba69ad0dcb23", + "https://deno.land/std@0.205.0/semver/constants.ts": "bb0c7652c433c7ec1dad5bf18c7e7e1557efe9ddfd5e70aa6305153e76dc318c", + "https://deno.land/std@0.205.0/semver/is_semver.ts": "666f4e1d8e41994150d4326d515046bc5fc72e59cbbd6e756a0b60548dcd00b5", + "https://deno.land/std@0.205.0/semver/parse.ts": "5d24ec0c5f681db1742c31332f6007395c84696c88ff4b58287485ed3f6d8c84", + "https://deno.land/std@0.205.0/semver/types.ts": "d44f442c2f27dd89bd6695b369e310b80549746f03c38f241fe28a83b33dd429", "https://deno.land/std@0.205.0/testing/_test_suite.ts": "30f018feeb3835f12ab198d8a518f9089b1bcb2e8c838a8b615ab10d5005465c", "https://deno.land/std@0.205.0/testing/bdd.ts": "3f446df5ef8e856a869e8eec54c8482590415741ff0b6358a00c43486cc15769", "https://deno.land/std@0.205.0/testing/snapshot.ts": "d53cc4ad3250e3a826df9a1a90bc19c9a92c8faa8fd508d16b5e6ce8699310ca", diff --git a/lib/dependency.ts b/lib/dependency.ts index 3ba52254..10ecbf53 100644 --- a/lib/dependency.ts +++ b/lib/dependency.ts @@ -1,43 +1,10 @@ import { assertExists } from "./std/assert.ts"; import { Mutex } from "./x/async.ts"; -import type { Path, SemVerString } from "./types.ts"; +import { ensure, is } from "./x/unknownutil.ts"; +import type { Path } from "./types.ts"; +import { SemVerString } from "./semver.ts"; import { URI } from "./uri.ts"; -// Ref: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string -const SEMVER_REGEXP = - /@v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/g; - -/** - * Parse a semver string from the given import specifier. - * - * @param specifier The import specifier to parse. - * - * @returns The semver string parsed from the specifier, or `undefined` if no - * semver string is found. - * - * @example - * ```ts - * const version = parseSemVer("https://deno.land/std@0.205.0/version.ts"); - * // -> "1" - * ``` - */ -export function parseSemVer( - specifier: string, -): SemVerString | undefined { - const match = specifier.match(SEMVER_REGEXP); - if (!match) { - return undefined; - } - if (match.length > 1) { - console.warn( - "Multiple semvers in a single specifier is not supported:", - specifier, - ); - return undefined; - } - return match[0].slice(1) as SemVerString; -} - /** * Properties of a dependency parsed from an import specifier. */ @@ -101,7 +68,7 @@ export const Dependency = { parse(url: URL): Dependency { const scheme = url.protocol === "npm:" ? "npm:" : url.protocol + "//"; const body = url.hostname + url.pathname; - const semver = parseSemVer(url.href); + const semver = SemVerString.parse(url.href); if (!semver) { return { scheme, name: body } as Dependency; } @@ -179,21 +146,27 @@ async function _resolveLatest( `Failed to fetch npm registry: ${response.statusText}`, ); } - const json = await response.json(); - if (!json["dist-tags"]?.latest) { - throw new Error( - `Could not find the latest version of ${dependency.name} from registry.`, - ); - } - const latestSemVer = json["dist-tags"].latest as SemVerString; - if (latestSemVer === dependency.version) { - // The dependency is up to date + const pkg = ensure( + await response.json(), + is.ObjectOf({ + "dist-tags": is.ObjectOf({ + latest: is.String, + }), + }), + { message: `Invalid response from NPM registry: ${response.url}` }, + ); + const latest = SemVerString.parse(pkg["dist-tags"].latest); + if ( + latest === undefined || // The latest version is not a semver + latest === dependency.version || // The dependency is already up to date + SemVerString.isPreRelease(latest) + ) { LatestDependencyCache.set(dependency.name, null); return; } return LatestDependencyCache.set( dependency.name, - { ...dependency, version: latestSemVer }, + { ...dependency, version: latest }, ); } case "http://": @@ -210,8 +183,9 @@ async function _resolveLatest( } const latest = Dependency.parse(new URL(response.url)); if ( - !latest.version || // The redirected URL has no semver - latest.version === dependency.version // The dependency is already up to date + latest.version === undefined || // The redirected URL has no semver + latest.version === dependency.version || // The dependency is already up to date + SemVerString.isPreRelease(latest.version) ) { LatestDependencyCache.set(dependency.name, null); return; diff --git a/lib/dependency_test.ts b/lib/dependency_test.ts index c794bccc..fbfc8742 100644 --- a/lib/dependency_test.ts +++ b/lib/dependency_test.ts @@ -1,22 +1,10 @@ -import { beforeAll, describe, it } from "./std/testing.ts"; +import { afterAll, beforeAll, describe, it } from "./std/testing.ts"; import { assertEquals } from "./std/assert.ts"; -import { Dependency, parseSemVer } from "./dependency.ts"; -import { Path, SemVerString } from "./types.ts"; +import type { Path } from "./types.ts"; +import type { SemVerString } from "./semver.ts"; +import { Dependency } from "./dependency.ts"; import { LatestSemVerStub } from "./testing.ts"; -describe("parseSemVer()", () => { - it("https://deno.land/std", () => - assertEquals( - parseSemVer("https://deno.land/std@0.1.0"), - "0.1.0", - )); - it("https://deno.land/std (no semver)", () => - assertEquals( - parseSemVer("https://deno.land/std"), - undefined, - )); -}); - describe("Dependency.parse()", () => { it("https://deno.land/std", () => assertEquals( @@ -99,9 +87,15 @@ describe("Dependency.toURI()", () => { describe("Dependency.resolveLatest()", () => { const LATEST = "123.456.789" as SemVerString; + let stub: LatestSemVerStub; + beforeAll(() => { - LatestSemVerStub.create(LATEST); + stub = LatestSemVerStub.create(LATEST); }); + afterAll(() => { + stub.restore(); + }); + it("https://deno.land/std/version.ts", async () => assertEquals( await Dependency.resolveLatest( @@ -146,3 +140,33 @@ describe("Dependency.resolveLatest()", () => { ), ); }); + +describe("Dependency.resolveLatest() - pre-release", () => { + let stub: LatestSemVerStub; + + beforeAll(() => { + stub = LatestSemVerStub.create("123.456.789-alpha.1" as SemVerString); + }); + afterAll(() => { + stub.restore(); + }); + + it("deno.land", async () => + assertEquals( + await Dependency.resolveLatest( + Dependency.parse( + new URL("https://deno.land/x/deno_graph@0.50.0/mod.ts"), + ), + ), + undefined, + )); + it("npm", async () => + assertEquals( + await Dependency.resolveLatest( + Dependency.parse( + new URL("npm:node-emoji@1.0.0"), + ), + ), + undefined, + )); +}); diff --git a/lib/file_test.ts b/lib/file_test.ts index 6153d013..904c7428 100644 --- a/lib/file_test.ts +++ b/lib/file_test.ts @@ -18,7 +18,7 @@ import { assertSnapshot } from "./testing.ts"; import { DependencyUpdate } from "./update.ts"; import { FileUpdate } from "./file.ts"; import { LatestSemVerStub } from "./testing.ts"; -import { SemVerString } from "./types.ts"; +import { SemVerString } from "./semver.ts"; const LATEST = "123.456.789" as SemVerString; LatestSemVerStub.create(LATEST); diff --git a/lib/import_map.ts b/lib/import_map.ts index b4fa8a86..5111cbba 100644 --- a/lib/import_map.ts +++ b/lib/import_map.ts @@ -4,10 +4,12 @@ import { type ImportMapJson, parseFromJson } from "./x/import_map.ts"; import { is } from "./x/unknownutil.ts"; import type { Maybe } from "./types.ts"; import { URI } from "./uri.ts"; -import { URIScheme } from "./types.ts"; export type { ImportMapJson }; +const URI_SCHEMES = ["http", "https", "file", "npm", "node"] as const; +type URIScheme = typeof URI_SCHEMES[number]; + export interface ImportMapResolveResult { /** The full specifier resolved from the import map. */ specifier: URI; @@ -71,7 +73,7 @@ async function readFromJson(specifier: URI<"file">): Promise> { URI.ensure("file")(resolved); } return { - specifier: URI.ensure(...URIScheme.values)(resolved), + specifier: URI.ensure(...URI_SCHEMES)(resolved), ...replacement, }; }, diff --git a/lib/semver.ts b/lib/semver.ts new file mode 100644 index 00000000..5fa6da83 --- /dev/null +++ b/lib/semver.ts @@ -0,0 +1,55 @@ +import { parse as parseSemVer } from "./std/semver.ts"; +import { Brand } from "./types.ts"; + +// Ref: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string +const SEMVER_REGEXP = + /v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/g; + +/** A string that represents a semver (e.g. `v1.0.0`.) */ +export type SemVerString = Brand; + +export const SemVerString = { + /** + * Parse a semver string from the given import specifier. + * + * @param specifier The import specifier to parse. + * + * @returns The semver string parsed from the specifier, or `undefined` if no + * semver string is found. + * + * @example + * ```ts + * parse("https://deno.land/std@0.205.0/version.ts"); + * // -> "0.205.0" + * ``` + */ + parse(from: string): SemVerString | undefined { + const match = from.match(SEMVER_REGEXP); + if (!match) { + return undefined; + } + if (match.length > 1) { + console.warn( + "Multiple semvers in a single specifier is not supported:", + from, + ); + return undefined; + } + return match[0] as SemVerString; + }, + + /** + * Check if the given semver string represents a pre-release. + * @example + * ```ts + * isPreRelease("0.1.0" as SemVerString); // -> false + * isPreRelease("0.1.0-alpha.1" as SemVerString); // -> true + */ + isPreRelease(semver: SemVerString): boolean { + const parsed = parseSemVer(semver); + if (!parsed) { + throw new TypeError(`Invalid semver: ${semver}`); + } + return parsed.prerelease.length > 0; + }, +}; diff --git a/lib/semver_test.ts b/lib/semver_test.ts new file mode 100644 index 00000000..0800e88f --- /dev/null +++ b/lib/semver_test.ts @@ -0,0 +1,32 @@ +import { assertEquals } from "./std/assert.ts"; +import { SemVerString } from "./semver.ts"; + +Deno.test("SemVerString.parse()", () => { + assertEquals( + SemVerString.parse("https://deno.land/std@0.1.0"), + "0.1.0", + ); + assertEquals( + SemVerString.parse("https://deno.land/std"), + undefined, + ); + assertEquals( + SemVerString.parse("https://deno.land/std@1.0.0-rc.1"), + "1.0.0-rc.1", + ); +}); + +Deno.test("SemVerString.isPreRelease()", () => { + assertEquals( + SemVerString.isPreRelease("0.1.0" as SemVerString), + false, + ); + assertEquals( + SemVerString.isPreRelease("0.1.0-alpha.1" as SemVerString), + true, + ); + assertEquals( + SemVerString.isPreRelease("0.1.0-rc.1" as SemVerString), + true, + ); +}); diff --git a/lib/std/semver.ts b/lib/std/semver.ts new file mode 100644 index 00000000..bf4561a9 --- /dev/null +++ b/lib/std/semver.ts @@ -0,0 +1 @@ +export { parse } from "https://deno.land/std@0.205.0/semver/parse.ts"; diff --git a/lib/testing.ts b/lib/testing.ts index 34c3340c..824529d9 100644 --- a/lib/testing.ts +++ b/lib/testing.ts @@ -13,8 +13,7 @@ import { AssertionError } from "./std/assert.ts"; import { EOL, formatEOL } from "./std/fs.ts"; import { fromFileUrl } from "./std/path.ts"; import { URI } from "./uri.ts"; -import { parseSemVer } from "./dependency.ts"; -import { SemVerString } from "./types.ts"; +import { SemVerString } from "./semver.ts"; export const assertSnapshot = createAssertSnapshot({ dir: fromFileUrl(new URL("../test/snapshots/", import.meta.url)), @@ -125,7 +124,7 @@ export const LatestSemVerStub = { } const response = await init.original(request); await response.arrayBuffer(); - const semver = parseSemVer(response.url); + const semver = SemVerString.parse(response.url); if (!semver) { return response; } diff --git a/lib/types.ts b/lib/types.ts index ef4dfbb8..ed2aeca4 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -3,12 +3,3 @@ export type Brand = T & { __brand: B }; /** A string that represents a path segment (e.g. `src/lib.ts`.) */ export type Path = Brand; - -/** A string that represents a semver (e.g. `v1.0.0`.) */ -export type SemVerString = Brand; - -const URI_SCHEMES = ["http", "https", "file", "npm", "node"] as const; -export type URIScheme = typeof URI_SCHEMES[number]; -export const URIScheme = { - values: URI_SCHEMES, -}; diff --git a/lib/update.ts b/lib/update.ts index 4e163d10..1d5fa3a0 100644 --- a/lib/update.ts +++ b/lib/update.ts @@ -13,7 +13,7 @@ import { Dependency, LatestDependency } from "./dependency.ts"; type DependencyJson = NonNullable[number]; -/** Representation of a dependency update. */ +/** Representation of an update to a dependency. */ export interface DependencyUpdate { /** Properties of the dependency being updated. */ from: Dependency; diff --git a/lib/update_test.ts b/lib/update_test.ts index 17c87635..55e3d4ba 100644 --- a/lib/update_test.ts +++ b/lib/update_test.ts @@ -12,7 +12,7 @@ import { URI } from "./uri.ts"; import { _create, DependencyUpdate } from "./update.ts"; import { ImportMap } from "./import_map.ts"; import { LatestSemVerStub } from "./testing.ts"; -import { SemVerString } from "./types.ts"; +import type { SemVerString } from "./semver.ts"; describe("DependencyUpdate", () => { const LATEST = "123.456.789" as SemVerString;