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
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ steps:
node-version: "22"
```

### With Node.js Version File

```yaml
steps:
- uses: actions/checkout@v6
- uses: voidzero-dev/setup-vp@v1
with:
node-version-file: ".node-version"
```

### With Caching and Install

```yaml
Expand Down Expand Up @@ -89,13 +99,14 @@ 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 |
| `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 |

## Outputs

Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ inputs:
node-version:
description: "Node.js version to install via `vp env use`. Defaults to Node.js latest LTS version."
required: false
node-version-file:
description: "Path to file containing the Node.js version spec (.nvmrc, .node-version, .tool-versions, package.json). Ignored when node-version is specified."
required: false
cache:
description: "Enable caching of project dependencies"
required: false
Expand Down
136 changes: 69 additions & 67 deletions dist/index.mjs

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { restoreCache } from "./cache-restore.js";
import { saveCache } from "./cache-save.js";
import { State, Outputs } from "./types.js";
import type { Inputs } from "./types.js";
import { resolveNodeVersionFile } from "./node-version-file.js";

async function runMain(inputs: Inputs): Promise<void> {
// Mark that post action should run
Expand All @@ -16,9 +17,14 @@ async function runMain(inputs: Inputs): Promise<void> {
await installVitePlus(inputs);

// Step 2: Set up Node.js version if specified
if (inputs.nodeVersion) {
info(`Setting up Node.js ${inputs.nodeVersion} via vp env use...`);
await exec("vp", ["env", "use", inputs.nodeVersion]);
let nodeVersion = inputs.nodeVersion;
if (!nodeVersion && inputs.nodeVersionFile) {
nodeVersion = resolveNodeVersionFile(inputs.nodeVersionFile);
}

if (nodeVersion) {
info(`Setting up Node.js ${nodeVersion} via vp env use...`);
await exec("vp", ["env", "use", nodeVersion]);
Comment on lines +20 to +27
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The action entrypoint is dist/index.mjs (per action.yml), but the compiled dist output doesn't include the new node-version-file/resolveNodeVersionFile logic (no references found). Please run the build/pack step and commit the updated dist/index.mjs, otherwise users won't actually get this feature when using the action.

Copilot uses AI. Check for mistakes.
}

// Step 3: Restore cache if enabled
Expand Down
14 changes: 14 additions & 0 deletions src/inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ describe("getInputs", () => {

expect(inputs).toEqual({
version: "latest",
nodeVersion: undefined,
nodeVersionFile: undefined,
runInstall: [],
cache: false,
cacheDependencyPath: undefined,
Expand Down Expand Up @@ -103,6 +105,18 @@ describe("getInputs", () => {
expect(inputs.cache).toBe(true);
});

it("should parse node-version-file input", () => {
vi.mocked(getInput).mockImplementation((name) => {
if (name === "node-version-file") return ".nvmrc";
return "";
});
vi.mocked(getBooleanInput).mockReturnValue(false);

const inputs = getInputs();

expect(inputs.nodeVersionFile).toBe(".nvmrc");
});

it("should parse cache-dependency-path input", () => {
vi.mocked(getInput).mockImplementation((name) => {
if (name === "cache-dependency-path") return "custom-lock.yaml";
Expand Down
1 change: 1 addition & 0 deletions src/inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export function getInputs(): Inputs {
return {
version: getInput("version") || "latest",
nodeVersion: getInput("node-version") || undefined,
nodeVersionFile: getInput("node-version-file") || undefined,
runInstall: parseRunInstall(getInput("run-install")),
cache: getBooleanInput("cache"),
cacheDependencyPath: getInput("cache-dependency-path") || undefined,
Expand Down
243 changes: 243 additions & 0 deletions src/node-version-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
import { readFileSync } from "node:fs";
import { resolveNodeVersionFile } from "./node-version-file.js";

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

vi.mock("node:fs", () => ({
readFileSync: vi.fn(),
}));

describe("resolveNodeVersionFile", () => {
const originalEnv = process.env;

beforeEach(() => {
vi.resetAllMocks();
process.env = { ...originalEnv, GITHUB_WORKSPACE: "/workspace" };
});

afterEach(() => {
process.env = originalEnv;
});

Comment on lines +14 to +24
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test mutates process.env by replacing the entire object. Elsewhere in the repo (e.g. utils.test.ts) the convention is vi.stubEnv(...)/vi.unstubAllEnvs(), which avoids leaking env state between tests and prevents issues with Node's env handling. Consider switching to vi.stubEnv('GITHUB_WORKSPACE', '/workspace') and cleaning up with vi.unstubAllEnvs() in afterEach.

Suggested change
const originalEnv = process.env;
beforeEach(() => {
vi.resetAllMocks();
process.env = { ...originalEnv, GITHUB_WORKSPACE: "/workspace" };
});
afterEach(() => {
process.env = originalEnv;
});
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv("GITHUB_WORKSPACE", "/workspace");
});
afterEach(() => {
vi.unstubAllEnvs();
});

Copilot uses AI. Check for mistakes.
describe("path resolution", () => {
it("should resolve relative path against GITHUB_WORKSPACE", () => {
vi.mocked(readFileSync).mockReturnValue("20.0.0\n");

resolveNodeVersionFile(".nvmrc");

expect(readFileSync).toHaveBeenCalledWith("/workspace/.nvmrc", "utf-8");
});

it("should use absolute path as-is", () => {
vi.mocked(readFileSync).mockReturnValue("20.0.0\n");

resolveNodeVersionFile("/custom/path/.nvmrc");

expect(readFileSync).toHaveBeenCalledWith("/custom/path/.nvmrc", "utf-8");
});

it("should throw if file does not exist", () => {
vi.mocked(readFileSync).mockImplementation(() => {
throw new Error("ENOENT");
});

expect(() => resolveNodeVersionFile(".nvmrc")).toThrow(
"node-version-file not found: /workspace/.nvmrc",
);
});
});

describe(".nvmrc / .node-version", () => {
it("should parse plain version", () => {
vi.mocked(readFileSync).mockReturnValue("20.11.0\n");

expect(resolveNodeVersionFile(".nvmrc")).toBe("20.11.0");
});

it("should strip v prefix", () => {
vi.mocked(readFileSync).mockReturnValue("v22.1.0\n");

expect(resolveNodeVersionFile(".node-version")).toBe("22.1.0");
});

it("should skip comments and empty lines", () => {
vi.mocked(readFileSync).mockReturnValue("# use latest LTS\n\n18.19.0\n");

expect(resolveNodeVersionFile(".nvmrc")).toBe("18.19.0");
});

it("should preserve lts/* alias", () => {
vi.mocked(readFileSync).mockReturnValue("lts/*\n");

expect(resolveNodeVersionFile(".nvmrc")).toBe("lts/*");
});

it("should normalize 'node' alias to latest", () => {
vi.mocked(readFileSync).mockReturnValue("node\n");

expect(resolveNodeVersionFile(".nvmrc")).toBe("latest");
});

it("should normalize 'stable' alias to latest", () => {
vi.mocked(readFileSync).mockReturnValue("stable\n");

expect(resolveNodeVersionFile(".nvmrc")).toBe("latest");
});

it("should strip inline comments", () => {
vi.mocked(readFileSync).mockReturnValue("20.11.0 # LTS version\n");

expect(resolveNodeVersionFile(".nvmrc")).toBe("20.11.0");
});

it("should throw on empty file", () => {
vi.mocked(readFileSync).mockReturnValue("\n\n");

expect(() => resolveNodeVersionFile(".nvmrc")).toThrow("No Node.js version found in .nvmrc");
});
});

describe(".tool-versions", () => {
it("should parse nodejs entry", () => {
vi.mocked(readFileSync).mockReturnValue("python 3.11.0\nnodejs 20.11.0\nruby 3.2.0\n");

expect(resolveNodeVersionFile(".tool-versions")).toBe("20.11.0");
});

it("should parse node entry", () => {
vi.mocked(readFileSync).mockReturnValue("node 22.0.0\n");

expect(resolveNodeVersionFile(".tool-versions")).toBe("22.0.0");
});

it("should strip v prefix from tool-versions", () => {
vi.mocked(readFileSync).mockReturnValue("nodejs v20.11.0\n");

expect(resolveNodeVersionFile(".tool-versions")).toBe("20.11.0");
});

it("should skip 'system' and use fallback version", () => {
vi.mocked(readFileSync).mockReturnValue("nodejs system 20.11.0\n");

expect(resolveNodeVersionFile(".tool-versions")).toBe("20.11.0");
});

it("should skip ref: and path: specs", () => {
vi.mocked(readFileSync).mockReturnValue("nodejs ref:v1.0.2 path:/opt/node 22.0.0\n");

expect(resolveNodeVersionFile(".tool-versions")).toBe("22.0.0");
});

it("should use first installable version from multiple fallbacks", () => {
vi.mocked(readFileSync).mockReturnValue("nodejs 20.11.0 18.19.0\n");

expect(resolveNodeVersionFile(".tool-versions")).toBe("20.11.0");
});

it("should throw when only non-installable specs present", () => {
vi.mocked(readFileSync).mockReturnValue("nodejs system\n");

expect(() => resolveNodeVersionFile(".tool-versions")).toThrow(
"No Node.js version found in .tool-versions",
);
});

it("should throw if no node entry found", () => {
vi.mocked(readFileSync).mockReturnValue("python 3.11.0\nruby 3.2.0\n");

expect(() => resolveNodeVersionFile(".tool-versions")).toThrow(
"No Node.js version found in .tool-versions",
);
});

it("should skip comments in .tool-versions", () => {
vi.mocked(readFileSync).mockReturnValue("# tools\n\nnodejs 20.0.0\n");

expect(resolveNodeVersionFile(".tool-versions")).toBe("20.0.0");
});
});

describe("package.json", () => {
it("should read devEngines.runtime with name node", () => {
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({
devEngines: { runtime: { name: "node", version: "^20.0.0" } },
}),
);

expect(resolveNodeVersionFile("package.json")).toBe("^20.0.0");
});

it("should read devEngines.runtime from array", () => {
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({
devEngines: {
runtime: [
{ name: "bun", version: "^1.0.0" },
{ name: "node", version: "^22.0.0" },
],
},
}),
);

expect(resolveNodeVersionFile("package.json")).toBe("^22.0.0");
});

it("should read engines.node", () => {
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ engines: { node: ">=18" } }));

expect(resolveNodeVersionFile("package.json")).toBe(">=18");
});

it("should prefer devEngines.runtime over engines.node", () => {
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({
devEngines: { runtime: { name: "node", version: "22.0.0" } },
engines: { node: ">=18" },
}),
);

expect(resolveNodeVersionFile("package.json")).toBe("22.0.0");
});

it("should fall back to engines.node when devEngines has no node runtime", () => {
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({
devEngines: { runtime: { name: "bun", version: "^1.0.0" } },
engines: { node: ">=20" },
}),
);

expect(resolveNodeVersionFile("package.json")).toBe(">=20");
});

it("should throw if no node version found", () => {
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ name: "test" }));

expect(() => resolveNodeVersionFile("package.json")).toThrow(
"No Node.js version found in package.json",
);
});

it("should strip v prefix from devEngines.runtime version", () => {
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({
devEngines: { runtime: { name: "node", version: "v20.11.0" } },
}),
);

expect(resolveNodeVersionFile("package.json")).toBe("20.11.0");
});

it("should throw on invalid JSON", () => {
vi.mocked(readFileSync).mockReturnValue("not json{");

expect(() => resolveNodeVersionFile("package.json")).toThrow(
"Failed to parse package.json: invalid JSON",
);
});
});
});
Loading
Loading