Honor workspace npm config for CLI installs (#443)

## Summary
- Run npm commands from the caller workspace so npm remains the source
of truth for `.npmrc` parsing, auth, delegated config files, env
expansion, quoting, and precedence.
- Keep the CLI install isolated with `--prefix`, while explicitly
overriding action-owned npm policy such as `offline=false`,
`bin-links=true`, and no package lock.
- Treat existing `node` and `npm` commands on musl as sufficient only
when they actually run, probing Alpine `/usr/bin` binaries with
`/usr/bin` first on PATH.
- Prefer Alpine `/usr/bin` runtime binaries when an earlier PATH entry
shadows them, and keep apk installation for missing runtime libraries or
missing commands.

## Validation
- `bun run ci`
- Real npm sanity check for workspace `.npmrc` with quoted/env delegated
config, `globalconfig`, relative `cafile`, mTLS path keys,
`offline=true`, `bin-links=false`, and package-lock policy
- Fixture workflow:
https://github.com/jgoux/setup-cli-testing/actions/runs/28663875606

Addresses
https://github.com/supabase/setup-cli/pull/442#discussion_r3519786253
Addresses
https://github.com/supabase/setup-cli/pull/442#discussion_r3519786255
This commit is contained in:
Julien Goux
2026-07-07 13:11:42 +02:00
committed by GitHub
parent 23ef4b0416
commit 4c16bf7a1f
3 changed files with 177 additions and 6 deletions

View File

@@ -61,12 +61,33 @@ runs:
# actions/setup-node uses glibc Node builds, so install Alpine's Node/npm instead. # actions/setup-node uses glibc Node builds, so install Alpine's Node/npm instead.
if command -v apk >/dev/null 2>&1; then if command -v apk >/dev/null 2>&1; then
missing_packages="" missing_packages=""
for package in libstdc++ libgcc nodejs npm; do prefer_apk_bin=false
for package in libstdc++ libgcc; do
if ! apk info -e "${package}" >/dev/null 2>&1; then if ! apk info -e "${package}" >/dev/null 2>&1; then
missing_packages="${missing_packages} ${package}" missing_packages="${missing_packages} ${package}"
fi fi
done done
if ! command -v node >/dev/null 2>&1 || ! node -p 'process.versions.node' >/dev/null 2>&1; then
if [ -x /usr/bin/node ] && PATH="/usr/bin:${PATH}" /usr/bin/node -p 'process.versions.node' >/dev/null 2>&1; then
prefer_apk_bin=true
else
missing_packages="${missing_packages} nodejs"
fi
fi
if ! command -v npm >/dev/null 2>&1 || ! npm --version >/dev/null 2>&1; then
if [ -x /usr/bin/npm ] && PATH="/usr/bin:${PATH}" /usr/bin/npm --version >/dev/null 2>&1; then
prefer_apk_bin=true
else
missing_packages="${missing_packages} npm"
fi
fi
if [ "${prefer_apk_bin}" = "true" ]; then
echo "/usr/bin" >> "$GITHUB_PATH"
fi
if [ -z "${missing_packages}" ]; then if [ -z "${missing_packages}" ]; then
exit 0 exit 0
fi fi
@@ -77,6 +98,9 @@ runs:
fi fi
apk add --no-cache ${missing_packages} apk add --no-cache ${missing_packages}
if echo " ${missing_packages} " | grep -Eq ' (nodejs|npm) '; then
echo "/usr/bin" >> "$GITHUB_PATH"
fi
exit 0 exit 0
fi fi

View File

@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, 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";
@@ -7,6 +7,7 @@ import * as core from "@actions/core";
const CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; const CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY";
const originalPath = process.env.PATH; const originalPath = process.env.PATH;
const originalNpmUserconfig = process.env.NPM_CONFIG_USERCONFIG;
const originalRunnerTemp = process.env.RUNNER_TEMP; const originalRunnerTemp = process.env.RUNNER_TEMP;
const originalWorkspace = process.env.GITHUB_WORKSPACE; const originalWorkspace = process.env.GITHUB_WORKSPACE;
const tempDirs = new Set<string>(); const tempDirs = new Set<string>();
@@ -15,14 +16,23 @@ let mainModule: typeof import("./main.ts") | null = null;
afterEach(() => { afterEach(() => {
mock.restore(); mock.restore();
process.env.PATH = originalPath; process.env.PATH = originalPath;
if (originalNpmUserconfig === undefined) {
delete process.env.NPM_CONFIG_USERCONFIG;
} else {
process.env.NPM_CONFIG_USERCONFIG = originalNpmUserconfig;
}
process.env.RUNNER_TEMP = originalRunnerTemp; process.env.RUNNER_TEMP = originalRunnerTemp;
process.env.GITHUB_WORKSPACE = originalWorkspace; process.env.GITHUB_WORKSPACE = originalWorkspace;
delete process.env.FAKE_CLI_VERSION; delete process.env.FAKE_CLI_VERSION;
delete process.env.FAKE_NPM_BIN; delete process.env.FAKE_NPM_BIN;
delete process.env.FAKE_NPM_INTEGRITY; delete process.env.FAKE_NPM_INTEGRITY;
delete process.env.FAKE_NPM_CWD_LOG;
delete process.env.FAKE_NPM_ENV_LOG;
delete process.env.FAKE_NPM_LOG; delete process.env.FAKE_NPM_LOG;
delete process.env.FAKE_NPM_PACKAGE_VERSION; 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_SCRIPTS;
delete process.env.SETUP_CLI_TEST_WORKSPACE;
delete process.env.SUPABASE_SETUP_CLI_NPM; delete process.env.SUPABASE_SETUP_CLI_NPM;
for (const dir of tempDirs) { for (const dir of tempDirs) {
@@ -147,13 +157,27 @@ function createFakeNpm(): string {
mkdirSync(binDir, { recursive: true }); mkdirSync(binDir, { recursive: true });
writeFileSync( writeFileSync(
scriptPath, scriptPath,
`import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; `import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
const args = process.argv.slice(2); const args = process.argv.slice(2);
appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(args) + "\\n"); appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(args) + "\\n");
appendFileSync(process.env.FAKE_NPM_CWD_LOG, process.cwd() + "\\n");
appendFileSync(
process.env.FAKE_NPM_ENV_LOG,
JSON.stringify({ NPM_CONFIG_USERCONFIG: process.env.NPM_CONFIG_USERCONFIG ?? null }) + "\\n",
);
function recordConfig(configPath) {
appendFileSync(
process.env.FAKE_NPM_PREFIX_CONFIG_LOG,
JSON.stringify(existsSync(configPath) ? readFileSync(configPath, "utf8") : null) + "\\n",
);
}
if (args[0] === "view") { if (args[0] === "view") {
recordConfig(path.join(process.cwd(), ".npmrc"));
const bin = const bin =
process.env.FAKE_NPM_BIN === "missing" process.env.FAKE_NPM_BIN === "missing"
? undefined ? undefined
@@ -185,6 +209,7 @@ if (!prefix) {
const binDir = path.join(prefix, "node_modules", ".bin"); const binDir = path.join(prefix, "node_modules", ".bin");
mkdirSync(binDir, { recursive: true }); mkdirSync(binDir, { recursive: true });
recordConfig(path.join(prefix, ".npmrc"));
if (process.platform === "win32") { if (process.platform === "win32") {
writeFileSync( writeFileSync(
@@ -229,10 +254,21 @@ function installFakeNpm(
} = {}, } = {},
): string { ): string {
const binDir = createFakeNpm(); const binDir = createFakeNpm();
const cwdLogPath = path.join(createTempDir("setup-cli-fake-npm-cwd-log-"), "npm-cwd.log");
const envLogPath = path.join(createTempDir("setup-cli-fake-npm-env-log-"), "npm-env.log");
const logPath = path.join(createTempDir("setup-cli-fake-npm-log-"), "npm.log"); const logPath = path.join(createTempDir("setup-cli-fake-npm-log-"), "npm.log");
const prefixConfigLogPath = path.join(
createTempDir("setup-cli-fake-npm-prefix-config-log-"),
"npm-prefix-config.log",
);
writeFileSync(cwdLogPath, "");
writeFileSync(envLogPath, "");
writeFileSync(logPath, ""); writeFileSync(logPath, "");
writeFileSync(prefixConfigLogPath, "");
process.env.FAKE_CLI_VERSION = versionOutput; process.env.FAKE_CLI_VERSION = versionOutput;
process.env.FAKE_NPM_BIN = options.bin ?? "dist/supabase.js"; process.env.FAKE_NPM_BIN = options.bin ?? "dist/supabase.js";
process.env.FAKE_NPM_CWD_LOG = cwdLogPath;
process.env.FAKE_NPM_ENV_LOG = envLogPath;
process.env.FAKE_NPM_INTEGRITY = options.integrity ?? "sha512-test"; process.env.FAKE_NPM_INTEGRITY = options.integrity ?? "sha512-test";
process.env.FAKE_NPM_LOG = logPath; process.env.FAKE_NPM_LOG = logPath;
process.env.FAKE_NPM_PACKAGE_VERSION = process.env.FAKE_NPM_PACKAGE_VERSION =
@@ -248,6 +284,7 @@ function installFakeNpm(
binDir, binDir,
process.platform === "win32" ? "npm.cmd" : "npm", process.platform === "win32" ? "npm.cmd" : "npm",
); );
process.env.FAKE_NPM_PREFIX_CONFIG_LOG = prefixConfigLogPath;
return logPath; return logPath;
} }
@@ -260,8 +297,31 @@ function readNpmCalls(logPath: string): string[][] {
.map((line) => JSON.parse(line) as string[]); .map((line) => JSON.parse(line) as string[]);
} }
function readNpmCwds(): string[] {
return readFileSync(process.env.FAKE_NPM_CWD_LOG ?? "", "utf8")
.trim()
.split("\n")
.filter(Boolean);
}
function readNpmEnvs(): Array<{ NPM_CONFIG_USERCONFIG: string | null }> {
return readFileSync(process.env.FAKE_NPM_ENV_LOG ?? "", "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as { NPM_CONFIG_USERCONFIG: string | null });
}
function readNpmPrefixConfigs(): Array<string | null> {
return readFileSync(process.env.FAKE_NPM_PREFIX_CONFIG_LOG ?? "", "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as string | null);
}
function viewMetadataCall(spec: string): string[] { function viewMetadataCall(spec: string): string[] {
return ["view", spec, "version", "bin", "scripts", "dist.integrity", "--json"]; return ["view", spec, "version", "bin", "scripts", "dist.integrity", "--json", "--offline=false"];
} }
function createActionSpies(inputVersion: string) { function createActionSpies(inputVersion: string) {
@@ -442,6 +502,83 @@ test("installs the CLI with npm into an isolated prefix", async () => {
"--no-audit", "--no-audit",
"--no-fund", "--no-fund",
"--no-package-lock", "--no-package-lock",
"--offline=false",
"--bin-links=true",
"--ignore-scripts=true",
"supabase@2.101.0",
],
]);
});
test("runs npm with caller workspace config and action-owned overrides", async () => {
const workspace = createWorkspace({
".npmrc": [
"registry=https://registry.example.test",
"@internal:registry=https://registry.internal.example.test",
"//registry.example.test/:_authToken=${NPM_TOKEN}",
"//registry.example.test/:certfile=certs/client.pem",
"//registry.example.test/:keyfile=certs/client-key.pem",
"always-auth",
'cafile="certs/project-ca.pem"',
'userconfig="${SETUP_CLI_TEST_WORKSPACE}/.npmrc-ci"',
"globalconfig=.npmrc-global",
"bin-links=false",
"offline=true",
"package-lock=true",
].join("\n"),
".npmrc-ci": [
"registry=https://delegated-registry.example.test",
"//registry.example.test/:_password=delegated",
"cafile=certs/delegated-ca.pem",
"bin-links=false",
"offline=true",
].join("\n"),
".npmrc-global": [
"registry=https://global-registry.example.test",
"//registry.example.test/:username=global",
].join("\n"),
"certs/client-key.pem": "client-key",
"certs/client.pem": "client",
"certs/delegated-ca.pem": "delegated-ca",
"certs/project-ca.pem": "project-ca",
});
process.env.SETUP_CLI_TEST_WORKSPACE = workspace;
const userconfigPath = path.join(createTempDir("setup-cli-userconfig-"), ".npmrc");
writeFileSync(userconfigPath, "//registry.example.test/:username=existing\n");
process.env.NPM_CONFIG_USERCONFIG = userconfigPath;
process.env.GITHUB_WORKSPACE = workspace;
const logPath = installFakeNpm();
const { installCli } = await getMainModule();
await installCli({
spec: "supabase@2.101.0",
version: "2.101.0",
});
const realWorkspace = realpathSync(workspace);
const npmCwds = readNpmCwds().map((cwd) => realpathSync(cwd));
expect(npmCwds).toEqual([realWorkspace, realWorkspace]);
expect(readNpmEnvs().map((env) => env.NPM_CONFIG_USERCONFIG)).toEqual([
userconfigPath,
userconfigPath,
]);
expect(readNpmPrefixConfigs()).toEqual([
readFileSync(path.join(workspace, ".npmrc"), "utf8"),
null,
]);
expect(readNpmCalls(logPath)).toEqual([
viewMetadataCall("supabase@2.101.0"),
[
"install",
"--prefix",
expect.any(String),
"--omit=dev",
"--include=optional",
"--no-audit",
"--no-fund",
"--no-package-lock",
"--offline=false",
"--bin-links=true",
"--ignore-scripts=true", "--ignore-scripts=true",
"supabase@2.101.0", "supabase@2.101.0",
], ],
@@ -471,6 +608,8 @@ test("allows install scripts for legacy npm packages that declare a preinstall",
"--no-audit", "--no-audit",
"--no-fund", "--no-fund",
"--no-package-lock", "--no-package-lock",
"--offline=false",
"--bin-links=true",
"--ignore-scripts=false", "--ignore-scripts=false",
"supabase@1.15.1", "supabase@1.15.1",
], ],
@@ -500,6 +639,8 @@ test("allows install scripts for npm packages that declare a postinstall", async
"--no-audit", "--no-audit",
"--no-fund", "--no-fund",
"--no-package-lock", "--no-package-lock",
"--offline=false",
"--bin-links=true",
"--ignore-scripts=false", "--ignore-scripts=false",
"supabase@1.178.2", "supabase@1.178.2",
], ],
@@ -527,6 +668,8 @@ test("verifies lockfile integrity before installing", async () => {
"--no-audit", "--no-audit",
"--no-fund", "--no-fund",
"--no-package-lock", "--no-package-lock",
"--offline=false",
"--bin-links=true",
"--ignore-scripts=true", "--ignore-scripts=true",
"supabase@2.101.0", "supabase@2.101.0",
], ],

View File

@@ -252,6 +252,7 @@ async function getPackageMetadata(resolution: PackageResolution): Promise<Packag
"scripts", "scripts",
"dist.integrity", "dist.integrity",
"--json", "--json",
"--offline=false",
]); ]);
const metadata = JSON.parse(output) as unknown; const metadata = JSON.parse(output) as unknown;
@@ -293,6 +294,7 @@ function createInstallRoot(): string {
async function runNpm(args: string[]): Promise<string> { async function runNpm(args: string[]): Promise<string> {
const executable = process.env[NPM_EXECUTABLE_ENV]?.trim() || "npm"; const executable = process.env[NPM_EXECUTABLE_ENV]?.trim() || "npm";
const proc = Bun.spawn([executable, ...args], { const proc = Bun.spawn([executable, ...args], {
cwd: process.env.GITHUB_WORKSPACE?.trim() ?? process.cwd(),
env: process.env, env: process.env,
stderr: "pipe", stderr: "pipe",
stdout: "pipe", stdout: "pipe",
@@ -311,12 +313,12 @@ async function runNpm(args: string[]): Promise<string> {
} }
export async function installCli(resolution: PackageResolution): Promise<string> { export async function installCli(resolution: PackageResolution): Promise<string> {
const installRoot = createInstallRoot();
const metadata = await getPackageMetadata(resolution); const metadata = await getPackageMetadata(resolution);
verifyPackageMetadata(resolution, metadata); verifyPackageMetadata(resolution, metadata);
verifyPackageIntegrity(resolution, metadata); verifyPackageIntegrity(resolution, metadata);
const installRoot = createInstallRoot();
await runNpm([ await runNpm([
"install", "install",
"--prefix", "--prefix",
@@ -326,6 +328,8 @@ export async function installCli(resolution: PackageResolution): Promise<string>
"--no-audit", "--no-audit",
"--no-fund", "--no-fund",
"--no-package-lock", "--no-package-lock",
"--offline=false",
"--bin-links=true",
`--ignore-scripts=${shouldIgnoreInstallScripts(metadata)}`, `--ignore-scripts=${shouldIgnoreInstallScripts(metadata)}`,
resolution.spec, resolution.spec,
]); ]);