From aa2625bbb18e3a04015bcb9cfbe9d90f69ef84d7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 18 May 2026 13:00:05 +0200 Subject: [PATCH] Handle Supabase CLI v2.99 archives --- src/main.test.ts | 71 ++++++++++++++++++++++++++++++++--- src/main.ts | 98 ++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 153 insertions(+), 16 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index 022dd18..46627c6 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -156,9 +156,19 @@ function createActionSpies(inputVersion: string, cliDir: string, expectedUrlFrag return path.join(os.tmpdir(), "supabase-cli.tar.gz"); }), extractTar: spyOn(tc, "extractTar").mockImplementation(async () => cliDir), + extractZip: spyOn(tc, "extractZip").mockImplementation(async () => cliDir), }; } +function mockLatestRelease(version = "v2.99.0") { + return spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ tag_name: version }), { + status: 200, + statusText: "OK", + }), + ); +} + async function getMainModule(): Promise { if (!mainModule) { mainModule = await import("./main.ts"); @@ -167,8 +177,54 @@ async function getMainModule(): Promise { return mainModule; } +test("uses versioned tar archives for Supabase CLI v2.99.0 and later", async () => { + const { getDownloadArchive } = await getMainModule(); + + const archive = await getDownloadArchive("2.99.0", "linux", "x64"); + + expect(archive).toEqual({ + url: "https://github.com/supabase/cli/releases/download/v2.99.0/supabase_2.99.0_linux_amd64.tar.gz", + format: "tar", + }); +}); + +test("keeps the unversioned tar archive layout before Supabase CLI v2.99.0", async () => { + const { getDownloadArchive } = await getMainModule(); + + const archive = await getDownloadArchive("2.98.2", "linux", "x64"); + + expect(archive).toEqual({ + url: "https://github.com/supabase/cli/releases/download/v2.98.2/supabase_linux_amd64.tar.gz", + format: "tar", + }); +}); + +test("uses versioned zip archives for Windows Supabase CLI v2.99.0 and later", async () => { + const { getDownloadArchive } = await getMainModule(); + + const archive = await getDownloadArchive("2.99.0", "win32", "x64"); + + expect(archive).toEqual({ + url: "https://github.com/supabase/cli/releases/download/v2.99.0/supabase_2.99.0_windows_amd64.zip", + format: "zip", + }); +}); + +test("resolves latest before choosing a versioned Supabase CLI archive", async () => { + mockLatestRelease("v2.99.0"); + const { getDownloadArchive } = await getMainModule(); + + const archive = await getDownloadArchive("latest", "darwin", "arm64"); + + expect(archive).toEqual({ + url: "https://github.com/supabase/cli/releases/download/v2.99.0/supabase_2.99.0_darwin_arm64.tar.gz", + format: "tar", + }); +}); + test("awaits the action entrypoint with omitted version and latest fallback", async () => { process.env.GITHUB_WORKSPACE = repo; + mockLatestRelease(); const cliDir = createFakeCli("supabase 2.84.2"); let startDownload!: () => void; let finishDownload!: () => void; @@ -185,11 +241,12 @@ test("awaits the action entrypoint with omitted version and latest fallback", as exportVariable: spyOn(core, "exportVariable").mockImplementation(() => {}), setFailed: spyOn(core, "setFailed").mockImplementation(() => {}), downloadTool: spyOn(tc, "downloadTool").mockImplementation(async (url: string) => { - expect(url).toContain("/latest/download/"); + expect(url).toContain("/download/v2.99.0/supabase_2.99.0_"); startDownload(); return downloadFinished; }), extractTar: spyOn(tc, "extractTar").mockImplementation(async () => cliDir), + extractZip: spyOn(tc, "extractZip").mockImplementation(async () => cliDir), }; const originalArgv1 = process.argv[1]; process.argv[1] = defaultEntrypoint; @@ -283,8 +340,9 @@ test("falls back to latest when version is omitted and no supported root lockfil process.env.GITHUB_WORKSPACE = createWorkspace({ "README.md": "# app\n", }); + mockLatestRelease(); const cliDir = createFakeCli("supabase 2.84.2"); - const spies = createActionSpies("", cliDir, "/latest/download/"); + const spies = createActionSpies("", cliDir, "/download/v2.99.0/supabase_2.99.0_"); const { run } = await getMainModule(); await run(); @@ -296,8 +354,9 @@ test("falls back to latest when version is omitted and no supported root lockfil test("falls back to latest when version is omitted and no workspace is available", async () => { delete process.env.GITHUB_WORKSPACE; + mockLatestRelease(); const cliDir = createFakeCli("supabase 2.84.2"); - const spies = createActionSpies("", cliDir, "/latest/download/"); + const spies = createActionSpies("", cliDir, "/download/v2.99.0/supabase_2.99.0_"); const { run } = await getMainModule(); await run(); @@ -360,8 +419,9 @@ test("falls through unreadable bun.lock paths and malformed package-lock files t }); mkdirSync(path.join(workspace, "bun.lock"), { recursive: true }); process.env.GITHUB_WORKSPACE = workspace; + mockLatestRelease(); const cliDir = createFakeCli("supabase 2.84.2"); - const spies = createActionSpies("", cliDir, "/latest/download/"); + const spies = createActionSpies("", cliDir, "/download/v2.99.0/supabase_2.99.0_"); const { run } = await getMainModule(); await run(); @@ -375,8 +435,9 @@ test("falls back to latest when a pnpm dependency entry has no concrete version" process.env.GITHUB_WORKSPACE = createWorkspace({ "pnpm-lock.yaml": createPnpmLock("2.49.0", { includeVersion: false }), }); + mockLatestRelease(); const cliDir = createFakeCli("supabase 2.84.2"); - const spies = createActionSpies("", cliDir, "/latest/download/"); + const spies = createActionSpies("", cliDir, "/download/v2.99.0/supabase_2.99.0_"); const { run } = await getMainModule(); await run(); diff --git a/src/main.ts b/src/main.ts index 77655af..68ca9ba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,16 @@ import { fileURLToPath } from "node:url"; export const CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; const REGISTRY_VERSION = "1.28.0"; +const VERSIONED_ARCHIVE_VERSION = "2.99.0"; const DEFAULT_VERSION = "latest"; +const GITHUB_RELEASES_API = "https://api.github.com/repos/supabase/cli/releases/latest"; + +type ArchiveFormat = "tar" | "zip"; + +type DownloadArchive = { + url: string; + format: ArchiveFormat; +}; type BunLock = { workspaces?: { @@ -56,6 +65,10 @@ function extractConcreteVersion(raw: string | undefined): string | null { return match?.[0] ?? null; } +function normalizeVersion(version: string): string { + return version.replace(/^v/i, ""); +} + function readWorkspaceLockfile(workspaceRoot: string, filename: string): string | null { const filePath = path.join(workspaceRoot, filename); @@ -161,24 +174,83 @@ function resolveVersion(inputVersion: string): string { ); } -export function getDownloadUrl(version: string): string { - const platform = getArchivePlatform(process.platform); - const arch = getArchiveArch(process.arch); - const filename = `supabase_${platform}_${arch}.tar.gz`; +async function resolveLatestVersion(): Promise { + const response = await fetch(GITHUB_RELEASES_API); + if (!response.ok) { + throw new Error(`Failed to resolve latest Supabase CLI release: ${response.statusText}`); + } - if (version.toLowerCase() === "latest") { - return `https://github.com/supabase/cli/releases/latest/download/${filename}`; + const release = (await response.json()) as { tag_name?: unknown }; + if (typeof release.tag_name !== "string") { + throw new Error("Failed to resolve latest Supabase CLI release: missing tag name"); } + return normalizeVersion(release.tag_name); +} + +function getArchiveFormat(version: string, platform: NodeJS.Platform): ArchiveFormat { + if (platform === "win32" && semver.order(version, VERSIONED_ARCHIVE_VERSION) >= 0) { + return "zip"; + } + + return "tar"; +} + +function getArchiveFilename( + version: string, + platform: NodeJS.Platform, + arch: NodeJS.Architecture, +): string { + const archivePlatform = getArchivePlatform(platform); + const archiveArch = getArchiveArch(arch); + if (semver.order(version, REGISTRY_VERSION) === -1) { - return `https://github.com/supabase/cli/releases/download/v${version}/supabase_${version}_${platform}_${arch}.tar.gz`; + return `supabase_${version}_${archivePlatform}_${archiveArch}.tar.gz`; + } + + if (semver.order(version, VERSIONED_ARCHIVE_VERSION) >= 0) { + const extension = platform === "win32" ? "zip" : "tar.gz"; + return `supabase_${version}_${archivePlatform}_${archiveArch}.${extension}`; + } + + return `supabase_${archivePlatform}_${archiveArch}.tar.gz`; +} + +export async function getDownloadArchive( + version: string, + platform = process.platform, + arch = process.arch, +): Promise { + const resolvedVersion = + version.toLowerCase() === "latest" ? await resolveLatestVersion() : normalizeVersion(version); + const filename = getArchiveFilename(resolvedVersion, platform, arch); + + return { + url: `https://github.com/supabase/cli/releases/download/v${resolvedVersion}/${filename}`, + format: getArchiveFormat(resolvedVersion, platform), + }; +} + +function getCliExecutablePath(cliPath: string): string { + if (process.platform !== "win32") { + return path.join(cliPath, "supabase"); + } + + const exePath = path.join(cliPath, "supabase.exe"); + if (existsSync(exePath)) { + return exePath; + } + + const cmdPath = path.join(cliPath, "supabase.cmd"); + if (existsSync(cmdPath)) { + return cmdPath; } - return `https://github.com/supabase/cli/releases/download/v${version}/${filename}`; + return path.join(cliPath, "supabase"); } export async function determineInstalledVersion(cliPath: string): Promise { - const version = (await $`${path.join(cliPath, "supabase")} --version`.text()).trim(); + const version = (await $`${getCliExecutablePath(cliPath)} --version`.text()).trim(); if (!version) { throw new Error("Could not determine installed Supabase CLI version"); } @@ -189,8 +261,12 @@ export async function determineInstalledVersion(cliPath: string): Promise { try { const version = resolveVersion(core.getInput("version")); - const tarball = await tc.downloadTool(getDownloadUrl(version)); - const cliPath = await tc.extractTar(tarball); + const archive = await getDownloadArchive(version); + const archivePath = await tc.downloadTool(archive.url); + const cliPath = + archive.format === "zip" + ? await tc.extractZip(archivePath) + : await tc.extractTar(archivePath); const installedVersion = await determineInstalledVersion(cliPath); core.setOutput("version", installedVersion); core.addPath(cliPath);