fix: setup-cli on Linux musl containers (#431)

## Summary

Fixes setup-cli in Alpine/Linux musl containers after the Supabase CLI
v2.99+ release layout introduced a Bun/TypeScript `supabase` shim
alongside `supabase-go`.

This keeps the v2 action on Bun, but makes the musl paths explicit:

- Detect Linux musl before running `oven-sh/setup-bun` and pass the
matching `bun-linux-*-musl.zip` via `bun-download-url`.
- Use POSIX `sh` for composite shell steps so the action can run in
minimal Alpine containers without `bash`.
- Detect Linux musl in the CLI installer and download the existing
`.apk` release asset for CLI v2.99+.
- Add the extracted APK `usr/bin` directory to PATH.
- Keep existing tar/zip behavior for glibc Linux, macOS, and Windows.

## Root Cause

`oven-sh/setup-bun` does not currently include libc detection in its
automatic release asset selection, so Alpine containers received a glibc
Bun binary. Separately, setup-cli downloaded the generic Linux `.tar.gz`
Supabase CLI asset, whose `supabase` shim is glibc-linked in v2.99+; the
`.apk` asset contains the musl-linked shim.

## Testing

- `bun run ci`
- Manual GitHub workflow in
https://github.com/jgoux/setup-cli-testing/actions/runs/2616196598653
This commit is contained in:
Julien Goux
2026-05-20 15:15:47 +02:00
committed by GitHub
parent 3095b000b6
commit 52a446718e
3 changed files with 119 additions and 7 deletions

View File

@@ -15,19 +15,55 @@ outputs:
runs:
using: composite
steps:
- id: bun-download
name: Resolve Bun Download URL
shell: sh
working-directory: ${{ github.action_path }}
run: |
set -eu
if [ "${RUNNER_OS}" != "Linux" ]; then
exit 0
fi
# setup-bun does not detect Linux musl yet, so Alpine-like containers need the musl asset explicitly.
is_musl=false
if [ -f /etc/alpine-release ]; then
is_musl=true
elif command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then
is_musl=true
fi
if [ "${is_musl}" != "true" ]; then
exit 0
fi
version="$(cat .bun-version)"
case "$(uname -m)" in
x86_64) arch="x64" ;;
aarch64|arm64) arch="aarch64" ;;
*)
echo "Unsupported Linux musl architecture: $(uname -m)" >&2
exit 1
;;
esac
echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${version}/bun-linux-${arch}-musl.zip" >> "$GITHUB_OUTPUT"
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: ${{ github.action_path }}/.bun-version
bun-download-url: ${{ steps.bun-download.outputs.url }}
- name: Install Action Dependencies
shell: bash
shell: sh
working-directory: ${{ github.action_path }}
run: bun install --frozen-lockfile --production
- id: setup-cli
name: Setup Supabase CLI
shell: bash
shell: sh
working-directory: ${{ github.action_path }}
env:
INPUT_VERSION: ${{ inputs.version }}

View File

