Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,47 @@ jobs:
version: ${{ matrix.version }}
- run: supabase -h

github-release-fallback:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
# 2.33.0 predates versioned archive names; 2.122.0 uses them.
version: [2.33.0, 2.122.0]
env:
# An npm executable that does not exist makes every npm call fail.
SUPABASE_SETUP_CLI_NPM: setup-cli-npm-unavailable
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- id: setup-cli
uses: ./
with:
version: ${{ matrix.version }}
- env:
INSTALL_SOURCE: ${{ steps.setup-cli.outputs.install-source }}
INSTALLED_VERSION: ${{ steps.setup-cli.outputs.version }}
EXPECTED_VERSION: ${{ matrix.version }}
run: |
[[ "$INSTALL_SOURCE" == "github-release" ]]
[[ "$INSTALLED_VERSION" == "$EXPECTED_VERSION" ]]
supabase -h

ci:
if: ${{ always() && github.event_name == 'pull_request' }}
name: CI
runs-on: ubuntu-latest
needs: [validate, test]
needs: [validate, test, github-release-fallback]
timeout-minutes: 5
steps:
- run: |
validate_result="${{ needs.validate.result }}"
test_result="${{ needs.test.result }}"
fallback_result="${{ needs.github-release-fallback.result }}"
[[ "$validate_result" == "success" || "$validate_result" == "skipped" ]]
[[ "$test_result" == "success" || "$test_result" == "skipped" ]]
[[ "$fallback_result" == "success" || "$fallback_result" == "skipped" ]]
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ and requires Node.js 20 or newer. On non-musl runners without Node.js or npm, it
provisions them internally. Runners must be able to reach the npm registry to
install the CLI package.

