feat: rework thin wrapper to invoke game-ci/cli as a subprocess

Supersedes the previous approach on this branch, which imported
@game-ci/unity-engine-core as an in-process library. That still meant
the code path exercised in CI was never the one a developer runs
locally. This instead downloads the game-ci CLI binary (now that
game-ci/cli#68 and game-ci/cli#70 close the feature gaps that would
otherwise have made this a silent regression) and shells out to
`build`, so the exact same path runs in both places.

- build-args.ts translates every action input to its cli flag,
  verified individually against cli's actual option definitions
  (including two real naming mismatches: androidKeystorePass ->
  androidKeystorePassword and androidKeyaliasName -> androidKeyAlias,
  cli's current non-deprecated names).
- download-cli.ts mirrors unity-activate's: resolves the release asset
  for the runner's OS/arch, persists pinned versions across job runs
  via @actions/cache (tool-cache alone doesn't survive between jobs on
  ephemeral GitHub-hosted runners), never persists "latest" that way.
- Credentials (UNITY_EMAIL etc.) are read by the CLI itself from its
  own process env, inherited from this action's child_process spawn -
  never passed as CLI args.
- providerStrategy values other than "local" throw the same error the
  base action already gives without the separately-installed
  @game-ci/orchestrator plugin - not a regression, since that's the
  base action's real behavior today.
- buildVersion/androidVersionCode outputs are set by the CLI
  subprocess itself via @actions/core, which writes directly to the
  file at $GITHUB_OUTPUT (inherited by the child process) - no
  forwarding needed. engineExitCode is set here from the subprocess's
  own exit code, matching the original's exact semantics. `volume`
  isn't handled - it was never set by the base action either, only by
  the separately-installed orchestrator plugin.
- action.yml gains a `cliVersion` input (default "latest"). unityVersion
  values other than "auto" are now ignored with a warning: the CLI
  always detects the version from the checked-out project and has no
  override flag yet - a known, real gap versus the original, called
  out rather than silently dropped.
- Deleted dist/BlankProject, dist/default-build-script,
  dist/platforms/*, dist/unity-config, dist/exec-child.js: all dead
  under the new structure. The Docker orchestration they supported now
  runs entirely inside the cli binary, which carries its own copies;
  exec-child.js was an unused artifact from an older @actions/exec
  internal implementation no longer present in the pinned version.
This commit is contained in:
frostebite
2026-08-14 03:28:14 +01:00
parent 80a5eee0f7
commit 9a06a71753
108 changed files with 39705 additions and 78794 deletions
+105
View File
@@ -0,0 +1,105 @@
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 tc from '@actions/tool-cache';
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}`);
return platform === 'win32' ? `game-ci-${target}.exe` : `game-ci-${target}`;
}
/**
* Downloads (or reuses a cached copy of) the game-ci CLI binary matching the
* current runner, and returns its path.
*
* Pinned versions are cached via @actions/cache (GitHub's cache service),
* so repeat jobs on ephemeral, GitHub-hosted runners skip the 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 intentionally never persisted this way: caching a moving
* target under a fixed key would silently pin every job to whatever
* version happened to be "latest" on the first cache write.
*
* @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 isPinned = version !== 'latest';
if (isPinned) {
const cached = await restoreFromCache(version, asset);
if (cached) return cached;
}
const url = isPinned
? `https://github.com/${CLI_REPO}/releases/download/${version}/${asset}`
: `https://github.com/${CLI_REPO}/releases/latest/download/${asset}`;
core.info(`Downloading game-ci CLI (${version}) from ${url}`);
const downloadedPath = await tc.downloadTool(url);
if (process.platform !== 'win32') {
await fs.chmod(downloadedPath, 0o755);
}
if (isPinned) {
await saveToCache(version, asset, downloadedPath);
}
return downloadedPath;
}
function cachePathFor(version: string, asset: string): string {
return path.join(os.tmpdir(), 'game-ci-cli-cache', version, asset);
}
function cacheKeyFor(version: string, asset: string): string {
return `game-ci-cli-${version}-${asset}`;
}
async function restoreFromCache(version: string, asset: string): Promise<string | null> {
if (!cache.isFeatureAvailable()) return null;
const cachePath = cachePathFor(version, asset);
try {
const hitKey = await cache.restoreCache([cachePath], cacheKeyFor(version, asset));
if (!hitKey) return null;
// Cache restore doesn't guarantee the executable bit survives.
if (process.platform !== 'win32') await fs.chmod(cachePath, 0o755);
core.info(`Restored game-ci CLI ${version} from cache`);
return cachePath;
} catch (error: any) {
core.warning(`Failed to restore game-ci CLI from cache: ${error.message}`);
return null;
}
}
async function saveToCache(version: string, asset: string, downloadedPath: string): Promise<void> {
if (!cache.isFeatureAvailable()) return;
const cachePath = cachePathFor(version, asset);
try {
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await fs.copyFile(downloadedPath, cachePath);
await cache.saveCache([cachePath], cacheKeyFor(version, asset));
} catch (error: any) {
// A cache miss on save (e.g. another concurrent job already saved this
// key) isn't fatal - the download itself already succeeded.
core.warning(`Failed to save game-ci CLI to cache: ${error.message}`);
}
}