diff --git a/src/main.test.ts b/src/main.test.ts index 022dd18..37e21d5 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -159,6 +159,16 @@ function createActionSpies(inputVersion: string, cliDir: string, expectedUrlFrag }; } +function mockLatestRelease(version: string) { + const response = new Response(null, { + status: 302, + headers: { location: `https://github.com/supabase/cli/releases/tag/v${version}` }, + }); + return spyOn(globalThis, "fetch").mockImplementation( + (async () => response) as unknown as typeof fetch, + ); +} + async function getMainModule(): Promise { if (!mainModule) { mainModule = await import("./main.ts"); @@ -170,6 +180,7 @@ async function getMainModule(): Promise { test("awaits the action entrypoint with omitted version and latest fallback", async () => { process.env.GITHUB_WORKSPACE = repo; const cliDir = createFakeCli("supabase 2.84.2"); + mockLatestRelease("2.84.2"); let startDownload!: () => void; let finishDownload!: () => void; const downloadStarted = new Promise((resolve) => { @@ -185,7 +196,7 @@ 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.84.2/supabase_"); startDownload(); return downloadFinished; }), @@ -284,7 +295,8 @@ test("falls back to latest when version is omitted and no supported root lockfil "README.md": "# app\n", }); const cliDir = createFakeCli("supabase 2.84.2"); - const spies = createActionSpies("", cliDir, "/latest/download/"); + mockLatestRelease("2.84.2"); + const spies = createActionSpies("", cliDir, "/download/v2.84.2/supabase_"); const { run } = await getMainModule(); await run(); @@ -297,7 +309,8 @@ 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; const cliDir = createFakeCli("supabase 2.84.2"); - const spies = createActionSpies("", cliDir, "/latest/download/"); + mockLatestRelease("2.84.2"); + const spies = createActionSpies("", cliDir, "/download/v2.84.2/supabase_"); const { run } = await getMainModule(); await run(); @@ -361,7 +374,8 @@ 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; const cliDir = createFakeCli("supabase 2.84.2"); - const spies = createActionSpies("", cliDir, "/latest/download/"); + mockLatestRelease("2.84.2"); + const spies = createActionSpies("", cliDir, "/download/v2.84.2/supabase_"); const { run } = await getMainModule(); await run(); @@ -376,7 +390,8 @@ test("falls back to latest when a pnpm dependency entry has no concrete version" "pnpm-lock.yaml": createPnpmLock("2.49.0", { includeVersion: false }), }); const cliDir = createFakeCli("supabase 2.84.2"); - const spies = createActionSpies("", cliDir, "/latest/download/"); + mockLatestRelease("2.84.2"); + const spies = createActionSpies("", cliDir, "/download/v2.84.2/supabase_"); const { run } = await getMainModule(); await run(); @@ -401,6 +416,52 @@ test("explicit version overrides detected root lockfiles", async () => { expect(spies.setFailed).not.toHaveBeenCalled(); }); +test("downloads the version-prefixed tarball for releases >= 2.99.0", async () => { + // Regression for supabase/cli#5257: from v2.99.0 onward the CLI only + // publishes supabase___.tar.gz; the unversioned + // alias is gone. + delete process.env.GITHUB_WORKSPACE; + const cliDir = createFakeCli("supabase 2.99.0"); + mockLatestRelease("2.99.0"); + const spies = createActionSpies("", cliDir, "/download/v2.99.0/supabase_2.99.0_"); + const { run } = await getMainModule(); + + await run(); + + expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.99.0"); + expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io"); + expect(spies.setFailed).not.toHaveBeenCalled(); +}); + +test("downloads the version-prefixed tarball when explicit version >= 2.99.0 is requested", async () => { + delete process.env.GITHUB_WORKSPACE; + const cliDir = createFakeCli("supabase 2.99.0"); + const fetchSpy = spyOn(globalThis, "fetch"); + const spies = createActionSpies("2.99.0", cliDir, "/download/v2.99.0/supabase_2.99.0_"); + const { run } = await getMainModule(); + + await run(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.99.0"); + expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io"); + expect(spies.setFailed).not.toHaveBeenCalled(); +}); + +test("fails when the latest release redirect does not include a version tag", async () => { + delete process.env.GITHUB_WORKSPACE; + const cliDir = createFakeCli("supabase 2.99.0"); + const response = new Response(null, { status: 302, headers: { location: "/releases" } }); + spyOn(globalThis, "fetch").mockImplementation((async () => response) as unknown as typeof fetch); + const spies = createActionSpies("", cliDir, "/download/"); + const { run } = await getMainModule(); + + await run(); + + expect(spies.downloadTool).not.toHaveBeenCalled(); + expect(spies.setFailed).toHaveBeenCalled(); +}); + test("fails when the installed CLI does not report a version", async () => { process.env.GITHUB_WORKSPACE = createWorkspace({ "package-lock.json": createPackageLock("2.46.0"), diff --git a/src/main.ts b/src/main.ts index 77655af..bea67b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,12 @@ import { fileURLToPath } from "node:url"; export const CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; const REGISTRY_VERSION = "1.28.0"; +// Starting with this release, the CLI publishes only version-prefixed tarballs +// (e.g. supabase_2.99.0_linux_amd64.tar.gz); the unversioned aliases that used +// to live alongside them are no longer uploaded. See supabase/cli#5257. +const VERSIONED_ARCHIVE_VERSION = "2.99.0"; const DEFAULT_VERSION = "latest"; +const LATEST_RELEASE_URL = "https://github.com/supabase/cli/releases/latest"; type BunLock = { workspaces?: { @@ -161,20 +166,36 @@ function resolveVersion(inputVersion: string): string { ); } +export async function resolveLatestVersion(): Promise { + const response = await fetch(LATEST_RELEASE_URL, { method: "HEAD", redirect: "manual" }); + const location = response.headers.get("location"); + const version = extractConcreteVersion(location ?? undefined); + + if (!version) { + throw new Error( + `Could not resolve latest Supabase CLI version (status ${response.status}, location ${location ?? ""})`, + ); + } + + return version; +} + export function getDownloadUrl(version: string): string { const platform = getArchivePlatform(process.platform); const arch = getArchiveArch(process.arch); - const filename = `supabase_${platform}_${arch}.tar.gz`; + const versionedFilename = `supabase_${version}_${platform}_${arch}.tar.gz`; + const unversionedFilename = `supabase_${platform}_${arch}.tar.gz`; - if (version.toLowerCase() === "latest") { - return `https://github.com/supabase/cli/releases/latest/download/${filename}`; + // v2.99.0+ and the earliest releases (pre-v1.28.0) only publish version-prefixed + // tarballs; the intermediate releases publish unversioned aliases. + if ( + semver.order(version, REGISTRY_VERSION) === -1 || + semver.order(version, VERSIONED_ARCHIVE_VERSION) >= 0 + ) { + return `https://github.com/supabase/cli/releases/download/v${version}/${versionedFilename}`; } - if (semver.order(version, REGISTRY_VERSION) === -1) { - return `https://github.com/supabase/cli/releases/download/v${version}/supabase_${version}_${platform}_${arch}.tar.gz`; - } - - return `https://github.com/supabase/cli/releases/download/v${version}/${filename}`; + return `https://github.com/supabase/cli/releases/download/v${version}/${unversionedFilename}`; } export async function determineInstalledVersion(cliPath: string): Promise { @@ -188,14 +209,16 @@ export async function determineInstalledVersion(cliPath: string): Promise { try { - const version = resolveVersion(core.getInput("version")); + const requestedVersion = resolveVersion(core.getInput("version")); + const version = + requestedVersion.toLowerCase() === "latest" ? await resolveLatestVersion() : requestedVersion; const tarball = await tc.downloadTool(getDownloadUrl(version)); const cliPath = await tc.extractTar(tarball); const installedVersion = await determineInstalledVersion(cliPath); core.setOutput("version", installedVersion); core.addPath(cliPath); - if (version.toLowerCase() === "latest" || semver.order(version, REGISTRY_VERSION) >= 0) { + if (semver.order(version, REGISTRY_VERSION) >= 0) { core.exportVariable(CLI_CONFIG_REGISTRY, "ghcr.io"); } } catch (error) {