From beccaacca08d08ba441e55aa2da417dcfaf43f75 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 2 Nov 2026 13:25:34 +0100 Subject: [PATCH 1/4] feat: resolve semver ranges in the version input Accept npm semver ranges (^2.95, ~2.98.0, 2.x, >=2.98 <2.99, ^1 || ^2) and install the highest published version that satisfies them. Ranges are recognized by syntax so unknown words still fail, and npm resolves the range to an exact version before the existing install and GitHub release fallback run. --- .github/workflows/e2e.yml | 3 + README.md | 19 +++-- action.yml | 2 +- src/main.test.ts | 141 +++++++++++++++++++++++++++++++++++++- src/main.ts | 61 ++++++++++++++++- 5 files changed, 215 insertions(+), 11 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f754d1c..361a1e3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -40,6 +40,7 @@ jobs: - 1.178.2 - 2.33.0 - latest + - ^2 pg_major: - 14 - 15 @@ -49,6 +50,8 @@ jobs: pg_major: 17 - version: latest pg_major: 14 + - version: ^2 + pg_major: 14 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/README.md b/README.md index dfd162e..1f98395 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,10 @@ USER 1000:1000 ``` A fixed npm-published version, `latest`, `beta`, `next` (upcoming major -prereleases), or `v.stable` (maintenance line of an older major, like -`v2.stable`) of the `supabase` CLI can be installed. `next` only tracks +prereleases), `v.stable` (maintenance line of an older major, like +`v2.stable`), or an npm semver range (like `^2.95`, `~2.98.0`, `2.x`, +`>=2.98 <2.99`, or `^1 || ^2`) of the `supabase` CLI can be installed. A range +installs the highest matching published version. `next` only tracks prereleases, so once a major goes GA it can lag behind `latest` until the following major's prereleases start: @@ -87,6 +89,13 @@ steps: version: v2.stable ``` +```yaml +steps: + - uses: supabase/setup-cli@v3 + with: + version: ^2.95 +``` + Run `supabase db start` to execute all migrations on a fresh database: ```yaml @@ -105,9 +114,9 @@ on Windows and macOS runners. The action supports the following inputs: -| Name | Type | Description | Default | Required | -| --------- | ------ | -------------------------------------------------------------------------- | --------------------------------- | -------- | -| `version` | String | Supabase CLI `latest`, `beta`, `next`, `v.stable`, or fixed npm version | Root lockfile version or `latest` | false | +| Name | Type | Description | Default | Required | +| --------- | ------ | ---------------------------------------------------------------------------------------- | --------------------------------- | -------- | +| `version` | String | Supabase CLI `latest`, `beta`, `next`, `v.stable`, fixed npm version, or semver range | Root lockfile version or `latest` | false | ## Outputs diff --git a/action.yml b/action.yml index c7cf61c..ff490a8 100644 --- a/action.yml +++ b/action.yml @@ -3,7 +3,7 @@ description: Setup Supabase CLI, supabase, on GitHub Actions runners author: Supabase inputs: version: - description: Supabase CLI version to install. Supports latest, beta, next (upcoming major prereleases only; can lag behind latest after a major goes GA), v.stable (maintenance line of an older major, like v2.stable), or a fixed version published to npm. If omitted, detect from the root lockfile and otherwise use latest. + description: Supabase CLI version to install. Supports latest, beta, next (upcoming major prereleases only; can lag behind latest after a major goes GA), v.stable (maintenance line of an older major, like v2.stable), a fixed version published to npm, or an npm semver range like ^2.95 or 2.x (resolved to the highest matching published version). If omitted, detect from the root lockfile and otherwise use latest. required: false outputs: version: diff --git a/src/main.test.ts b/src/main.test.ts index 675d783..cfc5c21 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -50,6 +50,8 @@ afterEach(() => { delete process.env.FAKE_NPM_PREFIX_CONFIG_LOG; delete process.env.FAKE_NPM_SCRIPTS; delete process.env.FAKE_NPM_VIEW_ERROR; + delete process.env.FAKE_NPM_RANGE_RESULT; + delete process.env.FAKE_NPM_RANGE_ERROR; delete process.env.SETUP_CLI_TEST_WORKSPACE; delete process.env.SUPABASE_SETUP_CLI_NPM; @@ -196,6 +198,16 @@ function recordConfig(configPath) { if (args[0] === "view") { recordConfig(path.join(process.cwd(), ".npmrc")); + if (!args.includes("bin") && process.env.FAKE_NPM_RANGE_RESULT !== undefined) { + console.log(process.env.FAKE_NPM_RANGE_RESULT); + process.exit(0); + } + + if (!args.includes("bin") && process.env.FAKE_NPM_RANGE_ERROR) { + console.error(process.env.FAKE_NPM_RANGE_ERROR); + process.exit(1); + } + if (process.env.FAKE_NPM_VIEW_ERROR) { console.error(process.env.FAKE_NPM_VIEW_ERROR); process.exit(1); @@ -343,6 +355,10 @@ function readNpmPrefixConfigs(): Array { .map((line) => JSON.parse(line) as string | null); } +function viewRangeCall(spec: string): string[] { + return ["view", spec, "version", "--json", "--offline=false"]; +} + function viewMetadataCall(spec: string): string[] { return ["view", spec, "version", "bin", "scripts", "dist.integrity", "--json", "--offline=false"]; } @@ -352,6 +368,7 @@ function createActionSpies(inputVersion: string) { addPath: spyOn(core, "addPath").mockImplementation(() => {}), exportVariable: spyOn(core, "exportVariable").mockImplementation(() => {}), getInput: spyOn(core, "getInput").mockReturnValue(inputVersion), + info: spyOn(core, "info").mockImplementation(() => {}), setFailed: spyOn(core, "setFailed").mockImplementation(() => {}), setOutput: spyOn(core, "setOutput").mockImplementation(() => {}), warning: spyOn(core, "warning").mockImplementation(() => {}), @@ -475,13 +492,44 @@ test("rejects unsupported npm package selectors", async () => { const { resolvePackage } = await getMainModule(); const message = (selector: string) => - `Unsupported Supabase CLI version "${selector}". Use latest, beta, next, v.stable (like v2.stable), or a fixed npm package version like 2.101.0.`; - - for (const selector of ["hotfix", "v2", "2.x", "stable", "v2.stable.1", "next2", "2.stable"]) { + `Unsupported Supabase CLI version "${selector}". Use latest, beta, next, v.stable (like v2.stable), a fixed npm package version like 2.101.0, or a semver range like ^2.95 or 2.x.`; + + for (const selector of [ + "hotfix", + "canary", + "lastest", + "garbage!", + "stable", + "v2.stable.1", + "next2", + "2.stable", + "^1 ||", + "- 2.0.0", + "^2 -", + "^^2", + ]) { expect(() => resolvePackage(selector)).toThrow(message(selector)); } }); +test("accepts npm semver ranges as version selectors", async () => { + const { resolvePackage } = await getMainModule(); + + for (const range of [ + "^2.95", + "~2.98.0", + "2.x", + "2.98.*", + "2", + "v2", + "2.90.0 - 2.98.0", + ">=2.98 <2.99", + "^1 || ^2", + ]) { + expect(resolvePackage(` ${range} `)).toEqual({ spec: `supabase@${range}`, version: range }); + } +}); + test("uses the root bun.lock resolution when version is omitted", async () => { process.env.GITHUB_WORKSPACE = createWorkspace({ "bun.lock": createBunLock("2.41.0", { integrity: "sha512-bun-lock" }), @@ -953,6 +1001,93 @@ test("fails without a GitHub release fallback when npm cannot resolve a dist-tag expect(release.requested).toEqual([]); }); +test("installs the highest npm version that satisfies a caret range", async () => { + const logPath = installFakeNpm("supabase 2.98.2"); + process.env.FAKE_NPM_RANGE_RESULT = JSON.stringify(["2.95.0", "2.98.2", "2.97.10", "2.98.1"]); + const spies = createActionSpies("^2.95"); + const { run } = await getMainModule(); + + await run(); + + expect(spies.setFailed).not.toHaveBeenCalled(); + expect(spies.info).toHaveBeenCalledWith('Resolved Supabase CLI version "^2.95" to 2.98.2'); + expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.98.2"); + expect(spies.setOutput).toHaveBeenCalledWith("install-source", "npm"); + const calls = readNpmCalls(logPath); + expect(calls[0]).toEqual(viewRangeCall("supabase@^2.95")); + expect(calls[1]).toEqual(viewMetadataCall("supabase@2.98.2")); + expect(calls[2]).toContain("supabase@2.98.2"); +}); + +test("accepts x-ranges and comparator ranges", async () => { + for (const range of ["2.x", ">=2.98 <2.99"]) { + const logPath = installFakeNpm("supabase 2.98.1"); + process.env.FAKE_NPM_RANGE_RESULT = JSON.stringify(["2.98.0", "2.98.1"]); + const spies = createActionSpies(range); + const { run } = await getMainModule(); + + await run(); + + expect(spies.setFailed).not.toHaveBeenCalled(); + expect(readNpmCalls(logPath)[0]).toEqual(viewRangeCall(`supabase@${range}`)); + expect(readNpmCalls(logPath)[1]).toEqual(viewMetadataCall("supabase@2.98.1")); + expect(spies.setOutput).toHaveBeenCalledWith("install-source", "npm"); + } +}); + +test("resolves a range that npm reports as a single JSON string", async () => { + const logPath = installFakeNpm("supabase 2.98.0"); + process.env.FAKE_NPM_RANGE_RESULT = JSON.stringify("2.98.0"); + const spies = createActionSpies("~2.98.0"); + const { run } = await getMainModule(); + + await run(); + + expect(spies.setFailed).not.toHaveBeenCalled(); + expect(readNpmCalls(logPath)[1]).toEqual(viewMetadataCall("supabase@2.98.0")); + expect(spies.setOutput).toHaveBeenCalledWith("install-source", "npm"); +}); + +test("fails without a GitHub release fallback when no version satisfies a range", async () => { + installFakeNpm("supabase 2.101.0"); + process.env.FAKE_NPM_RANGE_ERROR = + "npm error code E404\nnpm error No match found for version ^99"; + const release = serveRelease("2.101.0", {}); + const spies = createActionSpies("^99"); + const { run } = await getMainModule(); + + await run(); + + expect(spies.setFailed).toHaveBeenCalledWith('No published Supabase CLI version matches "^99"'); + expect(release.requested).toEqual([]); +}); + +test("fails when npm returns no versions for a range", async () => { + installFakeNpm("supabase 2.101.0"); + process.env.FAKE_NPM_RANGE_RESULT = ""; + const release = serveRelease("2.101.0", {}); + const spies = createActionSpies("^99"); + const { run } = await getMainModule(); + + await run(); + + expect(spies.setFailed).toHaveBeenCalledWith('No published Supabase CLI version matches "^99"'); + expect(release.requested).toEqual([]); +}); + +test("fails without a GitHub release fallback when npm cannot resolve a range", async () => { + installFakeNpm("supabase 2.101.0"); + process.env.FAKE_NPM_RANGE_ERROR = "npm error code ETIMEDOUT"; + const release = serveRelease("2.101.0", {}); + const spies = createActionSpies("^2.95"); + const { run } = await getMainModule(); + + await run(); + + expect(spies.setFailed).toHaveBeenCalledWith("npm error code ETIMEDOUT"); + expect(release.requested).toEqual([]); +}); + test("rejects a GitHub release archive that does not match its checksum", async () => { installFakeNpm("", { packageVersion: "2.101.0" }); const archiveName = `supabase_2.101.0_${RELEASE_PLATFORM}_${RELEASE_ARCH}.tar.gz`; diff --git a/src/main.ts b/src/main.ts index 067be23..023bb14 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,8 @@ const INSTALL_LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall"] as co const SUPPORTED_DIST_TAGS = new Set([DEFAULT_VERSION, "beta", "next"]); const MAINTENANCE_DIST_TAG_PATTERN = /^v\d+\.stable$/; const CONCRETE_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const RANGE_TOKEN_PATTERN = + /^(?:[<>]=?|=|\^|~)?v?(?:\d+|[xX*])(?:\.(?:\d+|[xX*])){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const CONCRETE_VERSION_EXTRACT_PATTERN = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/; type PackageResolution = { @@ -114,6 +116,23 @@ function normalizeVersion(version: string): string { return version.replace(/^v/i, ""); } +/** Recognizes npm semver ranges (^2.95, 2.x, >=2.98 <2.99, 2.90.0 - 2.98.0, ^1 || ^2) by syntax. */ +function isVersionRange(version: string): boolean { + if (CONCRETE_VERSION_PATTERN.test(version)) { + return false; + } + + return version.split("||").every((clause) => { + const tokens = clause.split(/\s+/).filter(Boolean); + return ( + tokens.length > 0 && + tokens[0] !== "-" && + tokens[tokens.length - 1] !== "-" && + tokens.every((token) => token === "-" || RANGE_TOKEN_PATTERN.test(token)) + ); + }); +} + function normalizeSupportedVersion(version: string): string { const trimmedVersion = version.trim(); const distTag = trimmedVersion.toLowerCase(); @@ -132,8 +151,12 @@ function normalizeSupportedVersion(version: string): string { return normalizedVersion; } + if (isVersionRange(trimmedVersion)) { + return trimmedVersion; + } + throw new Error( - `Unsupported Supabase CLI version "${version}". Use latest, beta, next, v.stable (like v2.stable), or a fixed npm package version like 2.101.0.`, + `Unsupported Supabase CLI version "${version}". Use latest, beta, next, v.stable (like v2.stable), a fixed npm package version like 2.101.0, or a semver range like ^2.95 or 2.x.`, ); } @@ -532,8 +555,42 @@ async function installGitHubReleaseInstead( } } +/** Resolves a semver range to the highest published version that satisfies it. */ +async function resolveVersionRange(resolution: PackageResolution): Promise { + const output = await runNpm(["view", resolution.spec, "version", "--json", "--offline=false"]); + const parsed: unknown = output.trim() ? JSON.parse(output) : []; + const candidates = (Array.isArray(parsed) ? parsed : [parsed]).filter( + (candidate): candidate is string => + typeof candidate === "string" && CONCRETE_VERSION_PATTERN.test(candidate), + ); + const version = candidates.reduce( + (highest, candidate) => + highest === null || semver.order(candidate, highest) > 0 ? candidate : highest, + null, + ); + + if (!version) { + throw new Error(`No published Supabase CLI version matches "${resolution.version}"`); + } + + core.info(`Resolved Supabase CLI version "${resolution.version}" to ${version}`); + return { ...resolution, spec: `${NPM_PACKAGE}@${version}`, version }; +} + /** Installs from npm, falling back to the GitHub release when npm cannot produce a working CLI. */ -export async function installCli(resolution: PackageResolution): Promise { +export async function installCli(requested: PackageResolution): Promise { + let resolution = requested; + if (isVersionRange(requested.version)) { + try { + resolution = await resolveVersionRange(requested); + } catch (error) { + if (/\bE404\b|No match found/.test(errorMessage(error))) { + throw new Error(`No published Supabase CLI version matches "${requested.version}"`); + } + throw error; + } + } + let metadata: PackageMetadata; try { metadata = await getPackageMetadata(resolution); From 7e1486208a71da346ed7ab79dd1119c5c9364592 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 2 Nov 2026 13:27:00 +0100 Subject: [PATCH 2/4] docs: add an upgrade notice for v1 and v2 users --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 1f98395..2e9003a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,26 @@ This action can be run on `ubuntu-latest`, `windows-latest`, and `macos-latest` GitHub Actions runners, and will install and expose a specified version of the `supabase` CLI on the runner environment. +## Upgrading from v1 or v2 + +`supabase/setup-cli@v1` and `@v2` are legacy and only receive compatibility +fixes. New features, such as semver ranges in `version`, are only available in +`@v3`. Upgrading the action does not force a CLI upgrade: `@v3` installs any +published CLI version, so you can keep an older CLI with an exact version or a +range like `^1` or `^2`: + +```yaml +steps: + - uses: supabase/setup-cli@v3 + with: + version: ^2 +``` + +`@v3` installs the CLI from npm, so runners need Node.js 20+ (provisioned +automatically when missing) and access to the npm registry. `@v1` defaulted to +CLI `2.20.3`; `@v3` defaults to the version in your root lockfile, or `latest` +when there is none, so set `version` explicitly to keep a specific CLI. + ## Usage Setup the `supabase` CLI: From 5ae5997ec5db1527bede1795988b93e581db8abf Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 2 Nov 2026 13:34:58 +0100 Subject: [PATCH 3/4] fix: resolve version ranges the way npm installs them Pick npm's latest when it satisfies the range, otherwise the highest non-deprecated match, from a single npm view call, and install the exact verified version. Accept spaced operators and v prefixes in ranges, and only report a missing match when npm says no version matches. --- README.md | 6 +- action.yml | 2 +- src/main.test.ts | 174 ++++++++++++++++++++++++++++++++++------------- src/main.ts | 106 ++++++++++++++++------------- 4 files changed, 189 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 2e9003a..71347c7 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,9 @@ A fixed npm-published version, `latest`, `beta`, `next` (upcoming major prereleases), `v.stable` (maintenance line of an older major, like `v2.stable`), or an npm semver range (like `^2.95`, `~2.98.0`, `2.x`, `>=2.98 <2.99`, or `^1 || ^2`) of the `supabase` CLI can be installed. A range -installs the highest matching published version. `next` only tracks -prereleases, so once a major goes GA it can lag behind `latest` until the -following major's prereleases start: +installs the version npm picks: `latest` if it matches, otherwise the highest +non-deprecated match. `next` only tracks prereleases, so once a major goes GA +it can lag behind `latest` until the following major's prereleases start: ```yaml steps: diff --git a/action.yml b/action.yml index ff490a8..c8fb0a7 100644 --- a/action.yml +++ b/action.yml @@ -3,7 +3,7 @@ description: Setup Supabase CLI, supabase, on GitHub Actions runners author: Supabase inputs: version: - description: Supabase CLI version to install. Supports latest, beta, next (upcoming major prereleases only; can lag behind latest after a major goes GA), v.stable (maintenance line of an older major, like v2.stable), a fixed version published to npm, or an npm semver range like ^2.95 or 2.x (resolved to the highest matching published version). If omitted, detect from the root lockfile and otherwise use latest. + description: Supabase CLI version to install. Supports latest, beta, next (upcoming major prereleases only; can lag behind latest after a major goes GA), v.stable (maintenance line of an older major, like v2.stable), a fixed version published to npm, or an npm semver range like ^2.95 or 2.x (resolved like npm does, preferring the latest tag when it matches, else the highest non-deprecated match). If omitted, detect from the root lockfile and otherwise use latest. required: false outputs: version: diff --git a/src/main.test.ts b/src/main.test.ts index cfc5c21..00191ea 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -50,8 +50,7 @@ afterEach(() => { delete process.env.FAKE_NPM_PREFIX_CONFIG_LOG; delete process.env.FAKE_NPM_SCRIPTS; delete process.env.FAKE_NPM_VIEW_ERROR; - delete process.env.FAKE_NPM_RANGE_RESULT; - delete process.env.FAKE_NPM_RANGE_ERROR; + delete process.env.FAKE_NPM_VIEW_RESULT; delete process.env.SETUP_CLI_TEST_WORKSPACE; delete process.env.SUPABASE_SETUP_CLI_NPM; @@ -198,21 +197,16 @@ function recordConfig(configPath) { if (args[0] === "view") { recordConfig(path.join(process.cwd(), ".npmrc")); - if (!args.includes("bin") && process.env.FAKE_NPM_RANGE_RESULT !== undefined) { - console.log(process.env.FAKE_NPM_RANGE_RESULT); - process.exit(0); - } - - if (!args.includes("bin") && process.env.FAKE_NPM_RANGE_ERROR) { - console.error(process.env.FAKE_NPM_RANGE_ERROR); - process.exit(1); - } - if (process.env.FAKE_NPM_VIEW_ERROR) { console.error(process.env.FAKE_NPM_VIEW_ERROR); process.exit(1); } + if (process.env.FAKE_NPM_VIEW_RESULT !== undefined) { + console.log(process.env.FAKE_NPM_VIEW_RESULT); + process.exit(0); + } + const bin = process.env.FAKE_NPM_BIN === "missing" ? undefined @@ -355,12 +349,34 @@ function readNpmPrefixConfigs(): Array { .map((line) => JSON.parse(line) as string | null); } -function viewRangeCall(spec: string): string[] { - return ["view", spec, "version", "--json", "--offline=false"]; +function viewMetadataCall(spec: string): string[] { + return [ + "view", + spec, + "version", + "bin", + "scripts", + "dist.integrity", + "deprecated", + "dist-tags", + "--json", + "--offline=false", + ]; } -function viewMetadataCall(spec: string): string[] { - return ["view", spec, "version", "bin", "scripts", "dist.integrity", "--json", "--offline=false"]; +/** Sets the manifests the fake npm prints for a range lookup, one per published version. */ +function setViewManifests( + versions: Array, + latest?: string, +): void { + process.env.FAKE_NPM_VIEW_RESULT = JSON.stringify( + versions.map((entry) => ({ + ...(typeof entry === "string" ? { version: entry } : entry), + bin: { supabase: "dist/supabase.js" }, + "dist-tags": { latest: latest ?? "2.101.0" }, + "dist.integrity": "sha512-test", + })), + ); } function createActionSpies(inputVersion: string) { @@ -521,7 +537,6 @@ test("accepts npm semver ranges as version selectors", async () => { "2.x", "2.98.*", "2", - "v2", "2.90.0 - 2.98.0", ">=2.98 <2.99", "^1 || ^2", @@ -530,6 +545,20 @@ test("accepts npm semver ranges as version selectors", async () => { } }); +test("normalizes operator spacing and v prefixes in ranges", async () => { + const { resolvePackage } = await getMainModule(); + + for (const [input, normalized] of [ + ["v2", "2"], + ["V2.x", "2.x"], + ["^v2.95", "^2.95"], + [">= 2.98 < 2.99", ">=2.98 <2.99"], + ["^ 1 || ~ v2.98", "^1 || ~2.98"], + ] as const) { + expect(resolvePackage(input)).toEqual({ spec: `supabase@${normalized}`, version: normalized }); + } +}); + test("uses the root bun.lock resolution when version is omitted", async () => { process.env.GITHUB_WORKSPACE = createWorkspace({ "bun.lock": createBunLock("2.41.0", { integrity: "sha512-bun-lock" }), @@ -1003,7 +1032,7 @@ test("fails without a GitHub release fallback when npm cannot resolve a dist-tag test("installs the highest npm version that satisfies a caret range", async () => { const logPath = installFakeNpm("supabase 2.98.2"); - process.env.FAKE_NPM_RANGE_RESULT = JSON.stringify(["2.95.0", "2.98.2", "2.97.10", "2.98.1"]); + setViewManifests(["2.95.0", "2.98.2", "2.97.10", "2.98.1"]); const spies = createActionSpies("^2.95"); const { run } = await getMainModule(); @@ -1014,57 +1043,99 @@ test("installs the highest npm version that satisfies a caret range", async () = expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.98.2"); expect(spies.setOutput).toHaveBeenCalledWith("install-source", "npm"); const calls = readNpmCalls(logPath); - expect(calls[0]).toEqual(viewRangeCall("supabase@^2.95")); - expect(calls[1]).toEqual(viewMetadataCall("supabase@2.98.2")); - expect(calls[2]).toContain("supabase@2.98.2"); + expect(calls).toHaveLength(2); + expect(calls[0]).toEqual(viewMetadataCall("supabase@^2.95")); + expect(calls[1]).toContain("supabase@2.98.2"); }); -test("accepts x-ranges and comparator ranges", async () => { - for (const range of ["2.x", ">=2.98 <2.99"]) { +test("passes ranges to npm as one argument and normalizes them first", async () => { + for (const [input, spec] of [ + ["2.x", "2.x"], + ["^1 || ^2", "^1 || ^2"], + ["2.90.0 - 2.98.0", "2.90.0 - 2.98.0"], + [">= 2.98 < 2.99", ">=2.98 <2.99"], + ["V2.x", "2.x"], + ] as const) { const logPath = installFakeNpm("supabase 2.98.1"); - process.env.FAKE_NPM_RANGE_RESULT = JSON.stringify(["2.98.0", "2.98.1"]); - const spies = createActionSpies(range); + setViewManifests(["2.98.0", "2.98.1"]); + const spies = createActionSpies(input); const { run } = await getMainModule(); await run(); expect(spies.setFailed).not.toHaveBeenCalled(); - expect(readNpmCalls(logPath)[0]).toEqual(viewRangeCall(`supabase@${range}`)); - expect(readNpmCalls(logPath)[1]).toEqual(viewMetadataCall("supabase@2.98.1")); + expect(spies.info).toHaveBeenCalledWith(`Resolved Supabase CLI version "${spec}" to 2.98.1`); + expect(readNpmCalls(logPath)[0]).toEqual(viewMetadataCall(`supabase@${spec}`)); + expect(readNpmCalls(logPath)[1]).toContain("supabase@2.98.1"); expect(spies.setOutput).toHaveBeenCalledWith("install-source", "npm"); } }); -test("resolves a range that npm reports as a single JSON string", async () => { +test("resolves a range that npm reports as a single manifest", async () => { const logPath = installFakeNpm("supabase 2.98.0"); - process.env.FAKE_NPM_RANGE_RESULT = JSON.stringify("2.98.0"); + process.env.FAKE_NPM_VIEW_RESULT = JSON.stringify({ + version: "2.98.0", + bin: { supabase: "dist/supabase.js" }, + "dist-tags": { latest: "2.101.0" }, + "dist.integrity": "sha512-test", + }); const spies = createActionSpies("~2.98.0"); const { run } = await getMainModule(); await run(); expect(spies.setFailed).not.toHaveBeenCalled(); - expect(readNpmCalls(logPath)[1]).toEqual(viewMetadataCall("supabase@2.98.0")); + expect(readNpmCalls(logPath)[1]).toContain("supabase@2.98.0"); expect(spies.setOutput).toHaveBeenCalledWith("install-source", "npm"); }); -test("fails without a GitHub release fallback when no version satisfies a range", async () => { - installFakeNpm("supabase 2.101.0"); - process.env.FAKE_NPM_RANGE_ERROR = - "npm error code E404\nnpm error No match found for version ^99"; - const release = serveRelease("2.101.0", {}); - const spies = createActionSpies("^99"); +test("picks npm's latest when it satisfies a range even if a higher version matches", async () => { + const logPath = installFakeNpm("supabase 2.98.1"); + setViewManifests(["2.98.0", "2.98.1", "2.99.0", "2.100.0-beta.1"], "2.98.1"); + const spies = createActionSpies("2.x"); const { run } = await getMainModule(); await run(); - expect(spies.setFailed).toHaveBeenCalledWith('No published Supabase CLI version matches "^99"'); - expect(release.requested).toEqual([]); + expect(spies.setFailed).not.toHaveBeenCalled(); + expect(readNpmCalls(logPath)[1]).toContain("supabase@2.98.1"); }); -test("fails when npm returns no versions for a range", async () => { +test("skips deprecated versions when picking from a range", async () => { + for (const [versions, picked] of [ + [ + [ + { version: "2.98.0" }, + { version: "2.98.1" }, + { version: "2.98.2", deprecated: "broken release" }, + ], + "2.98.1", + ], + [ + [ + { version: "2.98.0", deprecated: "broken release" }, + { version: "2.98.2", deprecated: "broken release" }, + { version: "2.98.1", deprecated: "broken release" }, + ], + "2.98.2", + ], + ] as const) { + const logPath = installFakeNpm(`supabase ${picked}`); + setViewManifests([...versions], "2.98.2"); + const spies = createActionSpies("~2.98.0"); + const { run } = await getMainModule(); + + await run(); + + expect(spies.setFailed).not.toHaveBeenCalled(); + expect(readNpmCalls(logPath)[1]).toContain(`supabase@${picked}`); + } +}); + +test("fails without a GitHub release fallback when no version satisfies a range", async () => { installFakeNpm("supabase 2.101.0"); - process.env.FAKE_NPM_RANGE_RESULT = ""; + process.env.FAKE_NPM_VIEW_ERROR = + "npm error code E404\nnpm error 404 No match found for version ^99"; const release = serveRelease("2.101.0", {}); const spies = createActionSpies("^99"); const { run } = await getMainModule(); @@ -1075,17 +1146,22 @@ test("fails when npm returns no versions for a range", async () => { expect(release.requested).toEqual([]); }); -test("fails without a GitHub release fallback when npm cannot resolve a range", async () => { - installFakeNpm("supabase 2.101.0"); - process.env.FAKE_NPM_RANGE_ERROR = "npm error code ETIMEDOUT"; - const release = serveRelease("2.101.0", {}); - const spies = createActionSpies("^2.95"); - const { run } = await getMainModule(); +test("rethrows other npm errors for a range unchanged", async () => { + for (const error of [ + "npm error code ETIMEDOUT", + "npm error code E404\nnpm error 404 'supabase@^2.95' is not in this registry.", + ]) { + installFakeNpm("supabase 2.101.0"); + process.env.FAKE_NPM_VIEW_ERROR = error; + const release = serveRelease("2.101.0", {}); + const spies = createActionSpies("^2.95"); + const { run } = await getMainModule(); - await run(); + await run(); - expect(spies.setFailed).toHaveBeenCalledWith("npm error code ETIMEDOUT"); - expect(release.requested).toEqual([]); + expect(spies.setFailed).toHaveBeenCalledWith(error); + expect(release.requested).toEqual([]); + } }); test("rejects a GitHub release archive that does not match its checksum", async () => { diff --git a/src/main.ts b/src/main.ts index 023bb14..f48a752 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,7 +28,7 @@ const SUPPORTED_DIST_TAGS = new Set([DEFAULT_VERSION, "beta", "next"]); const MAINTENANCE_DIST_TAG_PATTERN = /^v\d+\.stable$/; const CONCRETE_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const RANGE_TOKEN_PATTERN = - /^(?:[<>]=?|=|\^|~)?v?(?:\d+|[xX*])(?:\.(?:\d+|[xX*])){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + /^(?:[<>]=?|=|\^|~)?(?:\d+|[xX*])(?:\.(?:\d+|[xX*])){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const CONCRETE_VERSION_EXTRACT_PATTERN = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/; type PackageResolution = { @@ -60,6 +60,8 @@ type PackageMetadata = { bin?: unknown; scripts?: unknown; "dist.integrity"?: unknown; + "dist-tags"?: unknown; + deprecated?: unknown; }; type BunLock = { @@ -116,6 +118,11 @@ function normalizeVersion(version: string): string { return version.replace(/^v/i, ""); } +/** Joins operators to their versions and drops `v` prefixes, which npm only accepts in lowercase. */ +function normalizeRange(version: string): string { + return version.replace(/([<>]=?|=|\^|~)\s+/g, "$1").replace(/(^|[\s<>=^~])v(?=\d|[xX*])/gi, "$1"); +} + /** Recognizes npm semver ranges (^2.95, 2.x, >=2.98 <2.99, 2.90.0 - 2.98.0, ^1 || ^2) by syntax. */ function isVersionRange(version: string): boolean { if (CONCRETE_VERSION_PATTERN.test(version)) { @@ -151,8 +158,9 @@ function normalizeSupportedVersion(version: string): string { return normalizedVersion; } - if (isVersionRange(trimmedVersion)) { - return trimmedVersion; + const range = normalizeRange(trimmedVersion); + if (isVersionRange(range)) { + return range; } throw new Error( @@ -304,6 +312,30 @@ function verifyPackageIntegrity(resolution: PackageResolution, metadata: Package } } +function isDeprecated(manifest: PackageMetadata): boolean { + return Boolean(manifest.deprecated); +} + +/** Picks a manifest like npm's installer: `latest` if it matches, else the highest non-deprecated version. */ +function pickManifest(manifests: PackageMetadata[]): PackageMetadata | undefined { + const candidates = manifests.filter( + (manifest) => + typeof manifest.version === "string" && CONCRETE_VERSION_PATTERN.test(manifest.version), + ); + const distTags = manifests.map((manifest) => manifest["dist-tags"]).find(isRecord); + const latest = candidates.find((manifest) => manifest.version === distTags?.latest); + + if (latest && !isDeprecated(latest)) { + return latest; + } + + const nonDeprecated = candidates.filter((manifest) => !isDeprecated(manifest)); + const pool = nonDeprecated.length > 0 ? nonDeprecated : candidates; + const [highest] = pool.sort((a, b) => semver.order(b.version as string, a.version as string)); + + return highest; +} + async function getPackageMetadata(resolution: PackageResolution): Promise { const output = await runNpm([ "view", @@ -312,16 +344,22 @@ async function getPackageMetadata(resolution: PackageResolution): Promise { + const npmFailure = errorMessage(npmError); + if (!version) { + if (/No match found for version/.test(npmFailure)) { + throw new Error(`No published Supabase CLI version matches "${resolution.version}"`); + } throw npmError; } - const npmFailure = errorMessage(npmError); core.warning( `npm could not install ${resolution.spec}, so installing v${version} from its GitHub release instead: ${npmFailure}`, ); @@ -555,52 +597,24 @@ async function installGitHubReleaseInstead( } } -/** Resolves a semver range to the highest published version that satisfies it. */ -async function resolveVersionRange(resolution: PackageResolution): Promise { - const output = await runNpm(["view", resolution.spec, "version", "--json", "--offline=false"]); - const parsed: unknown = output.trim() ? JSON.parse(output) : []; - const candidates = (Array.isArray(parsed) ? parsed : [parsed]).filter( - (candidate): candidate is string => - typeof candidate === "string" && CONCRETE_VERSION_PATTERN.test(candidate), - ); - const version = candidates.reduce( - (highest, candidate) => - highest === null || semver.order(candidate, highest) > 0 ? candidate : highest, - null, - ); - - if (!version) { - throw new Error(`No published Supabase CLI version matches "${resolution.version}"`); - } - - core.info(`Resolved Supabase CLI version "${resolution.version}" to ${version}`); - return { ...resolution, spec: `${NPM_PACKAGE}@${version}`, version }; -} - /** Installs from npm, falling back to the GitHub release when npm cannot produce a working CLI. */ export async function installCli(requested: PackageResolution): Promise { - let resolution = requested; - if (isVersionRange(requested.version)) { - try { - resolution = await resolveVersionRange(requested); - } catch (error) { - if (/\bE404\b|No match found/.test(errorMessage(error))) { - throw new Error(`No published Supabase CLI version matches "${requested.version}"`); - } - throw error; - } - } - let metadata: PackageMetadata; try { - metadata = await getPackageMetadata(resolution); + metadata = await getPackageMetadata(requested); } catch (error) { - const version = CONCRETE_VERSION_PATTERN.test(resolution.version) ? resolution.version : null; - return installGitHubReleaseInstead(resolution, version, error); + const version = CONCRETE_VERSION_PATTERN.test(requested.version) ? requested.version : null; + return installGitHubReleaseInstead(requested, version, error); + } + + const version = verifyPackageMetadata(requested, metadata); + verifyPackageIntegrity(requested, metadata); + + if (version !== requested.version) { + core.info(`Resolved Supabase CLI version "${requested.version}" to ${version}`); } - const version = verifyPackageMetadata(resolution, metadata); - verifyPackageIntegrity(resolution, metadata); + const resolution = { ...requested, spec: `${NPM_PACKAGE}@${version}`, version }; try { const cliPath = await installNpmPackage(resolution, metadata); From af3fb729ae0e185b48bd15afd1ff4da71da3106e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 2 Nov 2026 13:34:59 +0100 Subject: [PATCH 4/4] ci: reserve Supabase local ports on e2e runners Supabase's local ports fall inside Linux's ephemeral port range, so outbound connections during image pulls could hold one when supabase start binds it, failing with "address already in use" on ports like 54322 and 54324. --- .github/workflows/e2e.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 361a1e3..aa5cec0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -53,6 +53,10 @@ jobs: - version: ^2 pg_major: 14 steps: + # Supabase's local ports fall inside Linux's ephemeral port range, so outbound + # connections (like image pulls) can hold one when `supabase start` binds it. + - name: Reserve Supabase local ports + run: sudo sysctl -w net.ipv4.ip_local_reserved_ports=54320-54329 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false