fix: authenticate resolveLatestTag's GitHub API call to avoid rate limiting

Confirmed hitting this for real on #844: "Failed to resolve the latest
game-ci CLI release: GitHub API returned 403" on both the MacOS and
Ubuntu re-triggered runs. Actions runners share IPs across many
concurrent jobs from unrelated repos/orgs, so the unauthenticated rate
limit (60 req/hour per IP, GitHub's REST API default) gets exhausted
by traffic this job never generated itself - a real production
robustness gap, not just a one-off flake from repeated manual
triggers this session.

Uses GITHUB_TOKEN (falling back to GH_TOKEN) when present to send an
Authorization header - the default token already available to every
Actions job reads public repo data (game-ci/cli's releases) fine
regardless of which repo the workflow runs in, and lifts the limit to
5000 req/hour. No token still works exactly as before (no header).

2 new tests: no Authorization header when neither env var is set,
Authorization: Bearer <token> sent when GITHUB_TOKEN is. 13/13 pass in
download-cli.test.ts, 40/40 across the full suite.

Rebuilds dist/index.js - action.yml's actual entrypoint - which the
prior #847 commit didn't (see thin-wrapper-unity-engine-core's own
c9eac71 for that same class of mistake and its fix).
This commit is contained in:
frostebite
2026-08-25 20:05:47 +01:00
parent c9eac71f84
commit b5caacf1c2
4 changed files with 70 additions and 3 deletions
Generated Vendored
+10 -1
View File
@@ -316,8 +316,17 @@ exports.binaryNameFor = binaryNameFor;
* every run in between.
*/
async function resolveLatestTag(fetchFn = fetch) {
const headers = { Accept: 'application/vnd.github+json' };
// Actions runners share IPs across many concurrent jobs from unrelated
// repos/orgs, so the unauthenticated rate limit (60 req/hour per IP) gets
// exhausted by traffic this job never generated. The default GITHUB_TOKEN
// reads public repo data (game-ci/cli's releases) fine regardless of which
// repo the workflow runs in, and lifts the limit to 5000 req/hour.
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (token)
headers.Authorization = `Bearer ${token}`;
const response = await fetchFn(`https://api.github.com/repos/${CLI_REPO}/releases/latest`, {
headers: { Accept: 'application/vnd.github+json' },
headers,
});
if (!response.ok) {
throw new Error(`Failed to resolve the latest game-ci CLI release: GitHub API returned ${response.status}.`);
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+49
View File
@@ -63,6 +63,55 @@ describe('resolveLatestTag', () => {
);
});
it('sends no Authorization header when GITHUB_TOKEN/GH_TOKEN are unset', async () => {
const originalGithub = process.env.GITHUB_TOKEN;
const originalGh = process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.GH_TOKEN;
const fetchFn = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ tag_name: 'v0.1.15' }),
})) as unknown as typeof fetch;
try {
await resolveLatestTag(fetchFn);
const [, init] = vi.mocked(fetchFn).mock.calls[0] as [
string,
RequestInit & { headers: Record<string, string> },
];
expect(init.headers.Authorization).toBeUndefined();
} finally {
if (originalGithub !== undefined) process.env.GITHUB_TOKEN = originalGithub;
if (originalGh !== undefined) process.env.GH_TOKEN = originalGh;
}
});
it('sends an Authorization header from GITHUB_TOKEN when set, to avoid the unauthenticated rate limit', async () => {
const original = process.env.GITHUB_TOKEN;
process.env.GITHUB_TOKEN = 'test-token-123';
const fetchFn = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ tag_name: 'v0.1.15' }),
})) as unknown as typeof fetch;
try {
await resolveLatestTag(fetchFn);
expect(fetchFn).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer test-token-123' }),
}),
);
} finally {
if (original === undefined) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = original;
}
});
it('throws with a clear message on a non-ok response', async () => {
const fetchFn = vi.fn(async () => ({
ok: false,
+10 -1
View File
@@ -40,8 +40,17 @@ export function binaryNameFor(platform: NodeJS.Platform): string {
* every run in between.
*/
export async function resolveLatestTag(fetchFn: typeof fetch = fetch): Promise<string> {
const headers: Record<string, string> = { Accept: 'application/vnd.github+json' };
// Actions runners share IPs across many concurrent jobs from unrelated
// repos/orgs, so the unauthenticated rate limit (60 req/hour per IP) gets
// exhausted by traffic this job never generated. The default GITHUB_TOKEN
// reads public repo data (game-ci/cli's releases) fine regardless of which
// repo the workflow runs in, and lifts the limit to 5000 req/hour.
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetchFn(`https://api.github.com/repos/${CLI_REPO}/releases/latest`, {
headers: { Accept: 'application/vnd.github+json' },
headers,
});
if (!response.ok) {