Skip to content

Commit bd9b9bf

Browse files
committed
feat: cache vp CLI installation to speed up setup
The vp CLI was re-downloaded from viteplus.dev on every CI run (~60s). Now the ~/.vite-plus/ directory is cached using @actions/cache, keyed by OS, arch, and resolved version. On cache hit the install script is skipped entirely, reducing setup to a few seconds. Also updates install URLs from staging.viteplus.dev to viteplus.dev.
1 parent d37b386 commit bd9b9bf

File tree

7 files changed

+337
-47
lines changed

7 files changed

+337
-47
lines changed

dist/index.mjs

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

src/cache-vp.test.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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 return undefined when fetch fails", async () => {
52+
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network error"));
53+
54+
const result = await resolveVersion("latest");
55+
expect(result).toBeUndefined();
56+
});
57+
58+
it("should return undefined when fetch returns non-ok status", async () => {
59+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not Found", { status: 404 }));
60+
61+
const result = await resolveVersion("latest");
62+
expect(result).toBeUndefined();
63+
});
64+
65+
it("should return undefined for empty string input", async () => {
66+
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("should not be called"));
67+
68+
const result = await resolveVersion("");
69+
expect(result).toBeUndefined();
70+
});
71+
});
72+
73+
describe("restoreVpCache", () => {
74+
beforeEach(() => {
75+
vi.stubEnv("RUNNER_OS", "Linux");
76+
vi.stubEnv("HOME", "/home/runner");
77+
});
78+
79+
afterEach(() => {
80+
vi.unstubAllEnvs();
81+
vi.resetAllMocks();
82+
});
83+
84+
it("should return true on cache hit", async () => {
85+
vi.mocked(restoreCache).mockResolvedValue(`setup-vp-Linux-${arch()}-0.1.8`);
86+
87+
const result = await restoreVpCache("0.1.8");
88+
89+
expect(result).toBe(true);
90+
expect(saveState).toHaveBeenCalledWith(
91+
State.VpCachePrimaryKey,
92+
`setup-vp-Linux-${arch()}-0.1.8`,
93+
);
94+
expect(saveState).toHaveBeenCalledWith(
95+
State.VpCacheMatchedKey,
96+
`setup-vp-Linux-${arch()}-0.1.8`,
97+
);
98+
});
99+
100+
it("should return false on cache miss", async () => {
101+
vi.mocked(restoreCache).mockResolvedValue(undefined);
102+
103+
const result = await restoreVpCache("0.1.8");
104+
105+
expect(result).toBe(false);
106+
expect(saveState).toHaveBeenCalledWith(
107+
State.VpCachePrimaryKey,
108+
`setup-vp-Linux-${arch()}-0.1.8`,
109+
);
110+
});
111+
112+
it("should return false and warn on cache restore error", async () => {
113+
vi.mocked(restoreCache).mockRejectedValue(new Error("cache error"));
114+
115+
const result = await restoreVpCache("0.1.8");
116+
117+
expect(result).toBe(false);
118+
expect(warning).toHaveBeenCalled();
119+
});
120+
121+
it("should use correct cache path", async () => {
122+
vi.mocked(restoreCache).mockResolvedValue(undefined);
123+
124+
await restoreVpCache("0.1.8");
125+
126+
expect(restoreCache).toHaveBeenCalledWith(
127+
["/home/runner/.vite-plus"],
128+
`setup-vp-Linux-${arch()}-0.1.8`,
129+
);
130+
});
131+
});
132+
133+
describe("saveVpCache", () => {
134+
beforeEach(() => {
135+
vi.stubEnv("HOME", "/home/runner");
136+
});
137+
138+
afterEach(() => {
139+
vi.unstubAllEnvs();
140+
vi.resetAllMocks();
141+
});
142+
143+
it("should skip when no primary key", async () => {
144+
vi.mocked(getState).mockReturnValue("");
145+
146+
await saveVpCache();
147+
148+
expect(saveCache).not.toHaveBeenCalled();
149+
});
150+
151+
it("should skip when primary key matches matched key", async () => {
152+
vi.mocked(getState).mockImplementation((key: string) => {
153+
if (key === State.VpCachePrimaryKey) return `setup-vp-Linux-${arch()}-0.1.8`;
154+
if (key === State.VpCacheMatchedKey) return `setup-vp-Linux-${arch()}-0.1.8`;
155+
return "";
156+
});
157+
158+
await saveVpCache();
159+
160+
expect(saveCache).not.toHaveBeenCalled();
161+
});
162+
163+
it("should save cache on cache miss", async () => {
164+
vi.mocked(getState).mockImplementation((key: string) => {
165+
if (key === State.VpCachePrimaryKey) return `setup-vp-Linux-${arch()}-0.1.8`;
166+
return "";
167+
});
168+
vi.mocked(saveCache).mockResolvedValue(12345);
169+
170+
await saveVpCache();
171+
172+
expect(saveCache).toHaveBeenCalledWith(
173+
["/home/runner/.vite-plus"],
174+
`setup-vp-Linux-${arch()}-0.1.8`,
175+
);
176+
});
177+
178+
it("should handle save errors gracefully", async () => {
179+
vi.mocked(getState).mockImplementation((key: string) => {
180+
if (key === State.VpCachePrimaryKey) return `setup-vp-Linux-${arch()}-0.1.8`;
181+
return "";
182+
});
183+
vi.mocked(saveCache).mockRejectedValue(new Error("ReserveCacheError"));
184+
185+
await saveVpCache();
186+
187+
expect(warning).toHaveBeenCalled();
188+
});
189+
});

