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