Compare commits

..

1 Commits

Author SHA1 Message Date
Claude
3dacd93e8f feat: support installing the latest beta release via version: beta
The `latest` channel resolves through the GitHub `/releases/latest`
endpoint, which never returns prereleases, so there was no way to track
the CLI beta channel — any other value was treated as an exact version.

Add a `beta` channel that lists releases and installs the most recent
beta prerelease. This lets consumers surface (and fix) breakages early.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKXLPMGXsnWUSU3i16Lfv
2026-06-26 14:55:02 +02:00
4 changed files with 141 additions and 15 deletions

View File

@@ -31,11 +31,6 @@ If `version` is omitted, the action checks the repository root for `bun.lock`,
`pnpm-lock.yaml`, or `package-lock.json` and uses the declared `supabase` `pnpm-lock.yaml`, or `package-lock.json` and uses the declared `supabase`
version. If no supported lockfile is present, it falls back to `latest`. version. If no supported lockfile is present, it falls back to `latest`.
When the action resolves `latest`, it queries the GitHub releases API. In CI,
pass `github-token: ${{ github.token }}` to avoid unauthenticated API rate
limits. Pinning `version` to a specific Supabase CLI release avoids that lookup
entirely.
A specific version of the `supabase` CLI can be installed: A specific version of the `supabase` CLI can be installed:
```yaml ```yaml
@@ -45,6 +40,17 @@ steps:
version: 2.84.2 version: 2.84.2
``` ```
To always track the latest beta prerelease, set `version` to `beta`. This is
useful for surfacing (and fixing) breakages early:
```yaml
steps:
- uses: supabase/setup-cli@v2
with:
version: beta
github-token: ${{ github.token }}
```
Run `supabase db start` to execute all migrations on a fresh database: Run `supabase db start` to execute all migrations on a fresh database:
```yaml ```yaml
@@ -64,10 +70,10 @@ on Windows and macOS runners.
The action supports the following inputs: The action supports the following inputs:
| Name | Type | Description | Default | Required | | Name | Type | Description | Default | Required |
| -------------- | ------ | -------------------------------------------------------------------------- | --------------------------------- | -------- | | -------------- | ------ | --------------------------------------------------------------------------------- | --------------------------------- | -------- |
| `version` | String | Supabase CLI version (or `latest`) | Root lockfile version or `latest` | false | | `version` | String | Supabase CLI version (or `latest`, or `beta` for the latest beta release) | Root lockfile version or `latest` | false |
| `github-token` | String | GitHub token used to resolve `latest` without unauthenticated API limiting | | false | | `github-token` | String | GitHub token used to resolve `latest`/`beta` without unauthenticated API limiting | | false |
## Advanced Usage ## Advanced Usage

View File

@@ -3,7 +3,7 @@ description: Setup Supabase CLI, supabase, on GitHub Actions runners
author: Supabase author: Supabase
inputs: inputs:
version: version:
description: Version of Supabase CLI to install. If omitted, detect from the root lockfile and otherwise use latest. description: Version of Supabase CLI to install. Accepts a specific version, "latest", or "beta" (latest beta prerelease). If omitted, detect from the root lockfile and otherwise use latest.
required: false required: false
github-token: github-token:
description: GitHub token used to resolve the latest Supabase CLI release without hitting unauthenticated API limits. description: GitHub token used to resolve the latest Supabase CLI release without hitting unauthenticated API limits.

View File

