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
24 changes: 24 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,30 @@ jobs:
echo "Installed version: ${{ steps.setup.outputs.version }}"
echo "Cache hit: ${{ steps.setup.outputs.cache-hit }}"

test-registry-url:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6

- name: Setup Vite+ with registry-url
uses: ./
with:
run-install: false
cache: false
registry-url: "https://npm.pkg.github.com"
scope: "@voidzero-dev"

- name: Verify .npmrc was created
run: |
echo "NPM_CONFIG_USERCONFIG=$NPM_CONFIG_USERCONFIG"
cat "$NPM_CONFIG_USERCONFIG"
grep -q "@voidzero-dev:registry=https://npm.pkg.github.com/" "$NPM_CONFIG_USERCONFIG"
grep -q "_authToken=\${NODE_AUTH_TOKEN}" "$NPM_CONFIG_USERCONFIG"

- name: Verify NODE_AUTH_TOKEN is exported
run: |
echo "NODE_AUTH_TOKEN is set: ${NODE_AUTH_TOKEN:+yes}"

build:
runs-on: ubuntu-latest
steps:
Expand Down
36 changes: 28 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,24 @@ steps:
- cwd: ./packages/lib
```

### With Private Registry (GitHub Packages)

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.

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

### Matrix Testing with Multiple Node.js Versions

```yaml
Expand All @@ -100,14 +118,16 @@ jobs:

## Inputs

| Input | Description | Required | Default |
| ----------------------- | ----------------------------------------------------------------------------------------------------- | -------- | ------------- |
| `version` | Version of Vite+ to install | No | `latest` |
| `node-version` | Node.js version to install via `vp env use` | No | Latest LTS |
| `node-version-file` | Path to file containing Node.js version (`.nvmrc`, `.node-version`, `.tool-versions`, `package.json`) | No | |
| `run-install` | Run `vp install` after setup. Accepts boolean or YAML object with `cwd`/`args` | No | `true` |
| `cache` | Enable caching of project dependencies | No | `false` |
| `cache-dependency-path` | Path to lock file for cache key generation | No | Auto-detected |
| Input | Description | Required | Default |
| ----------------------- | --------------------------------------------------------------------------------------------------------- | -------- | ------------- |
| `version` | Version of Vite+ to install | No | `latest` |
| `node-version` | Node.js version to install via `vp env use` | No | Latest LTS |
| `node-version-file` | Path to file containing Node.js version (`.nvmrc`, `.node-version`, `.tool-versions`, `package.json`) | No | |
| `run-install` | Run `vp install` after setup. Accepts boolean or YAML object with `cwd`/`args` | No | `true` |
| `cache` | Enable caching of project dependencies | No | `false` |
| `cache-dependency-path` | Path to lock file for cache key generation | No | Auto-detected |
| `registry-url` | Optional registry to set up for auth. Sets the registry in `.npmrc` and reads auth from `NODE_AUTH_TOKEN` | No | |
| `scope` | Optional scope for scoped registries. Falls back to repo owner for GitHub Packages | No | |

## Outputs

Expand Down
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ inputs:
cache-dependency-path:
description: "Path to lock file for cache key generation. Auto-detected if not specified."
required: false
registry-url:
description: "Optional registry to set up for auth. Will write .npmrc in $RUNNER_TEMP with registry and auth config, and set NPM_CONFIG_USERCONFIG to point npm at it. Auth token is read from env.NODE_AUTH_TOKEN."
required: false
scope:
description: "Optional scope for authenticating against scoped registries. Will fall back to the repository owner when using the GitHub Packages registry (https://npm.pkg.github.com/)."
required: false

outputs:
version:
Expand Down
150 changes: 75 additions & 75 deletions dist/index.mjs

Large diffs are not rendered by default.

179 changes: 179 additions & 0 deletions src/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
import { join } from "node:path";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { configAuthentication } from "./auth.js";
import { exportVariable } from "@actions/core";

vi.mock("@actions/core", () => ({
debug: vi.fn(),
exportVariable: vi.fn(),
}));

vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
};
});

