diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f754d1c..aa5cec0 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,7 +50,13 @@ jobs: pg_major: 17 - version: latest pg_major: 14 + - 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 diff --git a/README.md b/README.md index dfd162e..71347c7 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: @@ -61,10 +81,12 @@ 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, so once a major goes GA it can lag behind `latest` until the -following major's prereleases start: +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 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: @@ -87,6 +109,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 +134,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 8131916..c10f3a5 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 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 675d783..00191ea 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -50,6 +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_VIEW_RESULT; delete process.env.SETUP_CLI_TEST_WORKSPACE; delete process.env.SUPABASE_SETUP_CLI_NPM; @@ -201,6 +202,11 @@ if (args[0] === "view") { 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 @@ -344,7 +350,33 @@ function readNpmPrefixConfigs(): Array { } function viewMetadataCall(spec: string): string[] { - return ["view", spec, "version", "bin", "scripts", "dist.integrity", "--json", "--offline=false"]; + return [ + "view", + spec, + "version", + "bin", + "scripts", + "dist.integrity", + "deprecated", + "dist-tags", + "--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) { @@ -352,6 +384,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 +508,57 @@ 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", + "2.90.0 - 2.98.0", + ">=2.98 <2.99", + "^1 || ^2", + ]) { + expect(resolvePackage(` ${range} `)).toEqual({ spec: `supabase@${range}`, version: range }); + } +}); + +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" }), @@ -953,6 +1030,140 @@ 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"); + setViewManifests(["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).toHaveLength(2); + expect(calls[0]).toEqual(viewMetadataCall("supabase@^2.95")); + expect(calls[1]).toContain("supabase@2.98.2"); +}); + +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"); + setViewManifests(["2.98.0", "2.98.1"]); + const spies = createActionSpies(input); + const { run } = await getMainModule(); + + await run(); + + expect(spies.setFailed).not.toHaveBeenCalled(); + 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 manifest", async () => { + const logPath = installFakeNpm("supabase 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]).toContain("supabase@2.98.0"); + expect(spies.setOutput).toHaveBeenCalledWith("install-source", "npm"); +}); + +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).not.toHaveBeenCalled(); + expect(readNpmCalls(logPath)[1]).toContain("supabase@2.98.1"); +}); + +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_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(); + + await run(); + + expect(spies.setFailed).toHaveBeenCalledWith('No published Supabase CLI version matches "^99"'); + expect(release.requested).toEqual([]); +}); + +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(); + + expect(spies.setFailed).toHaveBeenCalledWith(error); + 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..f48a752 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 = + /^(?:[<>]=?|=|\^|~)?(?:\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 = { @@ -58,6 +60,8 @@ type PackageMetadata = { bin?: unknown; scripts?: unknown; "dist.integrity"?: unknown; + "dist-tags"?: unknown; + deprecated?: unknown; }; type BunLock = { @@ -114,6 +118,28 @@ 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)) { + 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 +158,13 @@ function normalizeSupportedVersion(version: string): string { return normalizedVersion; } + const range = normalizeRange(trimmedVersion); + if (isVersionRange(range)) { + return range; + } + 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.`, ); } @@ -281,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", @@ -289,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}`, ); @@ -533,17 +598,23 @@ async function installGitHubReleaseInstead( } /** 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 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);