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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ GitHub Action to set up [Vite+](https://viteplus.dev) (`vp`) with dependency cac
## Features

- Install Vite+ globally via official install scripts
- **Cache the Vite+ installation** to skip re-downloading on subsequent runs
- Optionally set up a specific Node.js version via `vp env use`
- Cache project dependencies with auto-detection of lock files
- Optionally run `vp install` after setup
Expand Down Expand Up @@ -117,15 +118,26 @@ jobs:

## Caching

When `cache: true` is set, the action automatically detects your lock file and caches the appropriate package manager store:
### Vite+ Installation Cache

The Vite+ CLI installation (`~/.vite-plus/`) is cached automatically on a best-effort basis — no configuration needed. If a cache key can be constructed for the resolved version, it will be saved and reused on subsequent runs. On cache hit, the install script is skipped entirely, saving 10–60s depending on network conditions.

The cache key includes OS, architecture, Vite+ version, and Node.js version:
`setup-vp-{OS}-{arch}-{vp-version}-node{node-version}`

Comment on lines +125 to +127
When the `version` input is a dist-tag (e.g. `latest`, `alpha`), it is resolved to a precise semver version via the npm registry before constructing the cache key. If version resolution fails (for example, due to npm registry/network issues or an unresolvable version/tag), no cache key is saved and the Vite+ installation will not be cached for that run.

### Dependency Cache

When `cache: true` is set, the action additionally caches project dependencies by auto-detecting your lock file:

| Lock File | Package Manager | Cache Directory |
| ------------------- | --------------- | --------------- |
| `pnpm-lock.yaml` | pnpm | pnpm store |
| `package-lock.json` | npm | npm cache |
| `yarn.lock` | yarn | yarn cache |

The cache key format is: `vite-plus-{OS}-{arch}-{pm}-{lockfile-hash}`
The dependency cache key format is: `vite-plus-{OS}-{arch}-{pm}-{lockfile-hash}`

## Example Workflow

Expand Down
144 changes: 72 additions & 72 deletions dist/index.mjs

Large diffs are not rendered by default.

203 changes: 203 additions & 0 deletions src/cache-vp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
import { arch } from "node:os";
import { resolveVersion, restoreVpCache, saveVpCache } from "./cache-vp.js";
import { State } from "./types.js";
import { restoreCache, saveCache } from "@actions/cache";
import { saveState, getState, warning } from "@actions/core";

// Mock @actions/cache
vi.mock("@actions/cache", () => ({
restoreCache: vi.fn(),
saveCache: vi.fn(),
}));

// Mock @actions/core
vi.mock("@actions/core", () => ({
info: vi.fn(),
debug: vi.fn(),
warning: vi.fn(),
saveState: vi.fn(),
getState: vi.fn(),
}));

describe("resolveVersion", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("should return explicit version as-is", async () => {
const result = await resolveVersion("0.1.8");
expect(result).toBe("0.1.8");
});

it("should return explicit semver-like versions as-is", async () => {
const result = await resolveVersion("1.0.0-beta.1");
expect(result).toBe("1.0.0-beta.1");
});

it("should resolve 'latest' from npm registry", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(JSON.stringify({ version: "0.2.0" }), { status: 200 }));

const result = await resolveVersion("latest");
expect(result).toBe("0.2.0");
expect(fetchSpy).toHaveBeenCalledWith(
"https://registry.npmjs.org/vite-plus/latest",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});

it("should resolve dist-tag 'alpha' from npm registry", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(JSON.stringify({ version: "0.3.0-alpha.1" }), { status: 200 }),
);

const result = await resolveVersion("alpha");
expect(result).toBe("0.3.0-alpha.1");
expect(fetchSpy).toHaveBeenCalledWith(
"https://registry.npmjs.org/vite-plus/alpha",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});

it("should return undefined when fetch fails", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network error"));

const result = await resolveVersion("latest");
expect(result).toBeUndefined();
});

it("should return undefined when fetch returns non-ok status", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not Found", { status: 404 }));

const result = await resolveVersion("alpha");
expect(result).toBeUndefined();
});

it("should return undefined for empty string input", async () => {
const result = await resolveVersion("");
expect(result).toBeUndefined();
});
});