@@ -11,6 +11,7 @@ const repo = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const defaultEntrypoint = fileURLToPath(new URL("./main.ts", import.meta.url)); const defaultEntrypoint = fileURLToPath(new URL("./main.ts", import.meta.url));
const CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; const CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY";
const GITHUB_RELEASES_API = "https://api.github.com/repos/supabase/cli/releases/latest"; const GITHUB_RELEASES_API = "https://api.github.com/repos/supabase/cli/releases/latest";
const GITHUB_RELEASES_LIST_API = "https://api.github.com/repos/supabase/cli/releases";
const GITHUB_TOKEN_ENV = "SUPABASE_CLI_GITHUB_TOKEN"; const GITHUB_TOKEN_ENV = "SUPABASE_CLI_GITHUB_TOKEN";
const originalWorkspace = process.env.GITHUB_WORKSPACE; const originalWorkspace = process.env.GITHUB_WORKSPACE;
const originalGithubToken = process.env[GITHUB_TOKEN_ENV]; const originalGithubToken = process.env[GITHUB_TOKEN_ENV];
@@ -177,6 +178,21 @@ function mockLatestRelease(version = "v2.99.0") {
); );
} }
function mockBetaReleases(
releases: Array<{ tag_name: string; prerelease: boolean }> = [
{ tag_name: "v2.100.0-beta.2", prerelease: true },
{ tag_name: "v2.100.0-beta.1", prerelease: true },
{ tag_name: "v2.99.0", prerelease: false },
],
) {
return spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify(releases), {
status: 200,
statusText: "OK",
}),
);
}
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");
@@ -276,6 +292,66 @@ test("authenticates latest release lookup when a GitHub token is provided", asyn
}); });
}); });
test("resolves the latest beta prerelease for the beta channel", async () => {
mockBetaReleases();
const { getDownloadArchive } = await getMainModule();
const archive = await getDownloadArchive("beta", "darwin", "arm64");
expect(archive).toEqual({
url: "https://github.com/supabase/cli/releases/download/v2.100.0-beta.2/supabase_2.100.0-beta.2_darwin_arm64.tar.gz",
format: "tar",
});
});
test("treats the beta channel case-insensitively and skips stable releases", async () => {
mockBetaReleases([
{ tag_name: "v2.99.0", prerelease: false },
{ tag_name: "v2.100.0-beta.5", prerelease: true },
]);
const { getDownloadArchive } = await getMainModule();
const archive = await getDownloadArchive("BETA", "linux", "x64");
expect(archive.url).toContain("/download/v2.100.0-beta.5/supabase_2.100.0-beta.5_linux_amd64");
});
test("fails when no beta prerelease is available", async () => {
mockBetaReleases([{ tag_name: "v2.99.0", prerelease: false }]);
const { getDownloadArchive } = await getMainModule();
expect(getDownloadArchive("beta", "linux", "x64")).rejects.toThrow(
"Failed to resolve latest Supabase CLI beta release: no beta release found",
);
});
test("queries the releases list when resolving the beta channel", async () => {
const fetch = mockBetaReleases();
const { getDownloadArchive } = await getMainModule();
await getDownloadArchive("beta", "darwin", "arm64");
expect(fetch).toHaveBeenCalledWith(GITHUB_RELEASES_LIST_API, {
headers: expect.objectContaining({
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}),
});
});
test("exports the internal registry when installing the beta channel", async () => {
mockBetaReleases();
const cliDir = createFakeCli("supabase 2.100.0-beta.2");
const spies = createActionSpies("beta", cliDir, "/download/v2.100.0-beta.2/supabase_");
const { run } = await getMainModule();
await run();
expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.100.0-beta.2");
expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
});
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(); mockLatestRelease();

View File

