refactor: delegate CLI install to game-ci/cli's shared install.sh

Moves the actual install mechanics - platform/arch detection, archive
format, download, extraction - out of this wrapper and into game-ci/
cli's own scripts/install.sh (game-ci/cli#187), fetched and run at the
resolved version's tag. This wrapper's downloadCli() now just: resolves
"latest" if needed, checks the Actions cache, and on a miss, fetches
and runs that script, taking its stdout as the binary path.

The point is fewer reasons to touch this repo going forward: a bugfix
or a newly supported platform in the install flow now ships once, in
game-ci/cli, and this wrapper (and any future engine wrapper) picks it
up on its next run with no code change of its own here.

@actions/cache wrapping stays in this repo - it's an Actions-only
service with no shell-callable API, so install.sh has no way to drive
it itself. Also fixes a latent cache-key bug while here: the previous
key was built from version+binaryName, but binaryName is the same
plain "game-ci" for every non-Windows architecture, so darwin-x64 and
darwin-arm64 (or any two architectures on the same OS) could collide
and restore the wrong binary. The new key includes process.arch.
This commit is contained in:
frostebite
2026-08-26 18:29:53 +01:00
parent 6adfd079d3
commit 8b0cc6cbd9
2 changed files with 184 additions and 111 deletions
+85 -28
View File
@@ -1,32 +1,10 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { assetNameFor, binaryNameFor, resolveLatestTag } from './download-cli'; import * as cache from '@actions/cache';
import * as exec from '@actions/exec';
import { binaryNameFor, downloadCli, resolveLatestTag } from './download-cli';
describe('assetNameFor', () => { vi.mock('@actions/exec');
it('maps linux x64 to a .tar.gz archive', () => { vi.mock('@actions/cache');
expect(assetNameFor('linux', 'x64')).toBe('game-ci-linux-x64.tar.gz');
});
it('maps linux arm64 to a .tar.gz archive', () => {
expect(assetNameFor('linux', 'arm64')).toBe('game-ci-linux-arm64.tar.gz');
});
it('maps darwin x64 to a .tar.gz archive', () => {
expect(assetNameFor('darwin', 'x64')).toBe('game-ci-macos-x64.tar.gz');
});
it('maps darwin arm64 to a .tar.gz archive', () => {
expect(assetNameFor('darwin', 'arm64')).toBe('game-ci-macos-arm64.tar.gz');
});
it('maps win32 x64 to a .zip archive', () => {
expect(assetNameFor('win32', 'x64')).toBe('game-ci-windows-x64.zip');
});
it('throws for an unsupported platform/arch combination', () => {
expect(() => assetNameFor('win32', 'arm64')).toThrow(/unsupported/i);
expect(() => assetNameFor('freebsd', 'x64')).toThrow(/unsupported/i);
});
});
describe('binaryNameFor', () => { describe('binaryNameFor', () => {
it('is game-ci.exe on win32', () => { it('is game-ci.exe on win32', () => {
@@ -132,3 +110,82 @@ describe('resolveLatestTag', () => {
await expect(resolveLatestTag(fetchFn)).rejects.toThrow(/no tag_name/); await expect(resolveLatestTag(fetchFn)).rejects.toThrow(/no tag_name/);
}); });
}); });
describe('downloadCli', () => {
beforeEach(() => {
vi.mocked(cache.isFeatureAvailable).mockReturnValue(false);
});
afterEach(() => {
vi.resetAllMocks();
});
// 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: build that command correctly, and take
// install.sh's stdout as the binary path.
it('fetches and runs install.sh for the given version, returning its stdout as the binary path', async () => {
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(exec.exec).toHaveBeenCalledWith(
'bash',
expect.arrayContaining([
'https://raw.githubusercontent.com/game-ci/cli/v0.1.32/scripts/install.sh',
'v0.1.32',
]),
expect.anything(),
);
});
it('resolves "latest" to a concrete tag before fetching install.sh', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ tag_name: 'v0.1.33' }),
})) 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;
});
try {
await downloadCli('latest');
expect(exec.exec).toHaveBeenCalledWith(
'bash',
expect.arrayContaining([
'https://raw.githubusercontent.com/game-ci/cli/v0.1.33/scripts/install.sh',
'v0.1.33',
]),
expect.anything(),
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('throws a clear error when install.sh produces no output', async () => {
vi.mocked(exec.exec).mockImplementation(async () => 0);
await expect(downloadCli('v0.1.32')).rejects.toThrow(/produced no output/);
});
it('restores from cache instead of running 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();
});
});
+99 -83
View File
@@ -3,26 +3,10 @@ import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
import * as cache from '@actions/cache'; import * as cache from '@actions/cache';
import * as core from '@actions/core'; import * as core from '@actions/core';
import * as tc from '@actions/tool-cache'; import * as exec from '@actions/exec';
const CLI_REPO = 'game-ci/cli'; const CLI_REPO = 'game-ci/cli';
export function assetNameFor(platform: NodeJS.Platform, arch: string): string {
const targets: Partial<Record<NodeJS.Platform, Partial<Record<string, string>>>> = {
linux: { x64: 'linux-x64', arm64: 'linux-arm64' },
darwin: { x64: 'macos-x64', arm64: 'macos-arm64' },
win32: { x64: 'windows-x64' },
};
const target = targets[platform]?.[arch];
if (!target)
throw new Error(`Unsupported platform/arch for the game-ci CLI: ${platform}/${arch}`);
const extension = platform === 'win32' ? 'zip' : 'tar.gz';
return `game-ci-${target}.${extension}`;
}
/** The binary's name once extracted - matches release-cli.yml's per-platform `binary` matrix value. */ /** The binary's name once extracted - matches release-cli.yml's per-platform `binary` matrix value. */
export function binaryNameFor(platform: NodeJS.Platform): string { export function binaryNameFor(platform: NodeJS.Platform): string {
return platform === 'win32' ? 'game-ci.exe' : 'game-ci'; return platform === 'win32' ? 'game-ci.exe' : 'game-ci';
@@ -37,7 +21,10 @@ export function binaryNameFor(platform: NodeJS.Platform): string {
* happened to be current on the first cache write, but caching under the * 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 * *resolved* tag self-invalidates the moment a new release ships (a new
* tag is a cache miss by construction), while still hitting cache on * tag is a cache miss by construction), while still hitting cache on
* every run in between. * 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> { export async function resolveLatestTag(fetchFn: typeof fetch = fetch): Promise<string> {
const headers: Record<string, string> = { Accept: 'application/vnd.github+json' }; const headers: Record<string, string> = { Accept: 'application/vnd.github+json' };
@@ -67,73 +54,30 @@ export async function resolveLatestTag(fetchFn: typeof fetch = fetch): Promise<s
return body.tag_name; return body.tag_name;
} }
/**
* 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 archive - not a bare binary - is what's published: cli.ts resolves
* its own static assets (default-build-script/, platforms/*,
* unity-config/services-config.json.template, all needed for Docker
* volume mounts) relative to its own directory on disk, and those assets
* aren't embedded in the compiled binary itself. dist/ ships as the
* binary's sibling inside the archive - see game-ci/cli#73.
*
* 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 - @actions/tool-cache alone
* only survives for the life of one runner's disk, which GitHub-hosted
* runners don't persist between jobs. "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 asset = assetNameFor(process.platform, process.arch);
const binaryName = binaryNameFor(process.platform);
const resolvedVersion = version === 'latest' ? await resolveLatestTag() : version;
const cached = await restoreFromCache(resolvedVersion, binaryName);
if (cached) return cached;
const url = `https://github.com/${CLI_REPO}/releases/download/${resolvedVersion}/${asset}`;
core.info(`Downloading game-ci CLI ${resolvedVersion} from ${url}`);
const archivePath = await tc.downloadTool(url);
const extractedDir =
process.platform === 'win32'
? await tc.extractZip(archivePath)
: await tc.extractTar(archivePath);
const binaryPath = path.join(extractedDir, binaryName);
if (process.platform !== 'win32') {
await fs.chmod(binaryPath, 0o755);
}
await saveToCache(resolvedVersion, binaryName, extractedDir);
return binaryPath;
}
function cacheDirFor(version: string): string { function cacheDirFor(version: string): string {
return path.join(os.tmpdir(), 'game-ci-cli-cache', version); return path.join(os.tmpdir(), 'game-ci-cli-cache', version);
} }
function cacheKeyFor(version: string, binaryName: string): string { /**
return `game-ci-cli-${version}-${binaryName}`; * 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, binaryName: string): Promise<string | null> { async function restoreFromCache(version: string): Promise<string | null> {
if (!cache.isFeatureAvailable()) return null; if (!cache.isFeatureAvailable()) return null;
const cacheDir = cacheDirFor(version); const cacheDir = cacheDirFor(version);
try { try {
const hitKey = await cache.restoreCache([cacheDir], cacheKeyFor(version, binaryName)); const hitKey = await cache.restoreCache([cacheDir], cacheKeyFor(version));
if (!hitKey) return null; if (!hitKey) return null;
const binaryPath = path.join(cacheDir, binaryName); const binaryPath = path.join(cacheDir, binaryNameFor(process.platform));
// Cache restore doesn't guarantee the executable bit survives. // Cache restore doesn't guarantee the executable bit survives.
if (process.platform !== 'win32') await fs.chmod(binaryPath, 0o755); if (process.platform !== 'win32') await fs.chmod(binaryPath, 0o755);
@@ -145,21 +89,93 @@ async function restoreFromCache(version: string, binaryName: string): Promise<st
} }
} }
async function saveToCache( async function saveToCache(version: string): Promise<void> {
version: string,
binaryName: string,
extractedDir: string,
): Promise<void> {
if (!cache.isFeatureAvailable()) return; if (!cache.isFeatureAvailable()) return;
const cacheDir = cacheDirFor(version);
try { try {
await fs.mkdir(path.dirname(cacheDir), { recursive: true }); await cache.saveCache([cacheDirFor(version)], cacheKeyFor(version));
await fs.cp(extractedDir, cacheDir, { recursive: true });
await cache.saveCache([cacheDir], cacheKeyFor(version, binaryName));
} catch (error: any) { } catch (error: any) {
// A cache miss on save (e.g. another concurrent job already saved this // A cache miss on save (e.g. another concurrent job already saved this
// key) isn't fatal - the download itself already succeeded. // key) isn't fatal - the install itself already succeeded.
core.warning(`Failed to save game-ci CLI to cache: ${error.message}`); 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}`);
let stdout = '';
await exec.exec(
'bash',
[
'-c',
// `set -o pipefail` matters here: without it, a failed curl (e.g. a
// typo'd/deleted tag giving a 404) still exits 0 because it's not the
// pipeline's last command, and the inner `bash -s` would silently run
// on an empty script instead of failing loudly.
'set -o pipefail; curl -fsSL "$0" | bash -s -- "$1" "$2"',
installScriptUrl,
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.
const binaryPath = stdout
.split('\n')
.map((line) => line.trim())
.toReversed()
.find(Boolean);
if (!binaryPath) {
throw new Error(
`Failed to install the game-ci CLI ${resolvedVersion}: install.sh produced no output.`,
);
}
await saveToCache(resolvedVersion);
return binaryPath;
}