mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-09-29 12:07:05 -07:00
CodeQL flagged the install.sh invocation (js/actions/uncontrolled- command-line) - correctly this time, unlike the pre-existing false positive on src/index.ts:45. resolvedVersion/destDir were passed safely as quoted positional params ($0/$1/$2) rather than concatenated into the command text, so it wasn't exploitable, but building a `bash -c '... "$0" ...'` string at all is exactly the shape that query looks for, and there was a strictly better option available: fetch install.sh's content directly, write it to a file, and run that file with a plain args array - the same shape this file's own callers already use for the CLI binary itself, with no shell-text construction step for the query to flag in the first place. Also fixes a real, environment-dependent test bug found while touching this: the "restores from cache" test's real fs.chmod call throws ENOENT on Linux for a path that doesn't exist on disk, gets swallowed by restoreFromCache's own try/catch, and silently falls through to the real install path - passing locally only because the chmod call is skipped entirely on `win32` (a Windows dev machine), never because the cache-restore logic under test actually worked. fs/promises is now mocked like @actions/cache and @actions/exec already were.
193 lines
8.0 KiB
TypeScript
193 lines
8.0 KiB
TypeScript
import * as fs from 'node:fs/promises';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import * as cache from '@actions/cache';
|
|
import * as core from '@actions/core';
|
|
import * as exec from '@actions/exec';
|
|
|
|
const CLI_REPO = 'game-ci/cli';
|
|
|
|
/** The binary's name once extracted - matches release-cli.yml's per-platform `binary` matrix value. */
|
|
export function binaryNameFor(platform: NodeJS.Platform): string {
|
|
return platform === 'win32' ? 'game-ci.exe' : 'game-ci';
|
|
}
|
|
|
|
/**
|
|
* Resolves the "latest" alias to the actual release tag it currently
|
|
* points to, via a small GitHub API call - not the release-asset
|
|
* redirect, which never reveals the concrete tag it landed on. This is
|
|
* what 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, but caching under the
|
|
* *resolved* tag self-invalidates the moment a new release ships (a new
|
|
* tag is a cache miss by construction), while still hitting cache on
|
|
* every run in between. It's also what pins install.sh (below) to a
|
|
* specific, immutable version rather than "latest" - install.sh doesn't
|
|
* 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> {
|
|
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,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to resolve the latest game-ci CLI release: GitHub API returned ${response.status}.`,
|
|
);
|
|
}
|
|
|
|
const body = (await response.json()) as { tag_name?: string };
|
|
if (!body.tag_name) {
|
|
throw new Error('Failed to resolve the latest game-ci CLI release: response had no tag_name.');
|
|
}
|
|
|
|
return body.tag_name;
|
|
}
|
|
|
|
function cacheDirFor(version: string): string {
|
|
return path.join(os.tmpdir(), 'game-ci-cli-cache', version);
|
|
}
|
|
|
|
/**
|
|
* Includes platform+arch, not just version: darwin-x64 and darwin-arm64
|
|
* (or any two architectures on the same OS) both extract to a binary
|
|
* named plain "game-ci", so a key built from version+binaryName alone
|
|
* (this cache's previous scheme) can't tell them apart and would let one
|
|
* architecture's binary get restored onto the other's runner.
|
|
*/
|
|
function cacheKeyFor(version: string): string {
|
|
return `game-ci-cli-${version}-${process.platform}-${process.arch}`;
|
|
}
|
|
|
|
async function restoreFromCache(version: string): Promise<string | null> {
|
|
if (!cache.isFeatureAvailable()) return null;
|
|
|
|
const cacheDir = cacheDirFor(version);
|
|
try {
|
|
const hitKey = await cache.restoreCache([cacheDir], cacheKeyFor(version));
|
|
if (!hitKey) return null;
|
|
|
|
const binaryPath = path.join(cacheDir, binaryNameFor(process.platform));
|
|
// Cache restore doesn't guarantee the executable bit survives.
|
|
if (process.platform !== 'win32') await fs.chmod(binaryPath, 0o755);
|
|
|
|
core.info(`Restored game-ci CLI ${version} from cache`);
|
|
return binaryPath;
|
|
} catch (error: any) {
|
|
core.warning(`Failed to restore game-ci CLI from cache: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function saveToCache(version: string): Promise<void> {
|
|
if (!cache.isFeatureAvailable()) return;
|
|
|
|
try {
|
|
await cache.saveCache([cacheDirFor(version)], cacheKeyFor(version));
|
|
} catch (error: any) {
|
|
// A cache miss on save (e.g. another concurrent job already saved this
|
|
// key) isn't fatal - the install itself already succeeded.
|
|
core.warning(`Failed to save game-ci CLI to cache: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Downloads (or reuses a cached copy of) the game-ci CLI release archive
|
|
* matching the current runner, extracts it, and returns the path to the
|
|
* binary inside.
|
|
*
|
|
* The actual install mechanics - platform/arch detection, archive format,
|
|
* download, extraction - live in exactly one place: game-ci/cli's own
|
|
* scripts/install.sh, fetched and run at the resolved version's tag. That
|
|
* script is what every engine wrapper (this one today, others later)
|
|
* delegates to, so a bugfix or a new supported platform ships once, there,
|
|
* and every wrapper picks it up on its next run with no code change of its
|
|
* own - see game-ci/cli#187. GitHub Actions caching stays here, in the
|
|
* wrapper: it's an Actions-only service with no shell-callable API, so
|
|
* install.sh has no way to drive it itself.
|
|
*
|
|
* Every version - including "latest" - is cached via @actions/cache
|
|
* (GitHub's cache service), so repeat jobs on ephemeral, GitHub-hosted
|
|
* runners skip the archive download entirely. "latest" is resolved to its
|
|
* concrete tag first (see resolveLatestTag) and cached under *that*, not
|
|
* under the literal string "latest" - a real new release is a fresh tag,
|
|
* so it's a cache miss by construction, never a stale hit.
|
|
*
|
|
* @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;
|
|
|
|
const cached = await restoreFromCache(resolvedVersion);
|
|
if (cached) return cached;
|
|
|
|
const destDir = cacheDirFor(resolvedVersion);
|
|
await fs.mkdir(destDir, { recursive: true });
|
|
|
|
const installScriptUrl = `https://raw.githubusercontent.com/${CLI_REPO}/${resolvedVersion}/scripts/install.sh`;
|
|
core.info(`Installing game-ci CLI ${resolvedVersion} via ${installScriptUrl}`);
|
|
|
|
// Fetched and written to a file, then run as `bash <path> <args...>`,
|
|
// rather than piped straight into a `bash -c '... "$0" ...'` wrapper: a
|
|
// curl-into-bash one-liner needs resolvedVersion/destDir interpolated
|
|
// into the -c script text (even safely, as quoted positional params),
|
|
// which is exactly the shape CodeQL's uncontrolled-command-line query
|
|
// flags. Passing a real file path plus a plain string[] of args - the
|
|
// same pattern this file's own callers already use for the CLI binary
|
|
// itself - has no such shell-text construction step to flag at all.
|
|
const scriptResponse = await fetch(installScriptUrl);
|
|
if (!scriptResponse.ok) {
|
|
throw new Error(
|
|
`Failed to fetch install.sh for game-ci CLI ${resolvedVersion}: ` +
|
|
`GitHub returned ${scriptResponse.status} for ${installScriptUrl}.`,
|
|
);
|
|
}
|
|
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 = '';
|
|
await exec.exec('bash', [scriptPath, resolvedVersion, destDir], {
|
|
listeners: {
|
|
stdout: (data: Buffer) => {
|
|
stdout += data.toString();
|
|
},
|
|
},
|
|
});
|
|
|
|
// install.sh writes progress to stderr and only the final binary path to
|
|
// stdout, but take the last non-empty line regardless - defensive against
|
|
// any stray stdout noise from a future version of the script. Walked
|
|
// backwards by index rather than toReversed()/reverse() - toReversed()
|
|
// isn't available on this project's target Node version, and reverse()
|
|
// mutates in place, which unicorn/no-array-reverse flags even when the
|
|
// array being mutated is a fresh one from map().
|
|
const lines = stdout.split('\n').map((line) => line.trim());
|
|
let binaryPath: string | undefined;
|
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
if (lines[i]) {
|
|
binaryPath = lines[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!binaryPath) {
|
|
throw new Error(
|
|
`Failed to install the game-ci CLI ${resolvedVersion}: install.sh produced no output.`,
|
|
);
|
|
}
|
|
|
|
await saveToCache(resolvedVersion);
|
|
|
|
return binaryPath;
|
|
}
|