fix: download and extract the release archive, not a bare binary

The compiled game-ci binary was never actually self-contained - see
game-ci/cli#73. It now ships as an archive (.tar.gz / .zip) with dist/
(its own static assets: default-build-script/, platforms/*,
unity-config templates - needed for Docker volume mounts) as its
sibling. download-cli.ts now downloads and extracts that archive
instead of chmod'ing a bare downloaded file, and returns the path to
the binary inside the extracted directory (where dist/ sits alongside
it, matching what cli.ts now expects on disk).
This commit is contained in:
frostebite
2026-08-14 05:03:50 +01:00
parent b9c1b08327
commit 8ca64ed6e1
4 changed files with 113 additions and 58 deletions
Generated Vendored
+42 -24
View File
@@ -139,7 +139,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
return result; return result;
}; };
Object.defineProperty(exports, "__esModule", ({ value: true })); Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.downloadCli = exports.assetNameFor = void 0; exports.downloadCli = exports.binaryNameFor = exports.assetNameFor = void 0;
const fs = __importStar(__nccwpck_require__(3977)); const fs = __importStar(__nccwpck_require__(3977));
const os = __importStar(__nccwpck_require__(612)); const os = __importStar(__nccwpck_require__(612));
const path = __importStar(__nccwpck_require__(9411)); const path = __importStar(__nccwpck_require__(9411));
@@ -156,12 +156,26 @@ function assetNameFor(platform, arch) {
const target = targets[platform]?.[arch]; const target = targets[platform]?.[arch];
if (!target) if (!target)
throw new Error(`Unsupported platform/arch for the game-ci CLI: ${platform}/${arch}`); throw new Error(`Unsupported platform/arch for the game-ci CLI: ${platform}/${arch}`);
return platform === 'win32' ? `game-ci-${target}.exe` : `game-ci-${target}`; const extension = platform === 'win32' ? 'zip' : 'tar.gz';
return `game-ci-${target}.${extension}`;
} }
exports.assetNameFor = assetNameFor; exports.assetNameFor = assetNameFor;
/** The binary's name once extracted - matches release-cli.yml's per-platform `binary` matrix value. */
function binaryNameFor(platform) {
return platform === 'win32' ? 'game-ci.exe' : 'game-ci';
}
exports.binaryNameFor = binaryNameFor;
/** /**
* Downloads (or reuses a cached copy of) the game-ci CLI binary matching the * Downloads (or reuses a cached copy of) the game-ci CLI release archive
* current runner, and returns its path. * 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.
* *
* Pinned versions are cached via @actions/cache (GitHub's cache service), * Pinned versions are cached via @actions/cache (GitHub's cache service),
* so repeat jobs on ephemeral, GitHub-hosted runners skip the download * so repeat jobs on ephemeral, GitHub-hosted runners skip the download
@@ -175,9 +189,10 @@ exports.assetNameFor = assetNameFor;
*/ */
async function downloadCli(version) { async function downloadCli(version) {
const asset = assetNameFor(process.platform, process.arch); const asset = assetNameFor(process.platform, process.arch);
const binaryName = binaryNameFor(process.platform);
const isPinned = version !== 'latest'; const isPinned = version !== 'latest';
if (isPinned) { if (isPinned) {
const cached = await restoreFromCache(version, asset); const cached = await restoreFromCache(version, binaryName);
if (cached) if (cached)
return cached; return cached;
} }
@@ -185,49 +200,52 @@ async function downloadCli(version) {
? `https://github.com/${CLI_REPO}/releases/download/${version}/${asset}` ? `https://github.com/${CLI_REPO}/releases/download/${version}/${asset}`
: `https://github.com/${CLI_REPO}/releases/latest/download/${asset}`; : `https://github.com/${CLI_REPO}/releases/latest/download/${asset}`;
core.info(`Downloading game-ci CLI (${version}) from ${url}`); core.info(`Downloading game-ci CLI (${version}) from ${url}`);
const downloadedPath = await tc.downloadTool(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') { if (process.platform !== 'win32') {
await fs.chmod(downloadedPath, 0o755); await fs.chmod(binaryPath, 0o755);
} }
if (isPinned) { if (isPinned) {
await saveToCache(version, asset, downloadedPath); await saveToCache(version, binaryName, extractedDir);
} }
return downloadedPath; return binaryPath;
} }
exports.downloadCli = downloadCli; exports.downloadCli = downloadCli;
function cachePathFor(version, asset) { function cacheDirFor(version) {
return path.join(os.tmpdir(), 'game-ci-cli-cache', version, asset); return path.join(os.tmpdir(), 'game-ci-cli-cache', version);
} }
function cacheKeyFor(version, asset) { function cacheKeyFor(version, binaryName) {
return `game-ci-cli-${version}-${asset}`; return `game-ci-cli-${version}-${binaryName}`;
} }
async function restoreFromCache(version, asset) { async function restoreFromCache(version, binaryName) {
if (!cache.isFeatureAvailable()) if (!cache.isFeatureAvailable())
return null; return null;
const cachePath = cachePathFor(version, asset); const cacheDir = cacheDirFor(version);
try { try {
const hitKey = await cache.restoreCache([cachePath], cacheKeyFor(version, asset)); const hitKey = await cache.restoreCache([cacheDir], cacheKeyFor(version, binaryName));
if (!hitKey) if (!hitKey)
return null; return null;
const binaryPath = path.join(cacheDir, binaryName);
// Cache restore doesn't guarantee the executable bit survives. // Cache restore doesn't guarantee the executable bit survives.
if (process.platform !== 'win32') if (process.platform !== 'win32')
await fs.chmod(cachePath, 0o755); await fs.chmod(binaryPath, 0o755);
core.info(`Restored game-ci CLI ${version} from cache`); core.info(`Restored game-ci CLI ${version} from cache`);
return cachePath; return binaryPath;
} }
catch (error) { catch (error) {
core.warning(`Failed to restore game-ci CLI from cache: ${error.message}`); core.warning(`Failed to restore game-ci CLI from cache: ${error.message}`);
return null; return null;
} }
} }
async function saveToCache(version, asset, downloadedPath) { async function saveToCache(version, binaryName, extractedDir) {
if (!cache.isFeatureAvailable()) if (!cache.isFeatureAvailable())
return; return;
const cachePath = cachePathFor(version, asset); const cacheDir = cacheDirFor(version);
try { try {
await fs.mkdir(path.dirname(cachePath), { recursive: true }); await fs.mkdir(path.dirname(cacheDir), { recursive: true });
await fs.copyFile(downloadedPath, cachePath); await fs.cp(extractedDir, cacheDir, { recursive: true });
await cache.saveCache([cachePath], cacheKeyFor(version, asset)); await cache.saveCache([cacheDir], cacheKeyFor(version, binaryName));
} }
catch (error) { catch (error) {
// 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
@@ -336,7 +354,7 @@ if (process.env.NODE_ENV !== 'test') {
Object.defineProperty(exports, "__esModule", ({ value: true })); Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.resolveProjectPath = void 0; exports.resolveProjectPath = void 0;
function resolveProjectPath({ input, existsSync, joinPath }) { function resolveProjectPath({ input, existsSync, joinPath, }) {
if (input) if (input)
return input.replace(/\/$/, ''); return input.replace(/\/$/, '');
const hasTestProject = existsSync(joinPath('test-project', 'ProjectSettings', 'ProjectVersion.txt')); const hasTestProject = existsSync(joinPath('test-project', 'ProjectSettings', 'ProjectVersion.txt'));
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+22 -11
View File
@@ -1,25 +1,25 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { assetNameFor } from './download-cli'; import { assetNameFor, binaryNameFor } from './download-cli';
describe('assetNameFor', () => { describe('assetNameFor', () => {
it('maps linux x64', () => { it('maps linux x64 to a .tar.gz archive', () => {
expect(assetNameFor('linux', 'x64')).toBe('game-ci-linux-x64'); expect(assetNameFor('linux', 'x64')).toBe('game-ci-linux-x64.tar.gz');
}); });
it('maps linux arm64', () => { it('maps linux arm64 to a .tar.gz archive', () => {
expect(assetNameFor('linux', 'arm64')).toBe('game-ci-linux-arm64'); expect(assetNameFor('linux', 'arm64')).toBe('game-ci-linux-arm64.tar.gz');
}); });
it('maps darwin x64', () => { it('maps darwin x64 to a .tar.gz archive', () => {
expect(assetNameFor('darwin', 'x64')).toBe('game-ci-macos-x64'); expect(assetNameFor('darwin', 'x64')).toBe('game-ci-macos-x64.tar.gz');
}); });
it('maps darwin arm64', () => { it('maps darwin arm64 to a .tar.gz archive', () => {
expect(assetNameFor('darwin', 'arm64')).toBe('game-ci-macos-arm64'); expect(assetNameFor('darwin', 'arm64')).toBe('game-ci-macos-arm64.tar.gz');
}); });
it('maps win32 x64 with an .exe suffix', () => { it('maps win32 x64 to a .zip archive', () => {
expect(assetNameFor('win32', 'x64')).toBe('game-ci-windows-x64.exe'); expect(assetNameFor('win32', 'x64')).toBe('game-ci-windows-x64.zip');
}); });
it('throws for an unsupported platform/arch combination', () => { it('throws for an unsupported platform/arch combination', () => {
@@ -27,3 +27,14 @@ describe('assetNameFor', () => {
expect(() => assetNameFor('freebsd', 'x64')).toThrow(/unsupported/i); expect(() => assetNameFor('freebsd', 'x64')).toThrow(/unsupported/i);
}); });
}); });
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');
});
});
+48 -22
View File
@@ -18,12 +18,27 @@ export function assetNameFor(platform: NodeJS.Platform, arch: string): string {
if (!target) if (!target)
throw new Error(`Unsupported platform/arch for the game-ci CLI: ${platform}/${arch}`); throw new Error(`Unsupported platform/arch for the game-ci CLI: ${platform}/${arch}`);
return platform === 'win32' ? `game-ci-${target}.exe` : `game-ci-${target}`; 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. */
export function binaryNameFor(platform: NodeJS.Platform): string {
return platform === 'win32' ? 'game-ci.exe' : 'game-ci';
} }
/** /**
* Downloads (or reuses a cached copy of) the game-ci CLI binary matching the * Downloads (or reuses a cached copy of) the game-ci CLI release archive
* current runner, and returns its path. * 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.
* *
* Pinned versions are cached via @actions/cache (GitHub's cache service), * Pinned versions are cached via @actions/cache (GitHub's cache service),
* so repeat jobs on ephemeral, GitHub-hosted runners skip the download * so repeat jobs on ephemeral, GitHub-hosted runners skip the download
@@ -37,10 +52,11 @@ export function assetNameFor(platform: NodeJS.Platform, arch: string): string {
*/ */
export async function downloadCli(version: string): Promise<string> { export async function downloadCli(version: string): Promise<string> {
const asset = assetNameFor(process.platform, process.arch); const asset = assetNameFor(process.platform, process.arch);
const binaryName = binaryNameFor(process.platform);
const isPinned = version !== 'latest'; const isPinned = version !== 'latest';
if (isPinned) { if (isPinned) {
const cached = await restoreFromCache(version, asset); const cached = await restoreFromCache(version, binaryName);
if (cached) return cached; if (cached) return cached;
} }
@@ -49,54 +65,64 @@ export async function downloadCli(version: string): Promise<string> {
: `https://github.com/${CLI_REPO}/releases/latest/download/${asset}`; : `https://github.com/${CLI_REPO}/releases/latest/download/${asset}`;
core.info(`Downloading game-ci CLI (${version}) from ${url}`); core.info(`Downloading game-ci CLI (${version}) from ${url}`);
const downloadedPath = await tc.downloadTool(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') { if (process.platform !== 'win32') {
await fs.chmod(downloadedPath, 0o755); await fs.chmod(binaryPath, 0o755);
} }
if (isPinned) { if (isPinned) {
await saveToCache(version, asset, downloadedPath); await saveToCache(version, binaryName, extractedDir);
} }
return downloadedPath; return binaryPath;
} }
function cachePathFor(version: string, asset: string): string { function cacheDirFor(version: string): string {
return path.join(os.tmpdir(), 'game-ci-cli-cache', version, asset); return path.join(os.tmpdir(), 'game-ci-cli-cache', version);
} }
function cacheKeyFor(version: string, asset: string): string { function cacheKeyFor(version: string, binaryName: string): string {
return `game-ci-cli-${version}-${asset}`; return `game-ci-cli-${version}-${binaryName}`;
} }
async function restoreFromCache(version: string, asset: string): Promise<string | null> { async function restoreFromCache(version: string, binaryName: string): Promise<string | null> {
if (!cache.isFeatureAvailable()) return null; if (!cache.isFeatureAvailable()) return null;
const cachePath = cachePathFor(version, asset); const cacheDir = cacheDirFor(version);
try { try {
const hitKey = await cache.restoreCache([cachePath], cacheKeyFor(version, asset)); const hitKey = await cache.restoreCache([cacheDir], cacheKeyFor(version, binaryName));
if (!hitKey) return null; if (!hitKey) return null;
const binaryPath = path.join(cacheDir, binaryName);
// 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(cachePath, 0o755); if (process.platform !== 'win32') await fs.chmod(binaryPath, 0o755);
core.info(`Restored game-ci CLI ${version} from cache`); core.info(`Restored game-ci CLI ${version} from cache`);
return cachePath; return binaryPath;
} catch (error: any) { } catch (error: any) {
core.warning(`Failed to restore game-ci CLI from cache: ${error.message}`); core.warning(`Failed to restore game-ci CLI from cache: ${error.message}`);
return null; return null;
} }
} }
async function saveToCache(version: string, asset: string, downloadedPath: string): Promise<void> { async function saveToCache(
version: string,
binaryName: string,
extractedDir: string,
): Promise<void> {
if (!cache.isFeatureAvailable()) return; if (!cache.isFeatureAvailable()) return;
const cachePath = cachePathFor(version, asset); const cacheDir = cacheDirFor(version);
try { try {
await fs.mkdir(path.dirname(cachePath), { recursive: true }); await fs.mkdir(path.dirname(cacheDir), { recursive: true });
await fs.copyFile(downloadedPath, cachePath); await fs.cp(extractedDir, cacheDir, { recursive: true });
await cache.saveCache([cachePath], cacheKeyFor(version, asset)); 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 download itself already succeeded.