@@ -196,6 +196,36 @@ test("uses versioned tar archives for Supabase CLI v2.99.0 and later", async ()
});
});
test("uses apk archives for Supabase CLI v2.99.0 and later on Linux musl", async () => {
const { getDownloadArchive } = await getMainModule();
const archive = await getDownloadArchive("2.100.1", "linux", "x64", true);
expect(archive).toEqual({
url: "https://github.com/supabase/cli/releases/download/v2.100.1/supabase_2.100.1_linux_amd64.apk",
format: "apk",
});
});
test("keeps tar archives before Supabase CLI v2.99.0 on Linux musl", async () => {
const { getDownloadArchive } = await getMainModule();
const archive = await getDownloadArchive("2.98.2", "linux", "x64", true);
expect(archive).toEqual({
url: "https://github.com/supabase/cli/releases/download/v2.98.2/supabase_linux_amd64.tar.gz",
format: "tar",
});
});
test("uses usr/bin as the CLI path for apk archives", async () => {
const { getCliPath } = await getMainModule();
expect(getCliPath("/tmp/extracted", "apk")).toBe(path.join("/tmp/extracted", "usr", "bin"));
expect(getCliPath("/tmp/extracted", "tar")).toBe("/tmp/extracted");
expect(getCliPath("/tmp/extracted", "zip")).toBe("/tmp/extracted");
});
test("keeps the unversioned tar archive layout before Supabase CLI v2.99.0", async () => {
const { getDownloadArchive } = await getMainModule();

View File

@@ -12,7 +12,7 @@ const DEFAULT_VERSION = "latest";
const GITHUB_RELEASES_API = "https://api.github.com/repos/supabase/cli/releases/latest";
const GITHUB_TOKEN_ENV = "SUPABASE_CLI_GITHUB_TOKEN";
type ArchiveFormat = "tar" | "zip";
type ArchiveFormat = "apk" | "tar" | "zip";
type DownloadArchive = {
url: string;
@@ -199,7 +199,19 @@ async function resolveLatestVersion(): Promise<string> {
return normalizeVersion(release.tag_name);
}
function getArchiveFormat(version: string, platform: NodeJS.Platform): ArchiveFormat {
function getArchiveFormat(
version: string,
platform: NodeJS.Platform,
isMuslLinux: boolean,
): ArchiveFormat {
if (
platform === "linux" &&
isMuslLinux &&
semver.order(version, VERSIONED_ARCHIVE_VERSION) >= 0
) {
return "apk";
}
if (platform === "win32" && semver.order(version, VERSIONED_ARCHIVE_VERSION) >= 0) {
return "zip";
}
@@ -211,6 +223,7 @@ function getArchiveFilename(
version: string,
platform: NodeJS.Platform,
arch: NodeJS.Architecture,
archiveFormat: ArchiveFormat,
): string {
const archivePlatform = getArchivePlatform(platform);
const archiveArch = getArchiveArch(arch);
@@ -219,6 +232,10 @@ function getArchiveFilename(
return `supabase_${version}_${archivePlatform}_${archiveArch}.tar.gz`;
}
if (platform === "linux" && archiveFormat === "apk") {
return `supabase_${version}_${archivePlatform}_${archiveArch}.apk`;
}
if (semver.order(version, VERSIONED_ARCHIVE_VERSION) >= 0) {
const extension = platform === "win32" ? "zip" : "tar.gz";
return `supabase_${version}_${archivePlatform}_${archiveArch}.${extension}`;
@@ -231,17 +248,45 @@ export async function getDownloadArchive(
version: string,
platform = process.platform,
arch = process.arch,
isMuslLinux?: boolean,
): Promise<DownloadArchive> {
const resolvedVersion =
version.toLowerCase() === "latest" ? await resolveLatestVersion() : normalizeVersion(version);
const filename = getArchiveFilename(resolvedVersion, platform, arch);
const format = getArchiveFormat(
resolvedVersion,
platform,
isMuslLinux ?? (await detectMuslLinux(platform)),
);
const filename = getArchiveFilename(resolvedVersion, platform, arch, format);
return {
url: `https://github.com/supabase/cli/releases/download/v${resolvedVersion}/${filename}`,
format: getArchiveFormat(resolvedVersion, platform),
format,
};
}
async function detectMuslLinux(platform = process.platform): Promise<boolean> {
if (platform !== "linux") {
return false;
}
if (existsSync("/etc/alpine-release")) {
return true;
}
try {
const output = await $`ldd --version`.quiet().text();
return output.toLowerCase().includes("musl");
} catch (error) {
const output = error instanceof Error ? error.message : String(error);
return output.toLowerCase().includes("musl");
}
}
export function getCliPath(extractedPath: string, archiveFormat: ArchiveFormat): string {
return archiveFormat === "apk" ? path.join(extractedPath, "usr", "bin") : extractedPath;
}
function getCliExecutablePath(cliPath: string): string {
if (process.platform !== "win32") {
return path.join(cliPath, "supabase");
@@ -274,10 +319,11 @@ export async function run(): Promise<void> {
const version = resolveVersion(core.getInput("version"));
const archive = await getDownloadArchive(version);
const archivePath = await tc.downloadTool(archive.url);
const cliPath =
const extractedPath =
archive.format === "zip"
? await tc.extractZip(archivePath)
: await tc.extractTar(archivePath);
const cliPath = getCliPath(extractedPath, archive.format);
const installedVersion = await determineInstalledVersion(cliPath);
core.setOutput("version", installedVersion);
core.addPath(cliPath);