Skip to content

Commit e1609b4

Browse files
fengmk2Copilot
andauthored
feat: cache vp CLI installation to speed up setup (#8)
## Summary - Cache the `~/.vite-plus/` directory using `@actions/cache`, keyed by OS + arch + resolved version - On cache hit, skip the install script entirely (~60s → ~2-3s) - For `version: "latest"`, resolve actual semver from npm registry so the cache key updates when new versions are released - Update install URLs from `staging.viteplus.dev` to `viteplus.dev` ## Test plan - [x] All 73 existing + new tests pass (`vp run test`) - [x] Lint/format clean (`vp run check:fix`) - [x] Build succeeds (`vp run build`) - [x] First CI run: installs vp fresh, saves cache in post phase - [x] Second CI run: restores vp from cache, skips install (should be ~2-3s) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: MK (fengmk2) <fengmk2@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent d37b386 commit e1609b4

File tree

8 files changed

+414
-95
lines changed

8 files changed

+414
-95
lines changed

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ GitHub Action to set up [Vite+](https://viteplus.dev) (`vp`) with dependency cac
55
## Features
66

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

118119
## Caching
119120

120-
When `cache: true` is set, the action automatically detects your lock file and caches the appropriate package manager store:
121+
### Vite+ Installation Cache
122+
123+
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.
124+
125+
The cache key includes OS, architecture, Vite+ version, and Node.js version:
126+
`setup-vp-{OS}-{arch}-{vp-version}-node{node-version}`
127+
128+
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.
129+
130+
### Dependency Cache
131+
132+
When `cache: true` is set, the action additionally caches project dependencies by auto-detecting your lock file:
121133

122134
| Lock File | Package Manager | Cache Directory |
123135
| ------------------- | --------------- | --------------- |
124136
| `pnpm-lock.yaml` | pnpm | pnpm store |
125137
| `package-lock.json` | npm | npm cache |
126138
| `yarn.lock` | yarn | yarn cache |
127139

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

130142
## Example Workflow
131143

dist/index.mjs

Lines changed: 72 additions & 72 deletions
Large diffs are not rendered by default.

src/cache-vp.test.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
2+
import { arch } from "node:os";
3+
import { resolveVersion, restoreVpCache, saveVpCache } from "./cache-vp.js";
4+
import { State } from "./types.js";
5+
import { restoreCache, saveCache } from "@actions/cache";
6+
import { saveState, getState, warning } from "@actions/core";
7+
8+
// Mock @actions/cache
9+
vi.mock("@actions/cache", () => ({
10+
restoreCache: vi.fn(),
11+
saveCache: vi.fn(),
12+
}));
13+
14+
// Mock @actions/core
15+
vi.mock("@actions/core", () => ({
16+
info: vi.fn(),
17+
debug: vi.fn(),
18+
warning: vi.fn(),
19+
saveState: vi.fn(),
20+
getState: vi.fn(),
21+
}));
22+
23+
describe("resolveVersion", () => {
24+
afterEach(() => {
25+
vi.restoreAllMocks();
26+
});
27+
28+
it("should return explicit version as-is", async () => {
29+
const result = await resolveVersion("0.1.8");
30+
expect(result).toBe("0.1.8");
31+
});
32+
33+
it("should return explicit semver-like versions as-is", async () => {
34+
const result = await resolveVersion("1.0.0-beta.1");
35+
expect(result).toBe("1.0.0-beta.1");
36+
});
37+
38+
it("should resolve 'latest' from npm registry", async () => {
39+
const fetchSpy = vi
40+
.spyOn(globalThis, "fetch")
41+
.mockResolvedValue(new Response(JSON.stringify({ version: "0.2.0" }), { status: 200 }));
42+
43+
const result = await resolveVersion("latest");
44+
expect(result).toBe("0.2.0");
45+
expect(fetchSpy).toHaveBeenCalledWith(
46+
"https://registry.npmjs.org/vite-plus/latest",
47+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
48+
);
49+
});
50+
51+
it("should resolve dist-tag 'alpha' from npm registry", async () => {
52+
const fetchSpy = vi
53+
.spyOn(globalThis, "fetch")
54+
.mockResolvedValue(
55+
new Response(JSON.stringify({ version: "0.3.0-alpha.1" }), { status: 200 }),
56+
);
57+
58+
const result = await resolveVersion("alpha");
59+
expect(result).toBe("0.3.0-alpha.1");
60+
expect(fetchSpy).toHaveBeenCalledWith(
61+
"https://registry.npmjs.org/vite-plus/alpha",
62+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
63+
);
64+
});
65+
66+
it("should return undefined when fetch fails", async () => {
67+
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network error"));
68+
69+
const result = await resolveVersion("latest");
70+
expect(result).toBeUndefined();
71+
});
72+
73+
it("should return undefined when fetch returns non-ok status", async () => {
74+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not Found", { status: 404 }));
75+
76+
const result = await resolveVersion("alpha");
77+
expect(result).toBeUndefined();
78+
});
79+
80+
it("should return undefined for empty string input", async () => {
81+
const result = await resolveVersion("");
82+
expect(result).toBeUndefined();
83+
});
84+
});
85+
86+
describe("restoreVpCache", () => {
87+
beforeEach(() => {
88+
vi.stubEnv("RUNNER_OS", "Linux");
89+
vi.stubEnv("HOME", "/home/runner");
90+
});
91+
92+
afterEach(() => {
93+
vi.unstubAllEnvs();
94+
vi.resetAllMocks();
95+
});
96+
97+
it("should return true on cache hit", async () => {
98+
const expectedKey = `setup-vp-Linux-${arch()}-0.1.8-node20`;
99+
vi.mocked(restoreCache).mockResolvedValue(expectedKey);
100+
101+
const result = await restoreVpCache("0.1.8", "20");
102+
103+
expect(result).toBe(true);
104+
expect(saveState).toHaveBeenCalledWith(State.VpCachePrimaryKey, expectedKey);
105+
expect(saveState).toHaveBeenCalledWith(State.VpCacheMatchedKey, expectedKey);
106+
});
107+
108+
it("should include node version in cache key", async () => {
109+
vi.mocked(restoreCache).mockResolvedValue(undefined);
110+
111+
await restoreVpCache("0.1.8", "22");
112+
113+
expect(restoreCache).toHaveBeenCalledWith(
114+
["/home/runner/.vite-plus"],
115+
`setup-vp-Linux-${arch()}-0.1.8-node22`,
116+
);
117+
});
118+
119+
it("should handle empty node version", async () => {
120+
vi.mocked(restoreCache).mockResolvedValue(undefined);
121+
122+
await restoreVpCache("0.1.8", "");
123+
124+
expect(restoreCache).toHaveBeenCalledWith(
125+
["/home/runner/.vite-plus"],
126+
`setup-vp-Linux-${arch()}-0.1.8-node`,
127+
);
128+
});
129+
130+
it("should return false on cache miss", async () => {
131+
vi.mocked(restoreCache).mockResolvedValue(undefined);
132+
133+
const result = await restoreVpCache("0.1.8", "20");
134+
135+
expect(result).toBe(false);
136+
});
137+
138+
it("should return false and warn on cache restore error", async () => {
139+
vi.mocked(restoreCache).mockRejectedValue(new Error("cache error"));
140+
141+
const result = await restoreVpCache("0.1.8", "20");
142+
143+
expect(result).toBe(false);
144+
expect(warning).toHaveBeenCalled();
145+
});
146+
});
147+
148+
describe("saveVpCache", () => {
149+
beforeEach(() => {
150+
vi.stubEnv("HOME", "/home/runner");
151+
});
152+
153+
afterEach(() => {
154+
vi.unstubAllEnvs();
155+
vi.resetAllMocks();
156+
});
157+
158+
it("should skip when no primary key", async () => {
159+
vi.mocked(getState).mockReturnValue("");
160+
161+
await saveVpCache();
162+
163+
expect(saveCache).not.toHaveBeenCalled();
164+
});
165+
166+
it("should skip when primary key matches matched key", async () => {
167+
const key = `setup-vp-Linux-${arch()}-0.1.8-node20`;
168+
vi.mocked(getState).mockImplementation((k: string) => {
169+
if (k === State.VpCachePrimaryKey) return key;
170+
if (k === State.VpCacheMatchedKey) return key;
171+
return "";
172+
});
173+
174+
await saveVpCache();
175+
176+
expect(saveCache).not.toHaveBeenCalled();
177+
});
178+
179+
it("should save cache on cache miss", async () => {
180+
const key = `setup-vp-Linux-${arch()}-0.1.8-node20`;
181+
vi.mocked(getState).mockImplementation((k: string) => {
182+
if (k === State.VpCachePrimaryKey) return key;
183+
return "";
184+
});
185+
vi.mocked(saveCache).mockResolvedValue(12345);
186+
187+
await saveVpCache();
188+
189+
expect(saveCache).toHaveBeenCalledWith(["/home/runner/.vite-plus"], key);
190+
});
191+
192+
it("should handle save errors gracefully", async () => {
193+
vi.mocked(getState).mockImplementation((k: string) => {
194+
if (k === State.VpCachePrimaryKey) return `setup-vp-Linux-${arch()}-0.1.8-node20`;
195+
return "";
196+
});
197+
vi.mocked(saveCache).mockRejectedValue(new Error("ReserveCacheError"));
198+
199+
await saveVpCache();
200+
201+
expect(warning).toHaveBeenCalled();
202+
});
203+
});

src/cache-vp.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { restoreCache, saveCache } from "@actions/cache";
2+
import { info, debug, saveState, getState, warning } from "@actions/core";
3+
import { arch, platform } from "node:os";
4+
import { State } from "./types.js";
5+
import { getVitePlusHome } from "./utils.js";
6+
7+
const SEMVER_RE = /^\d+\.\d+\.\d+/;
8+
9+
/**
10+
* Resolve version input to a precise semver version.
11+
* If the input is already a precise version (e.g. "0.1.8", "1.0.0-beta.1"), return as-is.
12+
* Otherwise treat it as a dist-tag (e.g. "latest", "alpha") and resolve via npm registry.
13+
* Returns undefined on failure so the caller can fall back to installing without cache.
14+
*/
15+
export async function resolveVersion(versionInput: string): Promise<string | undefined> {
16+
if (!versionInput) return undefined;
17+
if (SEMVER_RE.test(versionInput)) return versionInput;
18+
19+
try {
20+
const response = await fetch(
21+
`https://registry.npmjs.org/vite-plus/${encodeURIComponent(versionInput)}`,
22+
{ signal: AbortSignal.timeout(10_000) },
23+
);
24+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
25+
const data = (await response.json()) as { version: string };
26+
info(`Resolved vp@${versionInput} to ${data.version}`);
27+
return data.version;
28+
} catch (error) {
29+
warning(`Failed to resolve vp@${versionInput}: ${error}. Skipping vp cache.`);
30+
return undefined;
31+
}
32+
}
33+
34+
export async function restoreVpCache(version: string, nodeVersion: string): Promise<boolean> {
35+
const vpHome = getVitePlusHome();
36+
const runnerOS = process.env.RUNNER_OS || platform();
37+
const runnerArch = arch();
38+
const primaryKey = `setup-vp-${runnerOS}-${runnerArch}-${version}-node${nodeVersion}`;
39+
40+
debug(`Vp cache key: ${primaryKey}`);
41+
debug(`Vp cache path: ${vpHome}`);
42+
saveState(State.VpCachePrimaryKey, primaryKey);
43+
44+
try {
45+
const matchedKey = await restoreCache([vpHome], primaryKey);
46+
if (matchedKey) {
47+
info(`Vite+ restored from cache (key: ${matchedKey})`);
48+
saveState(State.VpCacheMatchedKey, matchedKey);
49+
return true;
50+
}
51+
} catch (error) {
52+
warning(`Failed to restore vp cache: ${error}`);
53+
}
54+
55+
return false;
56+
}
57+
58+
export async function saveVpCache(): Promise<void> {
59+
const primaryKey = getState(State.VpCachePrimaryKey);
60+
const matchedKey = getState(State.VpCacheMatchedKey);
61+
62+
if (!primaryKey) {
63+
debug("No vp cache key found. Skipping save.");
64+
return;
65+
}
66+
67+
if (primaryKey === matchedKey) {
68+
info(`Vp cache hit on primary key "${primaryKey}". Skipping save.`);
69+
return;
70+
}
71+
72+
try {
73+
const vpHome = getVitePlusHome();
74+
const cacheId = await saveCache([vpHome], primaryKey);
75+
if (cacheId === -1) {
76+
warning("Vp cache save failed or was skipped.");
77+
return;
78+
}
79+
info(`Vp cache saved with key: ${primaryKey}`);
80+
} catch (error) {
81+
warning(`Failed to save vp cache: ${String(error)}`);
82+
}
83+
}

src/index.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { installVitePlus } from "./install-viteplus.js";
55
import { runViteInstall } from "./run-install.js";
66
import { restoreCache } from "./cache-restore.js";
77
import { saveCache } from "./cache-save.js";
8+
import { saveVpCache } from "./cache-vp.js";
89
import { State, Outputs } from "./types.js";
910
import type { Inputs } from "./types.js";
1011
import { resolveNodeVersionFile } from "./node-version-file.js";
@@ -13,26 +14,27 @@ async function runMain(inputs: Inputs): Promise<void> {
1314
// Mark that post action should run
1415
saveState(State.IsPost, "true");
1516

16-
// Step 1: Install Vite+
17-
await installVitePlus(inputs);
18-
19-
// Step 2: Set up Node.js version if specified
17+
// Step 1: Resolve Node.js version (needed for cache key)
2018
let nodeVersion = inputs.nodeVersion;
2119
if (!nodeVersion && inputs.nodeVersionFile) {
2220
nodeVersion = resolveNodeVersionFile(inputs.nodeVersionFile);
2321
}
2422

23+
// Step 2: Install Vite+ (with cache keyed by vp version + node version)
24+
await installVitePlus(inputs, nodeVersion || "");
25+
26+
// Step 3: Set up Node.js version if specified
2527
if (nodeVersion) {
2628
info(`Setting up Node.js ${nodeVersion} via vp env use...`);
2729
await exec("vp", ["env", "use", nodeVersion]);
2830
}
2931

30-
// Step 3: Restore cache if enabled
32+
// Step 4: Restore cache if enabled
3133
if (inputs.cache) {
3234
await restoreCache(inputs);
3335
}
3436

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

6163
async function runPost(inputs: Inputs): Promise<void> {
62-
// Save cache if enabled
64+
const saves: Promise<void>[] = [saveVpCache()];
6365
if (inputs.cache) {
64-
await saveCache();
66+
saves.push(saveCache());
6567
}
68+
await Promise.all(saves);
6669
}
6770

6871
async function main(): Promise<void> {

0 commit comments

Comments
 (0)