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
7 changes: 7 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
- 1.178.2
- 2.33.0
- latest
- ^2
pg_major:
- 14
- 15
Expand All @@ -49,7 +50,13 @@ jobs:
pg_major: 17
- version: latest
pg_major: 14
- version: ^2
pg_major: 14
steps:
# Supabase's local ports fall inside Linux's ephemeral port range, so outbound
# connections (like image pulls) can hold one when `supabase start` binds it.
- name: Reserve Supabase local ports
run: sudo sysctl -w net.ipv4.ip_local_reserved_ports=54320-54329
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
Expand Down
43 changes: 36 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ This action can be run on `ubuntu-latest`, `windows-latest`, and `macos-latest`
GitHub Actions runners, and will install and expose a specified version of the
`supabase` CLI on the runner environment.

## Upgrading from v1 or v2

`supabase/setup-cli@v1` and `@v2` are legacy and only receive compatibility
fixes. New features, such as semver ranges in `version`, are only available in
`@v3`. Upgrading the action does not force a CLI upgrade: `@v3` installs any
published CLI version, so you can keep an older CLI with an exact version or a
range like `^1` or `^2`:

```yaml
steps:
- uses: supabase/setup-cli@v3
with:
version: ^2
```

`@v3` installs the CLI from npm, so runners need Node.js 20+ (provisioned
automatically when missing) and access to the npm registry. `@v1` defaulted to
CLI `2.20.3`; `@v3` defaults to the version in your root lockfile, or `latest`
when there is none, so set `version` explicitly to keep a specific CLI.

## Usage

Setup the `supabase` CLI:
Expand Down Expand Up @@ -61,10 +81,12 @@ USER 1000:1000
```

A fixed npm-published version, `latest`, `beta`, `next` (upcoming major
prereleases), or `v<N>.stable` (maintenance line of an older major, like
`v2.stable`) of the `supabase` CLI can be installed. `next` only tracks
prereleases, so once a major goes GA it can lag behind `latest` until the
following major's prereleases start:
prereleases), `v<N>.stable` (maintenance line of an older major, like
`v2.stable`), or an npm semver range (like `^2.95`, `~2.98.0`, `2.x`,
`>=2.98 <2.99`, or `^1 || ^2`) of the `supabase` CLI can be installed. A range
installs the version npm picks: `latest` if it matches, otherwise the highest
non-deprecated match. `next` only tracks prereleases, so once a major goes GA
it can lag behind `latest` until the following major's prereleases start:

```yaml
steps:
Expand All @@ -87,6 +109,13 @@ steps:
version: v2.stable
```

```yaml
steps:
- uses: supabase/setup-cli@v3
with:
version: ^2.95
```

Run `supabase db start` to execute all migrations on a fresh database:

```yaml
Expand All @@ -105,9 +134,9 @@ on Windows and macOS runners.

The action supports the following inputs:

| Name | Type | Description | Default | Required |
| --------- | ------ | -------------------------------------------------------------------------- | --------------------------------- | -------- |
| `version` | String | Supabase CLI `latest`, `beta`, `next`, `v<N>.stable`, or fixed npm version | Root lockfile version or `latest` | false |
| Name | Type | Description | Default | Required |
| --------- | ------ | ---------------------------------------------------------------------------------------- | --------------------------------- | -------- |
| `version` | String | Supabase CLI `latest`, `beta`, `next`, `v<N>.stable`, fixed npm version, or semver range | Root lockfile version or `latest` | false |

