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
47 changes: 18 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,47 +225,36 @@ steps:

### With Private Registry (GitHub Packages)

If your repo has a `.npmrc` that declares the registry, pass `NODE_AUTH_TOKEN`
via `env` and let the default `vp install` run — no `registry-url` needed.
When `NODE_AUTH_TOKEN` is set, the action auto-generates a matching
`_authToken` entry at `$RUNNER_TEMP/.npmrc` for each registry declared in your
repo `.npmrc` that doesn't already have one, so your repo `.npmrc` can stay
minimal:
Set `registry-url` explicitly and pass `NODE_AUTH_TOKEN` via `env` to configure
authentication for that registry:

```yaml
# .npmrc in the repo (auth line not required — action adds it):
# @myorg:registry=https://npm.pkg.github.com

steps:
- uses: actions/checkout@v7
- uses: voidzero-dev/setup-vp@v1.21.1
with:
node-version: "lts"
registry-url: "https://npm.pkg.github.com"
scope: "@myorg"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
```

If you already have the `_authToken` line in your repo `.npmrc` (e.g. for local
dev symmetry), that's respected as-is and the action won't overwrite it.

Alternatively, pass `registry-url` explicitly to bypass the action's repo-level
`.npmrc` detection and auth propagation logic (the package manager may still
read the repo `.npmrc` per its own config resolution):
Without `registry-url`, the action respects existing auth entries in the project
`.npmrc` and propagates their referenced environment variables. It does not
create token entries from registry URLs in repository files, even when
`NODE_AUTH_TOKEN` is set. Jobs that relied on automatic token entries must set
`registry-url` or supply an explicit auth entry:

