Skip to content

Commit 612772e

Browse files
authored
fix: replace GitHub API with npm registry for version resolution (#5)
## Summary - Removed all GitHub API code (`fetchGitHub`, `fetchRelease`, `parseRelease`, `GitHubRelease` interface, `GITHUB_TOKEN` env var) - Version resolution now uses npm registry only — specific tags resolve instantly with zero network calls via deterministic URL construction; latest version resolved via npm dist-tags - Simplified `getRelease()` caching: specific tags bypass cache entirely, latest path retains KV cache with stale fallback for npm outages Fixes the `GitHub API error: 403 Forbidden for default tag v0.1.18` production error caused by rate limiting. ## Test plan - [x] `vp test` — 23 tests pass - [x] `vp check` — no lint/type/format errors - [ ] Deploy and verify `https://setup.viteplus.dev/` renders download page with correct version - [ ] Verify `?tag=v0.1.18` and `?arch=x64` query params work - [ ] Remove `GITHUB_TOKEN` secret from Cloudflare Workers environment after deploy 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent d5052c6 commit 612772e

4 files changed

Lines changed: 67 additions & 291 deletions

File tree

.github/workflows/ci.yml

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,26 @@ jobs:
4444
uses: actions/github-script@v7
4545
with:
4646
script: |
47-
github.rest.issues.createComment({
47+
const marker = '<!-- staging-deploy -->';
48+
const body = `${marker}\n✅ Staging deployment successful!\n\nPreview: https://vp-setup-staging.void.app/\nCommit: ${context.sha}`;
49+
const comments = await github.paginate(github.rest.issues.listComments, {
4850
owner: context.repo.owner,
4951
repo: context.repo.repo,
5052
issue_number: context.issue.number,
51-
body: '✅ Staging deployment successful!\n\nPreview: https://vp-setup-staging.void.app/'
52-
})
53+
});
54+
const existing = comments.find(c => c.body.includes(marker));
55+
if (existing) {
56+
await github.rest.issues.updateComment({
57+
owner: context.repo.owner,
58+
repo: context.repo.repo,
59+
comment_id: existing.id,
60+
body,
61+
});
62+
} else {
63+
await github.rest.issues.createComment({
64+
owner: context.repo.owner,
65+
repo: context.repo.repo,
66+
issue_number: context.issue.number,
67+
body,
68+
});
69+
}

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ Deployed to [Cloudflare Workers](https://workers.cloudflare.com/) via [Void](htt
4040

4141
### Environment variables
4242

43-
| Variable | Required | Description |
44-
| -------------- | -------- | ---------------------------------------------------------- |
45-
| `GITHUB_TOKEN` | No | GitHub token for higher API rate limits (60/hr -> 5000/hr) |
46-
| `VOID_TOKEN` | Yes (CI) | Void deployment token |
43+
| Variable | Required | Description |
44+
| ------------ | -------- | --------------------- |
45+
| `VOID_TOKEN` | Yes (CI) | Void deployment token |

routes/index.ts

Lines changed: 29 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ const ASSET_NAMES: Record<Arch, string> = {
1111
arm64: "vp-setup-aarch64-pc-windows-msvc.exe",
1212
};
1313
const LATEST_CACHE_TTL = 300; // 5 minutes
14-
const TAGGED_CACHE_TTL = 86400; // 24 hours
1514
const DEFAULT_DIST_TAG = "latest";
1615

1716
type Arch = "x64" | "arm64";
@@ -21,11 +20,6 @@ interface CachedRelease {
2120
assets: Partial<Record<Arch, string>>;
2221
}
2322

24-
interface GitHubRelease {
25-
tag_name: string;
26-
assets: Array<{ name: string; browser_download_url: string }>;
27-
}
28-
2923
export function detectArch(
3024
queryArch: string | undefined,
3125
userAgent: string | undefined,
@@ -40,58 +34,6 @@ export function detectArch(
4034
return "x64";
4135
}
4236

43-
export function parseRelease(release: GitHubRelease): CachedRelease | null {
44-
const assets: Partial<Record<Arch, string>> = {};
45-
for (const asset of release.assets) {
46-
if (asset.name === ASSET_NAMES.x64) assets.x64 = asset.browser_download_url;
47-
if (asset.name === ASSET_NAMES.arm64) assets.arm64 = asset.browser_download_url;
48-
}
49-
if (!assets.x64 && !assets.arm64) return null;
50-
return { tag: release.tag_name, assets };
51-
}
52-
53-
async function fetchGitHub(path: string, githubToken: string | undefined): Promise<Response> {
54-
const headers: Record<string, string> = {
55-
Accept: "application/vnd.github.v3+json",
56-
"User-Agent": "vp-setup-exe-downloader",
57-
};
58-
if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
59-
return fetch(`https://api.github.com${path}`, { headers });
60-
}
61-
62-
// "not-found" means the tag definitively doesn't exist (GitHub 404).
63-
// null means the API failed (rate limit, network error) — fallbacks may still work.
64-
export async function fetchRelease(
65-
tag: string | undefined,
66-
githubToken: string | undefined,
67-
): Promise<GitHubRelease | null | "not-found"> {
68-
if (tag) {
69-
const res = await fetchGitHub(
70-
`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/${tag}`,
71-
githubToken,
72-
);
73-
if (!res.ok) {
74-
console.error(`GitHub API error: ${res.status} ${res.statusText} for tag ${tag}`);
75-
return res.status === 404 ? "not-found" : null;
76-
}
77-
return (await res.json()) as GitHubRelease;
78-
}
79-
80-
const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG);
81-
if (!version) return null;
82-
const res = await fetchGitHub(
83-
`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/v${version}`,
84-
githubToken,
85-
);
86-
if (!res.ok) {
87-
console.error(`GitHub API error: ${res.status} ${res.statusText} for default tag v${version}`);
88-
// Treat all failures (incl. 404) as transient so getRelease doesn't cache a negative
89-
// under `release:latest` when npm/GitHub are momentarily out of sync.
90-
return null;
91-
}
92-
return (await res.json()) as GitHubRelease;
93-
}
94-
9537
export function buildReleaseFromTag(tag: string): CachedRelease {
9638
const base = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${tag}`;
9739
return {
@@ -120,58 +62,36 @@ async function fetchNpmDistTagVersion(distTag: string): Promise<string | null> {
12062
}
12163
}
12264

123-
function cacheKey(tag: string | undefined): string {
124-
return tag ? `release:tag:${tag}` : "release:latest";
125-
}
65+
const LATEST_CACHE_KEY = "release:latest";
66+
const LATEST_STALE_KEY = "release:latest:stale";
12667

127-
function staleCacheKey(tag: string | undefined): string {
128-
return `${cacheKey(tag)}:stale`;
129-
}
68+
async function getRelease(tag: string | undefined): Promise<CachedRelease | null> {
69+
// Tags are immutable — construct the download URL directly, no network or cache needed
70+
if (tag) return buildReleaseFromTag(tag);
13071

131-
async function getRelease(
132-
tag: string | undefined,
133-
githubToken: string | undefined,
134-
): Promise<CachedRelease | null> {
135-
const key = cacheKey(tag);
136-
const cached = await kv.get<CachedRelease>(key);
72+
// "Latest" path: use KV cache to avoid hitting npm on every request
73+
const cached = await kv.get<CachedRelease>(LATEST_CACHE_KEY);
13774
if (cached) return cached;
13875

139-
try {
140-
const release = await fetchRelease(tag, githubToken);
141-
if (release === "not-found") {
142-
// Cache the negative result to avoid repeated API calls for the same bad tag
143-
await kv.put(key, null, { ttl: LATEST_CACHE_TTL });
144-
return null;
145-
}
146-
if (release) {
147-
const parsed = parseRelease(release);
148-
if (parsed) {
149-
const ttl = tag ? TAGGED_CACHE_TTL : LATEST_CACHE_TTL;
150-
const staleTtl = ttl + 3600;
151-
await Promise.all([
152-
kv.put(key, parsed, { ttl }),
153-
kv.put(staleCacheKey(tag), parsed, { ttl: staleTtl }),
154-
]);
155-
return parsed;
156-
}
76+
const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG);
77+
if (version) {
78+
const release = buildReleaseFromTag(`v${version}`);
79+
try {
80+
await Promise.all([
81+
kv.put(LATEST_CACHE_KEY, release, { ttl: LATEST_CACHE_TTL }),
82+
kv.put(LATEST_STALE_KEY, release, { ttl: LATEST_CACHE_TTL + 3600 }),
83+
]);
84+
} catch (err) {
85+
console.error("KV write failed:", err);
15786
}
158-
} catch (err) {
159-
console.error("Failed to fetch release from GitHub:", err);
87+
return release;
16088
}
16189

162-
// Fallback 1: stale KV cache
163-
const stale = await kv.get<CachedRelease>(staleCacheKey(tag));
164-
if (stale) return stale;
165-
166-
// Fallback 2: construct download URLs from tag or npm registry version
167-
if (tag) return buildReleaseFromTag(tag);
168-
const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG);
169-
if (version) return buildReleaseFromTag(`v${version}`);
170-
171-
return null;
90+
// npm unreachable — fall back to stale cache
91+
return kv.get<CachedRelease>(LATEST_STALE_KEY);
17292
}
17393

174-
function escapeHtml(s: string): string {
94+
export function escapeHtml(s: string): string {
17595
return s
17696
.replace(/&/g, "&amp;")
17797
.replace(/"/g, "&quot;")
@@ -291,7 +211,12 @@ async function setupDownloadLink() {
291211
mainBtn.textContent = "Download for Windows (ARM64)";
292212
293213
if (altEl && x64Url) {
294-
altEl.innerHTML = 'Also available: <a href="' + x64Url + '" download>Windows x64</a>';
214+
var link = document.createElement("a");
215+
link.href = x64Url;
216+
link.download = "";
217+
link.textContent = "Windows x64";
218+
altEl.textContent = "Also available: ";
219+
altEl.appendChild(link);
295220
}
296221
}
297222
@@ -306,15 +231,14 @@ setupDownloadLink();
306231
export const GET = defineHandler(async (c) => {
307232
const queryArch = c.req.query("arch");
308233
const tag = c.req.query("tag");
309-
const githubToken = c.env.GITHUB_TOKEN as string | undefined;
310234

311235
// When ?arch= is specified, redirect directly (backward-compatible for CLI/curl)
312236
if (queryArch) {
313237
const arch = detectArch(queryArch, c.req.header("user-agent"));
314238
if (arch === null) {
315239
return c.json({ error: "Invalid architecture. Use 'x64' or 'arm64'" }, 400);
316240
}
317-
const release = await getRelease(tag || undefined, githubToken);
241+
const release = await getRelease(tag || undefined);
318242
if (!release) {
319243
return c.json({ error: tag ? `Release '${tag}' not found` : "No release found" }, 404);
320244
}
@@ -326,7 +250,7 @@ export const GET = defineHandler(async (c) => {
326250
}
327251

328252
// Serve the download page with client-side architecture detection
329-
const release = await getRelease(tag || undefined, githubToken);
253+
const release = await getRelease(tag || undefined);
330254
if (!release) {
331255
return c.json({ error: tag ? `Release '${tag}' not found` : "No release found" }, 404);
332256
}

0 commit comments

Comments
 (0)