describe("configAuthentication", () => {
const runnerTemp = "/tmp/runner";

beforeEach(() => {
vi.stubEnv("RUNNER_TEMP", runnerTemp);
vi.mocked(existsSync).mockReturnValue(false);
});

afterEach(() => {
vi.unstubAllEnvs();
vi.resetAllMocks();
});

it("should write .npmrc with registry and auth token", () => {
configAuthentication("https://registry.npmjs.org/");

const expectedPath = join(runnerTemp, ".npmrc");
expect(writeFileSync).toHaveBeenCalledWith(
expectedPath,
expect.stringContaining("//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}"),
);
expect(writeFileSync).toHaveBeenCalledWith(
expectedPath,
expect.stringContaining("registry=https://registry.npmjs.org/"),
);
});

it("should append trailing slash if missing", () => {
configAuthentication("https://registry.npmjs.org");

expect(writeFileSync).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining("registry=https://registry.npmjs.org/"),
);
});

it("should auto-detect scope for GitHub Packages registry", () => {
vi.stubEnv("GITHUB_REPOSITORY_OWNER", "voidzero-dev");

configAuthentication("https://npm.pkg.github.com");

expect(writeFileSync).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining("@voidzero-dev:registry=https://npm.pkg.github.com/"),
);
});

it("should use explicit scope", () => {
configAuthentication("https://npm.pkg.github.com", "@myorg");

expect(writeFileSync).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining("@myorg:registry=https://npm.pkg.github.com/"),
);
});

it("should prepend @ to scope if missing", () => {
configAuthentication("https://npm.pkg.github.com", "myorg");

expect(writeFileSync).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining("@myorg:registry=https://npm.pkg.github.com/"),
);
});

it("should lowercase scope", () => {
configAuthentication("https://npm.pkg.github.com", "@MyOrg");

expect(writeFileSync).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining("@myorg:registry=https://npm.pkg.github.com/"),
);
});

it("should preserve existing .npmrc content except registry and auth lines", () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(
[
"always-auth=true",
"registry=https://old.reg/",
"//old.reg/:_authToken=${NODE_AUTH_TOKEN}",
].join("\n"),
);

configAuthentication("https://registry.npmjs.org/");

const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
expect(written).toContain("always-auth=true");
expect(written).not.toContain("https://old.reg/");
expect(written).toContain("registry=https://registry.npmjs.org/");
});

it("should remove existing auth token lines for the same registry", () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(
[
"//registry.npmjs.org/:_authToken=old-token",
"registry=https://registry.npmjs.org/",
"other-config=true",
].join("\n"),
);

configAuthentication("https://registry.npmjs.org/");

const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
expect(written).not.toContain("old-token");
expect(written).toContain("other-config=true");
expect(written).toContain("//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}");
});

it("should handle Windows-style line endings in existing .npmrc", () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue("always-auth=true\r\nregistry=https://old.reg/\r\n");

configAuthentication("https://registry.npmjs.org/");

const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
expect(written).toContain("always-auth=true");
expect(written).not.toContain("https://old.reg/");
});

it("should not auto-detect scope for lookalike GitHub Packages URLs", () => {
vi.stubEnv("GITHUB_REPOSITORY_OWNER", "voidzero-dev");

configAuthentication("https://npm.pkg.github.com.evil.example");

const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
// Should NOT have scoped registry — the host doesn't match exactly
expect(written).not.toContain("@voidzero-dev:");
});

it("should throw on invalid URL", () => {
expect(() => configAuthentication("not-a-url")).toThrow("Invalid registry-url");
});

it("should export NPM_CONFIG_USERCONFIG", () => {
configAuthentication("https://registry.npmjs.org/");

expect(exportVariable).toHaveBeenCalledWith(
"NPM_CONFIG_USERCONFIG",
join(runnerTemp, ".npmrc"),
);
});

it("should export NODE_AUTH_TOKEN placeholder when not set", () => {
configAuthentication("https://registry.npmjs.org/");

expect(exportVariable).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "XXXXX-XXXXX-XXXXX-XXXXX");
});

it("should preserve existing NODE_AUTH_TOKEN", () => {
vi.stubEnv("NODE_AUTH_TOKEN", "my-real-token");

configAuthentication("https://registry.npmjs.org/");

expect(exportVariable).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "my-real-token");
});
});
67 changes: 67 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { EOL } from "node:os";
import { resolve } from "node:path";
import { debug, exportVariable } from "@actions/core";