```yaml
steps:
- uses: actions/checkout@v7
- uses: voidzero-dev/setup-vp@v1.21.1
with:
node-version: "lts"
registry-url: "https://npm.pkg.github.com"
scope: "@myorg"
run-install: false
- run: vp install
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
```ini
@myorg:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
```

An explicit `registry-url` bypasses project `.npmrc` auth propagation in the
action. The package manager still reads the project `.npmrc` according to its
own configuration rules.

### With Socket Firewall Free (sfw)

Set `sfw: true` to wrap `vp install` with [Socket Firewall Free](https://docs.socket.dev/docs/socket-firewall-free). The action downloads the matching `sfw` binary from the upstream [releases](https://github.com/SocketDev/sfw-free/releases) (auto-detected per OS/arch, with musl support on Alpine) and runs `sfw vp install …` so the underlying npm / pnpm / yarn fetches are inspected before packages are installed. Works on Linux, macOS, and Windows:
Expand Down Expand Up @@ -623,7 +612,7 @@ Include `gitlab/setup-vp-windows.yml` instead of `gitlab/setup-vp.yml` on a Powe
- The template expects a Unix-like runner image with Node.js, `bash`, and either `curl` or `wget`.
- Node.js must be available to start the bootstrap. Set `node-version` or `node-version-file` to select the project runtime with `vp env use`. Neither can be combined with `node-manager: "false"`; omit both to retain Vite+'s normal project-based resolution.
- Vite+ version precedence matches GitHub Actions: explicit `version`, explicit `version-file`, project package/catalog pin, lockfile, then `latest`. Paths are relative to `working-directory`. An unresolvable explicit `version-file` warns and falls back to `latest`.
- Without `registry-url`, an existing project `.npmrc` is inspected for registry auth. Missing token entries use `NODE_AUTH_TOKEN` when available; existing entries and referenced token variables are preserved.
- Without `registry-url`, existing project `.npmrc` auth entries and referenced token variables are preserved. Registry URLs in repository files do not receive automatic token entries. Set `registry-url` to generate auth configuration for a registry.

## Azure Pipelines

Expand Down Expand Up @@ -697,7 +686,7 @@ On refs containing these changes, `nodeVersion` selects the managed project runt

Finalize tasks also expose `version` and `cacheHit` as named outputs. Use `$(setupVpUnix.version)` on Linux/macOS or `$(setupVpWindows.version)` on Windows; `stepName` changes the prefix. `cacheHit` is `true` only for an exact match, while the job variable retains Azure's `inexact` value. Cross-job consumers use Azure's `dependencies.<job>.outputs['setupVpUnix.version']` syntax. [Azure output variables](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/set-variables-scripts?view=azure-devops)

Without `registryUrl`, the runtime can supplement the project's `.npmrc` with `NODE_AUTH_TOKEN`. Other referenced token variables are propagated as secret pipeline variables, not public outputs. Pass custom secret mappings through `authEnv`, for example `authEnv: { CUSTOM_TOKEN: "$(CUSTOM_TOKEN)" }`. Azure does not automatically put secret pipeline variables in task environments.
Without `registryUrl`, the runtime preserves existing project `.npmrc` auth entries but does not generate token entries for its registry URLs. Set `registryUrl` to generate auth configuration for a registry. Referenced custom token variables are propagated as secret pipeline variables, not public outputs. Pass custom secret mappings through `authEnv`, for example `authEnv: { CUSTOM_TOKEN: "$(CUSTOM_TOKEN)" }`. Azure does not automatically put secret pipeline variables in task environments.

### Azure Notes

Expand Down
4 changes: 1 addition & 3 deletions dist/azure/index.mjs

Large diffs are not rendered by default.

4 changes: 1 addition & 3 deletions dist/gitlab/index.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.mjs

Large diffs are not rendered by default.

95 changes: 21 additions & 74 deletions src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,36 +315,24 @@ describe("propagateProjectNpmrcAuth", () => {
expect(() => propagateProjectNpmrcAuth(projectDir)).toThrow("EACCES");
});

it("auto-writes _authToken for a scoped registry when NODE_AUTH_TOKEN is set", () => {
mockNpmrc("@myorg:registry=https://npm.pkg.github.com");
vi.stubEnv("NODE_AUTH_TOKEN", "ghp_xxx");

propagateProjectNpmrcAuth(projectDir);

expect(writeFileSync).toHaveBeenCalledWith(
supplementalPath,
expect.stringContaining("//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}"),
);
expect(exportVariable).toHaveBeenCalledWith("NPM_CONFIG_USERCONFIG", supplementalPath);
expect(exportVariable).toHaveBeenCalledWith("PNPM_CONFIG_USERCONFIG", supplementalPath);
expect(exportVariable).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "ghp_xxx");
});

it("auto-writes _authToken for the default registry", () => {
mockNpmrc("registry=https://registry.example.com");
vi.stubEnv("NODE_AUTH_TOKEN", "tok");
it.each([
"registry=https://attacker.invalid/",
"@scope:registry=https://attacker.invalid/npm/",
"registry=https://registry.npmjs.org/\n@scope:registry=https://attacker.invalid/",
])("does not add auth for repository-controlled registries: %s", (content) => {
mockNpmrc(content);
vi.stubEnv("NODE_AUTH_TOKEN", "secret");

propagateProjectNpmrcAuth(projectDir);

expect(writeFileSync).toHaveBeenCalledWith(
supplementalPath,
expect.stringContaining("//registry.example.com/:_authToken=${NODE_AUTH_TOKEN}"),
);
expect(writeFileSync).not.toHaveBeenCalled();
expect(exportVariable).not.toHaveBeenCalled();
});

it("does not overwrite existing _authToken entries in the project .npmrc", () => {
it("preserves explicit auth without adding tokens for other project registries", () => {
mockNpmrc(
[
"registry=https://attacker.invalid/",
"@myorg:registry=https://npm.pkg.github.com",
"//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}",
].join("\n"),
Expand All @@ -355,7 +343,7 @@ describe("propagateProjectNpmrcAuth", () => {
propagateProjectNpmrcAuth(projectDir);

expect(writeFileSync).not.toHaveBeenCalled();
expect(exportVariable).toHaveBeenCalledWith("GITHUB_TOKEN", "gh-token");
expect(exportVariable).toHaveBeenCalledExactlyOnceWith("GITHUB_TOKEN", "gh-token");
});

it("does not write supplemental .npmrc when NODE_AUTH_TOKEN is not set", () => {
Expand All @@ -368,63 +356,21 @@ describe("propagateProjectNpmrcAuth", () => {
expect(exportVariable).not.toHaveBeenCalledWith("PNPM_CONFIG_USERCONFIG", expect.anything());
});

it("writes _authToken for multiple missing registries", () => {
it("leaves the runner user config unchanged for project registries", () => {
mockNpmrc(
["@a:registry=https://one.example.com", "@b:registry=https://two.example.com"].join("\n"),
"@scope:registry=https://attacker.invalid/",
"strict-ssl=true\n//trusted.example/:_authToken=${CUSTOM_TOKEN}",
);
vi.stubEnv("NODE_AUTH_TOKEN", "tok");

propagateProjectNpmrcAuth(projectDir);

const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
expect(written).toContain("//one.example.com/:_authToken=${NODE_AUTH_TOKEN}");
expect(written).toContain("//two.example.com/:_authToken=${NODE_AUTH_TOKEN}");
});

it("preserves unrelated lines already in RUNNER_TEMP/.npmrc", () => {
mockNpmrc(
"@myorg:registry=https://npm.pkg.github.com",
"always-auth=true\n//other.example.com/:_authToken=preserved",
);
vi.stubEnv("NODE_AUTH_TOKEN", "tok");

propagateProjectNpmrcAuth(projectDir);

const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
expect(written).toContain("always-auth=true");
expect(written).toContain("//other.example.com/:_authToken=preserved");
expect(written).toContain("//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}");
});

it("replaces stale _authToken for the same registry in RUNNER_TEMP/.npmrc", () => {
mockNpmrc(
"@myorg:registry=https://npm.pkg.github.com",
"//npm.pkg.github.com/:_authToken=old-value",
);
vi.stubEnv("NODE_AUTH_TOKEN", "tok");

propagateProjectNpmrcAuth(projectDir);

const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
expect(written).not.toContain("old-value");
expect(written).toContain("//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}");
});

it("skips the write on re-run when RUNNER_TEMP/.npmrc already matches", () => {
mockNpmrc(
"@myorg:registry=https://npm.pkg.github.com",
`//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}`,
);
vi.stubEnv("NODE_AUTH_TOKEN", "tok");
vi.stubEnv("NODE_AUTH_TOKEN", "secret");

