Files
unity-builder/src/download-cli.test.ts
FrostebiteandClaude Sonnet 5 ae0171202c fix: resolve "latest" CLI version using the action's own token, not an env var (#852)
* fix: resolve "latest" CLI version using the action's own token, not an env var

`resolveLatestTag()` only checked `process.env.GITHUB_TOKEN`/`GH_TOKEN` for
authenticating the GitHub API call that resolves `cliVersion: latest`. GitHub
Actions does not inject GITHUB_TOKEN into a JS action's process environment
automatically - a calling workflow has to set it explicitly via `env:` - and
essentially no consumer workflow had reason to do that before this action
started making its own API calls. So this was unauthenticated for effectively
every consumer, not just ones under unusual load, and the unauthenticated
limit (60 req/hour, shared across every job on the runner's IP) is easy to
exhaust.

Hit live via game-ci/unity-test-runner#328's consumer, whose six-version test
matrix failed simultaneously with "GitHub API returned 403" - this action
shares the identical resolveLatestTag/downloadCli pattern (copy-pasted, per
the original comments referencing each other) and is exposed to the exact same
gap.

This action had no `githubToken` input at all, unlike unity-test-runner, so
there was no way for a consumer to hand it a token even deliberately. Added
one, defaulting to `${{ github.token }}` - populated by GitHub Actions on
every run with no consumer action needed - and threaded it through
downloadCli -> resolveLatestTag, ahead of the env var fallback (kept for the
CLI/install.sh path, which has no Action input to read from).

2 new tests, confirmed to catch the regression: removing the parameter
threading fails exactly "sends an Authorization header from the githubToken
parameter" and "forwards its githubToken parameter to resolveLatestTag", and
nothing else in the existing 12.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix: satisfy yarn format:check on download-cli.ts

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* test: cover the GH_TOKEN fallback branch in resolveLatestTag

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* test: verify explicit githubToken takes precedence over env fallbacks

Addresses a CodeRabbit nitpick on PR #852: the existing tests covered
the GITHUB_TOKEN and GH_TOKEN fallback paths individually but not the
precedence rule itself (githubToken > GITHUB_TOKEN > GH_TOKEN) when
more than one is present at once.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-09-16 16:25:16 +01:00

333 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as cache from '@actions/cache';
import * as exec from '@actions/exec';
import { binaryNameFor, downloadCli, resolveLatestTag } from './download-cli';
vi.mock('@actions/exec');
vi.mock('@actions/cache');
vi.mock('node:fs/promises');
function mockInstallScriptFetch(): void {
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => '#!/usr/bin/env bash\necho mock install.sh',
})) as unknown as typeof fetch;
}
describe('binaryNameFor', () => {
it('is game-ci.exe on win32', () => {
expect(binaryNameFor('win32')).toBe('game-ci.exe');
});
it('is game-ci on every other platform', () => {
expect(binaryNameFor('linux')).toBe('game-ci');
expect(binaryNameFor('darwin')).toBe('game-ci');
});
});
describe('resolveLatestTag', () => {
// This is the piece that makes caching "latest" possible at all: caching
// under the literal string "latest" would silently pin every job to
// whatever version happened to be current on the first cache write.
// Resolving to the concrete tag first means a real new release is a fresh
// cache key (a miss by construction), never a stale hit.
it('returns the tag_name from a successful GitHub API response', async () => {
const fetchFn = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ tag_name: 'v0.1.15' }),
})) as unknown as typeof fetch;
const tag = await resolveLatestTag(fetchFn);
expect(tag).toBe('v0.1.15');
expect(fetchFn).toHaveBeenCalledWith(
'https://api.github.com/repos/game-ci/cli/releases/latest',
expect.objectContaining({
headers: expect.objectContaining({ Accept: 'application/vnd.github+json' }),
}),
);
});
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;
}
});
// The githubToken parameter is the action's own `githubToken` input,
// which defaults to `${{ github.token }}` - populated by GitHub Actions on
// every run with no consumer action needed. process.env.GITHUB_TOKEN, by
// contrast, is NOT auto-injected into a JS action's environment - a
// calling workflow has to set it explicitly, which essentially none did.
// Confirmed live via game-ci/unity-test-runner#328: a consumer's six-
// version matrix failed simultaneously with "GitHub API returned 403"
// despite every job having a real, usable token the whole time.
it('sends an Authorization header from the githubToken parameter even when no env var is set', async () => {
const fetchFn = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ tag_name: 'v0.1.15' }),
})) as unknown as typeof fetch;
await resolveLatestTag(fetchFn, 'gha-token-from-input');
expect(fetchFn).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer gha-token-from-input' }),
}),
);
});
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('falls back to GH_TOKEN when GITHUB_TOKEN is unset', async () => {
const originalGithub = process.env.GITHUB_TOKEN;
const originalGh = process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
process.env.GH_TOKEN = 'gh-token-456';
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 gh-token-456' }),
}),
);
} finally {
if (originalGithub !== undefined) process.env.GITHUB_TOKEN = originalGithub;
if (originalGh === undefined) delete process.env.GH_TOKEN;
else process.env.GH_TOKEN = originalGh;
}
});
it('prefers the explicit githubToken parameter over GITHUB_TOKEN/GH_TOKEN when both are present', async () => {
const originalGithub = process.env.GITHUB_TOKEN;
const originalGh = process.env.GH_TOKEN;
process.env.GITHUB_TOKEN = 'env-github-token';
process.env.GH_TOKEN = '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, 'explicit-input-token');
expect(fetchFn).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer explicit-input-token' }),
}),
);
} finally {
if (originalGithub === undefined) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = originalGithub;
if (originalGh === undefined) delete process.env.GH_TOKEN;
else process.env.GH_TOKEN = originalGh;
}
});
it('throws with a clear message on a non-ok response', async () => {
const fetchFn = vi.fn(async () => ({
ok: false,
status: 404,
json: async () => ({}),
})) as unknown as typeof fetch;
await expect(resolveLatestTag(fetchFn)).rejects.toThrow(/404/);
});
it('throws with a clear message when the response has no tag_name', async () => {
const fetchFn = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({}),
})) as unknown as typeof fetch;
await expect(resolveLatestTag(fetchFn)).rejects.toThrow(/no tag_name/);
});
});
describe('downloadCli', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.mocked(cache.isFeatureAvailable).mockReturnValue(false);
// Real chmod/mkdir/writeFile aren't under test here (that's install.sh's
// job, and it isn't actually run - see mockInstallScriptFetch) and the
// paths involved don't exist on disk in this test environment. Without
// these, restoreFromCache's real fs.chmod call throws ENOENT on any
// platform where that branch actually runs (linux/darwin - the
// `process.platform !== 'win32'` guard means it's silently skipped, and
// the bug masked, on a Windows dev machine) - restoreFromCache's own
// try/catch then swallows that and returns null, so a cache-hit test
// silently falls through to the real install path instead of catching
// what it's meant to catch.
vi.mocked(fs.chmod).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
afterEach(() => {
vi.resetAllMocks();
globalThis.fetch = originalFetch;
});
// The actual install mechanics (platform detection, archive format,
// extraction) live in game-ci/cli's own scripts/install.sh now, fetched
// and run at the resolved version's tag - see game-ci/cli#187. This
// wrapper's own job is just: fetch that script, run it correctly, and
// take its stdout as the binary path.
it('fetches and runs install.sh for the given version, returning its stdout as the binary path', async () => {
mockInstallScriptFetch();
vi.mocked(exec.exec).mockImplementation(async (_cmd, _args, options) => {
options?.listeners?.stdout?.(Buffer.from('/tmp/game-ci-cli-cache/v0.1.32/game-ci\n'));
return 0;
});
const binaryPath = await downloadCli('v0.1.32');
expect(binaryPath).toBe('/tmp/game-ci-cli-cache/v0.1.32/game-ci');
expect(globalThis.fetch).toHaveBeenCalledWith(
'https://raw.githubusercontent.com/game-ci/cli/v0.1.32/scripts/install.sh',
);
expect(exec.exec).toHaveBeenCalledWith(
'bash',
expect.arrayContaining(['v0.1.32']),
expect.anything(),
);
});
// downloadCli is the only caller of resolveLatestTag in production code,
// so this is what actually exercises the wiring index.ts depends on - the
// resolveLatestTag-level test above only proves the function accepts the
// parameter, not that anything passes it one.
it('forwards its githubToken parameter to resolveLatestTag when resolving "latest"', async () => {
globalThis.fetch = vi.fn(async (url: string) => {
if (url.includes('/releases/latest')) {
return { ok: true, status: 200, json: async () => ({ tag_name: 'v0.1.33' }) };
}
return { ok: true, status: 200, text: async () => 'echo mock install.sh' };
}) as unknown as typeof fetch;
vi.mocked(exec.exec).mockImplementation(async (_cmd, _args, options) => {
options?.listeners?.stdout?.(Buffer.from('/tmp/game-ci\n'));
return 0;
});
await downloadCli('latest', 'gha-token-from-input');
expect(globalThis.fetch).toHaveBeenCalledWith(
'https://api.github.com/repos/game-ci/cli/releases/latest',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer gha-token-from-input' }),
}),
);
});
it('resolves "latest" to a concrete tag before fetching install.sh', async () => {
globalThis.fetch = vi.fn(async (url: string) => {
if (url.includes('/releases/latest')) {
return { ok: true, status: 200, json: async () => ({ tag_name: 'v0.1.33' }) };
}
return { ok: true, status: 200, text: async () => 'echo mock install.sh' };
}) as unknown as typeof fetch;
vi.mocked(exec.exec).mockImplementation(async (_cmd, _args, options) => {
options?.listeners?.stdout?.(Buffer.from('/tmp/game-ci\n'));
return 0;
});
await downloadCli('latest');
expect(globalThis.fetch).toHaveBeenCalledWith(
'https://raw.githubusercontent.com/game-ci/cli/v0.1.33/scripts/install.sh',
);
expect(exec.exec).toHaveBeenCalledWith(
'bash',
expect.arrayContaining(['v0.1.33']),
expect.anything(),
);
});
it('throws a clear error when the install.sh fetch fails', async () => {
globalThis.fetch = vi.fn(async () => ({
ok: false,
status: 404,
})) as unknown as typeof fetch;
await expect(downloadCli('v0.1.32')).rejects.toThrow(/404/);
expect(exec.exec).not.toHaveBeenCalled();
});
it('throws a clear error when install.sh produces no output', async () => {
mockInstallScriptFetch();
vi.mocked(exec.exec).mockImplementation(async () => 0);
await expect(downloadCli('v0.1.32')).rejects.toThrow(/produced no output/);
});
it('restores from cache instead of fetching install.sh on a cache hit', async () => {
vi.mocked(cache.isFeatureAvailable).mockReturnValue(true);
vi.mocked(cache.restoreCache).mockResolvedValue('game-ci-cli-v0.1.32-key');
const binaryPath = await downloadCli('v0.1.32');
expect(binaryPath).toContain(binaryNameFor(process.platform));
expect(exec.exec).not.toHaveBeenCalled();
});
});