Compare commits

..

1 Commits

Author SHA1 Message Date
Julien Goux
afb1b15109 fix: allow registry fallback in supported CLI versions
The action currently pins every recent CLI to GHCR, which disables the
CLI's registry fallback and leaves image pulls exposed to GHCR
throttling.

Use the installed CLI version to keep the GHCR default for versions
before v2.108.0 and allow fallback for newer versions. Preserve a
caller's explicit registry choice.
2026-09-24 00:20:56 +02:00
7 changed files with 583 additions and 427 deletions

View File

@@ -47,6 +47,8 @@ jobs:
exclude: exclude:
- version: 1.178.2 - version: 1.178.2
pg_major: 17 pg_major: 17
- version: latest
pg_major: 14
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:

View File

@@ -28,10 +28,8 @@ steps:
``` ```
If `version` is omitted, the action checks the repository root for `bun.lock`, If `version` is omitted, the action checks the repository root for `bun.lock`,
`pnpm-lock.yaml`, or `package-lock.json` and installs the declared `supabase` `pnpm-lock.yaml`, or `package-lock.json` and uses the declared `supabase`
package version through npm. If the lockfile includes package integrity version. If no supported lockfile is present, it falls back to `latest`.
metadata, the action verifies it against the npm registry before installing. If
no supported lockfile is present, it falls back to `latest`.
A specific version of the `supabase` CLI can be installed: A specific version of the `supabase` CLI can be installed:
@@ -49,6 +47,7 @@ steps:
- uses: supabase/setup-cli@v2 - uses: supabase/setup-cli@v2
with: with:
version: latest version: latest
github-token: ${{ github.token }}
- run: supabase init - run: supabase init
- run: supabase db start - run: supabase db start
``` ```
@@ -60,10 +59,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`) | Root lockfile version or `latest` | false |
| `github-token` | String | Deprecated; no longer used now that installs resolve through npm | | false | | `github-token` | String | GitHub token used to resolve `latest` without unauthenticated API limiting | | false |
## Advanced Usage ## Advanced Usage

View File

@@ -6,7 +6,7 @@ inputs:
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. If omitted, detect from the root lockfile and otherwise use latest.
required: false required: false
github-token: github-token:
description: Deprecated. The action now installs through npm and does not use GitHub release API requests. description: GitHub token used to resolve the latest Supabase CLI release without hitting unauthenticated API limits.
required: false required: false
outputs: outputs:
version: version:
@@ -112,4 +112,5 @@ runs:
working-directory: ${{ github.action_path }} working-directory: ${{ github.action_path }}
env: env:
INPUT_VERSION: ${{ inputs.version }} INPUT_VERSION: ${{ inputs.version }}
SUPABASE_CLI_GITHUB_TOKEN: ${{ inputs.github-token }}
run: bun src/main.ts run: bun src/main.ts

View File

@@ -6,6 +6,7 @@
"name": "setup-cli", "name": "setup-cli",
"dependencies": { "dependencies": {
"@actions/core": "^3.0.1", "@actions/core": "^3.0.1",
"@actions/tool-cache": "^4.0.0",
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/bun": "^1.0.10", "@tsconfig/bun": "^1.0.10",
@@ -26,6 +27,8 @@
"@actions/io": ["@actions/io@3.0.2", "", {}, "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw=="], "@actions/io": ["@actions/io@3.0.2", "", {}, "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw=="],
"@actions/tool-cache": ["@actions/tool-cache@4.0.0", "", { "dependencies": { "@actions/core": "^3.0.0", "@actions/exec": "^3.0.0", "@actions/http-client": "^4.0.0", "@actions/io": "^3.0.0", "semver": "^7.7.3" } }, "sha512-L8P9HbXvpvqjZDveb/fdsa55IVC0trfPgQ4ZwGo6r5af6YDVdM9vMGPZ7rgY2fAT9gGj4PSYd6bYlg3p3jD78A=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-HbifJ84prIh9+55CTPAU35JdRQrwg47y16cGerCC+iejSKOuHXYo2WDql6l7cQlzrYVtc3f4UWY+dBj2lRmOeA=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-HbifJ84prIh9+55CTPAU35JdRQrwg47y16cGerCC+iejSKOuHXYo2WDql6l7cQlzrYVtc3f4UWY+dBj2lRmOeA=="],
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ef7SKJqAaH2d7E6eXZZa2OffIShbhFMxnGK0zd93p4qiyTJr75B0qf7lrPD+qQOwcf04BrjYJ0JUxq8d5+yZwg=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ef7SKJqAaH2d7E6eXZZa2OffIShbhFMxnGK0zd93p4qiyTJr75B0qf7lrPD+qQOwcf04BrjYJ0JUxq8d5+yZwg=="],
@@ -144,6 +147,8 @@
"oxlint-tsgolint": ["oxlint-tsgolint@0.22.1", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.22.1", "@oxlint-tsgolint/darwin-x64": "0.22.1", "@oxlint-tsgolint/linux-arm64": "0.22.1", "@oxlint-tsgolint/linux-x64": "0.22.1", "@oxlint-tsgolint/win32-arm64": "0.22.1", "@oxlint-tsgolint/win32-x64": "0.22.1" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg=="], "oxlint-tsgolint": ["oxlint-tsgolint@0.22.1", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.22.1", "@oxlint-tsgolint/darwin-x64": "0.22.1", "@oxlint-tsgolint/linux-arm64": "0.22.1", "@oxlint-tsgolint/linux-x64": "0.22.1", "@oxlint-tsgolint/win32-arm64": "0.22.1", "@oxlint-tsgolint/win32-x64": "0.22.1" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
@@ -151,5 +156,7 @@
"undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], "undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@actions/tool-cache/@actions/core": ["@actions/core@3.0.0", "", { "dependencies": { "@actions/exec": "^3.0.0", "@actions/http-client": "^4.0.0" } }, "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg=="],
} }
} }

View File

@@ -24,7 +24,8 @@
"typecheck": "bun x tsgo -p tsconfig.json --noEmit" "typecheck": "bun x tsgo -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@actions/core": "^3.0.1" "@actions/core": "^3.0.1",
"@actions/tool-cache": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/bun": "^1.0.10", "@tsconfig/bun": "^1.0.10",

View File

@@ -1,26 +1,36 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import process from "node:process"; import process from "node:process";
import { fileURLToPath } from "node:url";
import { afterEach, expect, mock, spyOn, test } from "bun:test"; import { afterEach, expect, mock, spyOn, test } from "bun:test";
import * as core from "@actions/core"; import * as core from "@actions/core";
import * as tc from "@actions/tool-cache";
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 CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY";
const originalPath = process.env.PATH; const GITHUB_RELEASES_API = "https://api.github.com/repos/supabase/cli/releases/latest";
const originalRunnerTemp = process.env.RUNNER_TEMP; const GITHUB_TOKEN_ENV = "SUPABASE_CLI_GITHUB_TOKEN";
const originalCliConfigRegistry = process.env[CLI_CONFIG_REGISTRY];
const originalWorkspace = process.env.GITHUB_WORKSPACE; const originalWorkspace = process.env.GITHUB_WORKSPACE;
const originalGithubToken = process.env[GITHUB_TOKEN_ENV];
const tempDirs = new Set<string>(); const tempDirs = new Set<string>();
let mainModule: typeof import("./main.ts") | null = null; let mainModule: typeof import("./main.ts") | null = null;
afterEach(() => { afterEach(() => {
mock.restore(); mock.restore();
process.env.PATH = originalPath;
process.env.RUNNER_TEMP = originalRunnerTemp;
process.env.GITHUB_WORKSPACE = originalWorkspace; process.env.GITHUB_WORKSPACE = originalWorkspace;
delete process.env.FAKE_CLI_VERSION; if (originalGithubToken === undefined) {
delete process.env.FAKE_NPM_INTEGRITY; delete process.env[GITHUB_TOKEN_ENV];
delete process.env.FAKE_NPM_LOG; } else {
delete process.env.SUPABASE_SETUP_CLI_NPM; process.env[GITHUB_TOKEN_ENV] = originalGithubToken;
}
if (originalCliConfigRegistry === undefined) {
delete process.env[CLI_CONFIG_REGISTRY];
} else {
process.env[CLI_CONFIG_REGISTRY] = originalCliConfigRegistry;
}
for (const dir of tempDirs) { for (const dir of tempDirs) {
rmSync(dir, { force: true, recursive: true }); rmSync(dir, { force: true, recursive: true });
@@ -28,14 +38,32 @@ afterEach(() => {
tempDirs.clear(); tempDirs.clear();
}); });
function createTempDir(prefix: string): string { function createFakeCli(versionOutput: string): string {
const dir = mkdtempSync(path.join(os.tmpdir(), prefix)); const dir = mkdtempSync(path.join(os.tmpdir(), "setup-cli-"));
tempDirs.add(dir); tempDirs.add(dir);
if (process.platform === "win32") {
writeFileSync(
path.join(dir, "supabase.cmd"),
versionOutput ? `@echo off\r\necho ${versionOutput}\r\n` : "@echo off\r\n",
);
return dir;
}
const escapedOutput = versionOutput.replaceAll("'", "'\"'\"'");
writeFileSync(
path.join(dir, "supabase"),
versionOutput
? `#!/usr/bin/env bash\nprintf '%s\\n' '${escapedOutput}'\n`
: "#!/usr/bin/env bash\n",
);
Bun.spawnSync(["chmod", "+x", path.join(dir, "supabase")]);
return dir; return dir;
} }
function createWorkspace(files: Record<string, string>): string { function createWorkspace(files: Record<string, string>): string {
const dir = createTempDir("setup-cli-workspace-"); const dir = mkdtempSync(path.join(os.tmpdir(), "setup-cli-workspace-"));
tempDirs.add(dir);
for (const [relativePath, content] of Object.entries(files)) { for (const [relativePath, content] of Object.entries(files)) {
const filePath = path.join(dir, relativePath); const filePath = path.join(dir, relativePath);
@@ -51,7 +79,6 @@ function createBunLock(
options: { options: {
includeDependency?: boolean; includeDependency?: boolean;
includePackageEntry?: boolean; includePackageEntry?: boolean;
integrity?: string;
useDevDependency?: boolean; useDevDependency?: boolean;
} = {}, } = {},
): string { ): string {
@@ -77,7 +104,7 @@ ${
"supabase@${version}", "supabase@${version}",
"", "",
{}, {},
"${options.integrity ?? "sha512-bun"}" "sha512-test"
]` ]`
: "" : ""
} }
@@ -88,12 +115,7 @@ ${
function createPnpmLock( function createPnpmLock(
version: string, version: string,
options: { options: { asString?: boolean; includeVersion?: boolean; useDevDependency?: boolean } = {},
asString?: boolean;
includeVersion?: boolean;
integrity?: string;
useDevDependency?: boolean;
} = {},
): string { ): string {
const dependencyKey = options.useDevDependency ? "devDependencies" : "dependencies"; const dependencyKey = options.useDevDependency ? "devDependencies" : "dependencies";
@@ -111,11 +133,11 @@ ${options.includeVersion === false ? "" : ` version: ${version}`}`
packages: packages:
supabase@${version}: supabase@${version}:
resolution: resolution:
integrity: ${options.integrity ?? "sha512-pnpm"} integrity: sha512-test
`; `;
} }
function createPackageLock(version: string, integrity = "sha512-package-lock"): string { function createPackageLock(version: string): string {
return JSON.stringify( return JSON.stringify(
{ {
name: "app", name: "app",
@@ -127,7 +149,6 @@ function createPackageLock(version: string, integrity = "sha512-package-lock"):
}, },
}, },
"node_modules/supabase": { "node_modules/supabase": {
integrity,
version, version,
}, },
}, },
@@ -137,107 +158,31 @@ function createPackageLock(version: string, integrity = "sha512-package-lock"):
); );
} }
function createFakeNpm(): string { function createActionSpies(inputVersion: string, cliDir: string, expectedUrlFragment: string) {
const root = createTempDir("setup-cli-fake-npm-");
const binDir = path.join(root, "bin");
const scriptPath = path.join(root, "fake-npm.js");
mkdirSync(binDir, { recursive: true });
writeFileSync(
scriptPath,
`import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
const args = process.argv.slice(2);
appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(args) + "\\n");
if (args[0] === "view") {
console.log(JSON.stringify(process.env.FAKE_NPM_INTEGRITY ?? "sha512-test"));
process.exit(0);
}
if (args[0] !== "install") {
console.error("Unexpected npm command: " + args.join(" "));
process.exit(1);
}
const prefixIndex = args.indexOf("--prefix");
const prefix = prefixIndex === -1 ? undefined : args[prefixIndex + 1];
if (!prefix) {
console.error("Missing --prefix");
process.exit(1);
}
const binDir = path.join(prefix, "node_modules", ".bin");
mkdirSync(binDir, { recursive: true });
if (process.platform === "win32") {
writeFileSync(
path.join(binDir, "supabase.cmd"),
process.env.FAKE_CLI_VERSION ? "@echo off\\r\\necho " + process.env.FAKE_CLI_VERSION + "\\r\\n" : "@echo off\\r\\n",
);
} else {
writeFileSync(
path.join(binDir, "supabase"),
process.env.FAKE_CLI_VERSION
? "#!/usr/bin/env bash\\nprintf '%s\\\\n' '" + process.env.FAKE_CLI_VERSION.replaceAll("'", "'\\\\''") + "'\\n"
: "#!/usr/bin/env bash\\n",
{ mode: 0o755 },
);
}
`,
);
if (process.platform === "win32") {
writeFileSync(
path.join(binDir, "npm.cmd"),
`@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`,
);
} else {
writeFileSync(
path.join(binDir, "npm"),
`#!/usr/bin/env bash\nexec "${process.execPath}" "${scriptPath}" "$@"\n`,
{ mode: 0o755 },
);
}
return binDir;
}
function installFakeNpm(versionOutput = "supabase 2.101.0", integrity = "sha512-test"): string {
const binDir = createFakeNpm();
const logPath = path.join(createTempDir("setup-cli-fake-npm-log-"), "npm.log");
writeFileSync(logPath, "");
process.env.FAKE_CLI_VERSION = versionOutput;
process.env.FAKE_NPM_INTEGRITY = integrity;
process.env.FAKE_NPM_LOG = logPath;
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`;
process.env.RUNNER_TEMP = createTempDir("setup-cli-runner-temp-");
process.env.SUPABASE_SETUP_CLI_NPM = path.join(
binDir,
process.platform === "win32" ? "npm.cmd" : "npm",
);
return logPath;
}
function readNpmCalls(logPath: string): string[][] {
return readFileSync(logPath, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as string[]);
}
function createActionSpies(inputVersion: string) {
return { return {
getInput: spyOn(core, "getInput").mockReturnValue(inputVersion),
setOutput: spyOn(core, "setOutput").mockImplementation(() => {}),
addPath: spyOn(core, "addPath").mockImplementation(() => {}), addPath: spyOn(core, "addPath").mockImplementation(() => {}),
exportVariable: spyOn(core, "exportVariable").mockImplementation(() => {}), exportVariable: spyOn(core, "exportVariable").mockImplementation(() => {}),
getInput: spyOn(core, "getInput").mockReturnValue(inputVersion),
setFailed: spyOn(core, "setFailed").mockImplementation(() => {}), setFailed: spyOn(core, "setFailed").mockImplementation(() => {}),
setOutput: spyOn(core, "setOutput").mockImplementation(() => {}), downloadTool: spyOn(tc, "downloadTool").mockImplementation(async (url: string) => {
expect(url).toContain(expectedUrlFragment);
return path.join(os.tmpdir(), "supabase-cli.tar.gz");
}),
extractTar: spyOn(tc, "extractTar").mockImplementation(async () => cliDir),
extractZip: spyOn(tc, "extractZip").mockImplementation(async () => cliDir),
}; };
} }
function mockLatestRelease(version = "v2.99.0") {
return spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ tag_name: version }), {
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");
@@ -246,100 +191,254 @@ async function getMainModule(): Promise<typeof import("./main.ts")> {
return mainModule; return mainModule;
} }
test("uses an explicit npm package version when provided", async () => { test("uses versioned tar archives for Supabase CLI v2.99.0 and later", async () => {
const { resolvePackage } = await getMainModule(); const { getDownloadArchive } = await getMainModule();
expect(resolvePackage("v2.101.0")).toEqual({ const archive = await getDownloadArchive("2.99.0", "linux", "x64");
spec: "supabase@2.101.0",
version: "2.101.0", 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("uses the root bun.lock resolution when version is omitted", async () => { test("uses apk archives for Supabase CLI v2.99.0 and later on Linux musl", async () => {
process.env.GITHUB_WORKSPACE = createWorkspace({ const { getDownloadArchive } = await getMainModule();
"bun.lock": createBunLock("2.41.0", { integrity: "sha512-bun-lock" }),
});
const { resolvePackage } = await getMainModule();
expect(resolvePackage("")).toEqual({ const archive = await getDownloadArchive("2.100.1", "linux", "x64", true);
integrity: "sha512-bun-lock",
spec: "supabase@2.41.0", expect(archive).toEqual({
version: "2.41.0", url: "https://github.com/supabase/cli/releases/download/v2.100.1/supabase_2.100.1_linux_amd64.apk",
format: "apk",
}); });
}); });
test("uses the root pnpm-lock.yaml resolution when version is omitted", async () => { test("keeps tar archives before Supabase CLI v2.99.0 on Linux musl", async () => {
process.env.GITHUB_WORKSPACE = createWorkspace({ const { getDownloadArchive } = await getMainModule();
"pnpm-lock.yaml": createPnpmLock("2.42.0", { integrity: "sha512-pnpm-lock" }),
});
const { resolvePackage } = await getMainModule();
expect(resolvePackage("")).toEqual({ const archive = await getDownloadArchive("2.98.2", "linux", "x64", true);
integrity: "sha512-pnpm-lock",
spec: "supabase@2.42.0", expect(archive).toEqual({
version: "2.42.0", url: "https://github.com/supabase/cli/releases/download/v2.98.2/supabase_linux_amd64.tar.gz",
format: "tar",
}); });
}); });
test("uses the root package-lock.json resolution when version is omitted", async () => { test("uses usr/bin as the CLI path for apk archives", async () => {
process.env.GITHUB_WORKSPACE = createWorkspace({ const { getCliPath } = await getMainModule();
"package-lock.json": createPackageLock("2.43.0", "sha512-package-lock"),
});
const { resolvePackage } = await getMainModule();
expect(resolvePackage("")).toEqual({ expect(getCliPath("/tmp/extracted", "apk")).toBe(path.join("/tmp/extracted", "usr", "bin"));
integrity: "sha512-package-lock", expect(getCliPath("/tmp/extracted", "tar")).toBe("/tmp/extracted");
spec: "supabase@2.43.0", expect(getCliPath("/tmp/extracted", "zip")).toBe("/tmp/extracted");
version: "2.43.0", });
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("authenticates latest release lookup when a GitHub token is provided", async () => {
process.env[GITHUB_TOKEN_ENV] = "ghs_test-token";
const fetch = mockLatestRelease("v2.99.0");
const { getDownloadArchive } = await getMainModule();
await getDownloadArchive("latest", "darwin", "arm64");
expect(fetch).toHaveBeenCalledWith(GITHUB_RELEASES_API, {
headers: expect.objectContaining({
Accept: "application/vnd.github+json",
Authorization: "Bearer ghs_test-token",
"X-GitHub-Api-Version": "2022-11-28",
}),
});
});
test("awaits the action entrypoint with omitted version and latest fallback", async () => {
process.env.GITHUB_WORKSPACE = repo;
mockLatestRelease();
const cliDir = createFakeCli("supabase 2.84.2");
let startDownload!: () => void;
let finishDownload!: () => void;
const downloadStarted = new Promise<void>((resolve) => {
startDownload = resolve;
});
const downloadFinished = new Promise<string>((resolve) => {
finishDownload = () => resolve(path.join(os.tmpdir(), "supabase-cli.tar.gz"));
});
const spies = {
getInput: spyOn(core, "getInput").mockReturnValue(""),
setOutput: spyOn(core, "setOutput").mockImplementation(() => {}),
addPath: spyOn(core, "addPath").mockImplementation(() => {}),
exportVariable: spyOn(core, "exportVariable").mockImplementation(() => {}),
setFailed: spyOn(core, "setFailed").mockImplementation(() => {}),
downloadTool: spyOn(tc, "downloadTool").mockImplementation(async (url: string) => {
expect(url).toContain("/download/v2.99.0/supabase_2.99.0_");
startDownload();
return downloadFinished;
}),
extractTar: spyOn(tc, "extractTar").mockImplementation(async () => cliDir),
extractZip: spyOn(tc, "extractZip").mockImplementation(async () => cliDir),
};
const originalArgv1 = process.argv[1];
process.argv[1] = defaultEntrypoint;
try {
let importSettled = false;
const entrypoint = import(`./main.ts?entrypoint=${Date.now()}`).finally(() => {
importSettled = true;
});
await downloadStarted;
await Bun.sleep(0);
expect(importSettled).toBe(false);
finishDownload();
await entrypoint;
} finally {
process.argv[1] = originalArgv1 ?? "";
}
expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.84.2");
expect(spies.addPath).toHaveBeenCalledWith(cliDir);
expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
});
test("uses the root bun.lock version when version is omitted", async () => {
process.env.GITHUB_WORKSPACE = createWorkspace({
"bun.lock": createBunLock("2.41.0"),
});
const cliDir = createFakeCli("supabase 2.41.0");
const spies = createActionSpies("", cliDir, "/download/v2.41.0/supabase_");
const { run } = await getMainModule();
await run();
expect(spies.downloadTool).not.toHaveBeenCalledWith(expect.stringContaining("/latest/download/"));
expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.41.0");
expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
});
test("uses the root pnpm-lock.yaml version when version is omitted", async () => {
process.env.GITHUB_WORKSPACE = createWorkspace({
"pnpm-lock.yaml": createPnpmLock("2.42.0"),
});
const cliDir = createFakeCli("supabase 2.42.0");
const spies = createActionSpies("", cliDir, "/download/v2.42.0/supabase_");
const { run } = await getMainModule();
await run();
expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.42.0");
expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
});
test("uses the root package-lock.json version when version is omitted", async () => {
process.env.GITHUB_WORKSPACE = createWorkspace({
"package-lock.json": createPackageLock("2.43.0"),
});
const cliDir = createFakeCli("supabase 2.43.0");
const spies = createActionSpies("", cliDir, "/download/v2.43.0/supabase_");
const { run } = await getMainModule();
await run();
expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.43.0");
expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
});
test("falls through malformed lockfiles and uses the next supported root lockfile", async () => { test("falls through malformed lockfiles and uses the next supported root lockfile", async () => {
process.env.GITHUB_WORKSPACE = createWorkspace({ process.env.GITHUB_WORKSPACE = createWorkspace({
"bun.lock": "{ not valid", "bun.lock": "{ not valid",
"package-lock.json": createPackageLock("2.44.0"), "package-lock.json": createPackageLock("2.44.0"),
}); });
const { resolvePackage } = await getMainModule(); const cliDir = createFakeCli("supabase 2.44.0");
const spies = createActionSpies("", cliDir, "/download/v2.44.0/supabase_");
const { run } = await getMainModule();
expect(resolvePackage("")).toEqual({ await run();
integrity: "sha512-package-lock",
spec: "supabase@2.44.0", expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.44.0");
version: "2.44.0", expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
}); expect(spies.setFailed).not.toHaveBeenCalled();
}); });
test("falls back to latest when version is omitted and no supported root lockfile is present", async () => { test("falls back to latest when version is omitted and no supported root lockfile is present", async () => {
process.env.GITHUB_WORKSPACE = createWorkspace({ process.env.GITHUB_WORKSPACE = createWorkspace({
"README.md": "# app\n", "README.md": "# app\n",
}); });
const { resolvePackage } = await getMainModule(); mockLatestRelease();
const cliDir = createFakeCli("supabase 2.84.2");
const spies = createActionSpies("", cliDir, "/download/v2.99.0/supabase_2.99.0_");
const { run } = await getMainModule();
expect(resolvePackage("")).toEqual({ await run();
spec: "supabase@latest",
version: "latest", expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.84.2");
}); expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
}); });
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;
const { resolvePackage } = await getMainModule(); mockLatestRelease();
const cliDir = createFakeCli("supabase 2.84.2");
const spies = createActionSpies("", cliDir, "/download/v2.99.0/supabase_2.99.0_");
const { run } = await getMainModule();
expect(resolvePackage("")).toEqual({ await run();
spec: "supabase@latest",
version: "latest", expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.84.2");
}); expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
}); });
test("uses the declared bun.lock version when the resolved package entry is missing", async () => { test("uses the declared bun.lock version when the resolved package entry is missing", async () => {
process.env.GITHUB_WORKSPACE = createWorkspace({ process.env.GITHUB_WORKSPACE = createWorkspace({
"bun.lock": createBunLock("2.44.1", { includePackageEntry: false, useDevDependency: true }), "bun.lock": createBunLock("2.44.1", { includePackageEntry: false, useDevDependency: true }),
}); });
const { resolvePackage } = await getMainModule(); const cliDir = createFakeCli("supabase 2.44.1");
const spies = createActionSpies("", cliDir, "/download/v2.44.1/supabase_");
const { run } = await getMainModule();
expect(resolvePackage("")).toEqual({ await run();
spec: "supabase@2.44.1",
version: "2.44.1", expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.44.1");
}); expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
}); });
test("falls through bun.lock without supabase and uses a pnpm string dependency version", async () => { test("falls through bun.lock without supabase and uses a pnpm string dependency version", async () => {
@@ -347,119 +446,73 @@ test("falls through bun.lock without supabase and uses a pnpm string dependency
"bun.lock": createBunLock("2.47.0", { includeDependency: false }), "bun.lock": createBunLock("2.47.0", { includeDependency: false }),
"pnpm-lock.yaml": createPnpmLock("2.47.0", { asString: true }), "pnpm-lock.yaml": createPnpmLock("2.47.0", { asString: true }),
}); });
const { resolvePackage } = await getMainModule(); const cliDir = createFakeCli("supabase 2.47.0");
const spies = createActionSpies("", cliDir, "/download/v2.47.0/supabase_");
const { run } = await getMainModule();
expect(resolvePackage("")).toEqual({ await run();
integrity: "sha512-pnpm",
spec: "supabase@2.47.0", expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.47.0");
version: "2.47.0", expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
});
test("falls through malformed pnpm lockfiles and uses the next supported root lockfile", async () => {
process.env.GITHUB_WORKSPACE = createWorkspace({
"pnpm-lock.yaml": "not: [valid",
"package-lock.json": createPackageLock("2.48.0"),
}); });
const cliDir = createFakeCli("supabase 2.48.0");
const spies = createActionSpies("", cliDir, "/download/v2.48.0/supabase_");
const { run } = await getMainModule();
await run();
expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.48.0");
expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
});
test("falls through unreadable bun.lock paths and malformed package-lock files to latest", async () => {
const workspace = createWorkspace({
"package-lock.json": "{ invalid",
});
mkdirSync(path.join(workspace, "bun.lock"), { recursive: true });
process.env.GITHUB_WORKSPACE = workspace;
mockLatestRelease();
const cliDir = createFakeCli("supabase 2.84.2");
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.84.2");
expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
}); });
test("falls back to latest when a pnpm dependency entry has no concrete version", async () => { test("falls back to latest when a pnpm dependency entry has no concrete version", async () => {
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 }),
}); });
const { resolvePackage } = await getMainModule(); mockLatestRelease();
const cliDir = createFakeCli("supabase 2.84.2");
expect(resolvePackage("")).toEqual({ const spies = createActionSpies("", cliDir, "/download/v2.99.0/supabase_2.99.0_");
spec: "supabase@latest",
version: "latest",
});
});
test("installs the CLI with npm into an isolated prefix", async () => {
const logPath = installFakeNpm();
const { installCli } = await getMainModule();
const cliPath = await installCli({
spec: "supabase@2.101.0",
version: "2.101.0",
});
expect(cliPath).toContain(`${path.sep}node_modules${path.sep}.bin`);
expect(readNpmCalls(logPath)).toEqual([
[
"install",
"--prefix",
expect.any(String),
"--omit=dev",
"--no-audit",
"--no-fund",
"--no-package-lock",
"--ignore-scripts",
"supabase@2.101.0",
],
]);
});
test("verifies lockfile integrity before installing", async () => {
const logPath = installFakeNpm("supabase 2.101.0", "sha512-lock");
const { installCli } = await getMainModule();
await installCli({
integrity: "sha512-lock",
spec: "supabase@2.101.0",
version: "2.101.0",
});
expect(readNpmCalls(logPath)).toEqual([
["view", "supabase@2.101.0", "dist.integrity", "--json"],
[
"install",
"--prefix",
expect.any(String),
"--omit=dev",
"--no-audit",
"--no-fund",
"--no-package-lock",
"--ignore-scripts",
"supabase@2.101.0",
],
]);
});
test("fails when lockfile integrity does not match the registry", async () => {
installFakeNpm("supabase 2.101.0", "sha512-registry");
const { installCli } = await getMainModule();
try {
await installCli({
integrity: "sha512-lock",
spec: "supabase@2.101.0",
version: "2.101.0",
});
throw new Error("Expected installCli to reject");
} catch (error) {
expect(error).toEqual(
new Error("Lockfile integrity for supabase@2.101.0 does not match the npm registry"),
);
}
});
test("runs the action with a package-lock resolution", async () => {
const logPath = installFakeNpm("supabase 2.43.0", "sha512-package-lock");
process.env.GITHUB_WORKSPACE = createWorkspace({
"package-lock.json": createPackageLock("2.43.0", "sha512-package-lock"),
});
const spies = createActionSpies("");
const { run } = await getMainModule(); const { run } = await getMainModule();
await run(); await run();
expect(readNpmCalls(logPath)[0]).toEqual(["view", "supabase@2.43.0", "dist.integrity", "--json"]); expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.84.2");
expect(spies.setOutput).toHaveBeenCalledWith("version", "supabase 2.43.0");
expect(spies.addPath).toHaveBeenCalledWith(expect.stringContaining("node_modules"));
expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io"); expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled(); expect(spies.setFailed).not.toHaveBeenCalled();
}); });
test("explicit version overrides detected root lockfiles", async () => { test("explicit version overrides detected root lockfiles", async () => {
installFakeNpm("supabase 1.0.0");
process.env.GITHUB_WORKSPACE = createWorkspace({ process.env.GITHUB_WORKSPACE = createWorkspace({
"bun.lock": createBunLock("2.45.0"), "bun.lock": createBunLock("2.45.0"),
}); });
const spies = createActionSpies("1.0.0"); const cliDir = createFakeCli("supabase 1.0.0");
const spies = createActionSpies("1.0.0", cliDir, "/download/v1.0.0/supabase_1.0.0_");
const { run } = await getMainModule(); const { run } = await getMainModule();
await run(); await run();
@@ -469,12 +522,81 @@ test("explicit version overrides detected root lockfiles", async () => {
expect(spies.setFailed).not.toHaveBeenCalled(); expect(spies.setFailed).not.toHaveBeenCalled();
}); });
test("keeps the GHCR registry pin through Supabase CLI v2.107.x", async () => {
const cliDir = createFakeCli("supabase 2.107.9");
const spies = createActionSpies("2.107.9", cliDir, "/download/v2.107.9/supabase_");
const { run } = await getMainModule();
await run();
expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
});
test("keeps the GHCR registry pin starting with Supabase CLI v1.28.0", async () => {
const cliDir = createFakeCli("supabase 1.28.0");
const spies = createActionSpies("1.28.0", cliDir, "/download/v1.28.0/supabase_");
const { run } = await getMainModule();
await run();
expect(spies.exportVariable).toHaveBeenCalledWith(CLI_CONFIG_REGISTRY, "ghcr.io");
expect(spies.setFailed).not.toHaveBeenCalled();
});
test("uses the CLI built-in registry fallback starting with Supabase CLI v2.108.0", async () => {
const cliDir = createFakeCli("supabase 2.108.0");
const spies = createActionSpies("2.108.0", cliDir, "/download/v2.108.0/supabase_");
const { run } = await getMainModule();
await run();
expect(spies.exportVariable).not.toHaveBeenCalled();
expect(spies.setFailed).not.toHaveBeenCalled();
});
test("preserves an explicitly configured internal image registry", async () => {
process.env[CLI_CONFIG_REGISTRY] = "registry.example.test";
const cliDir = createFakeCli("supabase 2.108.0");
const spies = createActionSpies("2.108.0", cliDir, "/download/v2.108.0/supabase_");
const { run } = await getMainModule();
await run();
expect(process.env[CLI_CONFIG_REGISTRY]).toBe("registry.example.test");
expect(spies.exportVariable).not.toHaveBeenCalled();
});
test("preserves a whitespace-only internal image registry", async () => {
process.env[CLI_CONFIG_REGISTRY] = " ";
const cliDir = createFakeCli("supabase 2.108.0");
const spies = createActionSpies("2.108.0", cliDir, "/download/v2.108.0/supabase_");
const { run } = await getMainModule();
await run();
expect(process.env[CLI_CONFIG_REGISTRY]).toBe(" ");
expect(spies.exportVariable).not.toHaveBeenCalled();
});
test("uses the installed version to select the registry for latest", async () => {
mockLatestRelease("v2.108.0");
const cliDir = createFakeCli("supabase 2.108.0");
const spies = createActionSpies("latest", cliDir, "/download/v2.108.0/supabase_");
const { run } = await getMainModule();
await run();
expect(spies.exportVariable).not.toHaveBeenCalled();
expect(spies.setFailed).not.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 () => {
installFakeNpm("");
process.env.GITHUB_WORKSPACE = createWorkspace({ process.env.GITHUB_WORKSPACE = createWorkspace({
"package-lock.json": createPackageLock("2.46.0", "sha512-test"), "package-lock.json": createPackageLock("2.46.0"),
}); });
const spies = createActionSpies(""); const cliDir = createFakeCli("");
const spies = createActionSpies("", cliDir, "/download/v2.46.0/supabase_");
const { run } = await getMainModule(); const { run } = await getMainModule();
await run(); await run();

View File

@@ -1,20 +1,23 @@
import { semver } from "bun"; import { $, semver } from "bun";
import * as core from "@actions/core"; import * as core from "@actions/core";
import { existsSync, mkdtempSync, readFileSync } from "node:fs"; import * as tc from "@actions/tool-cache";
import os from "node:os"; import { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; 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";
const DEFAULT_REGISTRY_FALLBACK_VERSION = "2.108.0";
const VERSIONED_ARCHIVE_VERSION = "2.99.0";
const DEFAULT_VERSION = "latest"; const DEFAULT_VERSION = "latest";
const NPM_PACKAGE = "supabase"; const GITHUB_RELEASES_API = "https://api.github.com/repos/supabase/cli/releases/latest";
const NPM_EXECUTABLE_ENV = "SUPABASE_SETUP_CLI_NPM"; const GITHUB_TOKEN_ENV = "SUPABASE_CLI_GITHUB_TOKEN";
type PackageResolution = { type ArchiveFormat = "apk" | "tar" | "zip";
spec: string;
version: string; type DownloadArchive = {
integrity?: string; url: string;
format: ArchiveFormat;
}; };
type BunLock = { type BunLock = {
@@ -33,12 +36,6 @@ type PnpmDependency =
version?: string; version?: string;
}; };
type PnpmPackage = {
resolution?: {
integrity?: string;
};
};
type PnpmLock = { type PnpmLock = {
importers?: { importers?: {
".": { ".": {
@@ -46,14 +43,21 @@ type PnpmLock = {
devDependencies?: Record<string, PnpmDependency>; devDependencies?: Record<string, PnpmDependency>;
}; };
}; };
packages?: Record<string, PnpmPackage>;
}; };
type PackageLock = { type PackageLock = {
packages?: Record<string, { integrity?: string; version?: string }>; packages?: Record<string, { version?: string }>;
dependencies?: Record<string, { integrity?: string; version?: string }>; dependencies?: Record<string, { version?: string }>;
}; };
function getArchivePlatform(platform: NodeJS.Platform): string {
return platform === "win32" ? "windows" : platform;
}
function getArchiveArch(arch: NodeJS.Architecture): string {
return arch === "x64" ? "amd64" : arch;
}
function extractConcreteVersion(raw: string | undefined): string | null { function extractConcreteVersion(raw: string | undefined): string | null {
if (!raw) { if (!raw) {
return null; return null;
@@ -67,16 +71,6 @@ function normalizeVersion(version: string): string {
return version.replace(/^v/i, ""); return version.replace(/^v/i, "");
} }
function toPackageResolution(version: string, integrity?: string): PackageResolution {
const normalizedVersion = normalizeVersion(version);
return {
spec: `${NPM_PACKAGE}@${normalizedVersion}`,
version: normalizedVersion,
integrity,
};
}
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);
@@ -91,7 +85,7 @@ function readWorkspaceLockfile(workspaceRoot: string, filename: string): string
} }
} }
function detectResolutionFromBunLock(workspaceRoot: string): PackageResolution | null { function detectVersionFromBunLock(workspaceRoot: string): string | null {
const text = readWorkspaceLockfile(workspaceRoot, "bun.lock"); const text = readWorkspaceLockfile(workspaceRoot, "bun.lock");
if (!text) { if (!text) {
@@ -102,28 +96,24 @@ function detectResolutionFromBunLock(workspaceRoot: string): PackageResolution |
const lockfile = JSON.parse(text.replace(/,\s*([}\]])/g, "$1")) as BunLock; const lockfile = JSON.parse(text.replace(/,\s*([}\]])/g, "$1")) as BunLock;
const rootWorkspace = lockfile.workspaces?.[""]; const rootWorkspace = lockfile.workspaces?.[""];
const declaredVersion = const declaredVersion =
rootWorkspace?.dependencies?.[NPM_PACKAGE] ?? rootWorkspace?.devDependencies?.[NPM_PACKAGE]; rootWorkspace?.dependencies?.supabase ?? rootWorkspace?.devDependencies?.supabase;
if (!declaredVersion) { if (!declaredVersion) {
return null; return null;
} }
const resolvedPackage = lockfile.packages?.[NPM_PACKAGE]; const resolvedPackage = lockfile.packages?.supabase;
if (Array.isArray(resolvedPackage) && typeof resolvedPackage[0] === "string") { if (Array.isArray(resolvedPackage) && typeof resolvedPackage[0] === "string") {
const version = extractConcreteVersion(resolvedPackage[0]); return extractConcreteVersion(resolvedPackage[0]);
const integrity = typeof resolvedPackage[3] === "string" ? resolvedPackage[3] : undefined;
return version ? toPackageResolution(version, integrity) : null;
} }
const version = extractConcreteVersion(declaredVersion); return extractConcreteVersion(declaredVersion);
return version ? toPackageResolution(version) : null;
} catch { } catch {
return null; return null;
} }
} }
function detectResolutionFromPnpmLock(workspaceRoot: string): PackageResolution | null { function detectVersionFromPnpmLock(workspaceRoot: string): string | null {
const text = readWorkspaceLockfile(workspaceRoot, "pnpm-lock.yaml"); const text = readWorkspaceLockfile(workspaceRoot, "pnpm-lock.yaml");
if (!text) { if (!text) {
@@ -134,29 +124,19 @@ function detectResolutionFromPnpmLock(workspaceRoot: string): PackageResolution
const lockfile = Bun.YAML.parse(text) as PnpmLock; const lockfile = Bun.YAML.parse(text) as PnpmLock;
const rootImporter = lockfile.importers?.["."]; const rootImporter = lockfile.importers?.["."];
const dependency = const dependency =
rootImporter?.dependencies?.[NPM_PACKAGE] ?? rootImporter?.devDependencies?.[NPM_PACKAGE]; rootImporter?.dependencies?.supabase ?? rootImporter?.devDependencies?.supabase;
const version =
typeof dependency === "string"
? extractConcreteVersion(dependency)
: extractConcreteVersion(dependency?.version);
if (!version) { if (typeof dependency === "string") {
return null; return extractConcreteVersion(dependency);
} }
const integrity = Object.entries(lockfile.packages ?? {}).find( return extractConcreteVersion(dependency?.version);
([packageKey]) =>
packageKey === `${NPM_PACKAGE}@${version}` ||
packageKey.startsWith(`/${NPM_PACKAGE}@${version}`),
)?.[1].resolution?.integrity;
return toPackageResolution(version, integrity);
} catch { } catch {
return null; return null;
} }
} }
function detectResolutionFromPackageLock(workspaceRoot: string): PackageResolution | null { function detectVersionFromPackageLock(workspaceRoot: string): string | null {
const text = readWorkspaceLockfile(workspaceRoot, "package-lock.json"); const text = readWorkspaceLockfile(workspaceRoot, "package-lock.json");
if (!text) { if (!text) {
@@ -165,97 +145,147 @@ function detectResolutionFromPackageLock(workspaceRoot: string): PackageResoluti
try { try {
const lockfile = JSON.parse(text) as PackageLock; const lockfile = JSON.parse(text) as PackageLock;
const packageEntry = lockfile.packages?.[`node_modules/${NPM_PACKAGE}`];
const dependencyEntry = lockfile.dependencies?.[NPM_PACKAGE];
const version =
extractConcreteVersion(packageEntry?.version) ??
extractConcreteVersion(dependencyEntry?.version);
return version return (
? toPackageResolution(version, packageEntry?.integrity ?? dependencyEntry?.integrity) extractConcreteVersion(lockfile.packages?.["node_modules/supabase"]?.version) ??
: null; extractConcreteVersion(lockfile.dependencies?.supabase?.version)
);
} catch { } catch {
return null; return null;
} }
} }
export function resolvePackage(inputVersion: string): PackageResolution { function resolveVersion(inputVersion: string): string {
const requestedVersion = inputVersion.trim(); const requestedVersion = inputVersion.trim();
if (requestedVersion) { if (requestedVersion) {
return toPackageResolution(requestedVersion); return requestedVersion;
} }
const workspaceRoot = process.env.GITHUB_WORKSPACE?.trim(); const workspaceRoot = process.env.GITHUB_WORKSPACE?.trim();
if (!workspaceRoot) { if (!workspaceRoot) {
return toPackageResolution(DEFAULT_VERSION); return DEFAULT_VERSION;
} }
return ( return (
detectResolutionFromBunLock(workspaceRoot) ?? detectVersionFromBunLock(workspaceRoot) ??
detectResolutionFromPnpmLock(workspaceRoot) ?? detectVersionFromPnpmLock(workspaceRoot) ??
detectResolutionFromPackageLock(workspaceRoot) ?? detectVersionFromPackageLock(workspaceRoot) ??
toPackageResolution(DEFAULT_VERSION) DEFAULT_VERSION
); );
} }
async function verifyExpectedIntegrity(resolution: PackageResolution): Promise<void> { async function resolveLatestVersion(): Promise<string> {
if (!resolution.integrity) { const headers: Record<string, string> = {
return; Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
const githubToken = process.env[GITHUB_TOKEN_ENV]?.trim();
if (githubToken) {
headers.Authorization = `Bearer ${githubToken}`;
} }
const output = await runNpm(["view", resolution.spec, "dist.integrity", "--json"]); const response = await fetch(GITHUB_RELEASES_API, { headers });
const registryIntegrity = JSON.parse(output) as unknown; if (!response.ok) {
throw new Error(`Failed to resolve latest Supabase CLI release: ${response.statusText}`);
}
if (registryIntegrity !== resolution.integrity) { const release = (await response.json()) as { tag_name?: unknown };
throw new Error(`Lockfile integrity for ${resolution.spec} does not match the npm registry`); if (typeof release.tag_name !== "string") {
throw new Error("Failed to resolve latest Supabase CLI release: missing tag name");
}
return normalizeVersion(release.tag_name);
}
function getArchiveFormat(
version: string,
platform: NodeJS.Platform,
isMuslLinux: boolean,
): ArchiveFormat {
if (
platform === "linux" &&
isMuslLinux &&
semver.order(version, VERSIONED_ARCHIVE_VERSION) >= 0
) {
return "apk";
}
if (platform === "win32" && semver.order(version, VERSIONED_ARCHIVE_VERSION) >= 0) {
return "zip";
}
return "tar";
}
function getArchiveFilename(
version: string,
platform: NodeJS.Platform,
arch: NodeJS.Architecture,
archiveFormat: ArchiveFormat,
): string {
const archivePlatform = getArchivePlatform(platform);
const archiveArch = getArchiveArch(arch);
if (semver.order(version, REGISTRY_VERSION) === -1) {
return `supabase_${version}_${archivePlatform}_${archiveArch}.tar.gz`;
}
if (platform === "linux" && archiveFormat === "apk") {
return `supabase_${version}_${archivePlatform}_${archiveArch}.apk`;
}
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,
isMuslLinux?: boolean,
): Promise<DownloadArchive> {
const resolvedVersion =
version.toLowerCase() === "latest" ? await resolveLatestVersion() : normalizeVersion(version);
const format = getArchiveFormat(
resolvedVersion,
platform,
isMuslLinux ?? (await detectMuslLinux(platform)),
);
const filename = getArchiveFilename(resolvedVersion, platform, arch, format);
return {
url: `https://github.com/supabase/cli/releases/download/v${resolvedVersion}/${filename}`,
format,
};
}
async function detectMuslLinux(platform = process.platform): Promise<boolean> {
if (platform !== "linux") {
return false;
}
if (existsSync("/etc/alpine-release")) {
return true;
}
try {
const output = await $`ldd --version`.quiet().text();
return output.toLowerCase().includes("musl");
} catch (error) {
const output = error instanceof Error ? error.message : String(error);
return output.toLowerCase().includes("musl");
} }
} }
function createInstallRoot(): string { export function getCliPath(extractedPath: string, archiveFormat: ArchiveFormat): string {
const tempRoot = process.env.RUNNER_TEMP?.trim() || os.tmpdir(); return archiveFormat === "apk" ? path.join(extractedPath, "usr", "bin") : extractedPath;
return mkdtempSync(path.join(tempRoot, "setup-cli-"));
}
async function runNpm(args: string[]): Promise<string> {
const executable = process.env[NPM_EXECUTABLE_ENV]?.trim() || "npm";
const proc = Bun.spawn([executable, ...args], {
env: process.env,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) {
throw new Error(stderr.trim() || `npm ${args.join(" ")} failed`);
}
return stdout;
}
export async function installCli(resolution: PackageResolution): Promise<string> {
await verifyExpectedIntegrity(resolution);
const installRoot = createInstallRoot();
await runNpm([
"install",
"--prefix",
installRoot,
"--omit=dev",
"--no-audit",
"--no-fund",
"--no-package-lock",
"--ignore-scripts",
resolution.spec,
]);
return path.join(installRoot, "node_modules", ".bin");
} }
function getCliExecutablePath(cliPath: string): string { function getCliExecutablePath(cliPath: string): string {
@@ -263,36 +293,21 @@ function getCliExecutablePath(cliPath: string): string {
return path.join(cliPath, "supabase"); return path.join(cliPath, "supabase");
} }
const cmdPath = path.join(cliPath, "supabase.cmd");
if (existsSync(cmdPath)) {
return cmdPath;
}
const exePath = path.join(cliPath, "supabase.exe"); const exePath = path.join(cliPath, "supabase.exe");
if (existsSync(exePath)) { if (existsSync(exePath)) {
return exePath; return exePath;
} }
const cmdPath = path.join(cliPath, "supabase.cmd");
if (existsSync(cmdPath)) {
return cmdPath;
}
return path.join(cliPath, "supabase"); return path.join(cliPath, "supabase");
} }
export async function determineInstalledVersion(cliPath: string): Promise<string> { export async function determineInstalledVersion(cliPath: string): Promise<string> {
const executable = getCliExecutablePath(cliPath); const version = (await $`${getCliExecutablePath(cliPath)} --version`.text()).trim();
const proc = Bun.spawn([executable, "--version"], {
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) {
throw new Error(stderr.trim() || "Could not determine installed Supabase CLI version");
}
const version = stdout.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");
} }
@@ -300,24 +315,33 @@ export async function determineInstalledVersion(cliPath: string): Promise<string
return version; return version;
} }
function shouldUseGhcrRegistry(requestedVersion: string, installedVersion: string): boolean {
if (requestedVersion.toLowerCase() === DEFAULT_VERSION) {
return true;
}
const concreteVersion = extractConcreteVersion(installedVersion);
return concreteVersion !== null && semver.order(concreteVersion, REGISTRY_VERSION) >= 0;
}
export async function run(): Promise<void> { export async function run(): Promise<void> {
try { try {
const resolution = resolvePackage(core.getInput("version")); const version = resolveVersion(core.getInput("version"));
const cliPath = await installCli(resolution); const archive = await getDownloadArchive(version);
const archivePath = await tc.downloadTool(archive.url);
const extractedPath =
archive.format === "zip"
? await tc.extractZip(archivePath)
: await tc.extractTar(archivePath);
const cliPath = getCliPath(extractedPath, archive.format);
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 (shouldUseGhcrRegistry(resolution.version, installedVersion)) { if (process.env[CLI_CONFIG_REGISTRY]) {
return;
}
const installedVersionNumber = extractConcreteVersion(installedVersion);
if (!installedVersionNumber) {
throw new Error("Could not determine installed Supabase CLI version");
}
if (
semver.order(installedVersionNumber, REGISTRY_VERSION) >= 0 &&
semver.order(installedVersionNumber, DEFAULT_REGISTRY_FALLBACK_VERSION) === -1
) {
core.exportVariable(CLI_CONFIG_REGISTRY, "ghcr.io"); core.exportVariable(CLI_CONFIG_REGISTRY, "ghcr.io");
} }
} catch (error) { } catch (error) {