Compare commits

..

2 Commits

Author SHA1 Message Date
Claude
688c6655dc ci(e2e): trigger e2e from supabase/cli beta releases
Adds a workflow_dispatch input and a `cli-released` repository_dispatch
listener so supabase/cli can run this e2e against a freshly published
beta build before the same bytes flow to the stable channel. The CLI
v2.99 archive layout change (CLI-1475) was only caught after the stable
release; with this in place a packaging-incompatibility regression on
develop fails this workflow and surfaces in the cli release run that
dispatched it.

When a version is supplied, the matrix narrows to that single CLI
version across all supported Postgres majors instead of the default
multi-version sweep.
2026-05-18 11:25:41 +00:00
Julien Goux
a4d563a017 fix: handle Supabase CLI v2.99 archives (#425)
## Summary

Supabase CLI v2.99.0 changed the release archive layout. The `latest`
release no longer exposes assets like `supabase_linux_amd64.tar.gz`; the
downloadable tarballs are now versioned, for example
`supabase_2.99.0_linux_amd64.tar.gz`. Windows archives also switched to
`.zip` for v2.99.0+.

This updates the setup action to:

- Resolve `latest` to the actual Supabase CLI release tag before
building the download URL.
- Keep the existing unversioned archive path for CLI versions before
v2.99.0.
- Use the new versioned archive path for v2.99.0 and later.
- Extract Windows v2.99.0+ archives with `extractZip`; keep tar
extraction for Linux and macOS.
- Continue executing the main `supabase` binary even though the archive
now also contains `supabase-go`.

This should fix the `latest` download failure reported in
supabase/cli#5257.

## Testing

- `bun test`
- `bun run ci`
- Local smoke test with `INPUT_VERSION=latest bun src/main.ts`, which
downloaded and executed `supabase_2.99.0_darwin_arm64.tar.gz`
successfully.
2026-05-18 13:09:18 +02:00
3 changed files with 204 additions and 112 deletions

View File

@@ -16,9 +16,22 @@ on:
# * is a special character in YAML so you have to quote this string # * is a special character in YAML so you have to quote this string
- cron: "30 1,9 * * *" - cron: "30 1,9 * * *"
workflow_dispatch: workflow_dispatch:
inputs:
cli_version:
description: Specific Supabase CLI version to test. When set, the matrix runs only this version across all supported Postgres majors. Leave empty to run the full version matrix.
required: false
type: string
default: ""
# Triggered from supabase/cli after a successful beta release so the
# symmetric e2e runs before a stale archive layout (or any other
# packaging change) reaches the stable channel. The dispatcher sends
# `client_payload.version` (e.g. "2.99.0") and `client_payload.channel`.
repository_dispatch:
types:
- cli-released
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs.cli_version || github.event.client_payload.version }}
cancel-in-progress: true cancel-in-progress: true
defaults: defaults:
@@ -29,24 +42,50 @@ permissions:
contents: read contents: read
jobs: jobs:
e2e: # make sure the action works on a clean machine without building plan:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
matrix: ${{ steps.compute.outputs.matrix }}
cli_version: ${{ steps.compute.outputs.cli_version }}
source: ${{ steps.compute.outputs.source }}
steps:
- id: compute
env:
DISPATCH_VERSION: ${{ github.event.inputs.cli_version }}
PAYLOAD_VERSION: ${{ github.event.client_payload.version }}
PAYLOAD_CHANNEL: ${{ github.event.client_payload.channel }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
version=""
source="default"
if [[ "$EVENT_NAME" == "repository_dispatch" && -n "$PAYLOAD_VERSION" ]]; then
version="${PAYLOAD_VERSION#v}"
source="cli-${PAYLOAD_CHANNEL:-release}"
elif [[ -n "$DISPATCH_VERSION" ]]; then
version="${DISPATCH_VERSION#v}"
source="workflow_dispatch"
fi
if [[ -n "$version" ]]; then
matrix='{"version":["'"$version"'"],"pg_major":[14,15,17]}'
else
matrix='{"version":["1.178.2","2.33.0","latest"],"pg_major":[14,15,17],"exclude":[{"version":"1.178.2","pg_major":17}]}'
fi
{
echo "cli_version=$version"
echo "source=$source"
echo "matrix=$matrix"
} >> "$GITHUB_OUTPUT"
e2e: # make sure the action works on a clean machine without building
needs: plan
runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 45
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
version:
- 1.178.2
- 2.33.0
- latest
pg_major:
- 14
- 15
- 17
exclude:
- version: 1.178.2
pg_major: 17
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:

View File

@@ -156,16 +156,16 @@ function createActionSpies(inputVersion: string, cliDir: string, expectedUrlFrag
return path.join(os.tmpdir(), "supabase-cli.tar.gz"); return path.join(os.tmpdir(), "supabase-cli.tar.gz");
}), }),
extractTar: spyOn(tc, "extractTar").mockImplementation(async () => cliDir), extractTar: spyOn(tc, "extractTar").mockImplementation(async () => cliDir),
extractZip: spyOn(tc, "extractZip").mockImplementation(async () => cliDir),
}; };
} }
function mockLatestRelease(version: string) { function mockLatestRelease(version = "v2.99.0") {
const response = new Response(null, { return spyOn(globalThis, "fetch").mockResolvedValue(
status: 302, new Response(JSON.stringify({ tag_name: version }), {
headers: { location: `https://github.com/supabase/cli/releases/tag/v${version}` }, status: 200,
}); statusText: "OK",
return spyOn(globalThis, "fetch").mockImplementation( }),
(async () => response) as unknown as typeof fetch,
); );
} }
@@ -177,10 +177,55 @@ async function getMainModule(): Promise<typeof import("./main.ts")> {
return mainModule; 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 () => { test("awaits the action entrypoint with omitted version and latest fallback", async () => {
process.env.GITHUB_WORKSPACE = repo; process.env.GITHUB_WORKSPACE = repo;
mockLatestRelease();
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) => {
@@ -196,11 +241,12 @@ 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("/download/v2.84.2/supabase_"); expect(url).toContain("/download/v2.99.0/supabase_2.99.0_");
startDownload(); startDownload();
return downloadFinished; return downloadFinished;
}), }),
extractTar: spyOn(tc, "extractTar").mockImplementation(async () => cliDir), extractTar: spyOn(tc, "extractTar").mockImplementation(async () => cliDir),
extractZip: spyOn(tc, "extractZip").mockImplementation(async () => cliDir),
}; };
const originalArgv1 = process.argv[1]; const originalArgv1 = process.argv[1];
process.argv[1] = defaultEntrypoint; process.argv[1] = defaultEntrypoint;
@@ -294,9 +340,9 @@ test("falls back to latest when version is omitted and no supported root lockfil
process.env.GITHUB_WORKSPACE = createWorkspace({ process.env.GITHUB_WORKSPACE = createWorkspace({
"README.md": "# app\n", "README.md": "# app\n",
}); });
mockLatestRelease();
const cliDir = createFakeCli("supabase 2.84.2"); const cliDir = createFakeCli("supabase 2.84.2");
mockLatestRelease("2.84.2"); const spies = createActionSpies("", cliDir, "/download/v2.99.0/supabase_2.99.0_");
const spies = createActionSpies("", cliDir, "/download/v2.84.2/supabase_");
const { run } = await getMainModule(); const { run } = await getMainModule();
await run(); await run();
@@ -308,9 +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 () => { 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;
mockLatestRelease();
const cliDir = createFakeCli("supabase 2.84.2"); const cliDir = createFakeCli("supabase 2.84.2");
mockLatestRelease("2.84.2"); const spies = createActionSpies("", cliDir, "/download/v2.99.0/supabase_2.99.0_");
const spies = createActionSpies("", cliDir, "/download/v2.84.2/supabase_");
const { run } = await getMainModule(); const { run } = await getMainModule();
await run(); await run();
@@ -373,9 +419,9 @@ 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;
mockLatestRelease();
const cliDir = createFakeCli("supabase 2.84.2"); const cliDir = createFakeCli("supabase 2.84.2");
mockLatestRelease("2.84.2"); const spies = createActionSpies("", cliDir, "/download/v2.99.0/supabase_2.99.0_");
const spies = createActionSpies("", cliDir, "/download/v2.84.2/supabase_");
const { run } = await getMainModule(); const { run } = await getMainModule();
await run(); await run();
@@ -389,9 +435,9 @@ test("falls back to latest when a pnpm dependency entry has no concrete version"
process.env.GITHUB_WORKSPACE = createWorkspace({ process.env.GITHUB_WORKSPACE = createWorkspace({
"pnpm-lock.yaml": createPnpmLock("2.49.0", { includeVersion: false }), "pnpm-lock.yaml": createPnpmLock("2.49.0", { includeVersion: false }),
}); });
mockLatestRelease();
const cliDir = createFakeCli("supabase 2.84.2"); const cliDir = createFakeCli("supabase 2.84.2");
mockLatestRelease("2.84.2"); const spies = createActionSpies("", cliDir, "/download/v2.99.0/supabase_2.99.0_");
const spies = createActionSpies("", cliDir, "/download/v2.84.2/supabase_");
const { run } = await getMainModule(); const { run } = await getMainModule();
await run(); await run();
@@ -416,52 +462,6 @@ 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,12 +7,16 @@ 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 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"; 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 = { type BunLock = {
workspaces?: { workspaces?: {
@@ -61,6 +65,10 @@ function extractConcreteVersion(raw: string | undefined): string | null {
return match?.[0] ?? null; return match?.[0] ?? null;
} }
function normalizeVersion(version: string): string {
return version.replace(/^v/i, "");
}
function readWorkspaceLockfile(workspaceRoot: string, filename: string): string | null { function readWorkspaceLockfile(workspaceRoot: string, filename: string): string | null {
const filePath = path.join(workspaceRoot, filename); const filePath = path.join(workspaceRoot, filename);
@@ -166,40 +174,83 @@ function resolveVersion(inputVersion: string): string {
); );
} }
export async function resolveLatestVersion(): Promise<string> { async function resolveLatestVersion(): Promise<string> {
const response = await fetch(LATEST_RELEASE_URL, { method: "HEAD", redirect: "manual" }); const response = await fetch(GITHUB_RELEASES_API);
const location = response.headers.get("location"); if (!response.ok) {
const version = extractConcreteVersion(location ?? undefined); throw new Error(`Failed to resolve latest Supabase CLI release: ${response.statusText}`);
if (!version) {
throw new Error(
`Could not resolve latest Supabase CLI version (status ${response.status}, location ${location ?? "<none>"})`,
);
} }
return version; 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");
} }
export function getDownloadUrl(version: string): string { return normalizeVersion(release.tag_name);
const platform = getArchivePlatform(process.platform);
const arch = getArchiveArch(process.arch);
const versionedFilename = `supabase_${version}_${platform}_${arch}.tar.gz`;
const unversionedFilename = `supabase_${platform}_${arch}.tar.gz`;
// 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}`;
} }
return `https://github.com/supabase/cli/releases/download/v${version}/${unversionedFilename}`; 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 `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<DownloadArchive> {
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 path.join(cliPath, "supabase");
} }
export async function determineInstalledVersion(cliPath: string): Promise<string> { export async function determineInstalledVersion(cliPath: string): Promise<string> {
const version = (await $`${path.join(cliPath, "supabase")} --version`.text()).trim(); const version = (await $`${getCliExecutablePath(cliPath)} --version`.text()).trim();
if (!version) { if (!version) {
throw new Error("Could not determine installed Supabase CLI version"); throw new Error("Could not determine installed Supabase CLI version");
} }
@@ -209,16 +260,18 @@ export async function determineInstalledVersion(cliPath: string): Promise<string
export async function run(): Promise<void> { export async function run(): Promise<void> {
try { try {
const requestedVersion = resolveVersion(core.getInput("version")); const version = resolveVersion(core.getInput("version"));
const version = const archive = await getDownloadArchive(version);
requestedVersion.toLowerCase() === "latest" ? await resolveLatestVersion() : requestedVersion; const archivePath = await tc.downloadTool(archive.url);
const tarball = await tc.downloadTool(getDownloadUrl(version)); const cliPath =
const cliPath = await tc.extractTar(tarball); archive.format === "zip"
? await tc.extractZip(archivePath)
: await tc.extractTar(archivePath);
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 (semver.order(version, REGISTRY_VERSION) >= 0) { if (version.toLowerCase() === "latest" || semver.order(version, REGISTRY_VERSION) >= 0) {
core.exportVariable(CLI_CONFIG_REGISTRY, "ghcr.io"); core.exportVariable(CLI_CONFIG_REGISTRY, "ghcr.io");
} }
} catch (error) { } catch (error) {