Skip to content

Commit 4f5aa3e

Browse files
authored
feat: respect project .npmrc and auto-generate _authToken (#54)
When no `registry-url` is passed, honor the project `.npmrc`: - If `NODE_AUTH_TOKEN` is set and the project `.npmrc` declares a registry without a matching `_authToken`, write a supplemental `_authToken` line to `$RUNNER_TEMP/.npmrc` and point `NPM_CONFIG_USERCONFIG` at it. - Existing `_authToken` lines (case-insensitive) are respected. - `${VAR}` references in the project `.npmrc` that are set in env are re-exported via `GITHUB_ENV` so they reach the `vp install` subprocess and later steps. `RUNNER_*` / `GITHUB_*` prefixes are blocked with `GITHUB_TOKEN` on an explicit allowlist. Registry values containing `${VAR}` are skipped for auth-key synthesis. Closes #50
1 parent e1660b7 commit 4f5aa3e

5 files changed

Lines changed: 449 additions & 25 deletions

File tree

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,32 @@ 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 has a `.npmrc` that declares the registry, pass `NODE_AUTH_TOKEN`
98+
via `env` and let the default `vp install` run — no `registry-url` needed.
99+
When `NODE_AUTH_TOKEN` is set, the action auto-generates a matching
100+
`_authToken` entry at `$RUNNER_TEMP/.npmrc` for each registry declared in your
101+
repo `.npmrc` that doesn't already have one, so your repo `.npmrc` can stay
102+
minimal:
103+
104+
```yaml
105+
# .npmrc in the repo (auth line not required — action adds it):
106+
# @myorg:registry=https://npm.pkg.github.com
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 you already have the `_authToken` line in your repo `.npmrc` (e.g. for local
118+
dev symmetry), that's respected as-is and the action won't overwrite it.
119+
120+
Alternatively, pass `registry-url` explicitly to bypass the action's repo-level
121+
`.npmrc` detection and auth propagation logic (the package manager may still
122+
read the repo `.npmrc` per its own config resolution):
98123

99124
```yaml
100125
steps:

dist/index.mjs

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

src/auth.test.ts

Lines changed: 263 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
2-
import { join } from "node:path";
32
import { existsSync, readFileSync, writeFileSync } from "node:fs";
4-
import { configAuthentication } from "./auth.js";
5-
import { exportVariable } from "@actions/core";
3+
import { join } from "node:path";
4+
import { configAuthentication, propagateProjectNpmrcAuth } from "./auth.js";
5+
import { exportVariable, info } from "@actions/core";
66

77
vi.mock("@actions/core", () => ({
88
debug: vi.fn(),
9+
info: vi.fn(),
910
exportVariable: vi.fn(),
1011
}));
1112

@@ -177,3 +178,262 @@ describe("configAuthentication", () => {
177178
expect(exportVariable).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "my-real-token");
178179
});
179180
});
181+
182+
describe("propagateProjectNpmrcAuth", () => {
183+
const runnerTemp = "/tmp/runner";
184+
const projectDir = "/workspace/project";
185+
const npmrcPath = join(projectDir, ".npmrc");
186+
const supplementalPath = join(runnerTemp, ".npmrc");
187+
188+
function mockNpmrc(content: string, supplemental?: string): void {
189+
vi.mocked(readFileSync).mockImplementation((p) => {
190+
if (p === npmrcPath) return content;
191+
if (p === supplementalPath && supplemental !== undefined) return supplemental;
192+
const err = Object.assign(new Error("ENOENT"), { code: "ENOENT" });
193+
throw err;
194+
});
195+
vi.mocked(existsSync).mockImplementation(
196+
(p) => p === supplementalPath && supplemental !== undefined,
197+
);
198+
}
199+
200+
function mockNoNpmrc(): void {
201+
vi.mocked(readFileSync).mockImplementation(() => {
202+
const err = Object.assign(new Error("ENOENT"), { code: "ENOENT" });
203+
throw err;
204+
});
205+
vi.mocked(existsSync).mockReturnValue(false);
206+
}
207+
208+
beforeEach(() => {
209+
vi.stubEnv("RUNNER_TEMP", runnerTemp);
210+
});
211+
212+
afterEach(() => {
213+
vi.unstubAllEnvs();
214+
vi.resetAllMocks();
215+
});
216+
217+
it("does nothing when there is no project .npmrc", () => {
218+
mockNoNpmrc();
219+
220+
propagateProjectNpmrcAuth(projectDir);
221+
222+
expect(exportVariable).not.toHaveBeenCalled();
223+
expect(writeFileSync).not.toHaveBeenCalled();
224+
});
225+
226+
it("exports referenced env vars that are set in the environment", () => {
227+
mockNpmrc("//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}");
228+
vi.stubEnv("NODE_AUTH_TOKEN", "my-real-token");
229+
230+
propagateProjectNpmrcAuth(projectDir);
231+
232+
expect(exportVariable).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "my-real-token");
233+
expect(info).toHaveBeenCalledWith(expect.stringContaining(".npmrc"));
234+
});
235+
236+
it("skips env vars that are not set", () => {
237+
mockNpmrc("//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}");
238+
vi.stubEnv("NODE_AUTH_TOKEN", "");
239+
240+
propagateProjectNpmrcAuth(projectDir);
241+
242+
expect(exportVariable).not.toHaveBeenCalled();
243+
});
244+
245+
it("does not re-export PATH or HOME even if referenced", () => {
246+
mockNpmrc("cache=${HOME}/.npm-cache");
247+
vi.stubEnv("HOME", "/home/runner");
248+
249+
propagateProjectNpmrcAuth(projectDir);
250+
251+
expect(exportVariable).not.toHaveBeenCalledWith("HOME", expect.anything());
252+
});
253+
254+
it("blocks runner-managed GITHUB_* and RUNNER_* vars by default", () => {
255+
mockNpmrc(
256+
[
257+
"tag=${GITHUB_REF}",
258+
"agent=${RUNNER_NAME}",
259+
"//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}",
260+
].join("\n"),
261+
);
262+
vi.stubEnv("GITHUB_REF", "refs/heads/main");
263+
vi.stubEnv("RUNNER_NAME", "runner-1");
264+
vi.stubEnv("NODE_AUTH_TOKEN", "tok");
265+
266+
propagateProjectNpmrcAuth(projectDir);
267+
268+
expect(exportVariable).not.toHaveBeenCalledWith("GITHUB_REF", expect.anything());
269+
expect(exportVariable).not.toHaveBeenCalledWith("RUNNER_NAME", expect.anything());
270+
expect(exportVariable).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "tok");
271+
});
272+
273+
it("allows GITHUB_TOKEN through as an auth token", () => {
274+
mockNpmrc("//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}");
275+
vi.stubEnv("GITHUB_TOKEN", "gh-token");
276+
277+
propagateProjectNpmrcAuth(projectDir);
278+
279+
expect(exportVariable).toHaveBeenCalledWith("GITHUB_TOKEN", "gh-token");
280+
});
281+
282+
it("exports all referenced auth-like env vars, deduping repeats", () => {
283+
mockNpmrc(
284+
[
285+
"//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}",
286+
"//registry.example.com/:_authToken=${NPM_TOKEN}",
287+
"//other.example.com/:_authToken=${GITHUB_TOKEN}",
288+
].join("\n"),
289+
);
290+
vi.stubEnv("GITHUB_TOKEN", "gh-token");
291+
vi.stubEnv("NPM_TOKEN", "npm-token");
292+
293+
propagateProjectNpmrcAuth(projectDir);
294+
295+
expect(exportVariable).toHaveBeenCalledWith("GITHUB_TOKEN", "gh-token");
296+
expect(exportVariable).toHaveBeenCalledWith("NPM_TOKEN", "npm-token");
297+
const ghCalls = vi.mocked(exportVariable).mock.calls.filter((c) => c[0] === "GITHUB_TOKEN");
298+
expect(ghCalls).toHaveLength(1);
299+
});
300+
301+
it("rethrows non-ENOENT read errors", () => {
302+
vi.mocked(readFileSync).mockImplementation(() => {
303+
throw Object.assign(new Error("EACCES"), { code: "EACCES" });
304+
});
305+
306+
expect(() => propagateProjectNpmrcAuth(projectDir)).toThrow("EACCES");
307+
});
308+
309+
it("auto-writes _authToken for a scoped registry when NODE_AUTH_TOKEN is set", () => {
310+
mockNpmrc("@myorg:registry=https://npm.pkg.github.com");
311+
vi.stubEnv("NODE_AUTH_TOKEN", "ghp_xxx");
312+
313+
propagateProjectNpmrcAuth(projectDir);
314+
315+
expect(writeFileSync).toHaveBeenCalledWith(
316+
supplementalPath,
317+
expect.stringContaining("//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}"),
318+
);
319+
expect(exportVariable).toHaveBeenCalledWith("NPM_CONFIG_USERCONFIG", supplementalPath);
320+
expect(exportVariable).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "ghp_xxx");
321+
});
322+
323+
it("auto-writes _authToken for the default registry", () => {
324+
mockNpmrc("registry=https://registry.example.com");
325+
vi.stubEnv("NODE_AUTH_TOKEN", "tok");
326+
327+
propagateProjectNpmrcAuth(projectDir);
328+
329+
expect(writeFileSync).toHaveBeenCalledWith(
330+
supplementalPath,
331+
expect.stringContaining("//registry.example.com/:_authToken=${NODE_AUTH_TOKEN}"),
332+
);
333+
});
334+
335+
it("does not overwrite existing _authToken entries in the project .npmrc", () => {
336+
mockNpmrc(
337+
[
338+
"@myorg:registry=https://npm.pkg.github.com",
339+
"//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}",
340+
].join("\n"),
341+
);
342+
vi.stubEnv("NODE_AUTH_TOKEN", "ghp_xxx");
343+
vi.stubEnv("GITHUB_TOKEN", "gh-token");
344+
345+
propagateProjectNpmrcAuth(projectDir);
346+
347+
expect(writeFileSync).not.toHaveBeenCalled();
348+
expect(exportVariable).toHaveBeenCalledWith("GITHUB_TOKEN", "gh-token");
349+
});
350+
351+
it("does not write supplemental .npmrc when NODE_AUTH_TOKEN is not set", () => {
352+
mockNpmrc("@myorg:registry=https://npm.pkg.github.com");
353+
354+
propagateProjectNpmrcAuth(projectDir);
355+
356+
expect(writeFileSync).not.toHaveBeenCalled();
357+
expect(exportVariable).not.toHaveBeenCalledWith("NPM_CONFIG_USERCONFIG", expect.anything());
358+
});
359+
360+
it("writes _authToken for multiple missing registries", () => {
361+
mockNpmrc(
362+
["@a:registry=https://one.example.com", "@b:registry=https://two.example.com"].join("\n"),
363+
);
364+
vi.stubEnv("NODE_AUTH_TOKEN", "tok");
365+
366+
propagateProjectNpmrcAuth(projectDir);
367+
368+
const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
369+
expect(written).toContain("//one.example.com/:_authToken=${NODE_AUTH_TOKEN}");
370+
expect(written).toContain("//two.example.com/:_authToken=${NODE_AUTH_TOKEN}");
371+
});
372+
373+
it("preserves unrelated lines already in RUNNER_TEMP/.npmrc", () => {
374+
mockNpmrc(
375+
"@myorg:registry=https://npm.pkg.github.com",
376+
"always-auth=true\n//other.example.com/:_authToken=preserved",
377+
);
378+
vi.stubEnv("NODE_AUTH_TOKEN", "tok");
379+
380+
propagateProjectNpmrcAuth(projectDir);
381+
382+
const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
383+
expect(written).toContain("always-auth=true");
384+
expect(written).toContain("//other.example.com/:_authToken=preserved");
385+
expect(written).toContain("//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}");
386+
});
387+
388+
it("replaces stale _authToken for the same registry in RUNNER_TEMP/.npmrc", () => {
389+
mockNpmrc(
390+
"@myorg:registry=https://npm.pkg.github.com",
391+
"//npm.pkg.github.com/:_authToken=old-value",
392+
);
393+
vi.stubEnv("NODE_AUTH_TOKEN", "tok");
394+
395+
propagateProjectNpmrcAuth(projectDir);
396+
397+
const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
398+
expect(written).not.toContain("old-value");
399+
expect(written).toContain("//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}");
400+
});
401+
402+
it("skips the write on re-run when RUNNER_TEMP/.npmrc already matches", () => {
403+
mockNpmrc(
404+
"@myorg:registry=https://npm.pkg.github.com",
405+
`//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}`,
406+
);
407+
vi.stubEnv("NODE_AUTH_TOKEN", "tok");
408+
409+
propagateProjectNpmrcAuth(projectDir);
410+
411+
expect(writeFileSync).not.toHaveBeenCalled();
412+
expect(exportVariable).toHaveBeenCalledWith("NPM_CONFIG_USERCONFIG", supplementalPath);
413+
});
414+
415+
it("skips registries whose value contains ${VAR} (cannot synthesize a valid auth key)", () => {
416+
mockNpmrc("@myorg:registry=${CUSTOM_REGISTRY}");
417+
vi.stubEnv("NODE_AUTH_TOKEN", "tok");
418+
vi.stubEnv("CUSTOM_REGISTRY", "https://npm.example.com");
419+
420+
propagateProjectNpmrcAuth(projectDir);
421+
422+
expect(writeFileSync).not.toHaveBeenCalled();
423+
expect(exportVariable).toHaveBeenCalledWith("CUSTOM_REGISTRY", "https://npm.example.com");
424+
});
425+
426+
it("treats _authToken key case-insensitively when checking project .npmrc", () => {
427+
mockNpmrc(
428+
[
429+
"@myorg:registry=https://npm.pkg.github.com",
430+
"//npm.pkg.github.com/:_AUTHTOKEN=${NODE_AUTH_TOKEN}",
431+
].join("\n"),
432+
);
433+
vi.stubEnv("NODE_AUTH_TOKEN", "tok");
434+
435+
propagateProjectNpmrcAuth(projectDir);
436+
437+
expect(writeFileSync).not.toHaveBeenCalled();
438+
});
439+
});

0 commit comments

Comments
 (0)