Skip to content

Commit e79fc32

Browse files
committed
feat: respect project .npmrc without requiring registry-url
When the workflow does not pass `registry-url`, detect the project's `.npmrc`, scan it for `${VAR}` references, and re-export any referenced env vars that are set (e.g. `NODE_AUTH_TOKEN`) via `@actions/core` `exportVariable`. This writes them to `GITHUB_ENV`, so the token is reliably visible to the `vp install` subprocess and subsequent steps. Users with a repo-level `.npmrc` (common for GitHub Packages setups) no longer have to duplicate their registry config in the workflow. Closes #50
1 parent e1660b7 commit e79fc32

5 files changed

Lines changed: 250 additions & 3 deletions

File tree

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,28 @@ steps:
9494
9595
### With Private Registry (GitHub Packages)
9696
97-
When using `registry-url`, set `run-install: false` and run install manually with the auth token, otherwise the default auto-install will fail for private packages.
97+
If your repo already has a `.npmrc` that declares the registry, you can just pass
98+
`NODE_AUTH_TOKEN` via `env` and let the default `vp install` run — no
99+
`registry-url` needed. The action detects the project `.npmrc`, propagates any
100+
referenced auth env vars (`${NODE_AUTH_TOKEN}`, `${GITHUB_TOKEN}`, etc.) to
101+
subsequent steps, and `vp install` picks up the existing registry config.
102+
103+
```yaml
104+
# .npmrc in the repo:
105+
# @myorg:registry=https://npm.pkg.github.com
106+
# //npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
107+
108+
steps:
109+
- uses: actions/checkout@v6
110+
- uses: voidzero-dev/setup-vp@v1
111+
with:
112+
node-version: "lts"
113+
env:
114+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
115+
```
116+
117+
If the repo does **not** have an `.npmrc`, use `registry-url` to have the action
118+
generate one at `$RUNNER_TEMP/.npmrc`:
98119

