From 35a24f1582cad0a3732f8a6c4e2b917d05d01a15 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 30 Oct 2026 13:33:38 +0000 Subject: [PATCH] feat: fall back to the GitHub release when npm cannot install the CLI When npm fails to resolve, install or run a fixed CLI version, download the same version's GitHub release archive, verify it against the release checksums, and report the source through a new install-source output. Lockfile integrity mismatches still fail without falling back. --- .github/workflows/ci.yml | 34 ++++- README.md | 15 +++ action.yml | 4 + docs/index.md | 4 +- src/main.test.ts | 207 ++++++++++++++++++++++++++++++- src/main.ts | 260 ++++++++++++++++++++++++++++++++++----- 6 files changed, 486 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 972b047..40fb228 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,15 +54,47 @@ jobs: version: ${{ matrix.version }} - run: supabase -h + github-release-fallback: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest, ubuntu-latest] + # 2.33.0 predates versioned archive names; 2.122.0 uses them. + version: [2.33.0, 2.122.0] + env: + # An npm executable that does not exist makes every npm call fail. + SUPABASE_SETUP_CLI_NPM: setup-cli-npm-unavailable + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - id: setup-cli + uses: ./ + with: + version: ${{ matrix.version }} + - env: + INSTALL_SOURCE: ${{ steps.setup-cli.outputs.install-source }} + INSTALLED_VERSION: ${{ steps.setup-cli.outputs.version }} + EXPECTED_VERSION: ${{ matrix.version }} + run: | + [[ "$INSTALL_SOURCE" == "github-release" ]] + [[ "$INSTALLED_VERSION" == "$EXPECTED_VERSION" ]] + supabase -h + ci: if: ${{ always() && github.event_name == 'pull_request' }} name: CI runs-on: ubuntu-latest - needs: [validate, test] + needs: [validate, test, github-release-fallback] timeout-minutes: 5 steps: - run: | validate_result="${{ needs.validate.result }}" test_result="${{ needs.test.result }}" + fallback_result="${{ needs.github-release-fallback.result }}" [[ "$validate_result" == "success" || "$validate_result" == "skipped" ]] [[ "$test_result" == "success" || "$test_result" == "skipped" ]] + [[ "$fallback_result" == "success" || "$fallback_result" == "skipped" ]] diff --git a/README.md b/README.md index ee21da2..dfd162e 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,14 @@ and requires Node.js 20 or newer. On non-musl runners without Node.js or npm, it provisions them internally. Runners must be able to reach the npm registry to install the CLI package. +If npm cannot install a working CLI for a resolved version, for example while a +fresh release is still propagating through npm, the action logs a warning and +installs the same version from its +[GitHub release](https://github.com/supabase/cli/releases) instead, after +verifying the download against the release's checksums. This needs a fixed +version, either requested or resolved by npm, and is skipped when the lockfile +integrity check fails. + When running in Alpine or other Linux musl containers, the action uses Alpine's `nodejs` and `npm` packages instead of `actions/setup-node`, because the standard Node.js runner binaries target glibc. Root containers can have missing @@ -101,6 +109,13 @@ The action supports the following inputs: | --------- | ------ | -------------------------------------------------------------------------- | --------------------------------- | -------- | | `version` | String | Supabase CLI `latest`, `beta`, `next`, `v.stable`, or fixed npm version | Root lockfile version or `latest` | false | +## Outputs + +| Name | Description | +| ---------------- | -------------------------------------------------------------- | +| `version` | Version reported by the installed `supabase --version` | +| `install-source` | `npm`, or `github-release` when the action fell back to GitHub | + ## Advanced Usage Check generated TypeScript types are up-to-date with Postgres schema: diff --git a/action.yml b/action.yml index feee4b8..c7cf61c 100644 --- a/action.yml +++ b/action.yml @@ -9,6 +9,9 @@ outputs: version: description: Version of installed Supabase CLI value: ${{ steps.setup-cli.outputs.version }} + install-source: + description: Where the CLI was installed from, either npm or github-release when npm could not install a working CLI. + value: ${{ steps.setup-cli.outputs.install-source }} runs: using: composite steps: @@ -178,4 +181,5 @@ runs: working-directory: ${{ github.action_path }} env: INPUT_VERSION: ${{ inputs.version }} + SUPABASE_SETUP_CLI_MUSL: ${{ steps.bun-download.outputs.is-musl }} run: bun src/main.ts diff --git a/docs/index.md b/docs/index.md index 0af0e20..8d1685c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,7 +12,9 @@ If `version` is omitted, the action checks the repository root for `bun.lock`, The action uses an existing Node.js/npm runtime when one is already available, and requires Node.js 20 or newer. On non-musl runners without Node.js or npm, it -provisions them internally. Runners only need network access to the npm registry. +provisions them internally. Runners need network access to the npm registry, +and to GitHub when npm cannot install a working CLI and the action falls back to +the version's GitHub release. ## Quick Start diff --git a/src/main.test.ts b/src/main.test.ts index 2a3b6ab..675d783 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -6,6 +6,9 @@ import { afterEach, expect, mock, spyOn, test } from "bun:test"; import * as core from "@actions/core"; const CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; +const RELEASES_URL = "https://github.com/supabase/cli/releases/download"; +const RELEASE_PLATFORM = process.platform === "win32" ? "windows" : process.platform; +const RELEASE_ARCH = process.arch === "x64" ? "amd64" : process.arch; const originalPath = process.env.PATH; const originalNpmUserconfig = process.env.NPM_CONFIG_USERCONFIG; const originalRunnerTemp = process.env.RUNNER_TEMP; @@ -46,6 +49,7 @@ afterEach(() => { delete process.env.FAKE_NPM_PACKAGE_VERSION; delete process.env.FAKE_NPM_PREFIX_CONFIG_LOG; delete process.env.FAKE_NPM_SCRIPTS; + delete process.env.FAKE_NPM_VIEW_ERROR; delete process.env.SETUP_CLI_TEST_WORKSPACE; delete process.env.SUPABASE_SETUP_CLI_NPM; @@ -192,6 +196,11 @@ function recordConfig(configPath) { if (args[0] === "view") { recordConfig(path.join(process.cwd(), ".npmrc")); + if (process.env.FAKE_NPM_VIEW_ERROR) { + console.error(process.env.FAKE_NPM_VIEW_ERROR); + process.exit(1); + } + const bin = process.env.FAKE_NPM_BIN === "missing" ? undefined @@ -345,7 +354,60 @@ function createActionSpies(inputVersion: string) { getInput: spyOn(core, "getInput").mockReturnValue(inputVersion), setFailed: spyOn(core, "setFailed").mockImplementation(() => {}), setOutput: spyOn(core, "setOutput").mockImplementation(() => {}), + warning: spyOn(core, "warning").mockImplementation(() => {}), + }; +} + +function releaseUrl(version: string, filename: string): string { + return `${RELEASES_URL}/v${version}/${filename}`; +} + +function createReleaseArchive(cliVersion: string, binDir = ""): Uint8Array { + const root = createTempDir("setup-cli-release-"); + const contents = path.join(root, "contents"); + const binPath = path.join(contents, binDir); + mkdirSync(binPath, { recursive: true }); + + if (process.platform === "win32") { + writeFileSync(path.join(binPath, "supabase.cmd"), `@echo off\r\necho ${cliVersion}\r\n`); + } else { + writeFileSync( + path.join(binPath, "supabase"), + `#!/usr/bin/env bash\nprintf '%s\\n' '${cliVersion}'\n`, + { mode: 0o755 }, + ); + } + + const archivePath = path.join(root, "archive.tar.gz"); + const tar = Bun.spawnSync(["tar", "-czf", archivePath, "-C", contents, "."]); + if (tar.exitCode !== 0) { + throw new Error(tar.stderr.toString()); + } + + return new Uint8Array(readFileSync(archivePath)); +} + +function sha256(bytes: Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); +} + +function serveRelease(version: string, assets: Record) { + const requested: string[] = []; + const responses = new Map( + Object.entries(assets).map(([filename, body]) => [releaseUrl(version, filename), body]), + ); + + const respond = async (input: string | URL | Request) => { + const url = input instanceof Request ? input.url : String(input); + requested.push(url); + const body = responses.get(url); + return body === undefined ? new Response("Not Found", { status: 404 }) : new Response(body); }; + spyOn(globalThis, "fetch").mockImplementation( + Object.assign(respond, { preconnect: globalThis.fetch.preconnect }), + ); + + return { requested }; } async function getMainModule(): Promise { @@ -537,11 +599,12 @@ test("installs the CLI with npm into an isolated prefix", async () => { const logPath = installFakeNpm(); const { installCli } = await getMainModule(); - const cliPath = await installCli({ + const { cliPath, source } = await installCli({ spec: "supabase@2.101.0", version: "2.101.0", }); + expect(source).toBe("npm"); expect(cliPath).toContain(`${path.sep}node_modules${path.sep}.bin`); expect(readNpmCalls(logPath)).toEqual([ viewMetadataCall("supabase@2.101.0"), @@ -728,8 +791,9 @@ test("verifies lockfile integrity before installing", async () => { ]); }); -test("fails when lockfile integrity does not match the registry", async () => { +test("fails without a GitHub release fallback when lockfile integrity does not match the registry", async () => { installFakeNpm("supabase 2.101.0", { integrity: "sha512-registry" }); + const release = serveRelease("2.101.0", {}); const { installCli } = await getMainModule(); try { @@ -744,6 +808,7 @@ test("fails when lockfile integrity does not match the registry", async () => { new Error("Lockfile integrity for supabase@2.101.0 does not match the npm registry"), ); } + expect(release.requested).toEqual([]); }); test("fails when the npm package does not expose a Supabase CLI executable", async () => { @@ -775,6 +840,7 @@ test("runs the action with a package-lock resolution", async () => { expect(readNpmCalls(logPath)[0]).toEqual(viewMetadataCall("supabase@2.43.0")); expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.43.0"); + expect(spies.setOutput).toHaveBeenCalledWith("install-source", "npm"); expect(spies.addPath).toHaveBeenCalledWith(expect.stringContaining("node_modules")); expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io"); expect(spies.setFailed).not.toHaveBeenCalled(); @@ -830,20 +896,151 @@ test("preserves an explicitly configured image registry for legacy CLI versions" expect(spies.setFailed).not.toHaveBeenCalled(); }); -test("fails when the installed CLI does not report a version", async () => { - installFakeNpm(""); +test("installs the GitHub release when the npm-installed CLI cannot run", async () => { + installFakeNpm("", { packageVersion: "2.101.0" }); + const archiveName = `supabase_2.101.0_${RELEASE_PLATFORM}_${RELEASE_ARCH}.tar.gz`; + const archive = createReleaseArchive("2.101.0"); + const release = serveRelease("2.101.0", { + "checksums.txt": `${sha256(archive)} ${archiveName}\n`, + [archiveName]: archive, + }); + const spies = createActionSpies("2.101.0"); + const { run } = await getMainModule(); + + await run(); + + expect(spies.setFailed).not.toHaveBeenCalled(); + expect(spies.setOutput).toHaveBeenCalledWith("version", "2.101.0"); + expect(spies.setOutput).toHaveBeenCalledWith("install-source", "github-release"); + expect(spies.addPath).toHaveBeenCalledTimes(1); + expect(spies.warning).toHaveBeenCalledWith( + expect.stringContaining("installing v2.101.0 from its GitHub release instead"), + ); + expect(release.requested).toEqual([ + releaseUrl("2.101.0", "checksums.txt"), + releaseUrl("2.101.0", archiveName), + ]); +}); + +test("installs the GitHub release of a pinned version when npm is unreachable", async () => { + installFakeNpm("supabase 2.101.0"); + process.env.FAKE_NPM_VIEW_ERROR = "npm error code ETIMEDOUT"; + const archiveName = `supabase_2.101.0_${RELEASE_PLATFORM}_${RELEASE_ARCH}.tar.gz`; + const archive = createReleaseArchive("2.101.0"); + serveRelease("2.101.0", { + "checksums.txt": `${sha256(archive)} ${archiveName}\n`, + [archiveName]: archive, + }); + const spies = createActionSpies("2.101.0"); + const { run } = await getMainModule(); + + await run(); + + expect(spies.setFailed).not.toHaveBeenCalled(); + expect(spies.setOutput).toHaveBeenCalledWith("install-source", "github-release"); +}); + +test("fails without a GitHub release fallback when npm cannot resolve a dist-tag", async () => { + installFakeNpm("supabase 2.101.0"); + process.env.FAKE_NPM_VIEW_ERROR = "npm error code ETIMEDOUT"; + const release = serveRelease("2.101.0", {}); + const spies = createActionSpies("latest"); + 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`; + serveRelease("2.101.0", { + "checksums.txt": `${"0".repeat(64)} ${archiveName}\n`, + [archiveName]: createReleaseArchive("2.101.0"), + }); + const spies = createActionSpies("2.101.0"); + const { run } = await getMainModule(); + + await run(); + + expect(spies.setFailed).toHaveBeenCalledWith( + expect.stringContaining(`Checksum mismatch for ${archiveName}`), + ); + expect(spies.setOutput).not.toHaveBeenCalled(); + expect(spies.addPath).not.toHaveBeenCalled(); +}); + +test("reports both failures when the GitHub release fallback also fails", async () => { + installFakeNpm("", { packageVersion: "2.46.0" }); process.env.GITHUB_WORKSPACE = createWorkspace({ "package-lock.json": createPackageLock("2.46.0", "sha512-test"), }); + serveRelease("2.46.0", {}); const spies = createActionSpies(""); const { run } = await getMainModule(); await run(); expect(spies.setFailed).toHaveBeenCalledWith( - "Could not determine installed Supabase CLI version", + "Could not determine installed Supabase CLI version\n" + + "The GitHub release fallback for v2.46.0 also failed: " + + "The v2.46.0 GitHub release has no checksums.txt or supabase_2.46.0_checksums.txt", ); expect(spies.setOutput).not.toHaveBeenCalled(); expect(spies.addPath).not.toHaveBeenCalled(); expect(spies.exportVariable).not.toHaveBeenCalled(); }); + +test("selects the release archive that runs on the runner", async () => { + const { selectReleaseAsset } = await getMainModule(); + const checksums = [ + "aaa supabase_3.0.0_linux_amd64.tar.gz", + "bbb supabase_3.0.0_linux_amd64.apk", + "ccc supabase_3.0.0_windows_arm64.tar.gz", + ].join("\n"); + + expect( + selectReleaseAsset("3.0.0", checksums, { platform: "linux", arch: "x64", musl: false }), + ).toEqual({ + binDir: "", + filename: "supabase_3.0.0_linux_amd64.tar.gz", + sha256: "aaa", + }); + expect( + selectReleaseAsset("3.0.0", checksums, { platform: "linux", arch: "x64", musl: true }), + ).toEqual({ + binDir: path.join("usr", "bin"), + filename: "supabase_3.0.0_linux_amd64.apk", + sha256: "bbb", + }); + expect( + selectReleaseAsset("3.0.0", checksums, { platform: "win32", arch: "arm64", musl: false }) + .filename, + ).toBe("supabase_3.0.0_windows_arm64.tar.gz"); +}); + +test("selects the unversioned archive from releases that predate versioned names", async () => { + const { selectReleaseAsset } = await getMainModule(); + + expect( + selectReleaseAsset("1.178.2", "ddd supabase_darwin_arm64.tar.gz\n", { + platform: "darwin", + arch: "arm64", + musl: false, + }).filename, + ).toBe("supabase_darwin_arm64.tar.gz"); +}); + +test("rejects runners that no release archive supports", async () => { + const { selectReleaseAsset } = await getMainModule(); + + expect(() => + selectReleaseAsset("3.0.0", "aaa supabase_3.0.0_linux_amd64.tar.gz", { + platform: "freebsd", + arch: "x64", + musl: false, + }), + ).toThrow("Supabase CLI releases have no binary for freebsd x64 runners"); +}); diff --git a/src/main.ts b/src/main.ts index 2c19019..067be23 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { semver } from "bun"; import * as core from "@actions/core"; -import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -11,6 +11,18 @@ const FALLBACK_VERSION = "2.108.0"; const DEFAULT_VERSION = "latest"; const NPM_PACKAGE = "supabase"; const NPM_EXECUTABLE_ENV = "SUPABASE_SETUP_CLI_NPM"; +const MUSL_ENV = "SUPABASE_SETUP_CLI_MUSL"; +const GITHUB_RELEASES_URL = "https://github.com/supabase/cli/releases/download"; +const RELEASE_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000; +const RELEASE_PLATFORMS: Partial> = { + darwin: "darwin", + linux: "linux", + win32: "windows", +}; +const RELEASE_ARCHES: Partial> = { + arm64: "arm64", + x64: "amd64", +}; const INSTALL_LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall"] as const; const SUPPORTED_DIST_TAGS = new Set([DEFAULT_VERSION, "beta", "next"]); const MAINTENANCE_DIST_TAG_PATTERN = /^v\d+\.stable$/; @@ -23,6 +35,24 @@ type PackageResolution = { integrity?: string; }; +type Installation = { + cliPath: string; + installedVersion: string; + source: "npm" | "github-release"; +}; + +type ReleaseTarget = { + platform: NodeJS.Platform; + arch: NodeJS.Architecture; + musl: boolean; +}; + +type ReleaseAsset = { + filename: string; + sha256: string; + binDir: string; +}; + type PackageMetadata = { version?: unknown; bin?: unknown; @@ -271,7 +301,7 @@ async function getPackageMetadata(resolution: PackageResolution): Promise { - const executable = process.env[NPM_EXECUTABLE_ENV]?.trim() || "npm"; - const proc = Bun.spawn([executable, ...args], { - cwd: process.env.GITHUB_WORKSPACE?.trim() ?? process.cwd(), +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function runProcess( + command: string[], + options: { cwd?: string; failure: string }, +): Promise { + const proc = Bun.spawn(command, { + cwd: options.cwd, env: process.env, stderr: "pipe", stdout: "pipe", @@ -314,18 +352,25 @@ async function runNpm(args: string[]): Promise { ]); if (exitCode !== 0) { - throw new Error(stderr.trim() || `npm ${args.join(" ")} failed`); + throw new Error(stderr.trim() || options.failure); } return stdout; } -export async function installCli(resolution: PackageResolution): Promise { - const installRoot = createInstallRoot(); +async function runNpm(args: string[]): Promise { + const executable = process.env[NPM_EXECUTABLE_ENV]?.trim() || "npm"; + return runProcess([executable, ...args], { + cwd: process.env.GITHUB_WORKSPACE?.trim() ?? process.cwd(), + failure: `npm ${args.join(" ")} failed`, + }); +} - const metadata = await getPackageMetadata(resolution); - verifyPackageMetadata(resolution, metadata); - verifyPackageIntegrity(resolution, metadata); +async function installNpmPackage( + resolution: PackageResolution, + metadata: PackageMetadata, +): Promise { + const installRoot = createInstallRoot(); await runNpm([ "install", @@ -345,6 +390,170 @@ export async function installCli(resolution: PackageResolution): Promise return path.join(installRoot, "node_modules", ".bin"); } +function parseChecksums(text: string): Map { + const checksums = new Map(); + + for (const line of text.split("\n")) { + const [sha256, filename] = line.trim().split(/\s+/); + if (sha256 && filename) { + checksums.set(filename.replace(/^\*/, ""), sha256.toLowerCase()); + } + } + + return checksums; +} + +export function selectReleaseAsset( + version: string, + checksumsText: string, + target: ReleaseTarget, +): ReleaseAsset { + const platform = RELEASE_PLATFORMS[target.platform]; + const arch = RELEASE_ARCHES[target.arch]; + + if (!platform || !arch) { + throw new Error( + `Supabase CLI releases have no binary for ${target.platform} ${target.arch} runners`, + ); + } + + const tarballs = [ + `supabase_${version}_${platform}_${arch}.tar.gz`, + `supabase_${platform}_${arch}.tar.gz`, + ]; + // Release tarballs may carry a glibc build; the apk carries the musl one. + const candidates = + target.musl && platform === "linux" + ? [`supabase_${version}_${platform}_${arch}.apk`, ...tarballs] + : tarballs; + const checksums = parseChecksums(checksumsText); + + for (const filename of candidates) { + const sha256 = checksums.get(filename); + if (sha256) { + return { + filename, + sha256, + binDir: filename.endsWith(".apk") ? path.join("usr", "bin") : "", + }; + } + } + + throw new Error(`The v${version} release has no checksum for ${candidates.join(" or ")}`); +} + +async function fetchReleaseAsset(version: string, filename: string): Promise { + const url = `${GITHUB_RELEASES_URL}/v${version}/${filename}`; + const response = await fetch(url, { signal: AbortSignal.timeout(RELEASE_DOWNLOAD_TIMEOUT_MS) }); + + if (!response.ok && response.status !== 404) { + throw new Error(`Downloading ${url} returned HTTP ${response.status}`); + } + + return response; +} + +async function downloadReleaseChecksums(version: string): Promise { + const filenames = ["checksums.txt", `supabase_${version}_checksums.txt`]; + + for (const filename of filenames) { + const response = await fetchReleaseAsset(version, filename); + if (response.ok) { + return response.text(); + } + } + + throw new Error(`The v${version} GitHub release has no ${filenames.join(" or ")}`); +} + +async function extractArchive(archivePath: string, destination: string): Promise { + // Git Bash's GNU tar reads `C:\...` as a remote host, so Windows uses its bundled bsdtar. + const tar = + process.platform === "win32" + ? path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe") + : "tar"; + + await runProcess([tar, "-xzf", archivePath, "-C", destination], { + failure: `Could not extract ${path.basename(archivePath)}`, + }); +} + +async function installGitHubRelease(version: string): Promise { + const asset = selectReleaseAsset(version, await downloadReleaseChecksums(version), { + platform: process.platform, + arch: process.arch, + musl: process.env[MUSL_ENV] === "true", + }); + const response = await fetchReleaseAsset(version, asset.filename); + if (!response.ok) { + throw new Error(`The v${version} GitHub release has no ${asset.filename}`); + } + + const archive = new Uint8Array(await response.arrayBuffer()); + const sha256 = new Bun.CryptoHasher("sha256").update(archive).digest("hex"); + + if (sha256 !== asset.sha256) { + throw new Error( + `Checksum mismatch for ${asset.filename}: the release lists ${asset.sha256}, but the download is ${sha256}`, + ); + } + + const installRoot = createInstallRoot(); + const archivePath = path.join(installRoot, asset.filename); + await Bun.write(archivePath, archive); + await extractArchive(archivePath, installRoot); + rmSync(archivePath, { force: true }); + + return path.join(installRoot, asset.binDir); +} + +async function installGitHubReleaseInstead( + resolution: PackageResolution, + version: string | null, + npmError: unknown, +): Promise { + if (!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}`, + ); + + try { + const cliPath = await installGitHubRelease(version); + const installedVersion = await determineInstalledVersion(cliPath); + return { cliPath, installedVersion, source: "github-release" }; + } catch (error) { + throw new Error( + `${npmFailure}\nThe GitHub release fallback for v${version} also failed: ${errorMessage(error)}`, + ); + } +} + +/** Installs from npm, falling back to the GitHub release when npm cannot produce a working CLI. */ +export async function installCli(resolution: PackageResolution): Promise { + let metadata: PackageMetadata; + try { + metadata = await getPackageMetadata(resolution); + } catch (error) { + const version = CONCRETE_VERSION_PATTERN.test(resolution.version) ? resolution.version : null; + return installGitHubReleaseInstead(resolution, version, error); + } + + const version = verifyPackageMetadata(resolution, metadata); + verifyPackageIntegrity(resolution, metadata); + + try { + const cliPath = await installNpmPackage(resolution, metadata); + const installedVersion = await determineInstalledVersion(cliPath); + return { cliPath, installedVersion, source: "npm" }; + } catch (error) { + return installGitHubReleaseInstead(resolution, version, error); + } +} + function getCliExecutablePath(cliPath: string): string { if (process.platform !== "win32") { return path.join(cliPath, "supabase"); @@ -364,24 +573,13 @@ function getCliExecutablePath(cliPath: string): string { } export async function determineInstalledVersion(cliPath: string): Promise { - const executable = getCliExecutablePath(cliPath); - const proc = Bun.spawn([executable, "--version"], { - stderr: "pipe", - stdout: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); + const failure = "Could not determine installed Supabase CLI version"; + const version = ( + await runProcess([getCliExecutablePath(cliPath), "--version"], { failure }) + ).trim(); - if (exitCode !== 0) { - throw new Error(stderr.trim() || "Could not determine installed Supabase CLI version"); - } - - const version = stdout.trim(); if (!version) { - throw new Error("Could not determine installed Supabase CLI version"); + throw new Error(failure); } return version; @@ -399,16 +597,16 @@ function shouldUseGhcrRegistry(installedVersion: string): boolean { export async function run(): Promise { try { const resolution = resolvePackage(core.getInput("version")); - const cliPath = await installCli(resolution); - const installedVersion = await determineInstalledVersion(cliPath); + const { cliPath, installedVersion, source } = await installCli(resolution); core.setOutput("version", installedVersion); + core.setOutput("install-source", source); core.addPath(cliPath); if (shouldUseGhcrRegistry(installedVersion) && !process.env[CLI_CONFIG_REGISTRY]) { core.exportVariable(CLI_CONFIG_REGISTRY, "ghcr.io"); } } catch (error) { - core.setFailed(error instanceof Error ? error.message : String(error)); + core.setFailed(errorMessage(error)); } }