diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d29ab0..369cb99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,9 +44,26 @@ jobs: uses: actions/github-script@v7 with: script: | - github.rest.issues.createComment({ + const marker = ''; + const body = `${marker}\n✅ Staging deployment successful!\n\nPreview: https://vp-setup-staging.void.app/\nCommit: ${context.sha}`; + const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, - body: '✅ Staging deployment successful!\n\nPreview: https://vp-setup-staging.void.app/' - }) + }); + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/README.md b/README.md index a286a24..901853f 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,6 @@ Deployed to [Cloudflare Workers](https://workers.cloudflare.com/) via [Void](htt ### Environment variables -| Variable | Required | Description | -| -------------- | -------- | ---------------------------------------------------------- | -| `GITHUB_TOKEN` | No | GitHub token for higher API rate limits (60/hr -> 5000/hr) | -| `VOID_TOKEN` | Yes (CI) | Void deployment token | +| Variable | Required | Description | +| ------------ | -------- | --------------------- | +| `VOID_TOKEN` | Yes (CI) | Void deployment token | diff --git a/routes/index.ts b/routes/index.ts index 677ef20..b1a73c6 100644 --- a/routes/index.ts +++ b/routes/index.ts @@ -11,7 +11,6 @@ const ASSET_NAMES: Record = { arm64: "vp-setup-aarch64-pc-windows-msvc.exe", }; const LATEST_CACHE_TTL = 300; // 5 minutes -const TAGGED_CACHE_TTL = 86400; // 24 hours const DEFAULT_DIST_TAG = "latest"; type Arch = "x64" | "arm64"; @@ -21,11 +20,6 @@ interface CachedRelease { assets: Partial>; } -interface GitHubRelease { - tag_name: string; - assets: Array<{ name: string; browser_download_url: string }>; -} - export function detectArch( queryArch: string | undefined, userAgent: string | undefined, @@ -40,58 +34,6 @@ export function detectArch( return "x64"; } -export function parseRelease(release: GitHubRelease): CachedRelease | null { - const assets: Partial> = {}; - for (const asset of release.assets) { - if (asset.name === ASSET_NAMES.x64) assets.x64 = asset.browser_download_url; - if (asset.name === ASSET_NAMES.arm64) assets.arm64 = asset.browser_download_url; - } - if (!assets.x64 && !assets.arm64) return null; - return { tag: release.tag_name, assets }; -} - -async function fetchGitHub(path: string, githubToken: string | undefined): Promise { - const headers: Record = { - Accept: "application/vnd.github.v3+json", - "User-Agent": "vp-setup-exe-downloader", - }; - if (githubToken) headers.Authorization = `Bearer ${githubToken}`; - return fetch(`https://api.github.com${path}`, { headers }); -} - -// "not-found" means the tag definitively doesn't exist (GitHub 404). -// null means the API failed (rate limit, network error) — fallbacks may still work. -export async function fetchRelease( - tag: string | undefined, - githubToken: string | undefined, -): Promise { - if (tag) { - const res = await fetchGitHub( - `/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/${tag}`, - githubToken, - ); - if (!res.ok) { - console.error(`GitHub API error: ${res.status} ${res.statusText} for tag ${tag}`); - return res.status === 404 ? "not-found" : null; - } - return (await res.json()) as GitHubRelease; - } - - const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG); - if (!version) return null; - const res = await fetchGitHub( - `/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/v${version}`, - githubToken, - ); - if (!res.ok) { - console.error(`GitHub API error: ${res.status} ${res.statusText} for default tag v${version}`); - // Treat all failures (incl. 404) as transient so getRelease doesn't cache a negative - // under `release:latest` when npm/GitHub are momentarily out of sync. - return null; - } - return (await res.json()) as GitHubRelease; -} - export function buildReleaseFromTag(tag: string): CachedRelease { const base = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${tag}`; return { @@ -120,58 +62,36 @@ async function fetchNpmDistTagVersion(distTag: string): Promise { } } -function cacheKey(tag: string | undefined): string { - return tag ? `release:tag:${tag}` : "release:latest"; -} +const LATEST_CACHE_KEY = "release:latest"; +const LATEST_STALE_KEY = "release:latest:stale"; -function staleCacheKey(tag: string | undefined): string { - return `${cacheKey(tag)}:stale`; -} +async function getRelease(tag: string | undefined): Promise { + // Tags are immutable — construct the download URL directly, no network or cache needed + if (tag) return buildReleaseFromTag(tag); -async function getRelease( - tag: string | undefined, - githubToken: string | undefined, -): Promise { - const key = cacheKey(tag); - const cached = await kv.get(key); + // "Latest" path: use KV cache to avoid hitting npm on every request + const cached = await kv.get(LATEST_CACHE_KEY); if (cached) return cached; - try { - const release = await fetchRelease(tag, githubToken); - if (release === "not-found") { - // Cache the negative result to avoid repeated API calls for the same bad tag - await kv.put(key, null, { ttl: LATEST_CACHE_TTL }); - return null; - } - if (release) { - const parsed = parseRelease(release); - if (parsed) { - const ttl = tag ? TAGGED_CACHE_TTL : LATEST_CACHE_TTL; - const staleTtl = ttl + 3600; - await Promise.all([ - kv.put(key, parsed, { ttl }), - kv.put(staleCacheKey(tag), parsed, { ttl: staleTtl }), - ]); - return parsed; - } + const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG); + if (version) { + const release = buildReleaseFromTag(`v${version}`); + try { + await Promise.all([ + kv.put(LATEST_CACHE_KEY, release, { ttl: LATEST_CACHE_TTL }), + kv.put(LATEST_STALE_KEY, release, { ttl: LATEST_CACHE_TTL + 3600 }), + ]); + } catch (err) { + console.error("KV write failed:", err); } - } catch (err) { - console.error("Failed to fetch release from GitHub:", err); + return release; } - // Fallback 1: stale KV cache - const stale = await kv.get(staleCacheKey(tag)); - if (stale) return stale; - - // Fallback 2: construct download URLs from tag or npm registry version - if (tag) return buildReleaseFromTag(tag); - const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG); - if (version) return buildReleaseFromTag(`v${version}`); - - return null; + // npm unreachable — fall back to stale cache + return kv.get(LATEST_STALE_KEY); } -function escapeHtml(s: string): string { +export function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(/"/g, """) @@ -291,7 +211,12 @@ async function setupDownloadLink() { mainBtn.textContent = "Download for Windows (ARM64)"; if (altEl && x64Url) { - altEl.innerHTML = 'Also available: Windows x64'; + var link = document.createElement("a"); + link.href = x64Url; + link.download = ""; + link.textContent = "Windows x64"; + altEl.textContent = "Also available: "; + altEl.appendChild(link); } } @@ -306,7 +231,6 @@ setupDownloadLink(); export const GET = defineHandler(async (c) => { const queryArch = c.req.query("arch"); const tag = c.req.query("tag"); - const githubToken = c.env.GITHUB_TOKEN as string | undefined; // When ?arch= is specified, redirect directly (backward-compatible for CLI/curl) if (queryArch) { @@ -314,7 +238,7 @@ export const GET = defineHandler(async (c) => { if (arch === null) { return c.json({ error: "Invalid architecture. Use 'x64' or 'arm64'" }, 400); } - const release = await getRelease(tag || undefined, githubToken); + const release = await getRelease(tag || undefined); if (!release) { return c.json({ error: tag ? `Release '${tag}' not found` : "No release found" }, 404); } @@ -326,7 +250,7 @@ export const GET = defineHandler(async (c) => { } // Serve the download page with client-side architecture detection - const release = await getRelease(tag || undefined, githubToken); + const release = await getRelease(tag || undefined); if (!release) { return c.json({ error: tag ? `Release '${tag}' not found` : "No release found" }, 404); } diff --git a/tests/index.test.ts b/tests/index.test.ts index f84ff3c..6599e73 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { buildReleaseFromTag, detectArch, fetchRelease, parseRelease } from "../routes/index"; +import { describe, expect, it } from "vite-plus/test"; +import { buildReleaseFromTag, detectArch, escapeHtml } from "../routes/index"; describe("detectArch", () => { it("defaults to x64 when no query param or user-agent", () => { @@ -64,59 +64,23 @@ describe("detectArch", () => { }); }); -describe("parseRelease", () => { - it("parses both x64 and arm64 assets", () => { - const result = parseRelease({ - tag_name: "v0.1.17-alpha.0", - assets: [ - { - name: "vp-setup-x86_64-pc-windows-msvc.exe", - browser_download_url: - "https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17-alpha.0/vp-setup-x86_64-pc-windows-msvc.exe", - }, - { - name: "vp-setup-aarch64-pc-windows-msvc.exe", - browser_download_url: - "https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17-alpha.0/vp-setup-aarch64-pc-windows-msvc.exe", - }, - ], - }); - expect(result).toEqual({ - tag: "v0.1.17-alpha.0", - assets: { - x64: "https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17-alpha.0/vp-setup-x86_64-pc-windows-msvc.exe", - arm64: - "https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17-alpha.0/vp-setup-aarch64-pc-windows-msvc.exe", - }, - }); +describe("escapeHtml", () => { + it("escapes HTML special characters", () => { + expect(escapeHtml('')).toBe( + "<script>alert("xss")</script>", + ); }); - it("returns null when no matching assets exist", () => { - const result = parseRelease({ - tag_name: "v1.0.0", - assets: [ - { - name: "some-other-file.tar.gz", - browser_download_url: "https://example.com/other.tar.gz", - }, - ], - }); - expect(result).toBeNull(); + it("escapes ampersands", () => { + expect(escapeHtml("a&b")).toBe("a&b"); }); - it("handles release with only x64 asset", () => { - const result = parseRelease({ - tag_name: "v0.1.0", - assets: [ - { - name: "vp-setup-x86_64-pc-windows-msvc.exe", - browser_download_url: "https://example.com/x64.exe", - }, - ], - }); - expect(result).not.toBeNull(); - expect(result!.assets.x64).toBe("https://example.com/x64.exe"); - expect(result!.assets.arm64).toBeUndefined(); + it("escapes a malicious tag used in attribute context", () => { + const malicious = 'x">'; + const escaped = escapeHtml(malicious); + expect(escaped).not.toContain("<"); + expect(escaped).not.toContain(">"); + expect(escaped).not.toContain('"'); }); }); @@ -133,131 +97,3 @@ describe("buildReleaseFromTag", () => { }); }); }); - -describe("fetchRelease", () => { - afterEach(() => vi.unstubAllGlobals()); - - it('returns "not-found" when GitHub returns 404 for a tag', async () => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValue(new Response("Not Found", { status: 404, statusText: "Not Found" })), - ); - const result = await fetchRelease("not-exists", undefined); - expect(result).toBe("not-found"); - }); - - it("returns null when GitHub returns 403 (rate limited) for a tag", async () => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValue(new Response("Forbidden", { status: 403, statusText: "Forbidden" })), - ); - const result = await fetchRelease("v1.0.0", undefined); - expect(result).toBeNull(); - }); - - it("returns null when GitHub returns 500 for a tag", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - new Response("Internal Server Error", { - status: 500, - statusText: "Internal Server Error", - }), - ), - ); - const result = await fetchRelease("v1.0.0", undefined); - expect(result).toBeNull(); - }); - - it("returns the release when GitHub returns 200 for a tag", async () => { - const release = { - tag_name: "v1.0.0", - assets: [ - { - name: "vp-setup-x86_64-pc-windows-msvc.exe", - browser_download_url: "https://example.com/x64.exe", - }, - ], - }; - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue(new Response(JSON.stringify(release), { status: 200 })), - ); - const result = await fetchRelease("v1.0.0", undefined); - expect(result).toEqual(release); - }); - - it("default path: resolves via npm latest dist-tag then fetches that GitHub tag", async () => { - const release = { - tag_name: "v0.1.17", - assets: [ - { - name: "vp-setup-x86_64-pc-windows-msvc.exe", - browser_download_url: - "https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17/vp-setup-x86_64-pc-windows-msvc.exe", - }, - ], - }; - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ "dist-tags": { latest: "0.1.17" } }), { status: 200 }), - ) - .mockResolvedValueOnce(new Response(JSON.stringify(release), { status: 200 })); - vi.stubGlobal("fetch", fetchMock); - - const result = await fetchRelease(undefined, undefined); - - expect(result).toEqual(release); - expect(fetchMock).toHaveBeenCalledTimes(2); - const secondCallUrl = fetchMock.mock.calls[1][0]; - expect(secondCallUrl).toContain("/releases/tags/v0.1.17"); - }); - - it("default path: returns null and skips GitHub when npm registry fails", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response("Service Unavailable", { status: 503, statusText: "Service Unavailable" }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await fetchRelease(undefined, undefined); - - expect(result).toBeNull(); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("default path: returns null and skips GitHub when npm has no latest dist-tag", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ "dist-tags": { alpha: "0.1.17-alpha.5" } }), { status: 200 }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await fetchRelease(undefined, undefined); - - expect(result).toBeNull(); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it('default path: returns null (not "not-found") when GitHub 404s the resolved tag', async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ "dist-tags": { latest: "0.1.17" } }), { status: 200 }), - ) - .mockResolvedValueOnce(new Response("Not Found", { status: 404, statusText: "Not Found" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await fetchRelease(undefined, undefined); - - expect(result).toBeNull(); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); -});