src/cache-vp.ts

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

src/index.ts

Lines changed: 5 additions & 1 deletion
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";
@@ -59,7 +60,10 @@ async function printViteVersion(): Promise<void> {
5960
}
6061

6162
async function runPost(inputs: Inputs): Promise<void> {
62-
// Save cache if enabled
63+
// Save vp binary cache (always)
64+
await saveVpCache();
65+
66+
// Save dependency cache if enabled
6367
if (inputs.cache) {
6468
await saveCache();
6569
}

src/install-viteplus.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
1-
import { info, debug, addPath } from "@actions/core";
1+
import { info, addPath } from "@actions/core";
22
import { exec } from "@actions/exec";
33
import { join } from "node:path";
44
import type { Inputs } from "./types.js";
55
import { DISPLAY_NAME } from "./types.js";
6+
import { resolveVersion, restoreVpCache } from "./cache-vp.js";
7+
import { getVitePlusHome } from "./utils.js";
68

7-
const INSTALL_URL_SH = "https://staging.viteplus.dev/install.sh";
8-
const INSTALL_URL_PS1 = "https://staging.viteplus.dev/install.ps1";
9+
const INSTALL_URL_SH = "https://viteplus.dev/install.sh";
10+
const INSTALL_URL_PS1 = "https://viteplus.dev/install.ps1";
911

1012
export async function installVitePlus(inputs: Inputs): Promise<void> {
1113
const { version } = inputs;
12-
info(`Installing ${DISPLAY_NAME}@${version}...`);
1314

14-
const env = { ...process.env, VITE_PLUS_VERSION: version };
15+
// Try to resolve version and restore from cache
16+
const resolvedVersion = await resolveVersion(version);
17+
if (resolvedVersion) {
18+
const cacheHit = await restoreVpCache(resolvedVersion);
19+
if (cacheHit) {
20+
ensureVitePlusBinInPath();
21+
info(`${DISPLAY_NAME} restored from cache`);
22+
return;
23+
}
24+
}
25+
26+
// Cache miss or resolution failed — install fresh
27+
const installVersion = resolvedVersion || version;
28+
info(`Installing ${DISPLAY_NAME}@${installVersion}...`);
29+
30+
const env = { ...process.env, VITE_PLUS_VERSION: installVersion };
1531
let exitCode: number;
1632

1733
if (process.platform === "win32") {
@@ -32,14 +48,8 @@ export async function installVitePlus(inputs: Inputs): Promise<void> {
3248
}
3349

3450
function ensureVitePlusBinInPath(): void {
35-
const home = process.platform === "win32" ? process.env.USERPROFILE : process.env.HOME;
36-
if (!home) {
37-
debug("Could not determine home directory");
38-
return;
39-
}
40-
const binDir = join(home, ".vite-plus", "bin");
51+
const binDir = join(getVitePlusHome(), "bin");
4152
if (!process.env.PATH?.includes(binDir)) {
4253
addPath(binDir);
43-
debug(`Added ${binDir} to PATH`);
4454
}
4555
}

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export enum State {
4646
CacheMatchedKey = "CACHE_MATCHED_KEY",
4747
CachePaths = "CACHE_PATHS",
4848
InstalledVersion = "INSTALLED_VERSION",
49+
VpCachePrimaryKey = "VP_CACHE_PRIMARY_KEY",
50+
VpCacheMatchedKey = "VP_CACHE_MATCHED_KEY",
4951
}
5052

5153
// Output keys

src/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import { isAbsolute, join, basename } from "node:path";
55
import { LockFileType } from "./types.js";
66
import type { LockFileInfo } from "./types.js";
77

8+
export function getVitePlusHome(): string {
9+
const home = process.platform === "win32" ? process.env.USERPROFILE : process.env.HOME;
10+
if (!home) throw new Error("Could not determine home directory");
11+
return join(home, ".vite-plus");
12+
}
13+
814
export function getWorkspaceDir(): string {
915
return process.env.GITHUB_WORKSPACE || process.cwd();
1016
}

0 commit comments

Comments
 (0)