@@ -9,7 +9,10 @@ export const CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY";
const REGISTRY_VERSION = "1.28.0"; const REGISTRY_VERSION = "1.28.0";
const VERSIONED_ARCHIVE_VERSION = "2.99.0"; const VERSIONED_ARCHIVE_VERSION = "2.99.0";
const DEFAULT_VERSION = "latest"; const DEFAULT_VERSION = "latest";
const LATEST_VERSION = "latest";
const BETA_VERSION = "beta";
const GITHUB_RELEASES_API = "https://api.github.com/repos/supabase/cli/releases/latest"; const GITHUB_RELEASES_API = "https://api.github.com/repos/supabase/cli/releases/latest";
const GITHUB_RELEASES_LIST_API = "https://api.github.com/repos/supabase/cli/releases";
const GITHUB_TOKEN_ENV = "SUPABASE_CLI_GITHUB_TOKEN"; const GITHUB_TOKEN_ENV = "SUPABASE_CLI_GITHUB_TOKEN";
type ArchiveFormat = "apk" | "tar" | "zip"; type ArchiveFormat = "apk" | "tar" | "zip";
@@ -175,7 +178,7 @@ function resolveVersion(inputVersion: string): string {
); );
} }
async function resolveLatestVersion(): Promise<string> { function buildGithubHeaders(): Record<string, string> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28", "X-GitHub-Api-Version": "2022-11-28",
@@ -186,7 +189,11 @@ async function resolveLatestVersion(): Promise<string> {
headers.Authorization = `Bearer ${githubToken}`; headers.Authorization = `Bearer ${githubToken}`;
} }
const response = await fetch(GITHUB_RELEASES_API, { headers }); return headers;
}
async function resolveLatestVersion(): Promise<string> {
const response = await fetch(GITHUB_RELEASES_API, { headers: buildGithubHeaders() });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to resolve latest Supabase CLI release: ${response.statusText}`); throw new Error(`Failed to resolve latest Supabase CLI release: ${response.statusText}`);
} }
@@ -199,6 +206,31 @@ async function resolveLatestVersion(): Promise<string> {
return normalizeVersion(release.tag_name); return normalizeVersion(release.tag_name);
} }
async function resolveLatestBetaVersion(): Promise<string> {
// The /releases/latest endpoint never returns prereleases, so list all
// releases (sorted newest-first) and pick the most recent beta prerelease.
const response = await fetch(GITHUB_RELEASES_LIST_API, { headers: buildGithubHeaders() });
if (!response.ok) {
throw new Error(`Failed to resolve latest Supabase CLI beta release: ${response.statusText}`);
}
const releases = (await response.json()) as Array<{ tag_name?: unknown; prerelease?: unknown }>;
const beta = Array.isArray(releases)
? releases.find(
(release) =>
release.prerelease === true &&
typeof release.tag_name === "string" &&
/-beta/i.test(release.tag_name),
)
: undefined;
if (!beta || typeof beta.tag_name !== "string") {
throw new Error("Failed to resolve latest Supabase CLI beta release: no beta release found");
}
return normalizeVersion(beta.tag_name);
}
function getArchiveFormat( function getArchiveFormat(
version: string, version: string,
platform: NodeJS.Platform, platform: NodeJS.Platform,
@@ -250,8 +282,15 @@ export async function getDownloadArchive(
arch = process.arch, arch = process.arch,
isMuslLinux?: boolean, isMuslLinux?: boolean,
): Promise<DownloadArchive> { ): Promise<DownloadArchive> {
const resolvedVersion = const channel = version.toLowerCase();
version.toLowerCase() === "latest" ? await resolveLatestVersion() : normalizeVersion(version); let resolvedVersion: string;
if (channel === LATEST_VERSION) {
resolvedVersion = await resolveLatestVersion();
} else if (channel === BETA_VERSION) {
resolvedVersion = await resolveLatestBetaVersion();
} else {
resolvedVersion = normalizeVersion(version);
}
const format = getArchiveFormat( const format = getArchiveFormat(
resolvedVersion, resolvedVersion,
platform, platform,
@@ -328,7 +367,12 @@ export async function run(): Promise<void> {
core.setOutput("version", installedVersion); core.setOutput("version", installedVersion);
core.addPath(cliPath); core.addPath(cliPath);
if (version.toLowerCase() === "latest" || semver.order(version, REGISTRY_VERSION) >= 0) { const channel = version.toLowerCase();
if (
channel === LATEST_VERSION ||
channel === BETA_VERSION ||
semver.order(version, REGISTRY_VERSION) >= 0
) {
core.exportVariable(CLI_CONFIG_REGISTRY, "ghcr.io"); core.exportVariable(CLI_CONFIG_REGISTRY, "ghcr.io");
} }
} catch (error) { } catch (error) {