mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-09-29 12:07:05 -07:00
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:
+85
-28
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user