propagateProjectNpmrcAuth(projectDir);

expect(writeFileSync).not.toHaveBeenCalled();
expect(exportVariable).toHaveBeenCalledWith("NPM_CONFIG_USERCONFIG", supplementalPath);
expect(exportVariable).toHaveBeenCalledWith("PNPM_CONFIG_USERCONFIG", supplementalPath);
expect(exportVariable).not.toHaveBeenCalled();
expect(readFileSync).toHaveBeenCalledExactlyOnceWith(npmrcPath, "utf8");
});

it("skips registries whose value contains ${VAR} (cannot synthesize a valid auth key)", () => {
it("propagates environment references in registry values without adding auth", () => {
mockNpmrc("@myorg:registry=${CUSTOM_REGISTRY}");
vi.stubEnv("NODE_AUTH_TOKEN", "tok");
vi.stubEnv("CUSTOM_REGISTRY", "https://npm.example.com");
Expand All @@ -435,7 +381,7 @@ describe("propagateProjectNpmrcAuth", () => {
expect(exportVariable).toHaveBeenCalledWith("CUSTOM_REGISTRY", "https://npm.example.com");
});

it("treats _authToken key case-insensitively when checking project .npmrc", () => {
it("propagates token references from uppercase auth keys without adding auth", () => {
mockNpmrc(
[
"@myorg:registry=https://npm.pkg.github.com",
Expand All @@ -447,5 +393,6 @@ describe("propagateProjectNpmrcAuth", () => {
propagateProjectNpmrcAuth(projectDir);

expect(writeFileSync).not.toHaveBeenCalled();
expect(exportVariable).toHaveBeenCalledExactlyOnceWith("NODE_AUTH_TOKEN", "tok");
});
});
50 changes: 5 additions & 45 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,6 @@ function stripProtocol(url: string): string {
return url.replace(/^\w+:/, "");
}

function authKeyFor(registryUrl: string): string {
return (stripProtocol(registryUrl) + ":_authtoken").toLowerCase();
}

function buildAuthLine(registryUrl: string): string {
return `${stripProtocol(registryUrl)}:_authToken=${NODE_AUTH_TOKEN_REF}`;
}
Expand Down Expand Up @@ -101,55 +97,19 @@ function isReservedEnvVar(name: string): boolean {
return false;
}

function writeSupplementalAuth(registries: string[]): void {
const npmrcPath = getRunnerNpmrcPath();
const authKeysToReplace = new Set(registries.map(authKeyFor));

const existing = readNpmrc(npmrcPath);
const existingLines = existing === undefined ? [] : existing.split(/\r?\n/);

const keepLines = existingLines.filter((line) => {
const eq = line.indexOf("=");
if (eq <= 0) return true;
return !authKeysToReplace.has(line.slice(0, eq).trim().toLowerCase());
});

const nextContent = [...keepLines, ...registries.map(buildAuthLine)].join(EOL);
exportVariable("NPM_CONFIG_USERCONFIG", npmrcPath);
exportVariable("PNPM_CONFIG_USERCONFIG", npmrcPath); // For pnpm 11+

if (existing === nextContent) {
debug(`Supplemental .npmrc at ${npmrcPath} already current`);
return;
}

writeFileSync(npmrcPath, nextContent);
info(`Wrote _authToken entries to ${npmrcPath} for registries: ${registries.join(", ")}`);
}

/**
* Handle auth for the project's existing `.npmrc` without requiring
* `registry-url` in the workflow.
* Re-export environment references already present in the project's `.npmrc`
* so they remain visible to package-manager subprocesses and subsequent steps.
*
* - If `.npmrc` declares a custom registry but no matching `_authToken` entry
* and `NODE_AUTH_TOKEN` is set, write a supplemental `_authToken=${NODE_AUTH_TOKEN}`
* line to `$RUNNER_TEMP/.npmrc` and point `NPM_CONFIG_USERCONFIG` at it, so the
* repo `.npmrc` can stay to just `@scope:registry=<url>`.
* - For any `${VAR}` references already in the project `.npmrc`, re-export those
* env vars via `GITHUB_ENV` so they remain visible to package-manager
* subprocesses and subsequent steps.
* Do not add auth for repository-controlled registry URLs. Generating token
* entries requires an explicit `registry-url` input via configAuthentication.
*/
export function propagateProjectNpmrcAuth(projectDir: string): void {
const npmrcPath = join(projectDir, ".npmrc");
const content = readNpmrc(npmrcPath);
if (content === undefined) return;

const { registriesNeedingAuth, envVarRefs } = analyzeProjectNpmrc(content);

if (process.env.NODE_AUTH_TOKEN && registriesNeedingAuth.length > 0) {
writeSupplementalAuth(registriesNeedingAuth);
envVarRefs.add("NODE_AUTH_TOKEN");
}
const { envVarRefs } = analyzeProjectNpmrc(content);

const propagatable = [...envVarRefs].filter(
(name) => !isReservedEnvVar(name) && !!process.env[name],
Expand Down
19 changes: 19 additions & 0 deletions src/azure/parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,25 @@ describe("Azure parity", () => {
);
});

it("does not attach NODE_AUTH_TOKEN to repository-controlled registries", async () => {
const { project, env, ports } = fixture();
writeFileSync(
path.join(project, ".npmrc"),
"registry=https://attacker.invalid/\n@scope:registry=https://other.invalid/npm/\n",
);
const target: NodeJS.ProcessEnv = { ...env, NODE_AUTH_TOKEN: "secret" };

await runFinalize(target, ports);
if (target.NPM_CONFIG_USERCONFIG) directories.push(path.dirname(target.NPM_CONFIG_USERCONFIG));

expect(target.NPM_CONFIG_USERCONFIG).toBeUndefined();
expect(target.PNPM_CONFIG_USERCONFIG).toBeUndefined();
expect(ports.setVariable.mock.calls.some(([name]) => name.endsWith("CONFIG_USERCONFIG"))).toBe(
false,
);
expect(ports.runInstall).toHaveBeenCalledWith(expect.anything(), project, "vp", target);
});

it("prepares a version/platform-specific sfw cache only when an install will run", async () => {
const { env, ports } = fixture();
await runPrepare({ ...env, SETUP_VP_SFW: "true", SETUP_VP_RUN_INSTALL: "false" }, ports);
Expand Down
42 changes: 37 additions & 5 deletions src/ci/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,35 @@ afterEach(() => {
});

describe("portable project auth", () => {
it("supplements scoped registries, preserves user config, and never writes secret values", () => {
it.each([
"registry=https://attacker.invalid/",
"@scope:registry=https://attacker.invalid/npm/",
"registry=https://registry.npmjs.org/\n@scope:registry=https://attacker.invalid/",
])("does not add auth for repository-controlled registries: %s", (content) => {
const project = tempDir();
const projectNpmrc = path.join(project, ".npmrc");
const userConfig = path.join(tempDir(), ".npmrc");
writeFileSync(projectNpmrc, content);
writeFileSync(userConfig, "strict-ssl=true\n");
const env: RuntimeEnv = {
NODE_AUTH_TOKEN: "secret",
NPM_CONFIG_USERCONFIG: userConfig,
PNPM_CONFIG_USERCONFIG: userConfig,
};
const originalEnv = { ...env };
const exporter = vi.fn();

const npmrc = configureAuth("", "", env, exporter, project);
if (npmrc) directories.push(path.dirname(npmrc));

expect(npmrc).toBeUndefined();
expect(env).toEqual(originalEnv);
expect(exporter).not.toHaveBeenCalled();
expect(readFileSync(userConfig, "utf8")).toBe("strict-ssl=true\n");
expect(readFileSync(projectNpmrc, "utf8")).toBe(content);
});

it("authenticates only the explicit registry and preserves existing user config", () => {
const project = tempDir();
const userConfig = path.join(tempDir(), ".npmrc");
const content =
Expand All @@ -29,17 +57,20 @@ describe("portable project auth", () => {
NPM_CONFIG_USERCONFIG: userConfig,
};
const exporter = vi.fn();
const npmrc = configureAuth("", "", env, exporter, project)!;
const npmrc = configureAuth("https://trusted.example/npm/", "trusted", env, exporter, project)!;
directories.push(path.dirname(npmrc));
const result = readFileSync(npmrc, "utf8");
expect(result).toContain("strict-ssl=true");
expect(result).toContain("//registry.example/npm/:_authToken=${NODE_AUTH_TOKEN}");
expect(result).toContain("//trusted.example/npm/:_authToken=${NODE_AUTH_TOKEN}");
expect(result).toContain("@trusted:registry=https://trusted.example/npm/");
expect(result).not.toContain("registry.example");
expect(result).not.toContain("other.example");
expect(result).not.toContain("secret");
expect(readFileSync(path.join(project, ".npmrc"), "utf8")).toBe(content);
// Windows does not expose POSIX owner/group permission bits.
if (process.platform !== "win32") expect(statSync(npmrc).mode & 0o777).toBe(0o600);
expect(exporter).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "secret");
expect(exporter).toHaveBeenCalledWith("CUSTOM_TOKEN", "other-secret");
expect(exporter).not.toHaveBeenCalledWith("CUSTOM_TOKEN", expect.anything());
expect(env.PNPM_CONFIG_USERCONFIG).toBe(npmrc);
});

Expand All @@ -48,7 +79,8 @@ describe("portable project auth", () => {
writeFileSync(
path.join(project, ".npmrc"),
[
"registry=https://registry.example/",
"registry=https://attacker.invalid/",
"@scope:registry=https://registry.example/",
"//registry.example/:_authToken=${CUSTOM_TOKEN}",
"@other:registry=https://${REGISTRY_HOST}/",
"cache=${PATH}",
Expand Down
Loading
Loading