describe("restoreVpCache", () => {
beforeEach(() => {
vi.stubEnv("RUNNER_OS", "Linux");
vi.stubEnv("HOME", "/home/runner");
});

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

it("should return true on cache hit", async () => {
const expectedKey = `setup-vp-Linux-${arch()}-0.1.8-node20`;
vi.mocked(restoreCache).mockResolvedValue(expectedKey);

const result = await restoreVpCache("0.1.8", "20");

expect(result).toBe(true);
expect(saveState).toHaveBeenCalledWith(State.VpCachePrimaryKey, expectedKey);
expect(saveState).toHaveBeenCalledWith(State.VpCacheMatchedKey, expectedKey);
});

it("should include node version in cache key", async () => {
vi.mocked(restoreCache).mockResolvedValue(undefined);

await restoreVpCache("0.1.8", "22");

expect(restoreCache).toHaveBeenCalledWith(
["/home/runner/.vite-plus"],
`setup-vp-Linux-${arch()}-0.1.8-node22`,
);
});

it("should handle empty node version", async () => {
vi.mocked(restoreCache).mockResolvedValue(undefined);

await restoreVpCache("0.1.8", "");

expect(restoreCache).toHaveBeenCalledWith(
["/home/runner/.vite-plus"],
`setup-vp-Linux-${arch()}-0.1.8-node`,
);
});

it("should return false on cache miss", async () => {
vi.mocked(restoreCache).mockResolvedValue(undefined);

const result = await restoreVpCache("0.1.8", "20");

expect(result).toBe(false);
});

it("should return false and warn on cache restore error", async () => {
vi.mocked(restoreCache).mockRejectedValue(new Error("cache error"));

const result = await restoreVpCache("0.1.8", "20");

expect(result).toBe(false);
expect(warning).toHaveBeenCalled();
});
});

describe("saveVpCache", () => {
beforeEach(() => {
vi.stubEnv("HOME", "/home/runner");
});

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

it("should skip when no primary key", async () => {
vi.mocked(getState).mockReturnValue("");

await saveVpCache();

expect(saveCache).not.toHaveBeenCalled();
});

it("should skip when primary key matches matched key", async () => {
const key = `setup-vp-Linux-${arch()}-0.1.8-node20`;
vi.mocked(getState).mockImplementation((k: string) => {
if (k === State.VpCachePrimaryKey) return key;
if (k === State.VpCacheMatchedKey) return key;
return "";
});

await saveVpCache();

expect(saveCache).not.toHaveBeenCalled();
});

it("should save cache on cache miss", async () => {
const key = `setup-vp-Linux-${arch()}-0.1.8-node20`;
vi.mocked(getState).mockImplementation((k: string) => {
if (k === State.VpCachePrimaryKey) return key;
return "";
});
vi.mocked(saveCache).mockResolvedValue(12345);

await saveVpCache();

expect(saveCache).toHaveBeenCalledWith(["/home/runner/.vite-plus"], key);
});

it("should handle save errors gracefully", async () => {
vi.mocked(getState).mockImplementation((k: string) => {
if (k === State.VpCachePrimaryKey) return `setup-vp-Linux-${arch()}-0.1.8-node20`;
return "";
});
vi.mocked(saveCache).mockRejectedValue(new Error("ReserveCacheError"));

await saveVpCache();

expect(warning).toHaveBeenCalled();
});
});
83 changes: 83 additions & 0 deletions src/cache-vp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { restoreCache, saveCache } from "@actions/cache";
import { info, debug, saveState, getState, warning } from "@actions/core";
import { arch, platform } from "node:os";
import { State } from "./types.js";
import { getVitePlusHome } from "./utils.js";

const SEMVER_RE = /^\d+\.\d+\.\d+/;

