diff --git a/README.md b/README.md index f71b28f..e318a65 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,17 @@ steps: 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: ```yaml @@ -59,10 +70,10 @@ on Windows and macOS runners. The action supports the following inputs: -| Name | Type | Description | Default | Required | -| -------------- | ------ | -------------------------------------------------------------------------- | --------------------------------- | -------- | -| `version` | String | Supabase CLI version (or `latest`) | Root lockfile version or `latest` | false | -| `github-token` | String | GitHub token used to resolve `latest` without unauthenticated API limiting | | false | +| Name | Type | Description | Default | Required | +| -------------- | ------ | --------------------------------------------------------------------------------- | --------------------------------- | -------- | +| `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`/`beta` without unauthenticated API limiting | | false | ## Advanced Usage diff --git a/action.yml b/action.yml index 89c3955..5ab2d95 100644 --- a/action.yml +++ b/action.yml @@ -3,7 +3,7 @@ description: Setup Supabase CLI, supabase, on GitHub Actions runners author: Supabase inputs: 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 github-token: description: GitHub token used to resolve the latest Supabase CLI release without hitting unauthenticated API limits. diff --git a/src/main.test.ts b/src/main.test.ts index b830817..ae39e50 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -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 CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; 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 originalWorkspace = process.env.GITHUB_WORKSPACE; 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 { if (!mainModule) { 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 () => { process.env.GITHUB_WORKSPACE = repo; mockLatestRelease(); diff --git a/src/main.ts b/src/main.ts index 1f59bfb..1671df8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,10 @@ 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 LATEST_VERSION = "latest"; +const BETA_VERSION = "beta"; 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"; type ArchiveFormat = "apk" | "tar" | "zip"; @@ -175,7 +178,7 @@ function resolveVersion(inputVersion: string): string { ); } -async function resolveLatestVersion(): Promise { +function buildGithubHeaders(): Record { const headers: Record = { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", @@ -186,7 +189,11 @@ async function resolveLatestVersion(): Promise { headers.Authorization = `Bearer ${githubToken}`; } - const response = await fetch(GITHUB_RELEASES_API, { headers }); + return headers; +} + +async function resolveLatestVersion(): Promise { + const response = await fetch(GITHUB_RELEASES_API, { headers: buildGithubHeaders() }); if (!response.ok) { throw new Error(`Failed to resolve latest Supabase CLI release: ${response.statusText}`); } @@ -199,6 +206,31 @@ async function resolveLatestVersion(): Promise { return normalizeVersion(release.tag_name); } +async function resolveLatestBetaVersion(): Promise { + // 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( version: string, platform: NodeJS.Platform, @@ -250,8 +282,15 @@ export async function getDownloadArchive( arch = process.arch, isMuslLinux?: boolean, ): Promise { - const resolvedVersion = - version.toLowerCase() === "latest" ? await resolveLatestVersion() : normalizeVersion(version); + const channel = version.toLowerCase(); + 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( resolvedVersion, platform, @@ -328,7 +367,12 @@ export async function run(): Promise { core.setOutput("version", installedVersion); 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"); } } catch (error) {