fix: resolve latest tag and use versioned tarball for v2.99.0+

From v2.99.0, the CLI release publishes only version-prefixed tarballs
(supabase_<version>_<platform>_<arch>.tar.gz); the unversioned aliases
this action used to hit at releases/latest/download/ are gone, so all
'latest' (and lockfile-less) installs were 404-ing.

Resolve 'latest' to a concrete version by following the
github.com/supabase/cli/releases/latest redirect, then pick the
versioned or unversioned filename based on the resolved version.

Fixes supabase/cli#5257.
This commit is contained in:
Claude
2026-05-18 11:18:16 +00:00
parent 0abc813ee3
commit b235d82dcd
2 changed files with 99 additions and 15 deletions

View File

@@ -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<typeof import("./main.ts")> { async function getMainModule(): Promise<typeof import("./main.ts")> {
if (!mainModule) { if (!mainModule) {
mainModule = await import("./main.ts"); mainModule = await import("./main.ts");
@@ -170,6 +180,7 @@ async function getMainModule(): Promise<typeof import("./main.ts")> {
test("awaits the action entrypoint with omitted version and latest fallback", async () => { test("awaits the action entrypoint with omitted version and latest fallback", async () => {
process.env.GITHUB_WORKSPACE = repo; process.env.GITHUB_WORKSPACE = repo;
const cliDir = createFakeCli("supabase 2.84.2"); const cliDir = createFakeCli("supabase 2.84.2");
mockLatestRelease("2.84.2");
let startDownload!: () => void; let startDownload!: () => void;
let finishDownload!: () => void; let finishDownload!: () => void;
const downloadStarted = new Promise<void>((resolve) => { const downloadStarted = new Promise<void>((resolve) => {
@@ -185,7 +196,7 @@ test("awaits the action entrypoint with omitted version and latest fallback", as
exportVariable: spyOn(core, "exportVariable").mockImplementation(() => {}), exportVariable: spyOn(core, "exportVariable").mockImplementation(() => {}),
setFailed: spyOn(core, "setFailed").mockImplementation(() => {}), setFailed: spyOn(core, "setFailed").mockImplementation(() => {}),
downloadTool: spyOn(tc, "downloadTool").mockImplementation(async (url: string) => { downloadTool: spyOn(tc, "downloadTool").mockImplementation(async (url: string) => {
expect(url).toContain("/latest/download/"); expect(url).toContain("/download/v2.84.2/supabase_");
startDownload(); startDownload();
return downloadFinished; return downloadFinished;
}), }),
@@ -284,7 +295,8 @@ test("falls back to latest when version is omitted and no supported root lockfil
"README.md": "# app\n", "README.md": "# app\n",
}); });
const cliDir = createFakeCli("supabase 2.84.2"); 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(); const { run } = await getMainModule();
await run(); 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 () => { test("falls back to latest when version is omitted and no workspace is available", async () => {
delete process.env.GITHUB_WORKSPACE; delete process.env.GITHUB_WORKSPACE;
const cliDir = createFakeCli("supabase 2.84.2"); 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(); const { run } = await getMainModule();
await run(); 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 }); mkdirSync(path.join(workspace, "bun.lock"), { recursive: true });
process.env.GITHUB_WORKSPACE = workspace; process.env.GITHUB_WORKSPACE = workspace;
const cliDir = createFakeCli("supabase 2.84.2"); 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(); const { run } = await getMainModule();
await run(); 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 }), "pnpm-lock.yaml": createPnpmLock("2.49.0", { includeVersion: false }),
}); });
const cliDir = createFakeCli("supabase 2.84.2"); 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(); const { run } = await getMainModule();
await run(); await run();
@@ -401,6 +416,52 @@ test("explicit version overrides detected root lockfiles", async () => {
expect(spies.setFailed).not.toHaveBeenCalled(); 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_<version>_<platform>_<arch>.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 () => { test("fails when the installed CLI does not report a version", async () => {
process.env.GITHUB_WORKSPACE = createWorkspace({ process.env.GITHUB_WORKSPACE = createWorkspace({
"package-lock.json": createPackageLock("2.46.0"), "package-lock.json": createPackageLock("2.46.0"),

View File

@@ -7,7 +7,12 @@ import { fileURLToPath } from "node:url";
export const CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; export const CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY";
const REGISTRY_VERSION = "1.28.0"; 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 DEFAULT_VERSION = "latest";
const LATEST_RELEASE_URL = "https://github.com/supabase/cli/releases/latest";
type BunLock = { type BunLock = {
workspaces?: { workspaces?: {
@@ -161,20 +166,36 @@ function resolveVersion(inputVersion: string): string {
); );
} }
export async function resolveLatestVersion(): Promise<string> {
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 ?? "<none>"})`,
);
}
return version;
}
export function getDownloadUrl(version: string): string { export function getDownloadUrl(version: string): string {
const platform = getArchivePlatform(process.platform); const platform = getArchivePlatform(process.platform);
const arch = getArchiveArch(process.arch); 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") { // v2.99.0+ and the earliest releases (pre-v1.28.0) only publish version-prefixed
return `https://github.com/supabase/cli/releases/latest/download/${filename}`; // 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}/${unversionedFilename}`;
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}`;
} }
export async function determineInstalledVersion(cliPath: string): Promise<string> { export async function determineInstalledVersion(cliPath: string): Promise<string> {
@@ -188,14 +209,16 @@ export async function determineInstalledVersion(cliPath: string): Promise<string
export async function run(): Promise<void> { export async function run(): Promise<void> {
try { 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 tarball = await tc.downloadTool(getDownloadUrl(version));
const cliPath = await tc.extractTar(tarball); const cliPath = await tc.extractTar(tarball);
const installedVersion = await determineInstalledVersion(cliPath); const installedVersion = await determineInstalledVersion(cliPath);
core.setOutput("version", installedVersion); core.setOutput("version", installedVersion);
core.addPath(cliPath); 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"); core.exportVariable(CLI_CONFIG_REGISTRY, "ghcr.io");
} }
} catch (error) { } catch (error) {