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
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { buildCliArgs } from './build-args';
function inputsOf(values: Record<string, string>) {
return { getInput: (name: string) => values[name] ?? '' };
}
describe('buildCliArgs', () => {
it('requires targetPlatform', () => {
expect(() => buildCliArgs(inputsOf({}))).toThrow(/targetPlatform/);
});
it('builds the minimal command for a bare targetPlatform', () => {
expect(buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64' }))).toStrictEqual([
'build',
'--targetPlatform',
'StandaloneLinux64',
]);
});
it('puts projectPath as the positional argument right after "build"', () => {
expect(
buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64', projectPath: 'game' })),
).toStrictEqual(['build', 'game', '--targetPlatform', 'StandaloneLinux64']);
});
it('throws for a non-local providerStrategy, matching the base action without @game-ci/orchestrator', () => {
expect(() =>
buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64', providerStrategy: 'aws' })),
).toThrow(/aws/);
});
it('passes string inputs through as their mapped flag', () => {
const args = buildCliArgs(
inputsOf({
targetPlatform: 'StandaloneLinux64',
buildName: 'MyGame',
buildMethod: 'Foo.Bar',
dockerCpuLimit: '4',
}),
);
expect(args).toContain('--buildName');
expect(args).toContain('MyGame');
expect(args).toContain('--buildMethod');
expect(args).toContain('Foo.Bar');
expect(args).toContain('--dockerCpuLimit');
expect(args).toContain('4');
});
it('remaps android inputs to their current (non-deprecated) cli flag names', () => {
const args = buildCliArgs(
inputsOf({
targetPlatform: 'Android',
androidKeystorePass: 'keystore-secret',
androidKeyaliasName: 'my-alias',
androidKeyaliasPass: 'alias-secret',
}),
);
expect(args).toContain('--androidKeystorePassword');
expect(args).toContain('keystore-secret');
expect(args).toContain('--androidKeyAlias');
expect(args).toContain('my-alias');
expect(args).toContain('--androidKeyAliasPassword');
expect(args).toContain('alias-secret');
// The deprecated cli flag names should never be emitted.
expect(args).not.toContain('--androidKeystorePass');
expect(args).not.toContain('--androidKeyAliasName');
expect(args).not.toContain('--androidKeyAliasPass');
});
it('renames versioning to versioningStrategy', () => {
const args = buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64', versioning: 'Tag' }));
expect(args).toContain('--versioningStrategy');
expect(args).toContain('Tag');
expect(args).not.toContain('--versioning');
});
it('emits boolean flags only when truthy, without a value', () => {
const args = buildCliArgs(
inputsOf({
targetPlatform: 'StandaloneLinux64',
manualExit: 'true',
enableGpu: 'false',
skipActivation: 'TRUE',
}),
);
expect(args).toContain('--manualExit');
expect(args).toContain('--skipActivation');
expect(args).not.toContain('--enableGpu');
});
it('omits flags for empty/unset inputs, leaving cli defaults in effect', () => {
const args = buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64' }));
expect(args).toStrictEqual(['build', '--targetPlatform', 'StandaloneLinux64']);
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* Translates unity-builder's action inputs into `game-ci build` CLI flags.
*
* Two deliberate omissions, both because there is nothing to translate to:
* - `unityVersion` (except "auto"): the CLI always detects the Unity
* version from the checked-out project's ProjectSettings/ProjectVersion.txt
* and has no flag to override that today (game-ci/cli's engine-detection
* middleware unconditionally overwrites any passed value). Pinning a
* version other than "auto" is a known, real gap versus the original
* action - see the PR this shipped in.
* - `providerStrategy` values other than "local": the base action (without
* the separately-installed @game-ci/orchestrator plugin) already throws
* for these today, so throwing here isn't a regression.
*
* Boolean inputs use GitHub Actions' own truthy/falsy string convention
* ('true'/'false', case-insensitive) - see actions/toolkit's getBooleanInput.
*/
const STRING_FLAGS: Array<[input: string, flag: string]> = [
['customImage', 'customImage'],
['buildProfile', 'buildProfile'],
['buildName', 'buildName'],
['buildsPath', 'buildsPath'],
['buildMethod', 'buildMethod'],
['customParameters', 'customParameters'],
['versioning', 'versioningStrategy'],
['version', 'version'],
['androidVersionCode', 'androidVersionCode'],
['androidExportType', 'androidExportType'],
['androidKeystoreName', 'androidKeystoreName'],
['androidKeystoreBase64', 'androidKeystoreBase64'],
// Mapped to the current (non-deprecated) cli option names, even though
// their own action-input names differ.
['androidKeystorePass', 'androidKeystorePassword'],
['androidKeyaliasName', 'androidKeyAlias'],
['androidKeyaliasPass', 'androidKeyAliasPassword'],
['androidTargetSdkVersion', 'androidTargetSdkVersion'],
['androidSymbolType', 'androidSymbolType'],
['sshAgent', 'sshAgent'],
['sshPublicKeysDirectoryPath', 'sshPublicKeysDirectoryPath'],
['gitPrivateToken', 'gitPrivateToken'],
['chownFilesTo', 'chownFilesTo'],
['dockerCpuLimit', 'dockerCpuLimit'],
['dockerMemoryLimit', 'dockerMemoryLimit'],
['dockerIsolationMode', 'dockerIsolationMode'],
['containerRegistryRepository', 'containerRegistryRepository'],
['containerRegistryImageVersion', 'containerRegistryImageVersion'],
['unityHubVersionOnMac', 'unityHubVersionOnMac'],
['unityLicensingServer', 'unityLicensingServer'],
['dockerWorkspacePath', 'dockerWorkspacePath'],
];
const BOOLEAN_FLAGS: Array<[input: string, flag: string]> = [
['manualExit', 'manualExit'],
['enableGpu', 'enableGpu'],
['useHostNetwork', 'useHostNetwork'],
['runAsHostUser', 'runAsHostUser'],
['allowDirtyBuild', 'allowDirtyBuild'],
['cacheUnityInstallationOnMac', 'cacheUnityInstallationOnMac'],
['skipActivation', 'skipActivation'],
['linux64RemoveExecutableExtension', 'linux64RemoveExecutableExtension'],
];
function isTruthy(value: string): boolean {
return value.trim().toLowerCase() === 'true';
}
export interface BuildArgsOptions {
getInput(name: string): string;
}
export function buildCliArgs({ getInput }: BuildArgsOptions): string[] {
const args: string[] = ['build'];
const projectPath = getInput('projectPath');
if (projectPath) args.push(projectPath);
const targetPlatform = getInput('targetPlatform');
if (!targetPlatform) {
throw new Error('targetPlatform is required.');
}
args.push('--targetPlatform', targetPlatform);
const providerStrategy = getInput('providerStrategy') || 'local';
if (providerStrategy !== 'local') {
throw new Error(
`Provider strategy "${providerStrategy}" is not supported by this thin wrapper. ` +
"Use providerStrategy=local, or invoke game-ci/cli's `orchestrate` command directly " +
'for remote builds.',
);
}
for (const [input, flag] of STRING_FLAGS) {
const value = getInput(input);
if (value) args.push(`--${flag}`, value);
}
for (const [input, flag] of BOOLEAN_FLAGS) {
const value = getInput(input);
if (value && isTruthy(value)) args.push(`--${flag}`);
}
return args;
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { assetNameFor } from './download-cli';
describe('assetNameFor', () => {
it('maps linux x64', () => {
expect(assetNameFor('linux', 'x64')).toBe('game-ci-linux-x64');
});
it('maps linux arm64', () => {
expect(assetNameFor('linux', 'arm64')).toBe('game-ci-linux-arm64');
});
it('maps darwin x64', () => {
expect(assetNameFor('darwin', 'x64')).toBe('game-ci-macos-x64');
});
it('maps darwin arm64', () => {
expect(assetNameFor('darwin', 'arm64')).toBe('game-ci-macos-arm64');
});
it('maps win32 x64 with an .exe suffix', () => {
expect(assetNameFor('win32', 'x64')).toBe('game-ci-windows-x64.exe');
});
it('throws for an unsupported platform/arch combination', () => {
expect(() => assetNameFor('win32', 'arm64')).toThrow(/unsupported/i);
expect(() => assetNameFor('freebsd', 'x64')).toThrow(/unsupported/i);
});
});
+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}`);
}
}
+50 -5
View File
@@ -1,9 +1,54 @@
// Thin wrapper: the actual build logic lives in game-ci/unity-engine-core.
// See game-ci/roadmap#11 (workstream 2) for the "thin wrapper" migration this is part of.
import { runMain } from '@game-ci/unity-engine-core/dist/unity-builder';
// Thin wrapper: this action installs and shells out to the game-ci CLI
// (game-ci/cli's `build` command) as a subprocess, so the exact same code
// path this runs in CI also runs when a developer invokes the CLI directly
// on their own machine. See game-ci/roadmap#11 (workstream 2), and the
// matching rewrite already shipped for game-ci/unity-activate.
//
// Unity credentials (UNITY_EMAIL, UNITY_PASSWORD, UNITY_SERIAL,
// UNITY_LICENSE, UNITY_LICENSING_SERVER) are read by the CLI itself from
// its own process environment - never passed as CLI arguments, since argv
// can leak through process listings and command-logging.
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import { buildCliArgs } from './build-args';
import { downloadCli } from './download-cli';
export { runMain };
export async function run() {
try {
const unityVersion = core.getInput('unityVersion') || 'auto';
if (unityVersion !== 'auto') {
core.warning(
`unityVersion="${unityVersion}" is ignored: the underlying game-ci CLI always detects the Unity ` +
"version from the checked-out project's ProjectSettings/ProjectVersion.txt and has no flag to " +
'override it yet.',
);
}
const cliVersion = core.getInput('cliVersion') || 'latest';
const cliPath = await downloadCli(cliVersion);
const args = buildCliArgs({ getInput: (name) => core.getInput(name) });
const exitCode = await exec.exec(cliPath, args, { ignoreReturnCode: true });
// Matches the original action's engineExitCode output: 0 on success,
// otherwise the exit code of whichever step (activation or build)
// failed - which is exactly what the CLI subprocess itself exits with.
core.setOutput('engineExitCode', exitCode);
if (exitCode !== 0) {
core.setFailed(`Build failed with exit code ${exitCode}`);
}
// buildVersion/androidVersionCode are set by the CLI subprocess itself
// via @actions/core, which writes directly to the file at
// $GITHUB_OUTPUT - inherited by the child process, so no extra
// plumbing is needed here to forward them.
} catch (error: any) {
core.setFailed(error.message);
}
}
if (process.env.NODE_ENV !== 'test') {
runMain();
run();
}