/**
* Configure npm registry authentication by writing a .npmrc file.
* Ported from actions/setup-node's authutil.ts.
*/
export function configAuthentication(registryUrl: string, scope?: string): void {
// Validate and normalize the registry URL
let url: URL;
try {
url = new URL(registryUrl);
} catch {
throw new Error(`Invalid registry-url: "${registryUrl}". Must be a valid URL.`);
}

// Ensure trailing slash
const normalizedUrl = url.href.endsWith("/") ? url.href : url.href + "/";
const npmrc = resolve(process.env.RUNNER_TEMP || process.cwd(), ".npmrc");

writeRegistryToFile(normalizedUrl, npmrc, scope);
}

function writeRegistryToFile(registryUrl: string, fileLocation: string, scope?: string): void {
// Auto-detect scope for GitHub Packages registry using exact host match
if (!scope) {
const url = new URL(registryUrl);
if (url.hostname === "npm.pkg.github.com") {
scope = process.env.GITHUB_REPOSITORY_OWNER;
}
}

let scopePrefix = "";
if (scope) {
scopePrefix = (scope.startsWith("@") ? scope : "@" + scope).toLowerCase() + ":";
}

debug(`Setting auth in ${fileLocation}`);

// Compute the auth line prefix for filtering existing entries
const authPrefix = registryUrl.replace(/^\w+:/, "").toLowerCase();

const lines: string[] = [];
if (existsSync(fileLocation)) {
const curContents = readFileSync(fileLocation, "utf8");
for (const line of curContents.split(/\r?\n/)) {
const lower = line.toLowerCase();
// Remove existing registry and auth token lines for this scope/registry
if (lower.startsWith(`${scopePrefix}registry`)) continue;
if (lower.startsWith(authPrefix) && lower.includes("_authtoken")) continue;
lines.push(line);
}
}

// Auth token line: remove protocol prefix from registry URL
const authString = registryUrl.replace(/^\w+:/, "") + ":_authToken=${NODE_AUTH_TOKEN}";
const registryString = `${scopePrefix}registry=${registryUrl}`;
lines.push(authString, registryString);

writeFileSync(fileLocation, lines.join(EOL));

exportVariable("NPM_CONFIG_USERCONFIG", fileLocation);
// Export placeholder if NODE_AUTH_TOKEN is not set so npm doesn't error
exportVariable("NODE_AUTH_TOKEN", process.env.NODE_AUTH_TOKEN || "XXXXX-XXXXX-XXXXX-XXXXX");
}
10 changes: 8 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { saveVpCache } from "./cache-vp.js";
import { State, Outputs } from "./types.js";
import type { Inputs } from "./types.js";
import { resolveNodeVersionFile } from "./node-version-file.js";
import { configAuthentication } from "./auth.js";

async function runMain(inputs: Inputs): Promise<void> {
// Mark that post action should run
Expand All @@ -29,12 +30,17 @@ async function runMain(inputs: Inputs): Promise<void> {
await exec("vp", ["env", "use", nodeVersion]);
}

// Step 4: Restore cache if enabled
// Step 4: Configure registry authentication if specified
if (inputs.registryUrl) {
configAuthentication(inputs.registryUrl, inputs.scope);
}

// Step 5: Restore cache if enabled
if (inputs.cache) {
await restoreCache(inputs);
}

// Step 5: Run vp install if requested
// Step 6: Run vp install if requested
if (inputs.runInstall.length > 0) {
await runViteInstall(inputs);
}
Expand Down
2 changes: 2 additions & 0 deletions src/inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export function getInputs(): Inputs {
runInstall: parseRunInstall(getInput("run-install")),
cache: getBooleanInput("cache"),
cacheDependencyPath: getInput("cache-dependency-path") || undefined,
registryUrl: getInput("registry-url") || undefined,
scope: getInput("scope") || undefined,
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface Inputs {
readonly runInstall: RunInstall[];
readonly cache: boolean;
readonly cacheDependencyPath?: string;
readonly registryUrl?: string;
readonly scope?: string;
}

// Lock file types
Expand Down
Loading