Compare commits

..
Author SHA1 Message Date
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
7 changed files with 226 additions and 69 deletions
+7
View File
@@ -19,6 +19,13 @@ inputs:
description:
'Release tag of the game-ci/cli binary to install and run (e.g. "v0.1.0"). Defaults to the latest release; pin
this for reproducible builds.'
githubToken:
required: false
default: '${{ github.token }}'
description:
'Token used to authenticate the GitHub API call that resolves cliVersion: latest to a concrete release tag.
Defaults to the token GitHub Actions provides for every run automatically - no need to set this unless you
have a reason to override it.'
customImage:
required: false
default: ''
Generated Vendored
+26 -8
View File
@@ -305,14 +305,28 @@ exports.binaryNameFor = binaryNameFor;
* know how to resolve "latest" itself, by design, since which tag it's
* fetched at IS the version it installs.
*/
async function resolveLatestTag(fetchFn = fetch) {
async function resolveLatestTag(fetchFn = fetch, githubToken) {
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;
// exhausted by traffic this job never generated - hit live via
// game-ci/unity-test-runner#328's consumer, whose six-version matrix failed
// simultaneously with "GitHub API returned 403".
//
// process.env.GITHUB_TOKEN/GH_TOKEN alone does not fix this: 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:,
// which essentially no consumer had reason to do before this action started
// making its own API calls. So the env-var-only version of this check was
// unauthenticated for effectively every consumer, not just ones under
// unusual load.
//
// The githubToken *input* (added alongside this) defaults to `${{
// github.token }}`, which GitHub Actions populates on every run with no
// consumer action needed - so it is the primary path, ahead of the env
// vars, which stay as a fallback for the CLI/install.sh path that has no
// Action input to read from.
const token = githubToken || 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`, {
@@ -397,8 +411,8 @@ async function saveToCache(version) {
*
* @param version A release tag (e.g. "v0.1.0"), or "latest".
*/
async function downloadCli(version) {
const resolvedVersion = version === 'latest' ? await resolveLatestTag() : version;
async function downloadCli(version, githubToken) {
const resolvedVersion = version === 'latest' ? await resolveLatestTag(fetch, githubToken) : version;
const cached = await restoreFromCache(resolvedVersion);
if (cached)
return cached;
@@ -505,7 +519,11 @@ const resolve_project_path_1 = __nccwpck_require__(2833);
async function run() {
try {
const cliVersion = core.getInput('cliVersion') || 'latest';
const cliPath = await (0, download_cli_1.downloadCli)(cliVersion);
// Defaults to ${{ github.token }}, always populated by Actions - see
// download-cli.ts's resolveLatestTag for why this has to be threaded
// through rather than left to a GITHUB_TOKEN env var.
const githubToken = core.getInput('githubToken') || '';
const cliPath = await (0, download_cli_1.downloadCli)(cliVersion, githubToken);
const projectPath = (0, resolve_project_path_1.resolveProjectPath)({
input: core.getInput('projectPath'),
existsSync: fs.existsSync,
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+107
View File
@@ -76,6 +76,31 @@ describe('resolveLatestTag', () => {
}
});
// 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';
@@ -100,6 +125,61 @@ describe('resolveLatestTag', () => {
}
});
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,
@@ -171,6 +251,33 @@ describe('downloadCli', () => {
);
});
// 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')) {
+29 -8
View File
@@ -26,14 +26,31 @@ export function binaryNameFor(platform: NodeJS.Platform): string {
* know how to resolve "latest" itself, by design, since which tag it's
* fetched at IS the version it installs.
*/
export async function resolveLatestTag(fetchFn: typeof fetch = fetch): Promise<string> {
export async function resolveLatestTag(
fetchFn: typeof fetch = fetch,
githubToken?: string,
): 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;
// exhausted by traffic this job never generated - hit live via
// game-ci/unity-test-runner#328's consumer, whose six-version matrix failed
// simultaneously with "GitHub API returned 403".
//
// process.env.GITHUB_TOKEN/GH_TOKEN alone does not fix this: 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:,
// which essentially no consumer had reason to do before this action started
// making its own API calls. So the env-var-only version of this check was
// unauthenticated for effectively every consumer, not just ones under
// unusual load.
//
// The githubToken *input* (added alongside this) defaults to `${{
// github.token }}`, which GitHub Actions populates on every run with no
// consumer action needed - so it is the primary path, ahead of the env
// vars, which stay as a fallback for the CLI/install.sh path that has no
// Action input to read from.
const token = githubToken || 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`, {
@@ -125,8 +142,9 @@ async function saveToCache(version: string): Promise<void> {
*
* @param version A release tag (e.g. "v0.1.0"), or "latest".
*/
export async function downloadCli(version: string): Promise<string> {
const resolvedVersion = version === 'latest' ? await resolveLatestTag() : version;
export async function downloadCli(version: string, githubToken?: string): Promise<string> {
const resolvedVersion =
version === 'latest' ? await resolveLatestTag(fetch, githubToken) : version;
const cached = await restoreFromCache(resolvedVersion);
if (cached) return cached;
@@ -152,7 +170,10 @@ export async function downloadCli(version: string): Promise<string> {
`GitHub returned ${scriptResponse.status} for ${installScriptUrl}.`,
);
}
const scriptPath = path.join(os.tmpdir(), `game-ci-install-${resolvedVersion.replace(/[^\w.-]/g, '_')}.sh`);
const scriptPath = path.join(
os.tmpdir(),
`game-ci-install-${resolvedVersion.replace(/[^\w.-]/g, '_')}.sh`,
);
await fs.writeFile(scriptPath, await scriptResponse.text(), { mode: 0o755 });
let stdout = '';
+5 -1
View File
@@ -19,7 +19,11 @@ import { resolveProjectPath } from './resolve-project-path';
export async function run() {
try {
const cliVersion = core.getInput('cliVersion') || 'latest';
const cliPath = await downloadCli(cliVersion);
// Defaults to ${{ github.token }}, always populated by Actions - see
// download-cli.ts's resolveLatestTag for why this has to be threaded
// through rather than left to a GITHUB_TOKEN env var.
const githubToken = core.getInput('githubToken') || '';
const cliPath = await downloadCli(cliVersion, githubToken);
const projectPath = resolveProjectPath({
input: core.getInput('projectPath'),
+51 -51
View File
@@ -1576,25 +1576,25 @@ __metadata:
languageName: node
linkType: hard
"@vitest/expect@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/expect@npm:4.1.11"
"@vitest/expect@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/expect@npm:4.1.10"
dependencies:
"@standard-schema/spec": "npm:^1.1.0"
"@types/chai": "npm:^5.2.2"
"@vitest/spy": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
"@vitest/spy": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
chai: "npm:^6.2.2"
tinyrainbow: "npm:^3.1.0"
checksum: 10/9bfcfe5ad926ab58beea1c700dc057f17422f14516506f8fc12c9881ed3e81d4c2faadb768042c8497fdee7007e201c1bd3e7d2e91157dbb47fb5c07c4c02aaa
checksum: 10/487fcad404a68968a54ae5fb9d099f12170cd793420a04b34a5606516317090c50a8303ab687c70166ee181864e3e138941d4a96d0405434dcd37696b3105350
languageName: node
linkType: hard
"@vitest/mocker@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/mocker@npm:4.1.11"
"@vitest/mocker@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/mocker@npm:4.1.10"
dependencies:
"@vitest/spy": "npm:4.1.11"
"@vitest/spy": "npm:4.1.10"
estree-walker: "npm:^3.0.3"
magic-string: "npm:^0.30.21"
peerDependencies:
@@ -1605,56 +1605,56 @@ __metadata:
optional: true
vite:
optional: true
checksum: 10/00b6e1266d8403194b49313e3a9a1af0dff2c773f4b2df11f4955fa0f244fd4b59484cd23381dfef8af30298e2651c1aaa42b439fdbc871bb4bb911de38a9509
checksum: 10/ae9645d1bcdad3ab7de7182feb4f1c9148a5ff97cef19581eec9257112aace94889eee9a1ad12e40ce59453ac05f52453b5fdb49ff76a31af8ccdbaaa4471ef3
languageName: node
linkType: hard
"@vitest/pretty-format@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/pretty-format@npm:4.1.11"
"@vitest/pretty-format@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/pretty-format@npm:4.1.10"
dependencies:
tinyrainbow: "npm:^3.1.0"
checksum: 10/2dfc2f20dbe1c4dbea33ec42e85a8b5648aa6585521bea47573406f5cefb81cd3b86b71f981c4e3d69946e77252cb42a700416c1dc656bb0478cb2932c953cdc
checksum: 10/e4f6907143ab0e40dda29d70b17027586c92921d622091321f10512e660b3995dcee7aa56e17b750b72560f295e25f96035372348415f18ebfd39b66a55b4704
languageName: node
linkType: hard
"@vitest/runner@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/runner@npm:4.1.11"
"@vitest/runner@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/runner@npm:4.1.10"
dependencies:
"@vitest/utils": "npm:4.1.11"
"@vitest/utils": "npm:4.1.10"
pathe: "npm:^2.0.3"
checksum: 10/5247df824fa28b458ba0102592dfec50707982193b62076db941fbe5d7c88fb7067e68a33c194db0092bbe35459cfbeaed33b3c667e5f02192c18baeb4f56239
checksum: 10/2c962cb13af0880990036808a35679b7ac6657c8f542490234c2faa6ffd2ab080ac6bf21b487c64d84aa635cfb37b49eb679098c2003a100dfc6c4d5e87bf055
languageName: node
linkType: hard
"@vitest/snapshot@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/snapshot@npm:4.1.11"
"@vitest/snapshot@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/snapshot@npm:4.1.10"
dependencies:
"@vitest/pretty-format": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
magic-string: "npm:^0.30.21"
pathe: "npm:^2.0.3"
checksum: 10/5d096373fb4b102f65ff884844a18c2d2e7d88caf68a64a842a245573311b2d531ca5a94ebf7e4fe39324e71a78670f74c949d4ec2cad3764c3f4c272b84d982
checksum: 10/7940d83ffd2fbebf9a04ea31e196b7e8bf981093ec739950959fe8dd29caa33c80823780fb4b1063d9459c44a0a8d8b2748c00dfb6941becd7404e6d687eea01
languageName: node
linkType: hard
"@vitest/spy@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/spy@npm:4.1.11"
checksum: 10/d49a7ed7501080e5f817d61250a169a46fcc7901887e4985a1e08705ce79aea8d1edcffd74f4dc6669ea1bc3d717a39354ce89c67188d81a63dc439d42f195f6
"@vitest/spy@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/spy@npm:4.1.10"
checksum: 10/7c1b79a95474338e0659f0f2e43be4df1ef7939ff5b37b044954e0287582947803bd417508f44a7f244809672309d9b3dd67660b704ec3fe7f323cc958ae47a3
languageName: node
linkType: hard
"@vitest/utils@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/utils@npm:4.1.11"
"@vitest/utils@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/utils@npm:4.1.10"
dependencies:
"@vitest/pretty-format": "npm:4.1.11"
"@vitest/pretty-format": "npm:4.1.10"
convert-source-map: "npm:^2.0.0"
tinyrainbow: "npm:^3.1.0"
checksum: 10/f05381e12d0926db7b01bfaae9a577fa664d36b96c23df185e4b0f6dad3a9fb59ac00931613da53b4511ee6ab473a14ac500c72c5ec5e9b3c3042875051f20c4
checksum: 10/95484aad55c7b00bbcd4963e27cbb86fe207620a6093973d68da9d0a06bad37c388d84c9ab43d5f35d88e46c8f376a5592d9c54c025c958361160f4802bb25ee
languageName: node
linkType: hard
@@ -4059,16 +4059,16 @@ __metadata:
linkType: hard
"vitest@npm:^4":
version: 4.1.11
resolution: "vitest@npm:4.1.11"
version: 4.1.10
resolution: "vitest@npm:4.1.10"
dependencies:
"@vitest/expect": "npm:4.1.11"
"@vitest/mocker": "npm:4.1.11"
"@vitest/pretty-format": "npm:4.1.11"
"@vitest/runner": "npm:4.1.11"
"@vitest/snapshot": "npm:4.1.11"
"@vitest/spy": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
"@vitest/expect": "npm:4.1.10"
"@vitest/mocker": "npm:4.1.10"
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/runner": "npm:4.1.10"
"@vitest/snapshot": "npm:4.1.10"
"@vitest/spy": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
es-module-lexer: "npm:^2.0.0"
expect-type: "npm:^1.3.0"
magic-string: "npm:^0.30.21"
@@ -4086,12 +4086,12 @@ __metadata:
"@edge-runtime/vm": "*"
"@opentelemetry/api": ^1.9.0
"@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
"@vitest/browser-playwright": 4.1.11
"@vitest/browser-preview": 4.1.11
"@vitest/browser-webdriverio": 4.1.11
"@vitest/coverage-istanbul": 4.1.11
"@vitest/coverage-v8": 4.1.11
"@vitest/ui": 4.1.11
"@vitest/browser-playwright": 4.1.10
"@vitest/browser-preview": 4.1.10
"@vitest/browser-webdriverio": 4.1.10
"@vitest/coverage-istanbul": 4.1.10
"@vitest/coverage-v8": 4.1.10
"@vitest/ui": 4.1.10
happy-dom: "*"
jsdom: "*"
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -4122,7 +4122,7 @@ __metadata:
optional: false
bin:
vitest: ./vitest.mjs
checksum: 10/054f1e25d90d911693b0b93c5b85a7c3105775aa3e39c3279c7d3e7af719e2d94070a898d6d74292445366c1215bb91d07063989ac0965973f0c5ae22ad3b06b
checksum: 10/020843460fe696c23be2a363634dde4daf54625f1c443c24066ba3f87c478b0ccfdd5124343ba30eb092f54902ffea09f4bed0af4a16a9ee805e494ee2dce34e
languageName: node
linkType: hard