## Outputs

Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ description: Setup Supabase CLI, supabase, on GitHub Actions runners
author: Supabase
inputs:
version:
description: Supabase CLI version to install. Supports latest, beta, next (upcoming major prereleases only; can lag behind latest after a major goes GA), v<N>.stable (maintenance line of an older major, like v2.stable), or a fixed version published to npm. If omitted, detect from the root lockfile and otherwise use latest.
description: Supabase CLI version to install. Supports latest, beta, next (upcoming major prereleases only; can lag behind latest after a major goes GA), v<N>.stable (maintenance line of an older major, like v2.stable), a fixed version published to npm, or an npm semver range like ^2.95 or 2.x (resolved like npm does, preferring the latest tag when it matches, else the highest non-deprecated match). If omitted, detect from the root lockfile and otherwise use latest.
required: false
outputs:
version:
Expand Down
219 changes: 215 additions & 4 deletions src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ afterEach(() => {
delete process.env.FAKE_NPM_PREFIX_CONFIG_LOG;
delete process.env.FAKE_NPM_SCRIPTS;
delete process.env.FAKE_NPM_VIEW_ERROR;
delete process.env.FAKE_NPM_VIEW_RESULT;
delete process.env.SETUP_CLI_TEST_WORKSPACE;
delete process.env.SUPABASE_SETUP_CLI_NPM;

Expand Down Expand Up @@ -201,6 +202,11 @@ if (args[0] === "view") {
process.exit(1);
}

if (process.env.FAKE_NPM_VIEW_RESULT !== undefined) {
console.log(process.env.FAKE_NPM_VIEW_RESULT);
process.exit(0);
}

const bin =
process.env.FAKE_NPM_BIN === "missing"
? undefined
Expand Down Expand Up @@ -344,14 +350,41 @@ function readNpmPrefixConfigs(): Array<string | null> {
}

function viewMetadataCall(spec: string): string[] {
return ["view", spec, "version", "bin", "scripts", "dist.integrity", "--json", "--offline=false"];
return [
"view",
spec,
"version",
"bin",
"scripts",
"dist.integrity",
"deprecated",
"dist-tags",
"--json",
"--offline=false",
];
}

/** Sets the manifests the fake npm prints for a range lookup, one per published version. */
function setViewManifests(
versions: Array<string | { version: string; deprecated?: string }>,
latest?: string,
): void {
process.env.FAKE_NPM_VIEW_RESULT = JSON.stringify(
versions.map((entry) => ({
...(typeof entry === "string" ? { version: entry } : entry),
bin: { supabase: "dist/supabase.js" },
"dist-tags": { latest: latest ?? "2.101.0" },
"dist.integrity": "sha512-test",
})),
);
}

function createActionSpies(inputVersion: string) {
return {
addPath: spyOn(core, "addPath").mockImplementation(() => {}),
exportVariable: spyOn(core, "exportVariable").mockImplementation(() => {}),
getInput: spyOn(core, "getInput").mockReturnValue(inputVersion),
info: spyOn(core, "info").mockImplementation(() => {}),
setFailed: spyOn(core, "setFailed").mockImplementation(() => {}),
setOutput: spyOn(core, "setOutput").mockImplementation(() => {}),
warning: spyOn(core, "warning").mockImplementation(() => {}),
Expand Down Expand Up @@ -475,13 +508,57 @@ test("rejects unsupported npm package selectors", async () => {
const { resolvePackage } = await getMainModule();

const message = (selector: string) =>
`Unsupported Supabase CLI version "${selector}". Use latest, beta, next, v<N>.stable (like v2.stable), or a fixed npm package version like 2.101.0.`;

for (const selector of ["hotfix", "v2", "2.x", "stable", "v2.stable.1", "next2", "2.stable"]) {
`Unsupported Supabase CLI version "${selector}". Use latest, beta, next, v<N>.stable (like v2.stable), a fixed npm package version like 2.101.0, or a semver range like ^2.95 or 2.x.`;

for (const selector of [
"hotfix",
"canary",
"lastest",
"garbage!",
"stable",
"v2.stable.1",
"next2",
"2.stable",
"^1 ||",
"- 2.0.0",
"^2 -",
"^^2",
]) {
expect(() => resolvePackage(selector)).toThrow(message(selector));
}
});

test("accepts npm semver ranges as version selectors", async () => {
const { resolvePackage } = await getMainModule();

for (const range of [
"^2.95",
"~2.98.0",
"2.x",
"2.98.*",
"2",
"2.90.0 - 2.98.0",
">=2.98 <2.99",
"^1 || ^2",
]) {
expect(resolvePackage(` ${range} `)).toEqual({ spec: `supabase@${range}`, version: range });
}
});

test("normalizes operator spacing and v prefixes in ranges", async () => {
const { resolvePackage } = await getMainModule();

for (const [input, normalized] of [
["v2", "2"],
["V2.x", "2.x"],
["^v2.95", "^2.95"],
[">= 2.98 < 2.99", ">=2.98 <2.99"],
["^ 1 || ~ v2.98", "^1 || ~2.98"],
] as const) {
expect(resolvePackage(input)).toEqual({ spec: `supabase@${normalized}`, version: normalized });
}
});

test("uses the root bun.lock resolution when version is omitted", async () => {
process.env.GITHUB_WORKSPACE = createWorkspace({
"bun.lock": createBunLock("2.41.0", { integrity: "sha512-bun-lock" }),
Expand Down Expand Up @@ -953,6 +1030,140 @@ test("fails without a GitHub release fallback when npm cannot resolve a dist-tag
expect(release.requested).toEqual([]);
});

test("installs the highest npm version that satisfies a caret range", async () => {
const logPath = installFakeNpm("supabase 2.98.2");
setViewManifests(["2.95.0", "2.98.2", "2.97.10", "2.98.1"]);
const spies = createActionSpies("^2.95");
const { run } = await getMainModule();

await run();

expect(spies.setFailed).not.toHaveBeenCalled();
expect(spies.info).toHaveBeenCalledWith('Resolved Supabase CLI version "^2.95" to 2.98.2');
expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.98.2");
expect(spies.setOutput).toHaveBeenCalledWith("install-source", "npm");
const calls = readNpmCalls(logPath);
expect(calls).toHaveLength(2);
expect(calls[0]).toEqual(viewMetadataCall("supabase@^2.95"));
expect(calls[1]).toContain("supabase@2.98.2");
});

test("passes ranges to npm as one argument and normalizes them first", async () => {
for (const [input, spec] of [
["2.x", "2.x"],
["^1 || ^2", "^1 || ^2"],
["2.90.0 - 2.98.0", "2.90.0 - 2.98.0"],
[">= 2.98 < 2.99", ">=2.98 <2.99"],
["V2.x", "2.x"],
] as const) {
const logPath = installFakeNpm("supabase 2.98.1");
setViewManifests(["2.98.0", "2.98.1"]);
const spies = createActionSpies(input);
const { run } = await getMainModule();

await run();

expect(spies.setFailed).not.toHaveBeenCalled();
expect(spies.info).toHaveBeenCalledWith(`Resolved Supabase CLI version "${spec}" to 2.98.1`);
expect(readNpmCalls(logPath)[0]).toEqual(viewMetadataCall(`supabase@${spec}`));
expect(readNpmCalls(logPath)[1]).toContain("supabase@2.98.1");
expect(spies.setOutput).toHaveBeenCalledWith("install-source", "npm");
}
});

test("resolves a range that npm reports as a single manifest", async () => {
const logPath = installFakeNpm("supabase 2.98.0");
process.env.FAKE_NPM_VIEW_RESULT = JSON.stringify({
version: "2.98.0",
bin: { supabase: "dist/supabase.js" },
"dist-tags": { latest: "2.101.0" },
"dist.integrity": "sha512-test",
});
const spies = createActionSpies("~2.98.0");
const { run } = await getMainModule();

await run();

expect(spies.setFailed).not.toHaveBeenCalled();
expect(readNpmCalls(logPath)[1]).toContain("supabase@2.98.0");
expect(spies.setOutput).toHaveBeenCalledWith("install-source", "npm");
});

test("picks npm's latest when it satisfies a range even if a higher version matches", async () => {
const logPath = installFakeNpm("supabase 2.98.1");
setViewManifests(["2.98.0", "2.98.1", "2.99.0", "2.100.0-beta.1"], "2.98.1");
const spies = createActionSpies("2.x");
const { run } = await getMainModule();

await run();

expect(spies.setFailed).not.toHaveBeenCalled();
expect(readNpmCalls(logPath)[1]).toContain("supabase@2.98.1");
});

test("skips deprecated versions when picking from a range", async () => {
for (const [versions, picked] of [
[
[
{ version: "2.98.0" },
{ version: "2.98.1" },
{ version: "2.98.2", deprecated: "broken release" },
],
"2.98.1",
],
[
[
{ version: "2.98.0", deprecated: "broken release" },
{ version: "2.98.2", deprecated: "broken release" },
{ version: "2.98.1", deprecated: "broken release" },
],
"2.98.2",
],
] as const) {
const logPath = installFakeNpm(`supabase ${picked}`);
setViewManifests([...versions], "2.98.2");
const spies = createActionSpies("~2.98.0");
const { run } = await getMainModule();

await run();

expect(spies.setFailed).not.toHaveBeenCalled();
expect(readNpmCalls(logPath)[1]).toContain(`supabase@${picked}`);
}
});

test("fails without a GitHub release fallback when no version satisfies a range", async () => {
installFakeNpm("supabase 2.101.0");
process.env.FAKE_NPM_VIEW_ERROR =
"npm error code E404\nnpm error 404 No match found for version ^99";
const release = serveRelease("2.101.0", {});
const spies = createActionSpies("^99");
const { run } = await getMainModule();

await run();

expect(spies.setFailed).toHaveBeenCalledWith('No published Supabase CLI version matches "^99"');
expect(release.requested).toEqual([]);
});

test("rethrows other npm errors for a range unchanged", async () => {
for (const error of [
"npm error code ETIMEDOUT",
"npm error code E404\nnpm error 404 'supabase@^2.95' is not in this registry.",
]) {
installFakeNpm("supabase 2.101.0");
process.env.FAKE_NPM_VIEW_ERROR = error;
const release = serveRelease("2.101.0", {});
const spies = createActionSpies("^2.95");
const { run } = await getMainModule();

await run();

expect(spies.setFailed).toHaveBeenCalledWith(error);
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`;
Expand Down
Loading
Loading