99120
```yaml
100121
steps:

dist/index.mjs

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { State, Outputs } from "./types.js";
99
import type { Inputs } from "./types.js";
1010
import { resolveNodeVersionFile } from "./node-version-file.js";
1111
import { configAuthentication } from "./auth.js";
12+
import { propagateProjectNpmrcAuth } from "./npmrc-detect.js";
1213
import { getConfiguredProjectDir } from "./utils.js";
1314

1415
async function runMain(inputs: Inputs): Promise<void> {
@@ -31,9 +32,14 @@ async function runMain(inputs: Inputs): Promise<void> {
3132
await exec("vp", ["env", "use", nodeVersion]);
3233
}
3334

34-
// Step 4: Configure registry authentication if specified
35+
// Step 4: Configure registry authentication
3536
if (inputs.registryUrl) {
3637
configAuthentication(inputs.registryUrl, inputs.scope);
38+
} else {
39+
// No explicit registry-url: respect the project's .npmrc if present.
40+
// Propagate referenced auth env vars (e.g. NODE_AUTH_TOKEN) via GITHUB_ENV
41+
// so they survive into package-manager subprocesses and later steps.
42+
propagateProjectNpmrcAuth(projectDir);
3743
}
3844

3945
// Step 5: Restore cache if enabled

src/npmrc-detect.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
2+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { detectProjectNpmrc, propagateProjectNpmrcAuth } from "./npmrc-detect.js";
6+
import { exportVariable, info } from "@actions/core";
7+
8+
vi.mock("@actions/core", () => ({
9+
info: vi.fn(),
10+
debug: vi.fn(),
11+
exportVariable: vi.fn(),
12+
}));
13+
14+
describe("detectProjectNpmrc", () => {
15+
let workDir: string;
16+
17+
beforeEach(() => {
18+
workDir = mkdtempSync(join(tmpdir(), "setup-vp-npmrc-"));
19+
});
20+
21+
afterEach(() => {
22+
rmSync(workDir, { recursive: true, force: true });
23+
});
24+
25+
it("returns undefined when no .npmrc exists", () => {
26+
expect(detectProjectNpmrc(workDir)).toBeUndefined();
27+
});
28+
29+
it("returns path and env vars when .npmrc references ${NODE_AUTH_TOKEN}", () => {
30+
const npmrcPath = join(workDir, ".npmrc");
31+
writeFileSync(
32+
npmrcPath,
33+
[
34+
"@myorg:registry=https://npm.pkg.github.com",
35+
"//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}",
36+
].join("\n"),
37+
);
38+
39+
const result = detectProjectNpmrc(workDir);
40+
expect(result).toEqual({
41+
path: npmrcPath,
42+
envVars: ["NODE_AUTH_TOKEN"],
43+
});
44+
});
45+
46+
it("collects multiple distinct env var references", () => {
47+
writeFileSync(
48+
join(workDir, ".npmrc"),
49+
[
50+
"@orgA:registry=https://npm.pkg.github.com",
51+
"//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}",
52+
"@orgB:registry=https://registry.example.com",
53+
"//registry.example.com/:_authToken=${NPM_TOKEN}",
54+
].join("\n"),
55+
);
56+
57+
const result = detectProjectNpmrc(workDir);
58+
expect(result?.envVars.sort()).toEqual(["GITHUB_TOKEN", "NPM_TOKEN"]);
59+
});
60+
61+
it("returns empty envVars when .npmrc has no ${...} references", () => {
62+
writeFileSync(join(workDir, ".npmrc"), "registry=https://registry.npmjs.org/");
63+
64+
const result = detectProjectNpmrc(workDir);
65+
expect(result?.envVars).toEqual([]);
66+
});
67+
});
68+
69+
describe("propagateProjectNpmrcAuth", () => {
70+
let workDir: string;
71+
72+
beforeEach(() => {
73+
workDir = mkdtempSync(join(tmpdir(), "setup-vp-npmrc-"));
74+
});
75+
76+
afterEach(() => {
77+
rmSync(workDir, { recursive: true, force: true });
78+
vi.unstubAllEnvs();
79+
vi.resetAllMocks();
80+
});
81+
82+
it("does nothing when there is no project .npmrc", () => {
83+
propagateProjectNpmrcAuth(workDir);
84+
expect(exportVariable).not.toHaveBeenCalled();
85+
});
86+
87+
it("exports referenced env vars that are set in the environment", () => {
88+
writeFileSync(join(workDir, ".npmrc"), "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}");
89+
vi.stubEnv("NODE_AUTH_TOKEN", "my-real-token");
90+
91+
propagateProjectNpmrcAuth(workDir);
92+
93+
expect(exportVariable).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "my-real-token");
94+
expect(info).toHaveBeenCalledWith(expect.stringContaining(".npmrc"));
95+
});
96+
97+
it("skips env vars that are not set", () => {
98+
writeFileSync(join(workDir, ".npmrc"), "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}");
99+
vi.stubEnv("NODE_AUTH_TOKEN", "");
100+
101+
propagateProjectNpmrcAuth(workDir);
102+
103+
expect(exportVariable).not.toHaveBeenCalled();
104+
});
105+
106+
it("does not re-export PATH or HOME even if referenced", () => {
107+
// Reserved/system vars should not be exported to GITHUB_ENV via exportVariable
108+
writeFileSync(join(workDir, ".npmrc"), "cache=${HOME}/.npm-cache");
109+
vi.stubEnv("HOME", "/home/runner");
110+
111+
propagateProjectNpmrcAuth(workDir);
112+
113+
expect(exportVariable).not.toHaveBeenCalledWith("HOME", expect.anything());
114+
});
115+
116+
it("exports all referenced auth-like env vars", () => {
117+
writeFileSync(
118+
join(workDir, ".npmrc"),
119+
[
120+
"//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}",
121+
"//registry.example.com/:_authToken=${NPM_TOKEN}",
122+
].join("\n"),
123+
);
124+
vi.stubEnv("GITHUB_TOKEN", "gh-token");
125+
vi.stubEnv("NPM_TOKEN", "npm-token");
126+
127+
propagateProjectNpmrcAuth(workDir);
128+
129+
expect(exportVariable).toHaveBeenCalledWith("GITHUB_TOKEN", "gh-token");
130+
expect(exportVariable).toHaveBeenCalledWith("NPM_TOKEN", "npm-token");
131+
});
132+
133+
it("works when .npmrc is in a nested working directory", () => {
134+
const nested = join(workDir, "packages", "app");
135+
mkdirSync(nested, { recursive: true });
136+
writeFileSync(join(nested, ".npmrc"), "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}");
137+
vi.stubEnv("NODE_AUTH_TOKEN", "abc");
138+
139+
propagateProjectNpmrcAuth(nested);
140+
141+
expect(exportVariable).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "abc");
142+
});
143+
});

src/npmrc-detect.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { exportVariable, info, debug } from "@actions/core";
4+
5+
export interface ProjectNpmrc {
6+
path: string;
7+
envVars: string[];
8+
}
9+
10+
// Env vars that should never be re-exported via GITHUB_ENV (system/runner-managed)
11+
const RESERVED_ENV_VARS = new Set([
12+
"PATH",
13+
"HOME",
14+
"USERPROFILE",
15+
"TMPDIR",
16+
"RUNNER_TEMP",
17+
"RUNNER_OS",
18+
"RUNNER_ARCH",
19+
"GITHUB_ACTIONS",
20+
"GITHUB_WORKSPACE",
21+
"GITHUB_REPOSITORY",
22+
"GITHUB_REPOSITORY_OWNER",
23+
"CI",
24+
]);
25+
26+
/**
27+
* Detect a project-level `.npmrc` at the given directory and collect any
28+
* `${VAR}` env var references inside it.
29+
*/
30+
export function detectProjectNpmrc(projectDir: string): ProjectNpmrc | undefined {
31+
const npmrcPath = join(projectDir, ".npmrc");
32+
if (!existsSync(npmrcPath)) return undefined;
33+
34+
const content = readFileSync(npmrcPath, "utf8");
35+
const seen = new Set<string>();
36+
const envVars: string[] = [];
37+
for (const match of content.matchAll(/\$\{(\w+)\}/g)) {
38+
const name = match[1];
39+
if (name && !seen.has(name)) {
40+
seen.add(name);
41+
envVars.push(name);
42+
}
43+
}
44+
45+
return { path: npmrcPath, envVars };
46+
}
47+
48+
/**
49+
* When the project has an `.npmrc` referencing env vars (commonly
50+
* `${NODE_AUTH_TOKEN}` for private registries), re-export the ones that are
51+
* already set so they persist via `GITHUB_ENV` and remain reliably visible to
52+
* package-manager subprocesses spawned by `vp install` and to subsequent
53+
* workflow steps.
54+
*
55+
* This lets users rely on their existing `.npmrc` without having to also pass
56+
* `registry-url` to `setup-vp` just to get auth forwarding.
57+
*/
58+
export function propagateProjectNpmrcAuth(projectDir: string): void {
59+
const npmrc = detectProjectNpmrc(projectDir);
60+
if (!npmrc) return;
61+
62+
const propagatable = npmrc.envVars.filter(
63+
(name) => !RESERVED_ENV_VARS.has(name) && !!process.env[name],
64+
);
65+
66+
if (propagatable.length === 0) {
67+
debug(`Project .npmrc at ${npmrc.path}: no auth env vars to propagate`);
68+
return;
69+
}
70+
71+
info(
72+
`Detected project .npmrc at ${npmrc.path}. Propagating auth env vars: ${propagatable.join(", ")}`,
73+
);
74+
for (const name of propagatable) {
75+
exportVariable(name, process.env[name]!);
76+
}
77+
}

0 commit comments

Comments
 (0)