If npm cannot install a working CLI for a resolved version, for example while a
fresh release is still propagating through npm, the action logs a warning and
installs the same version from its
[GitHub release](https://github.com/supabase/cli/releases) instead, after
verifying the download against the release's checksums. This needs a fixed
version, either requested or resolved by npm, and is skipped when the lockfile
integrity check fails.

When running in Alpine or other Linux musl containers, the action uses Alpine's
`nodejs` and `npm` packages instead of `actions/setup-node`, because the
standard Node.js runner binaries target glibc. Root containers can have missing
Expand Down Expand Up @@ -101,6 +109,13 @@ The action supports the following inputs:
| --------- | ------ | -------------------------------------------------------------------------- | --------------------------------- | -------- |
| `version` | String | Supabase CLI `latest`, `beta`, `next`, `v<N>.stable`, or fixed npm version | Root lockfile version or `latest` | false |

## Outputs

| Name | Description |
| ---------------- | -------------------------------------------------------------- |
| `version` | Version reported by the installed `supabase --version` |
| `install-source` | `npm`, or `github-release` when the action fell back to GitHub |

## Advanced Usage

Check generated TypeScript types are up-to-date with Postgres schema:
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ outputs:
version:
description: Version of installed Supabase CLI
value: ${{ steps.setup-cli.outputs.version }}
install-source:
description: Where the CLI was installed from, either npm or github-release when npm could not install a working CLI.
value: ${{ steps.setup-cli.outputs.install-source }}
runs:
using: composite
steps:
Expand Down Expand Up @@ -178,4 +181,5 @@ runs:
working-directory: ${{ github.action_path }}
env:
INPUT_VERSION: ${{ inputs.version }}
SUPABASE_SETUP_CLI_MUSL: ${{ steps.bun-download.outputs.is-musl }}
run: bun src/main.ts
4 changes: 3 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ If `version` is omitted, the action checks the repository root for `bun.lock`,

The action uses an existing Node.js/npm runtime when one is already available,
and requires Node.js 20 or newer. On non-musl runners without Node.js or npm, it
provisions them internally. Runners only need network access to the npm registry.
provisions them internally. Runners need network access to the npm registry,
and to GitHub when npm cannot install a working CLI and the action falls back to
the version's GitHub release.

## Quick Start

Expand Down
207 changes: 202 additions & 5 deletions src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { afterEach, expect, mock, spyOn, test } from "bun:test";
import * as core from "@actions/core";

const CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY";
const RELEASES_URL = "https://github.com/supabase/cli/releases/download";
const RELEASE_PLATFORM = process.platform === "win32" ? "windows" : process.platform;
const RELEASE_ARCH = process.arch === "x64" ? "amd64" : process.arch;
const originalPath = process.env.PATH;
const originalNpmUserconfig = process.env.NPM_CONFIG_USERCONFIG;
const originalRunnerTemp = process.env.RUNNER_TEMP;
Expand Down Expand Up @@ -46,6 +49,7 @@ afterEach(() => {
delete process.env.FAKE_NPM_PACKAGE_VERSION;
delete process.env.FAKE_NPM_PREFIX_CONFIG_LOG;
delete process.env.FAKE_NPM_SCRIPTS;
delete process.env.FAKE_NPM_VIEW_ERROR;
delete process.env.SETUP_CLI_TEST_WORKSPACE;
delete process.env.SUPABASE_SETUP_CLI_NPM;

Expand Down Expand Up @@ -192,6 +196,11 @@ function recordConfig(configPath) {
if (args[0] === "view") {
recordConfig(path.join(process.cwd(), ".npmrc"));

if (process.env.FAKE_NPM_VIEW_ERROR) {
console.error(process.env.FAKE_NPM_VIEW_ERROR);
process.exit(1);
}

const bin =
process.env.FAKE_NPM_BIN === "missing"
? undefined
Expand Down Expand Up @@ -345,7 +354,60 @@ function createActionSpies(inputVersion: string) {
getInput: spyOn(core, "getInput").mockReturnValue(inputVersion),
setFailed: spyOn(core, "setFailed").mockImplementation(() => {}),
setOutput: spyOn(core, "setOutput").mockImplementation(() => {}),
warning: spyOn(core, "warning").mockImplementation(() => {}),
};
}

function releaseUrl(version: string, filename: string): string {
return `${RELEASES_URL}/v${version}/${filename}`;
}

function createReleaseArchive(cliVersion: string, binDir = ""): Uint8Array {
const root = createTempDir("setup-cli-release-");
const contents = path.join(root, "contents");
const binPath = path.join(contents, binDir);
mkdirSync(binPath, { recursive: true });

if (process.platform === "win32") {
writeFileSync(path.join(binPath, "supabase.cmd"), `@echo off\r\necho ${cliVersion}\r\n`);
} else {
writeFileSync(
path.join(binPath, "supabase"),
`#!/usr/bin/env bash\nprintf '%s\\n' '${cliVersion}'\n`,
{ mode: 0o755 },
);
}

const archivePath = path.join(root, "archive.tar.gz");
const tar = Bun.spawnSync(["tar", "-czf", archivePath, "-C", contents, "."]);
if (tar.exitCode !== 0) {
throw new Error(tar.stderr.toString());
}

return new Uint8Array(readFileSync(archivePath));
}

function sha256(bytes: Uint8Array): string {
return new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
}

function serveRelease(version: string, assets: Record<string, string | Uint8Array>) {
const requested: string[] = [];
const responses = new Map(
Object.entries(assets).map(([filename, body]) => [releaseUrl(version, filename), body]),
);

const respond = async (input: string | URL | Request) => {
const url = input instanceof Request ? input.url : String(input);
requested.push(url);
const body = responses.get(url);
return body === undefined ? new Response("Not Found", { status: 404 }) : new Response(body);
};
spyOn(globalThis, "fetch").mockImplementation(
Object.assign(respond, { preconnect: globalThis.fetch.preconnect }),
);

return { requested };
}

async function getMainModule(): Promise<typeof import("./main.ts")> {
Expand Down Expand Up @@ -537,11 +599,12 @@ test("installs the CLI with npm into an isolated prefix", async () => {
const logPath = installFakeNpm();
const { installCli } = await getMainModule();

const cliPath = await installCli({
const { cliPath, source } = await installCli({
spec: "supabase@2.101.0",
version: "2.101.0",
});

expect(source).toBe("npm");
expect(cliPath).toContain(`${path.sep}node_modules${path.sep}.bin`);
expect(readNpmCalls(logPath)).toEqual([
viewMetadataCall("supabase@2.101.0"),
Expand Down Expand Up @@ -728,8 +791,9 @@ test("verifies lockfile integrity before installing", async () => {
]);
});

test("fails when lockfile integrity does not match the registry", async () => {
test("fails without a GitHub release fallback when lockfile integrity does not match the registry", async () => {
installFakeNpm("supabase 2.101.0", { integrity: "sha512-registry" });
const release = serveRelease("2.101.0", {});
const { installCli } = await getMainModule();

try {
Expand All @@ -744,6 +808,7 @@ test("fails when lockfile integrity does not match the registry", async () => {
new Error("Lockfile integrity for supabase@2.101.0 does not match the npm registry"),
);
}
expect(release.requested).toEqual([]);
});

test("fails when the npm package does not expose a Supabase CLI executable", async () => {
Expand Down Expand Up @@ -775,6 +840,7 @@ test("runs the action with a package-lock resolution", async () => {

expect(readNpmCalls(logPath)[0]).toEqual(viewMetadataCall("supabase@2.43.0"));
expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.43.0");
expect(spies.setOutput).toHaveBeenCalledWith("install-source", "npm");
expect(spies.addPath).toHaveBeenCalledWith(expect.stringContaining("node_modules"));
expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
Expand Down Expand Up @@ -830,20 +896,151 @@ test("preserves an explicitly configured image registry for legacy CLI versions"
expect(spies.setFailed).not.toHaveBeenCalled();
});

test("fails when the installed CLI does not report a version", async () => {
installFakeNpm("");
test("installs the GitHub release when the npm-installed CLI cannot run", async () => {
installFakeNpm("", { packageVersion: "2.101.0" });
const archiveName = `supabase_2.101.0_${RELEASE_PLATFORM}_${RELEASE_ARCH}.tar.gz`;
const archive = createReleaseArchive("2.101.0");
const release = serveRelease("2.101.0", {
"checksums.txt": `${sha256(archive)} ${archiveName}\n`,
[archiveName]: archive,
});
const spies = createActionSpies("2.101.0");
const { run } = await getMainModule();

await run();

expect(spies.setFailed).not.toHaveBeenCalled();
expect(spies.setOutput).toHaveBeenCalledWith("version", "2.101.0");
expect(spies.setOutput).toHaveBeenCalledWith("install-source", "github-release");
expect(spies.addPath).toHaveBeenCalledTimes(1);
expect(spies.warning).toHaveBeenCalledWith(
expect.stringContaining("installing v2.101.0 from its GitHub release instead"),
);
expect(release.requested).toEqual([
releaseUrl("2.101.0", "checksums.txt"),
releaseUrl("2.101.0", archiveName),
]);
});

test("installs the GitHub release of a pinned version when npm is unreachable", async () => {
installFakeNpm("supabase 2.101.0");
process.env.FAKE_NPM_VIEW_ERROR = "npm error code ETIMEDOUT";
const archiveName = `supabase_2.101.0_${RELEASE_PLATFORM}_${RELEASE_ARCH}.tar.gz`;
const archive = createReleaseArchive("2.101.0");
serveRelease("2.101.0", {
"checksums.txt": `${sha256(archive)} ${archiveName}\n`,
[archiveName]: archive,
});
const spies = createActionSpies("2.101.0");
const { run } = await getMainModule();

await run();

expect(spies.setFailed).not.toHaveBeenCalled();
expect(spies.setOutput).toHaveBeenCalledWith("install-source", "github-release");
});

test("fails without a GitHub release fallback when npm cannot resolve a dist-tag", async () => {
installFakeNpm("supabase 2.101.0");
process.env.FAKE_NPM_VIEW_ERROR = "npm error code ETIMEDOUT";
const release = serveRelease("2.101.0", {});
const spies = createActionSpies("latest");
const { run } = await getMainModule();

await run();

expect(spies.setFailed).toHaveBeenCalledWith("npm error code ETIMEDOUT");
expect(release.requested).toEqual([]);
});

test("rejects a GitHub release archive that does not match its checksum", async () => {
installFakeNpm("", { packageVersion: "2.101.0" });
const archiveName = `supabase_2.101.0_${RELEASE_PLATFORM}_${RELEASE_ARCH}.tar.gz`;
serveRelease("2.101.0", {
"checksums.txt": `${"0".repeat(64)} ${archiveName}\n`,
[archiveName]: createReleaseArchive("2.101.0"),
});
const spies = createActionSpies("2.101.0");
const { run } = await getMainModule();

await run();

expect(spies.setFailed).toHaveBeenCalledWith(
expect.stringContaining(`Checksum mismatch for ${archiveName}`),
);
expect(spies.setOutput).not.toHaveBeenCalled();
expect(spies.addPath).not.toHaveBeenCalled();
});

test("reports both failures when the GitHub release fallback also fails", async () => {
installFakeNpm("", { packageVersion: "2.46.0" });
process.env.GITHUB_WORKSPACE = createWorkspace({
"package-lock.json": createPackageLock("2.46.0", "sha512-test"),
});
serveRelease("2.46.0", {});
const spies = createActionSpies("");
const { run } = await getMainModule();

await run();

expect(spies.setFailed).toHaveBeenCalledWith(
"Could not determine installed Supabase CLI version",
"Could not determine installed Supabase CLI version\n" +
"The GitHub release fallback for v2.46.0 also failed: " +
"The v2.46.0 GitHub release has no checksums.txt or supabase_2.46.0_checksums.txt",
);
expect(spies.setOutput).not.toHaveBeenCalled();
expect(spies.addPath).not.toHaveBeenCalled();
expect(spies.exportVariable).not.toHaveBeenCalled();
});

test("selects the release archive that runs on the runner", async () => {
const { selectReleaseAsset } = await getMainModule();
const checksums = [
"aaa supabase_3.0.0_linux_amd64.tar.gz",
"bbb supabase_3.0.0_linux_amd64.apk",
"ccc supabase_3.0.0_windows_arm64.tar.gz",
].join("\n");

expect(
selectReleaseAsset("3.0.0", checksums, { platform: "linux", arch: "x64", musl: false }),
).toEqual({
binDir: "",
filename: "supabase_3.0.0_linux_amd64.tar.gz",
sha256: "aaa",
});
expect(
selectReleaseAsset("3.0.0", checksums, { platform: "linux", arch: "x64", musl: true }),
).toEqual({
binDir: path.join("usr", "bin"),
filename: "supabase_3.0.0_linux_amd64.apk",
sha256: "bbb",
});
expect(
selectReleaseAsset("3.0.0", checksums, { platform: "win32", arch: "arm64", musl: false })
.filename,
).toBe("supabase_3.0.0_windows_arm64.tar.gz");
});

test("selects the unversioned archive from releases that predate versioned names", async () => {
const { selectReleaseAsset } = await getMainModule();

expect(
selectReleaseAsset("1.178.2", "ddd supabase_darwin_arm64.tar.gz\n", {
platform: "darwin",
arch: "arm64",
musl: false,
}).filename,
).toBe("supabase_darwin_arm64.tar.gz");
});

test("rejects runners that no release archive supports", async () => {
const { selectReleaseAsset } = await getMainModule();

expect(() =>
selectReleaseAsset("3.0.0", "aaa supabase_3.0.0_linux_amd64.tar.gz", {
platform: "freebsd",
arch: "x64",
musl: false,
}),
).toThrow("Supabase CLI releases have no binary for freebsd x64 runners");
});
Loading
Loading