/**
* Resolve version input to a precise semver version.
* If the input is already a precise version (e.g. "0.1.8", "1.0.0-beta.1"), return as-is.
* Otherwise treat it as a dist-tag (e.g. "latest", "alpha") and resolve via npm registry.
* Returns undefined on failure so the caller can fall back to installing without cache.
*/
export async function resolveVersion(versionInput: string): Promise<string | undefined> {
if (!versionInput) return undefined;
if (SEMVER_RE.test(versionInput)) return versionInput;

try {
const response = await fetch(
`https://registry.npmjs.org/vite-plus/${encodeURIComponent(versionInput)}`,
{ signal: AbortSignal.timeout(10_000) },
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = (await response.json()) as { version: string };
info(`Resolved vp@${versionInput} to ${data.version}`);
return data.version;
} catch (error) {
warning(`Failed to resolve vp@${versionInput}: ${error}. Skipping vp cache.`);
return undefined;
}
}

export async function restoreVpCache(version: string, nodeVersion: string): Promise<boolean> {
const vpHome = getVitePlusHome();
const runnerOS = process.env.RUNNER_OS || platform();
const runnerArch = arch();
const primaryKey = `setup-vp-${runnerOS}-${runnerArch}-${version}-node${nodeVersion}`;

Comment on lines +34 to +39
debug(`Vp cache key: ${primaryKey}`);
debug(`Vp cache path: ${vpHome}`);
saveState(State.VpCachePrimaryKey, primaryKey);

try {
const matchedKey = await restoreCache([vpHome], primaryKey);
if (matchedKey) {
info(`Vite+ restored from cache (key: ${matchedKey})`);
saveState(State.VpCacheMatchedKey, matchedKey);
return true;
}
} catch (error) {
warning(`Failed to restore vp cache: ${error}`);
}

return false;
}

export async function saveVpCache(): Promise<void> {
const primaryKey = getState(State.VpCachePrimaryKey);
const matchedKey = getState(State.VpCacheMatchedKey);

if (!primaryKey) {
debug("No vp cache key found. Skipping save.");
return;
}

if (primaryKey === matchedKey) {
info(`Vp cache hit on primary key "${primaryKey}". Skipping save.`);
return;
}

try {
const vpHome = getVitePlusHome();
const cacheId = await saveCache([vpHome], primaryKey);
if (cacheId === -1) {
warning("Vp cache save failed or was skipped.");
return;
}
info(`Vp cache saved with key: ${primaryKey}`);
} catch (error) {
warning(`Failed to save vp cache: ${String(error)}`);
}
}
19 changes: 11 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { installVitePlus } from "./install-viteplus.js";
import { runViteInstall } from "./run-install.js";
import { restoreCache } from "./cache-restore.js";
import { saveCache } from "./cache-save.js";
import { saveVpCache } from "./cache-vp.js";
import { State, Outputs } from "./types.js";
Comment on lines 5 to 9
import type { Inputs } from "./types.js";
import { resolveNodeVersionFile } from "./node-version-file.js";
Expand All @@ -13,26 +14,27 @@ async function runMain(inputs: Inputs): Promise<void> {
// Mark that post action should run
saveState(State.IsPost, "true");

// Step 1: Install Vite+
await installVitePlus(inputs);

// Step 2: Set up Node.js version if specified
// Step 1: Resolve Node.js version (needed for cache key)
let nodeVersion = inputs.nodeVersion;
if (!nodeVersion && inputs.nodeVersionFile) {
nodeVersion = resolveNodeVersionFile(inputs.nodeVersionFile);
}

// Step 2: Install Vite+ (with cache keyed by vp version + node version)
await installVitePlus(inputs, nodeVersion || "");

// Step 3: Set up Node.js version if specified
if (nodeVersion) {
info(`Setting up Node.js ${nodeVersion} via vp env use...`);
await exec("vp", ["env", "use", nodeVersion]);
}

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

// Step 4: Run vp install if requested
// Step 5: Run vp install if requested
if (inputs.runInstall.length > 0) {
await runViteInstall(inputs);
}
Expand All @@ -59,10 +61,11 @@ async function printViteVersion(): Promise<void> {
}

async function runPost(inputs: Inputs): Promise<void> {
// Save cache if enabled
const saves: Promise<void>[] = [saveVpCache()];
if (inputs.cache) {
await saveCache();
saves.push(saveCache());
}
await Promise.all(saves);
Comment on lines 63 to +68
}

async function main(): Promise<void> {
Expand Down
Loading
Loading