mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-08 06:53:57 -07:00
refactor: move CLI to orchestrator, fix validate-orchestrator workflow
- Delete src/cli.ts, src/cli/ (commands, tests, input-mapper) — moved to game-ci/orchestrator repo (PR #813 reference) - Delete .github/workflows/release-cli.yml — moved to orchestrator - Remove bin, pkg, yargs, @types/yargs, pkg from package.json - Fix validate-orchestrator.yml: - Build TypeScript before running require() smoke tests - Remove || echo fallback that swallowed errors - Add smoke test that installs orchestrator via npm pack and verifies loadOrchestrator() returns defined exports Legacy src/model/cli/ (Cli class, CliFunctionsRepository) preserved — used by Input.getInput() and build-parameters.ts on main. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
-39
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import buildCommand from './cli/commands/build';
|
||||
import activateCommand from './cli/commands/activate';
|
||||
import orchestrateCommand from './cli/commands/orchestrate';
|
||||
import statusCommand from './cli/commands/status';
|
||||
import versionCommand from './cli/commands/version';
|
||||
import updateCommand from './cli/commands/update';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
.scriptName('game-ci')
|
||||
.usage('$0 <command> [options]')
|
||||
.command(buildCommand)
|
||||
.command(activateCommand)
|
||||
.command(orchestrateCommand)
|
||||
.command(statusCommand)
|
||||
.command(versionCommand)
|
||||
.command(updateCommand)
|
||||
.demandCommand(1, 'You must specify a command. Run game-ci --help for available commands.')
|
||||
.strict()
|
||||
.alias('h', 'help')
|
||||
.epilogue('For more information, visit https://game.ci')
|
||||
.wrap(Math.min(120, process.stdout.columns || 80));
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await cli.parse();
|
||||
} catch (error: any) {
|
||||
if (error.name !== 'YError') {
|
||||
core.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,110 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Integration tests that spawn the CLI as a child process and verify
|
||||
* exit codes and output. Uses node with --require ts-node/register to
|
||||
* run the TypeScript entry point directly so no build step is required.
|
||||
*/
|
||||
|
||||
const CLI_ENTRY = path.resolve(__dirname, '..', '..', 'cli.ts');
|
||||
|
||||
function runCli(cliArguments: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
['--require', 'ts-node/register/transpile-only', CLI_ENTRY, ...cliArguments],
|
||||
{ timeout: 30_000, cwd: path.resolve(__dirname, '..', '..', '..') },
|
||||
(error, stdout, stderr) => {
|
||||
resolve({
|
||||
code: error ? error.code ?? 1 : 0,
|
||||
stdout: stdout.toString(),
|
||||
stderr: stderr.toString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Integration tests spawn child processes which need more time than the default 5s
|
||||
jest.setTimeout(30_000);
|
||||
|
||||
describe('CLI integration', () => {
|
||||
it('exits 0 and shows all commands for --help', async () => {
|
||||
const result = await runCli(['--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('game-ci');
|
||||
expect(result.stdout).toContain('build');
|
||||
expect(result.stdout).toContain('activate');
|
||||
expect(result.stdout).toContain('orchestrate');
|
||||
expect(result.stdout).toContain('status');
|
||||
expect(result.stdout).toContain('version');
|
||||
expect(result.stdout).toContain('update');
|
||||
});
|
||||
|
||||
it('exits 0 and shows version info for version command', async () => {
|
||||
const result = await runCli(['version']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('unity-builder');
|
||||
});
|
||||
|
||||
it('exits 0 and shows build flags for build --help', async () => {
|
||||
const result = await runCli(['build', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('--target-platform');
|
||||
expect(result.stdout).toContain('--unity-version');
|
||||
expect(result.stdout).toContain('--project-path');
|
||||
expect(result.stdout).toContain('--build-name');
|
||||
expect(result.stdout).toContain('--builds-path');
|
||||
expect(result.stdout).toContain('--build-method');
|
||||
expect(result.stdout).toContain('--custom-parameters');
|
||||
expect(result.stdout).toContain('--provider-strategy');
|
||||
});
|
||||
|
||||
it('exits non-zero for an unknown command', async () => {
|
||||
const result = await runCli(['nonexistent']);
|
||||
|
||||
expect(result.code).not.toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('exits non-zero when no command is provided', async () => {
|
||||
const result = await runCli([]);
|
||||
|
||||
expect(result.code).not.toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('exits 0 for orchestrate --help', async () => {
|
||||
const result = await runCli(['orchestrate', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('--target-platform');
|
||||
expect(result.stdout).toContain('--provider-strategy');
|
||||
expect(result.stdout).toContain('cache');
|
||||
});
|
||||
|
||||
it('exits 0 for activate --help', async () => {
|
||||
const result = await runCli(['activate', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('activate');
|
||||
});
|
||||
|
||||
it('exits 0 for orchestrate cache --help', async () => {
|
||||
const result = await runCli(['orchestrate', 'cache', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('cache');
|
||||
});
|
||||
|
||||
it('exits 0 for update --help', async () => {
|
||||
const result = await runCli(['update', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('update');
|
||||
expect(result.stdout).toContain('--force');
|
||||
expect(result.stdout).toContain('--version');
|
||||
});
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
import buildCommand from '../commands/build';
|
||||
import activateCommand from '../commands/activate';
|
||||
import orchestrateCommand from '../commands/orchestrate';
|
||||
import statusCommand from '../commands/status';
|
||||
import versionCommand from '../commands/version';
|
||||
import updateCommand from '../commands/update';
|
||||
|
||||
function createFakeYargs(): { yargs: any; options: Record<string, any> } {
|
||||
const options: Record<string, any> = {};
|
||||
const yargs: any = {
|
||||
option: jest.fn(),
|
||||
positional: jest.fn(),
|
||||
example: jest.fn(),
|
||||
env: jest.fn(),
|
||||
command: jest.fn(),
|
||||
};
|
||||
|
||||
yargs.option.mockImplementation((name: string, config: any) => {
|
||||
options[name] = config;
|
||||
|
||||
return yargs;
|
||||
});
|
||||
yargs.positional.mockImplementation((name: string, config: any) => {
|
||||
options[name] = config;
|
||||
|
||||
return yargs;
|
||||
});
|
||||
yargs.example.mockReturnValue(yargs);
|
||||
yargs.env.mockReturnValue(yargs);
|
||||
yargs.command.mockReturnValue(yargs);
|
||||
|
||||
return { yargs, options };
|
||||
}
|
||||
|
||||
describe('CLI commands', () => {
|
||||
describe('build command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(buildCommand.command).toStrictEqual('build');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(buildCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof buildCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof buildCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('defines all expected build flags via builder', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(buildCommand.builder as Function)(yargs);
|
||||
|
||||
// Core build flags
|
||||
expect(options['target-platform']).toBeDefined();
|
||||
expect(options['target-platform'].demandOption).toStrictEqual(true);
|
||||
expect(options['unity-version']).toBeDefined();
|
||||
expect(options['project-path']).toBeDefined();
|
||||
expect(options['build-profile']).toBeDefined();
|
||||
expect(options['build-name']).toBeDefined();
|
||||
expect(options['builds-path']).toBeDefined();
|
||||
expect(options['build-method']).toBeDefined();
|
||||
expect(options['custom-parameters']).toBeDefined();
|
||||
expect(options['versioning']).toBeDefined();
|
||||
expect(options['version']).toBeDefined();
|
||||
expect(options['custom-image']).toBeDefined();
|
||||
expect(options['manual-exit']).toBeDefined();
|
||||
expect(options['enable-gpu']).toBeDefined();
|
||||
|
||||
// Android flags
|
||||
expect(options['android-version-code']).toBeDefined();
|
||||
expect(options['android-export-type']).toBeDefined();
|
||||
expect(options['android-keystore-name']).toBeDefined();
|
||||
expect(options['android-keystore-base64']).toBeDefined();
|
||||
expect(options['android-keystore-pass']).toBeDefined();
|
||||
expect(options['android-keyalias-name']).toBeDefined();
|
||||
expect(options['android-keyalias-pass']).toBeDefined();
|
||||
expect(options['android-target-sdk-version']).toBeDefined();
|
||||
expect(options['android-symbol-type']).toBeDefined();
|
||||
|
||||
// Docker flags
|
||||
expect(options['docker-cpu-limit']).toBeDefined();
|
||||
expect(options['docker-memory-limit']).toBeDefined();
|
||||
expect(options['docker-workspace-path']).toBeDefined();
|
||||
expect(options['run-as-host-user']).toBeDefined();
|
||||
expect(options['chown-files-to']).toBeDefined();
|
||||
|
||||
// Provider flags
|
||||
expect(options['provider-strategy']).toBeDefined();
|
||||
expect(options['skip-activation']).toBeDefined();
|
||||
expect(options['unity-licensing-server']).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets correct default values', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(buildCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['unity-version'].default).toStrictEqual('auto');
|
||||
expect(options['project-path'].default).toStrictEqual('.');
|
||||
expect(options['builds-path'].default).toStrictEqual('build');
|
||||
expect(options['versioning'].default).toStrictEqual('Semantic');
|
||||
expect(options['manual-exit'].default).toStrictEqual(false);
|
||||
expect(options['enable-gpu'].default).toStrictEqual(false);
|
||||
expect(options['android-export-type'].default).toStrictEqual('androidPackage');
|
||||
expect(options['android-symbol-type'].default).toStrictEqual('none');
|
||||
expect(options['provider-strategy'].default).toStrictEqual('local');
|
||||
});
|
||||
|
||||
it('provides camelCase aliases for kebab-case options', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(buildCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['target-platform'].alias).toStrictEqual('targetPlatform');
|
||||
expect(options['unity-version'].alias).toStrictEqual('unityVersion');
|
||||
expect(options['project-path'].alias).toStrictEqual('projectPath');
|
||||
expect(options['build-name'].alias).toStrictEqual('buildName');
|
||||
expect(options['builds-path'].alias).toStrictEqual('buildsPath');
|
||||
expect(options['build-method'].alias).toStrictEqual('buildMethod');
|
||||
});
|
||||
});
|
||||
|
||||
describe('activate command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(activateCommand.command).toStrictEqual('activate');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(activateCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof activateCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof activateCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('orchestrate command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(orchestrateCommand.command).toStrictEqual('orchestrate');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(orchestrateCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof orchestrateCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof orchestrateCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('defines key orchestrator flags', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(orchestrateCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['target-platform']).toBeDefined();
|
||||
expect(options['provider-strategy']).toBeDefined();
|
||||
expect(options['provider-strategy'].default).toStrictEqual('aws');
|
||||
expect(options['aws-stack-name']).toBeDefined();
|
||||
expect(options['kube-config']).toBeDefined();
|
||||
expect(options['kube-volume']).toBeDefined();
|
||||
expect(options['cache-key']).toBeDefined();
|
||||
expect(options['watch-to-end']).toBeDefined();
|
||||
expect(options['clone-depth']).toBeDefined();
|
||||
});
|
||||
|
||||
it('registers cache as a subcommand', () => {
|
||||
const { yargs } = createFakeYargs();
|
||||
|
||||
(orchestrateCommand.builder as Function)(yargs);
|
||||
|
||||
expect(yargs.command).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(statusCommand.command).toStrictEqual('status');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(statusCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof statusCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('version command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(versionCommand.command).toStrictEqual('version');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(versionCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof versionCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(updateCommand.command).toStrictEqual('update');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(updateCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof updateCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof updateCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('defines force and version flags', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(updateCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['force']).toBeDefined();
|
||||
expect(options['force'].type).toStrictEqual('boolean');
|
||||
expect(options['force'].default).toStrictEqual(false);
|
||||
expect(options['version']).toBeDefined();
|
||||
expect(options['version'].type).toStrictEqual('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
import { Cli } from '../../model/cli/cli';
|
||||
import GitHub from '../../model/github';
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
Cli.options = undefined;
|
||||
});
|
||||
|
||||
describe('mapCliArgumentsToInput', () => {
|
||||
describe('basic mapping', () => {
|
||||
it('populates Cli.options from CLI arguments', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
unityVersion: '2022.3.56f1',
|
||||
projectPath: './my-project',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options).toBeDefined();
|
||||
expect(Cli.options!['targetPlatform']).toStrictEqual('StandaloneLinux64');
|
||||
expect(Cli.options!['unityVersion']).toStrictEqual('2022.3.56f1');
|
||||
expect(Cli.options!['projectPath']).toStrictEqual('./my-project');
|
||||
});
|
||||
|
||||
it('disables GitHub Actions input reading', () => {
|
||||
const cliArguments: CliArguments = { targetPlatform: 'WebGL' };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(GitHub.githubInputEnabled).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it('sets mode to cli by default when not provided', () => {
|
||||
const cliArguments: CliArguments = { targetPlatform: 'Android' };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['mode']).toStrictEqual('cli');
|
||||
});
|
||||
|
||||
it('preserves an explicitly provided mode', () => {
|
||||
const cliArguments: CliArguments = { targetPlatform: 'Android', mode: 'custom-mode' };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['mode']).toStrictEqual('custom-mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
it('omits undefined values from Cli.options', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
unityVersion: undefined,
|
||||
buildName: undefined,
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['targetPlatform']).toStrictEqual('StandaloneLinux64');
|
||||
expect(Cli.options!).not.toHaveProperty('unityVersion');
|
||||
expect(Cli.options!).not.toHaveProperty('buildName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean conversion', () => {
|
||||
it('converts boolean true to string "true"', () => {
|
||||
const cliArguments: CliArguments = { manualExit: true };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['manualExit']).toStrictEqual('true');
|
||||
});
|
||||
|
||||
it('converts boolean false to string "false"', () => {
|
||||
const cliArguments: CliArguments = { enableGpu: false };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['enableGpu']).toStrictEqual('false');
|
||||
});
|
||||
|
||||
it('converts allowDirtyBuild boolean to string', () => {
|
||||
const cliArguments: CliArguments = { allowDirtyBuild: true };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['allowDirtyBuild']).toStrictEqual('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('yargs internal properties', () => {
|
||||
it('filters out yargs _ property', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
targetPlatform: 'iOS',
|
||||
_: ['build'] as any,
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!).not.toHaveProperty('_');
|
||||
});
|
||||
|
||||
it('filters out yargs $0 property', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
targetPlatform: 'iOS',
|
||||
$0: 'game-ci' as any,
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!).not.toHaveProperty('$0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flag name conversion', () => {
|
||||
it('passes camelCase keys through directly', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
androidKeystoreName: 'my.keystore',
|
||||
androidKeystorePass: 'secret',
|
||||
dockerCpuLimit: '4',
|
||||
dockerMemoryLimit: '8g',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['androidKeystoreName']).toStrictEqual('my.keystore');
|
||||
expect(Cli.options!['androidKeystorePass']).toStrictEqual('secret');
|
||||
expect(Cli.options!['dockerCpuLimit']).toStrictEqual('4');
|
||||
expect(Cli.options!['dockerMemoryLimit']).toStrictEqual('8g');
|
||||
});
|
||||
|
||||
it('maps all android-related arguments', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
androidVersionCode: '42',
|
||||
androidExportType: 'androidAppBundle',
|
||||
androidKeystoreBase64: 'base64data',
|
||||
androidKeyaliasName: 'myalias',
|
||||
androidKeyaliasPass: 'aliaspass',
|
||||
androidTargetSdkVersion: '33',
|
||||
androidSymbolType: 'public',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['androidVersionCode']).toStrictEqual('42');
|
||||
expect(Cli.options!['androidExportType']).toStrictEqual('androidAppBundle');
|
||||
expect(Cli.options!['androidKeystoreBase64']).toStrictEqual('base64data');
|
||||
expect(Cli.options!['androidKeyaliasName']).toStrictEqual('myalias');
|
||||
expect(Cli.options!['androidKeyaliasPass']).toStrictEqual('aliaspass');
|
||||
expect(Cli.options!['androidTargetSdkVersion']).toStrictEqual('33');
|
||||
expect(Cli.options!['androidSymbolType']).toStrictEqual('public');
|
||||
});
|
||||
|
||||
it('maps docker and container arguments', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
dockerIsolationMode: 'hyperv',
|
||||
dockerWorkspacePath: '/custom/workspace',
|
||||
containerRegistryRepository: 'custom/editor',
|
||||
containerRegistryImageVersion: '5',
|
||||
runAsHostUser: 'true',
|
||||
chownFilesTo: 'root:root',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['dockerIsolationMode']).toStrictEqual('hyperv');
|
||||
expect(Cli.options!['dockerWorkspacePath']).toStrictEqual('/custom/workspace');
|
||||
expect(Cli.options!['containerRegistryRepository']).toStrictEqual('custom/editor');
|
||||
expect(Cli.options!['containerRegistryImageVersion']).toStrictEqual('5');
|
||||
expect(Cli.options!['runAsHostUser']).toStrictEqual('true');
|
||||
expect(Cli.options!['chownFilesTo']).toStrictEqual('root:root');
|
||||
});
|
||||
|
||||
it('maps orchestrator-related arguments', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
providerStrategy: 'k8s',
|
||||
awsStackName: 'my-stack',
|
||||
kubeConfig: 'base64config',
|
||||
kubeVolume: 'my-pvc',
|
||||
kubeVolumeSize: '10Gi',
|
||||
kubeStorageClass: 'gp3',
|
||||
containerCpu: '2048',
|
||||
containerMemory: '4096',
|
||||
cacheKey: 'my-cache',
|
||||
watchToEnd: 'false',
|
||||
cloneDepth: '100',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['providerStrategy']).toStrictEqual('k8s');
|
||||
expect(Cli.options!['awsStackName']).toStrictEqual('my-stack');
|
||||
expect(Cli.options!['kubeConfig']).toStrictEqual('base64config');
|
||||
expect(Cli.options!['kubeVolume']).toStrictEqual('my-pvc');
|
||||
expect(Cli.options!['kubeVolumeSize']).toStrictEqual('10Gi');
|
||||
expect(Cli.options!['kubeStorageClass']).toStrictEqual('gp3');
|
||||
expect(Cli.options!['containerCpu']).toStrictEqual('2048');
|
||||
expect(Cli.options!['containerMemory']).toStrictEqual('4096');
|
||||
expect(Cli.options!['cacheKey']).toStrictEqual('my-cache');
|
||||
expect(Cli.options!['watchToEnd']).toStrictEqual('false');
|
||||
expect(Cli.options!['cloneDepth']).toStrictEqual('100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cli.isCliMode integration', () => {
|
||||
it('enables CLI mode after mapping', () => {
|
||||
const cliArguments: CliArguments = { targetPlatform: 'WebGL' };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.isCliMode).toStrictEqual(true);
|
||||
});
|
||||
|
||||
it('is not in CLI mode before mapping', () => {
|
||||
expect(Cli.isCliMode).toStrictEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
|
||||
interface ActivateArguments extends CliArguments {
|
||||
unityVersion?: string;
|
||||
unitySerial?: string;
|
||||
unityLicensingServer?: string;
|
||||
}
|
||||
|
||||
const activateCommand: CommandModule<object, ActivateArguments> = {
|
||||
command: 'activate',
|
||||
describe: 'Verify Unity license configuration',
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option('unity-version', {
|
||||
alias: 'unityVersion',
|
||||
type: 'string',
|
||||
description: 'Version of Unity to activate',
|
||||
default: 'auto',
|
||||
})
|
||||
.option('unity-licensing-server', {
|
||||
alias: 'unityLicensingServer',
|
||||
type: 'string',
|
||||
description: 'The Unity licensing server address for floating licenses',
|
||||
default: '',
|
||||
})
|
||||
.env('UNITY')
|
||||
.example(
|
||||
'UNITY_SERIAL=XXXX-XXXX-XXXX-XXXX game-ci activate',
|
||||
'Activate Unity using a serial from environment variable',
|
||||
)
|
||||
.example(
|
||||
'game-ci activate --unity-licensing-server http://license-server:8080',
|
||||
'Activate Unity using a floating license server',
|
||||
) as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
const unitySerial = process.env.UNITY_SERIAL;
|
||||
const unityLicense = process.env.UNITY_LICENSE;
|
||||
const licensingServer = cliArguments.unityLicensingServer || process.env.UNITY_LICENSING_SERVER || '';
|
||||
|
||||
if (licensingServer) {
|
||||
core.info(`Activating Unity via licensing server: ${licensingServer}`);
|
||||
core.info('Floating license activation is handled automatically during builds.');
|
||||
core.info('No manual activation step is needed when using a licensing server.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!unitySerial && !unityLicense) {
|
||||
throw new Error(
|
||||
'No Unity license found.\n\n' +
|
||||
'Provide one of the following:\n' +
|
||||
' - UNITY_SERIAL environment variable (professional license)\n' +
|
||||
' - UNITY_LICENSE environment variable (personal license file content)\n' +
|
||||
' - --unity-licensing-server flag (floating license)\n\n' +
|
||||
'For more information, visit: https://game.ci/docs/github/activation',
|
||||
);
|
||||
}
|
||||
|
||||
if (unitySerial) {
|
||||
const maskedSerial = unitySerial.length > 8 ? `${unitySerial.slice(0, 4)}...${unitySerial.slice(-4)}` : '****';
|
||||
core.info(`Unity serial detected: ${maskedSerial}`);
|
||||
core.info('License will be activated automatically when running a build.');
|
||||
} else if (unityLicense) {
|
||||
core.info('Unity license file detected from UNITY_LICENSE environment variable.');
|
||||
core.info('License will be activated automatically when running a build.');
|
||||
}
|
||||
|
||||
core.info('\nActivation verified. You can now run: game-ci build --target-platform <platform>');
|
||||
} catch (error: any) {
|
||||
core.setFailed(`Activation failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default activateCommand;
|
||||
@@ -1,306 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import { BuildParameters, ImageTag } from '../../model';
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
import { loadOrchestrator } from '../../model/orchestrator-plugin';
|
||||
import MacBuilder from '../../model/mac-builder';
|
||||
import Docker from '../../model/docker';
|
||||
import Action from '../../model/action';
|
||||
import PlatformSetup from '../../model/platform-setup';
|
||||
|
||||
interface BuildArguments extends CliArguments {
|
||||
targetPlatform: string;
|
||||
}
|
||||
|
||||
const buildCommand: CommandModule<object, BuildArguments> = {
|
||||
command: 'build',
|
||||
describe: 'Build a Unity project',
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option('target-platform', {
|
||||
alias: 'targetPlatform',
|
||||
type: 'string',
|
||||
description: 'Platform that the build should target',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('unity-version', {
|
||||
alias: 'unityVersion',
|
||||
type: 'string',
|
||||
description: 'Version of Unity to use for building the project. Use "auto" to detect.',
|
||||
default: 'auto',
|
||||
})
|
||||
.option('project-path', {
|
||||
alias: 'projectPath',
|
||||
type: 'string',
|
||||
description: 'Path to the Unity project to be built',
|
||||
default: '.',
|
||||
})
|
||||
.option('build-profile', {
|
||||
alias: 'buildProfile',
|
||||
type: 'string',
|
||||
description: 'Path to the build profile to activate, relative to the project root',
|
||||
default: '',
|
||||
})
|
||||
.option('build-name', {
|
||||
alias: 'buildName',
|
||||
type: 'string',
|
||||
description: 'Name of the build (no file extension)',
|
||||
default: '',
|
||||
})
|
||||
.option('builds-path', {
|
||||
alias: 'buildsPath',
|
||||
type: 'string',
|
||||
description: 'Path where the builds should be stored',
|
||||
default: 'build',
|
||||
})
|
||||
.option('build-method', {
|
||||
alias: 'buildMethod',
|
||||
type: 'string',
|
||||
description: 'Path to a Namespace.Class.StaticMethod to run to perform the build',
|
||||
default: '',
|
||||
})
|
||||
.option('custom-parameters', {
|
||||
alias: 'customParameters',
|
||||
type: 'string',
|
||||
description: 'Custom parameters to configure the build',
|
||||
default: '',
|
||||
})
|
||||
.option('versioning', {
|
||||
type: 'string',
|
||||
description: 'The versioning scheme to use when building the project',
|
||||
default: 'Semantic',
|
||||
})
|
||||
.option('version', {
|
||||
type: 'string',
|
||||
description: 'The version, when used with the "Custom" versioning scheme',
|
||||
default: '',
|
||||
})
|
||||
.option('custom-image', {
|
||||
alias: 'customImage',
|
||||
type: 'string',
|
||||
description: 'Specific docker image that should be used for building the project',
|
||||
default: '',
|
||||
})
|
||||
.option('manual-exit', {
|
||||
alias: 'manualExit',
|
||||
type: 'boolean',
|
||||
description: 'Suppresses -quit. Exit your build method using EditorApplication.Exit(0) instead.',
|
||||
default: false,
|
||||
})
|
||||
.option('enable-gpu', {
|
||||
alias: 'enableGpu',
|
||||
type: 'boolean',
|
||||
description: 'Launches unity without specifying -nographics',
|
||||
default: false,
|
||||
})
|
||||
.option('android-version-code', {
|
||||
alias: 'androidVersionCode',
|
||||
type: 'string',
|
||||
description: 'The android versionCode',
|
||||
default: '',
|
||||
})
|
||||
.option('android-export-type', {
|
||||
alias: 'androidExportType',
|
||||
type: 'string',
|
||||
description: 'The android export type (androidPackage, androidAppBundle, androidStudioProject)',
|
||||
default: 'androidPackage',
|
||||
})
|
||||
.option('android-keystore-name', {
|
||||
alias: 'androidKeystoreName',
|
||||
type: 'string',
|
||||
description: 'The android keystoreName',
|
||||
default: '',
|
||||
})
|
||||
.option('android-keystore-base64', {
|
||||
alias: 'androidKeystoreBase64',
|
||||
type: 'string',
|
||||
description: 'The base64 contents of the android keystore file',
|
||||
default: '',
|
||||
})
|
||||
.option('android-keystore-pass', {
|
||||
alias: 'androidKeystorePass',
|
||||
type: 'string',
|
||||
description: 'The android keystorePass',
|
||||
default: '',
|
||||
})
|
||||
.option('android-keyalias-name', {
|
||||
alias: 'androidKeyaliasName',
|
||||
type: 'string',
|
||||
description: 'The android keyaliasName',
|
||||
default: '',
|
||||
})
|
||||
.option('android-keyalias-pass', {
|
||||
alias: 'androidKeyaliasPass',
|
||||
type: 'string',
|
||||
description: 'The android keyaliasPass',
|
||||
default: '',
|
||||
})
|
||||
.option('android-target-sdk-version', {
|
||||
alias: 'androidTargetSdkVersion',
|
||||
type: 'string',
|
||||
description: 'The android target API level',
|
||||
default: '',
|
||||
})
|
||||
.option('android-symbol-type', {
|
||||
alias: 'androidSymbolType',
|
||||
type: 'string',
|
||||
description: 'The android symbol type to export (none, public, debugging)',
|
||||
default: 'none',
|
||||
})
|
||||
.option('docker-cpu-limit', {
|
||||
alias: 'dockerCpuLimit',
|
||||
type: 'string',
|
||||
description: 'Number of CPU cores to assign the docker container',
|
||||
default: '',
|
||||
})
|
||||
.option('docker-memory-limit', {
|
||||
alias: 'dockerMemoryLimit',
|
||||
type: 'string',
|
||||
description: 'Amount of memory to assign the docker container (e.g. 512m, 4g)',
|
||||
default: '',
|
||||
})
|
||||
.option('docker-workspace-path', {
|
||||
alias: 'dockerWorkspacePath',
|
||||
type: 'string',
|
||||
description: 'The path to mount the workspace inside the docker container',
|
||||
default: '/github/workspace',
|
||||
})
|
||||
.option('run-as-host-user', {
|
||||
alias: 'runAsHostUser',
|
||||
type: 'string',
|
||||
description: 'Whether to run as a user that matches the host system',
|
||||
default: 'false',
|
||||
})
|
||||
.option('chown-files-to', {
|
||||
alias: 'chownFilesTo',
|
||||
type: 'string',
|
||||
description: 'User and optionally group to give ownership of build artifacts',
|
||||
default: '',
|
||||
})
|
||||
.option('ssh-agent', {
|
||||
alias: 'sshAgent',
|
||||
type: 'string',
|
||||
description: 'SSH Agent path to forward to the container',
|
||||
default: '',
|
||||
})
|
||||
.option('git-private-token', {
|
||||
alias: 'gitPrivateToken',
|
||||
type: 'string',
|
||||
description: 'GitHub private token to pull from GitHub',
|
||||
default: '',
|
||||
})
|
||||
.option('provider-strategy', {
|
||||
alias: 'providerStrategy',
|
||||
type: 'string',
|
||||
description: 'Execution strategy: local, k8s, or aws',
|
||||
default: 'local',
|
||||
})
|
||||
.option('skip-activation', {
|
||||
alias: 'skipActivation',
|
||||
type: 'string',
|
||||
description: 'Skip the activation/deactivation of Unity',
|
||||
default: 'false',
|
||||
})
|
||||
.option('unity-licensing-server', {
|
||||
alias: 'unityLicensingServer',
|
||||
type: 'string',
|
||||
description: 'The Unity licensing server address',
|
||||
default: '',
|
||||
})
|
||||
.option('container-registry-repository', {
|
||||
alias: 'containerRegistryRepository',
|
||||
type: 'string',
|
||||
description: 'Container registry and repository to pull image from. Only applicable if customImage is not set.',
|
||||
default: 'unityci/editor',
|
||||
})
|
||||
.option('container-registry-image-version', {
|
||||
alias: 'containerRegistryImageVersion',
|
||||
type: 'string',
|
||||
description: 'Container registry image version. Only applicable if customImage is not set.',
|
||||
default: '3',
|
||||
})
|
||||
.option('docker-isolation-mode', {
|
||||
alias: 'dockerIsolationMode',
|
||||
type: 'string',
|
||||
description:
|
||||
'Isolation mode to use for the docker container (process, hyperv, or default). Only applicable on Windows.',
|
||||
default: 'default',
|
||||
})
|
||||
.option('ssh-public-keys-directory-path', {
|
||||
alias: 'sshPublicKeysDirectoryPath',
|
||||
type: 'string',
|
||||
description: 'Path to a directory containing SSH public keys to forward to the container',
|
||||
default: '',
|
||||
})
|
||||
.option('cache-unity-installation-on-mac', {
|
||||
alias: 'cacheUnityInstallationOnMac',
|
||||
type: 'boolean',
|
||||
description: 'Whether to cache the Unity hub and editor installation on MacOS',
|
||||
default: false,
|
||||
})
|
||||
.option('unity-hub-version-on-mac', {
|
||||
alias: 'unityHubVersionOnMac',
|
||||
type: 'string',
|
||||
description: 'The version of Unity Hub to install on MacOS (e.g. 3.4.0). Defaults to latest available on brew.',
|
||||
default: '',
|
||||
})
|
||||
.example('game-ci build --target-platform StandaloneLinux64', 'Build for Linux using auto-detected Unity version')
|
||||
.example(
|
||||
'game-ci build --target-platform Android --unity-version 2022.3.56f1 --build-method MyBuild.Run',
|
||||
'Build for Android with a specific Unity version and build method',
|
||||
) as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
const buildParameters = await BuildParameters.create();
|
||||
const baseImage = new ImageTag(buildParameters);
|
||||
|
||||
let exitCode = -1;
|
||||
|
||||
if (buildParameters.providerStrategy === 'local') {
|
||||
core.info(`Building locally for ${buildParameters.targetPlatform}...`);
|
||||
core.info(`Unity version: ${buildParameters.editorVersion}`);
|
||||
core.info(`Project path: ${buildParameters.projectPath}`);
|
||||
|
||||
const actionFolder = Action.actionFolder;
|
||||
await PlatformSetup.setup(buildParameters, actionFolder);
|
||||
|
||||
exitCode =
|
||||
process.platform === 'darwin'
|
||||
? await MacBuilder.run(actionFolder)
|
||||
: await Docker.run(baseImage.toString(), {
|
||||
workspace: process.cwd(),
|
||||
actionFolder,
|
||||
...buildParameters,
|
||||
});
|
||||
} else {
|
||||
core.info(`Building via orchestrator (${buildParameters.providerStrategy})...`);
|
||||
const orchestrator = await loadOrchestrator();
|
||||
if (!orchestrator) {
|
||||
throw new Error(
|
||||
'Orchestrator package not available. Install @game-ci/orchestrator or use --provider-strategy local.',
|
||||
);
|
||||
}
|
||||
const result = await orchestrator.run(buildParameters, baseImage.toString());
|
||||
exitCode = result.exitCode;
|
||||
}
|
||||
|
||||
// Output results
|
||||
core.info(`\nBuild completed with exit code: ${exitCode}`);
|
||||
core.info(`Build version: ${buildParameters.buildVersion}`);
|
||||
core.info(`Build path: ${buildParameters.buildPath}`);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Build failed with exit code ${exitCode}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
core.setFailed(`Build failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default buildCommand;
|
||||
@@ -1,160 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const cacheCommand: CommandModule = {
|
||||
command: 'cache <action>',
|
||||
describe: 'Manage build caches',
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.positional('action', {
|
||||
describe: 'Cache action to perform',
|
||||
choices: ['list', 'restore', 'clear'] as const,
|
||||
})
|
||||
.option('cache-dir', {
|
||||
alias: 'cacheDir',
|
||||
type: 'string',
|
||||
description: 'Path to the cache directory',
|
||||
default: '',
|
||||
})
|
||||
.option('project-path', {
|
||||
alias: 'projectPath',
|
||||
type: 'string',
|
||||
description: 'Path to the Unity project',
|
||||
default: '.',
|
||||
})
|
||||
.example('game-ci orchestrate cache list', 'List all cached workspaces')
|
||||
.example('game-ci orchestrate cache restore --cache-dir ./my-cache', 'Restore a cached workspace')
|
||||
.example('game-ci orchestrate cache clear', 'Clear all cached workspaces');
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
const action = cliArguments.action as string;
|
||||
const projectPath = (cliArguments.projectPath as string) || '.';
|
||||
const cacheDirectory = (cliArguments.cacheDir as string) || path.join(projectPath, 'Library');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'list': {
|
||||
await listCache(cacheDirectory, projectPath);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'restore': {
|
||||
await restoreCache(cacheDirectory);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'clear': {
|
||||
await clearCache(cacheDirectory);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unknown cache action: ${action}. Available actions: list, restore, clear`);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
core.setFailed(`Cache operation failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function listCache(cacheDirectory: string, projectPath: string): Promise<void> {
|
||||
const libraryPath = path.resolve(projectPath, 'Library');
|
||||
|
||||
core.info('Cache Status:');
|
||||
core.info('=============');
|
||||
|
||||
if (fs.existsSync(libraryPath)) {
|
||||
const stats = fs.statSync(libraryPath);
|
||||
const files = fs.readdirSync(libraryPath);
|
||||
core.info(` Library folder: ${libraryPath}`);
|
||||
core.info(` Entries: ${files.length}`);
|
||||
core.info(` Last modified: ${stats.mtime.toISOString()}`);
|
||||
|
||||
// Show size of key subdirectories
|
||||
const keyDirectories = ['PackageCache', 'ScriptAssemblies', 'ShaderCache', 'Bee'];
|
||||
for (const directory of keyDirectories) {
|
||||
const directoryPath = path.join(libraryPath, directory);
|
||||
if (fs.existsSync(directoryPath)) {
|
||||
const directoryStats = fs.statSync(directoryPath);
|
||||
core.info(` ${directory}/: exists (modified ${directoryStats.mtime.toISOString()})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
core.info(` Library folder not found at: ${libraryPath}`);
|
||||
core.info(' No cache available. First build will be a clean build.');
|
||||
}
|
||||
|
||||
// Check for .tar cache files if a custom cache dir is specified
|
||||
if (cacheDirectory && cacheDirectory !== libraryPath && fs.existsSync(cacheDirectory)) {
|
||||
core.info(`\nCache directory: ${cacheDirectory}`);
|
||||
const cacheFiles = fs.readdirSync(cacheDirectory).filter((f) => f.endsWith('.tar') || f.endsWith('.tar.lz4'));
|
||||
if (cacheFiles.length > 0) {
|
||||
core.info(` Cache archives found: ${cacheFiles.length}`);
|
||||
for (const file of cacheFiles) {
|
||||
const filePath = path.join(cacheDirectory, file);
|
||||
const fileStats = fs.statSync(filePath);
|
||||
const sizeMegabytes = (fileStats.size / (1024 * 1024)).toFixed(1);
|
||||
core.info(` - ${file} (${sizeMegabytes} MB, ${fileStats.mtime.toISOString()})`);
|
||||
}
|
||||
} else {
|
||||
core.info(' No cache archives found.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreCache(cacheDirectory: string): Promise<void> {
|
||||
if (!cacheDirectory) {
|
||||
throw new Error('--cache-dir is required for restore');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(cacheDirectory)) {
|
||||
core.info(`Cache directory does not exist: ${cacheDirectory}`);
|
||||
core.info('Nothing to restore.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheFiles = fs.readdirSync(cacheDirectory).filter((f) => f.endsWith('.tar') || f.endsWith('.tar.lz4'));
|
||||
if (cacheFiles.length === 0) {
|
||||
core.info('No cache archives found to restore.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by modification time, newest first
|
||||
const sorted = cacheFiles
|
||||
.map((f) => ({ name: f, mtime: fs.statSync(path.join(cacheDirectory, f)).mtime }))
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
core.info(`Found ${sorted.length} cache archive(s). Latest: ${sorted[0].name}`);
|
||||
core.info('Use the orchestrator cache system for full restore functionality:');
|
||||
core.info(' game-ci orchestrate --cache-key <key> ...');
|
||||
}
|
||||
|
||||
async function clearCache(cacheDirectory: string): Promise<void> {
|
||||
let cleared = false;
|
||||
|
||||
if (cacheDirectory && fs.existsSync(cacheDirectory)) {
|
||||
const cacheFiles = fs.readdirSync(cacheDirectory).filter((f) => f.endsWith('.tar') || f.endsWith('.tar.lz4'));
|
||||
if (cacheFiles.length > 0) {
|
||||
for (const file of cacheFiles) {
|
||||
fs.unlinkSync(path.join(cacheDirectory, file));
|
||||
core.info(` Removed: ${file}`);
|
||||
}
|
||||
cleared = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!cleared) {
|
||||
core.info('No cache archives found to clear.');
|
||||
} else {
|
||||
core.info('Cache cleared.');
|
||||
}
|
||||
}
|
||||
|
||||
export default cacheCommand;
|
||||
@@ -1,230 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import { BuildParameters, ImageTag } from '../../model';
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
import { loadOrchestrator } from '../../model/orchestrator-plugin';
|
||||
import cacheCommand from './cache';
|
||||
|
||||
interface OrchestrateArguments extends CliArguments {
|
||||
targetPlatform: string;
|
||||
providerStrategy?: string;
|
||||
}
|
||||
|
||||
const orchestrateCommand: CommandModule<object, OrchestrateArguments> = {
|
||||
command: 'orchestrate',
|
||||
describe: 'Orchestrator — remote builds, cache management, and provider tools',
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.command(cacheCommand)
|
||||
.option('target-platform', {
|
||||
alias: 'targetPlatform',
|
||||
type: 'string',
|
||||
description: 'Platform that the build should target',
|
||||
})
|
||||
.option('provider-strategy', {
|
||||
alias: 'providerStrategy',
|
||||
type: 'string',
|
||||
description: 'Orchestrator provider: aws, k8s, local-docker, local-system',
|
||||
default: 'aws',
|
||||
})
|
||||
.option('unity-version', {
|
||||
alias: 'unityVersion',
|
||||
type: 'string',
|
||||
description: 'Version of Unity to use for building',
|
||||
default: 'auto',
|
||||
})
|
||||
.option('project-path', {
|
||||
alias: 'projectPath',
|
||||
type: 'string',
|
||||
description: 'Path to the Unity project to be built',
|
||||
default: '.',
|
||||
})
|
||||
.option('build-name', {
|
||||
alias: 'buildName',
|
||||
type: 'string',
|
||||
description: 'Name of the build',
|
||||
default: '',
|
||||
})
|
||||
.option('builds-path', {
|
||||
alias: 'buildsPath',
|
||||
type: 'string',
|
||||
description: 'Path where the builds should be stored',
|
||||
default: 'build',
|
||||
})
|
||||
.option('build-method', {
|
||||
alias: 'buildMethod',
|
||||
type: 'string',
|
||||
description: 'Path to a Namespace.Class.StaticMethod to run to perform the build',
|
||||
default: '',
|
||||
})
|
||||
.option('custom-parameters', {
|
||||
alias: 'customParameters',
|
||||
type: 'string',
|
||||
description: 'Custom parameters to configure the build',
|
||||
default: '',
|
||||
})
|
||||
.option('versioning', {
|
||||
type: 'string',
|
||||
description: 'The versioning scheme to use',
|
||||
default: 'None',
|
||||
})
|
||||
.option('aws-stack-name', {
|
||||
alias: 'awsStackName',
|
||||
type: 'string',
|
||||
description: 'The Cloud Formation stack name (AWS provider)',
|
||||
default: 'game-ci',
|
||||
})
|
||||
.option('kube-config', {
|
||||
alias: 'kubeConfig',
|
||||
type: 'string',
|
||||
description: 'Base64 encoded Kubernetes config (K8s provider)',
|
||||
default: '',
|
||||
})
|
||||
.option('kube-volume', {
|
||||
alias: 'kubeVolume',
|
||||
type: 'string',
|
||||
description: 'Persistent Volume Claim name for Unity build (K8s provider)',
|
||||
default: '',
|
||||
})
|
||||
.option('kube-volume-size', {
|
||||
alias: 'kubeVolumeSize',
|
||||
type: 'string',
|
||||
description: 'Disc space for Kubernetes Persistent Volume',
|
||||
default: '5Gi',
|
||||
})
|
||||
.option('container-cpu', {
|
||||
alias: 'containerCpu',
|
||||
type: 'string',
|
||||
description: 'CPU allocation for remote build container',
|
||||
default: '1024',
|
||||
})
|
||||
.option('container-memory', {
|
||||
alias: 'containerMemory',
|
||||
type: 'string',
|
||||
description: 'Memory allocation for remote build container',
|
||||
default: '3072',
|
||||
})
|
||||
.option('cache-key', {
|
||||
alias: 'cacheKey',
|
||||
type: 'string',
|
||||
description: 'Cache key to indicate bucket for cache',
|
||||
default: '',
|
||||
})
|
||||
.option('git-private-token', {
|
||||
alias: 'gitPrivateToken',
|
||||
type: 'string',
|
||||
description: 'GitHub private token for repository access',
|
||||
default: '',
|
||||
})
|
||||
.option('allow-dirty-build', {
|
||||
alias: 'allowDirtyBuild',
|
||||
type: 'boolean',
|
||||
description: 'Allow builds from dirty branches',
|
||||
default: false,
|
||||
})
|
||||
.option('watch-to-end', {
|
||||
alias: 'watchToEnd',
|
||||
type: 'string',
|
||||
description: 'Whether to watch the build to completion',
|
||||
default: 'true',
|
||||
})
|
||||
.option('clone-depth', {
|
||||
alias: 'cloneDepth',
|
||||
type: 'string',
|
||||
description: 'Git clone depth (0 for full clone)',
|
||||
default: '50',
|
||||
})
|
||||
.option('skip-activation', {
|
||||
alias: 'skipActivation',
|
||||
type: 'string',
|
||||
description: 'Skip Unity activation/deactivation',
|
||||
default: 'false',
|
||||
})
|
||||
.option('kube-storage-class', {
|
||||
alias: 'kubeStorageClass',
|
||||
type: 'string',
|
||||
description: 'Kubernetes storage class to use for orchestrator jobs. Leave empty to install rook cluster.',
|
||||
default: '',
|
||||
})
|
||||
.option('read-input-from-override-list', {
|
||||
alias: 'readInputFromOverrideList',
|
||||
type: 'string',
|
||||
description: 'Comma separated list of input value names to read from the input override command',
|
||||
default: '',
|
||||
})
|
||||
.option('read-input-override-command', {
|
||||
alias: 'readInputOverrideCommand',
|
||||
type: 'string',
|
||||
description: 'Command to execute to pull input from an external source (e.g. cloud provider secret managers)',
|
||||
default: '',
|
||||
})
|
||||
.option('post-build-steps', {
|
||||
alias: 'postBuildSteps',
|
||||
type: 'string',
|
||||
description:
|
||||
'Post build job in yaml format with the keys image, secrets (name, value object array), command string',
|
||||
default: '',
|
||||
})
|
||||
.option('pre-build-steps', {
|
||||
alias: 'preBuildSteps',
|
||||
type: 'string',
|
||||
description:
|
||||
'Pre build job after repository setup but before the build job (yaml format with keys image, secrets, command)',
|
||||
default: '',
|
||||
})
|
||||
.option('custom-job', {
|
||||
alias: 'customJob',
|
||||
type: 'string',
|
||||
description:
|
||||
'Custom job instead of the standard build automation (yaml format with keys image, secrets, command)',
|
||||
default: '',
|
||||
})
|
||||
.example(
|
||||
'game-ci orchestrate --target-platform StandaloneLinux64 --provider-strategy aws',
|
||||
'Build on AWS using the orchestrator',
|
||||
)
|
||||
.example(
|
||||
'game-ci orchestrate --target-platform StandaloneLinux64 --provider-strategy k8s --kube-config <base64>',
|
||||
'Build on Kubernetes',
|
||||
) as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
if (!cliArguments.targetPlatform) {
|
||||
throw new Error('--target-platform is required for orchestrate builds. Run game-ci orchestrate --help.');
|
||||
}
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
const orchestrator = await loadOrchestrator();
|
||||
if (!orchestrator) {
|
||||
throw new Error(
|
||||
'Orchestrator package not available. Install @game-ci/orchestrator to use orchestrate commands.',
|
||||
);
|
||||
}
|
||||
|
||||
const buildParameters = await BuildParameters.create();
|
||||
const baseImage = new ImageTag(buildParameters);
|
||||
|
||||
core.info(`Orchestrating build via ${buildParameters.providerStrategy}...`);
|
||||
core.info(`Target platform: ${buildParameters.targetPlatform}`);
|
||||
core.info(`Unity version: ${buildParameters.editorVersion}`);
|
||||
core.info(`Build GUID: ${buildParameters.buildGuid}`);
|
||||
|
||||
const result = await orchestrator.run(buildParameters, baseImage.toString());
|
||||
|
||||
core.info(`\nOrchestrated build completed.`);
|
||||
if (result?.BuildSucceeded) {
|
||||
core.info(`Build succeeded.`);
|
||||
} else {
|
||||
core.warning('Build completed but did not succeed.');
|
||||
}
|
||||
} catch (error: any) {
|
||||
core.setFailed(`Orchestrated build failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default orchestrateCommand;
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import UnityVersioning from '../../model/unity-versioning';
|
||||
|
||||
const statusCommand: CommandModule = {
|
||||
command: 'status',
|
||||
describe: 'Show build status and workspace info',
|
||||
builder: (yargs) => {
|
||||
return yargs.option('project-path', {
|
||||
alias: 'projectPath',
|
||||
type: 'string',
|
||||
description: 'Path to the Unity project',
|
||||
default: '.',
|
||||
});
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
const projectPath = (cliArguments.projectPath as string) || '.';
|
||||
|
||||
core.info('game-ci Workspace Status');
|
||||
core.info('========================\n');
|
||||
|
||||
// Project detection
|
||||
const projectVersionPath = path.join(projectPath, 'ProjectSettings', 'ProjectVersion.txt');
|
||||
const hasProject = fs.existsSync(projectVersionPath);
|
||||
|
||||
core.info(`Project Path: ${path.resolve(projectPath)}`);
|
||||
core.info(`Unity Project Found: ${hasProject ? 'Yes' : 'No'}`);
|
||||
|
||||
if (hasProject) {
|
||||
try {
|
||||
const unityVersion = UnityVersioning.determineUnityVersion(projectPath, 'auto');
|
||||
core.info(`Unity Version: ${unityVersion}`);
|
||||
} catch {
|
||||
core.info(`Unity Version: Unable to detect`);
|
||||
}
|
||||
|
||||
// Library folder status
|
||||
const libraryPath = path.join(projectPath, 'Library');
|
||||
if (fs.existsSync(libraryPath)) {
|
||||
const stats = fs.statSync(libraryPath);
|
||||
core.info(`Library Cache: Present (modified ${stats.mtime.toISOString()})`);
|
||||
} else {
|
||||
core.info(`Library Cache: Not present (clean build required)`);
|
||||
}
|
||||
|
||||
// Build output detection
|
||||
const buildsPath = path.join(projectPath, '..', 'build');
|
||||
if (fs.existsSync(buildsPath)) {
|
||||
const builds = fs.readdirSync(buildsPath);
|
||||
if (builds.length > 0) {
|
||||
core.info(`\nBuild Outputs (${buildsPath}):`);
|
||||
for (const build of builds) {
|
||||
const buildPath = path.join(buildsPath, build);
|
||||
const buildStats = fs.statSync(buildPath);
|
||||
core.info(` - ${build} (${buildStats.isDirectory() ? 'dir' : 'file'}, ${buildStats.mtime.toISOString()})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Environment
|
||||
core.info('\nEnvironment:');
|
||||
core.info(` Platform: ${process.platform}`);
|
||||
core.info(` Node.js: ${process.version}`);
|
||||
core.info(` UNITY_SERIAL: ${process.env.UNITY_SERIAL ? 'Set' : 'Not set'}`);
|
||||
core.info(` UNITY_LICENSE: ${process.env.UNITY_LICENSE ? 'Set' : 'Not set'}`);
|
||||
core.info(` UNITY_EMAIL: ${process.env.UNITY_EMAIL ? 'Set' : 'Not set'}`);
|
||||
core.info(` UNITY_PASSWORD: ${process.env.UNITY_PASSWORD ? 'Set' : 'Not set'}`);
|
||||
|
||||
// Docker availability
|
||||
core.info(`\nDocker: Checking...`);
|
||||
try {
|
||||
const { execSync } = await import('node:child_process');
|
||||
const dockerVersion = execSync('docker --version', { encoding: 'utf8' }).trim();
|
||||
core.info(` ${dockerVersion}`);
|
||||
} catch {
|
||||
core.info(` Docker not found or not accessible`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default statusCommand;
|
||||
@@ -1,387 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const REPO = 'game-ci/unity-builder';
|
||||
|
||||
interface GitHubRelease {
|
||||
// eslint-disable-next-line camelcase
|
||||
tag_name: string;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface UpdateArguments {
|
||||
force?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches JSON from a URL via HTTPS, following redirects.
|
||||
*/
|
||||
function fetchJson(url: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const get = (targetUrl: string, redirectCount: number) => {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error('Too many redirects'));
|
||||
|
||||
return;
|
||||
}
|
||||
https
|
||||
.get(
|
||||
targetUrl,
|
||||
{
|
||||
headers: { 'User-Agent': 'game-ci-cli', Accept: 'application/json' },
|
||||
},
|
||||
(response) => {
|
||||
if (
|
||||
response.statusCode &&
|
||||
response.statusCode >= 300 &&
|
||||
response.statusCode < 400 &&
|
||||
response.headers.location
|
||||
) {
|
||||
get(response.headers.location, redirectCount + 1);
|
||||
|
||||
return;
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode} from ${targetUrl}`));
|
||||
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
response.on('data', (chunk) => (data += chunk));
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
reject(new Error('Invalid JSON response'));
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
.on('error', reject);
|
||||
};
|
||||
get(url, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a file from a URL, following redirects. Returns the file content as a Buffer.
|
||||
*/
|
||||
function downloadFile(url: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const get = (targetUrl: string, redirectCount: number) => {
|
||||
if (redirectCount > 10) {
|
||||
reject(new Error('Too many redirects'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = targetUrl.startsWith('https') ? https : http;
|
||||
protocol
|
||||
.get(targetUrl, { headers: { 'User-Agent': 'game-ci-cli' } }, (response) => {
|
||||
if (
|
||||
response.statusCode &&
|
||||
response.statusCode >= 300 &&
|
||||
response.statusCode < 400 &&
|
||||
response.headers.location
|
||||
) {
|
||||
get(response.headers.location, redirectCount + 1);
|
||||
|
||||
return;
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode} downloading ${targetUrl}`));
|
||||
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
response.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
})
|
||||
.on('error', reject);
|
||||
};
|
||||
get(url, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current version from package.json or the compiled binary.
|
||||
*/
|
||||
function getCurrentVersion(): string {
|
||||
// Try reading from package.json at various relative locations
|
||||
const candidates = [
|
||||
path.join(__dirname, '..', '..', '..', 'package.json'),
|
||||
path.join(__dirname, '..', '..', 'package.json'),
|
||||
path.join(process.cwd(), 'package.json'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const packageData = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
||||
if (packageData.version) {
|
||||
return packageData.version;
|
||||
}
|
||||
} catch {
|
||||
// Continue to next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the correct asset name for the current platform/architecture.
|
||||
*/
|
||||
function getAssetName(): string {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
let osPart: string;
|
||||
switch (platform) {
|
||||
case 'linux':
|
||||
osPart = 'linux';
|
||||
break;
|
||||
case 'darwin':
|
||||
osPart = 'macos';
|
||||
break;
|
||||
case 'win32':
|
||||
osPart = 'windows';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
let archPart: string;
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
archPart = 'x64';
|
||||
break;
|
||||
case 'arm64':
|
||||
archPart = 'arm64';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported architecture: ${arch}`);
|
||||
}
|
||||
|
||||
const assetBaseName = `game-ci-${osPart}-${archPart}`;
|
||||
|
||||
return osPart === 'windows' ? `${assetBaseName}.exe` : assetBaseName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the path to the currently running executable.
|
||||
* For standalone binaries (pkg), process.execPath points to the binary itself.
|
||||
* For Node.js execution, we return undefined since self-update does not apply.
|
||||
*/
|
||||
function getExecutablePath(): string | undefined {
|
||||
// When running as a pkg binary, process.pkg is defined
|
||||
if ((process as any).pkg) {
|
||||
return process.execPath;
|
||||
}
|
||||
|
||||
// When running via Node.js, check if there is a standalone binary in the typical install location
|
||||
const installDirectory = process.env.GAME_CI_INSTALL || path.join(os.homedir(), '.game-ci', 'bin');
|
||||
const binaryName = process.platform === 'win32' ? 'game-ci.exe' : 'game-ci';
|
||||
const installedPath = path.join(installDirectory, binaryName);
|
||||
|
||||
if (fs.existsSync(installedPath)) {
|
||||
return installedPath;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips leading 'v' from a version string and splits into numeric parts.
|
||||
*/
|
||||
function parseVersionParts(version: string): number[] {
|
||||
return version
|
||||
.replace(/^v/, '')
|
||||
.split('.')
|
||||
.map((part) => Number(part));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two semver strings. Returns:
|
||||
* -1 if a < b
|
||||
* 0 if a == b
|
||||
* 1 if a > b
|
||||
*/
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const partsA = parseVersionParts(a);
|
||||
const partsB = parseVersionParts(b);
|
||||
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const x = partsA[index] || 0;
|
||||
const y = partsB[index] || 0;
|
||||
if (x < y) return -1;
|
||||
if (x > y) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
const updateCommand: CommandModule<object, UpdateArguments> = {
|
||||
command: 'update',
|
||||
describe: 'Update game-ci to the latest version',
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option('force', {
|
||||
alias: 'f',
|
||||
type: 'boolean',
|
||||
description: 'Force update even if already on latest version',
|
||||
default: false,
|
||||
})
|
||||
.option('version', {
|
||||
type: 'string',
|
||||
description: 'Update to a specific version (e.g., v2.0.0)',
|
||||
default: '',
|
||||
})
|
||||
.example('game-ci update', 'Update to the latest version')
|
||||
.example('game-ci update --version v2.1.0', 'Update to a specific version')
|
||||
.example('game-ci update --force', 'Force reinstall of the current version') as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
const currentVersion = getCurrentVersion();
|
||||
core.info(`Current version: v${currentVersion}`);
|
||||
core.info(`Platform: ${process.platform} ${process.arch}`);
|
||||
core.info('');
|
||||
|
||||
// Fetch release info
|
||||
let release: GitHubRelease;
|
||||
const targetVersion = cliArguments.version as string;
|
||||
|
||||
if (targetVersion) {
|
||||
const tag = targetVersion.startsWith('v') ? targetVersion : `v${targetVersion}`;
|
||||
core.info(`Fetching release ${tag}...`);
|
||||
release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`);
|
||||
} else {
|
||||
core.info('Checking for updates...');
|
||||
release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
|
||||
}
|
||||
|
||||
const latestVersion = release.tag_name;
|
||||
core.info(`Latest version: ${latestVersion}`);
|
||||
core.info('');
|
||||
|
||||
// Compare versions
|
||||
const comparison = compareSemver(currentVersion, latestVersion);
|
||||
if (comparison >= 0 && !cliArguments.force) {
|
||||
core.info('You are already on the latest version. Use --force to reinstall.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison > 0 && !targetVersion) {
|
||||
core.info(`Current version (v${currentVersion}) is newer than latest release (${latestVersion}).`);
|
||||
core.info('Use --force to downgrade, or --version to target a specific release.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the correct asset
|
||||
const assetName = getAssetName();
|
||||
const asset = release.assets.find((a) => a.name === assetName);
|
||||
|
||||
if (!asset) {
|
||||
const available = release.assets.map((a) => a.name).join(', ');
|
||||
throw new Error(
|
||||
`No binary found for ${process.platform}-${process.arch} (looking for ${assetName}).\nAvailable assets: ${available}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sizeMb = (asset.size / (1024 * 1024)).toFixed(1);
|
||||
core.info(`Downloading ${assetName} (${sizeMb} MB)...`);
|
||||
|
||||
// Download the new binary
|
||||
const binaryData = await downloadFile(asset.browser_download_url);
|
||||
|
||||
// Determine where to write the updated binary
|
||||
const executablePath = getExecutablePath();
|
||||
|
||||
if (!executablePath) {
|
||||
core.info('');
|
||||
core.info('game-ci is running via Node.js (not as a standalone binary).');
|
||||
core.info('To update the npm package, run:');
|
||||
core.info(' npm install -g unity-builder@latest');
|
||||
core.info('');
|
||||
core.info('To install the standalone binary instead:');
|
||||
core.info(' curl -fsSL https://raw.githubusercontent.com/game-ci/unity-builder/main/install.sh | sh');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Write the new binary.
|
||||
// On Windows, we cannot overwrite a running executable directly.
|
||||
// Write to a temporary file, then rename.
|
||||
const temporaryPath = `${executablePath}.update`;
|
||||
const backupPath = `${executablePath}.backup`;
|
||||
|
||||
fs.writeFileSync(temporaryPath, binaryData);
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(temporaryPath, 0o755);
|
||||
}
|
||||
|
||||
// Verify the downloaded binary
|
||||
try {
|
||||
const output = execFileSync(temporaryPath, ['version'], { encoding: 'utf8', timeout: 10_000 });
|
||||
core.info(`Verified new binary: ${output.trim().split('\n')[0]}`);
|
||||
} catch (verifyError: any) {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
throw new Error(`Downloaded binary failed verification: ${verifyError.message}`);
|
||||
}
|
||||
|
||||
// Replace the current binary
|
||||
try {
|
||||
// Backup current
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.unlinkSync(backupPath);
|
||||
}
|
||||
fs.renameSync(executablePath, backupPath);
|
||||
fs.renameSync(temporaryPath, executablePath);
|
||||
|
||||
// Clean up backup
|
||||
try {
|
||||
fs.unlinkSync(backupPath);
|
||||
} catch {
|
||||
// On Windows the backup may be locked; that is fine
|
||||
}
|
||||
} catch (replaceError: any) {
|
||||
// Attempt to restore from backup
|
||||
if (fs.existsSync(backupPath) && !fs.existsSync(executablePath)) {
|
||||
fs.renameSync(backupPath, executablePath);
|
||||
}
|
||||
|
||||
// Clean up temporary file
|
||||
if (fs.existsSync(temporaryPath)) {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
}
|
||||
throw new Error(`Failed to replace binary: ${replaceError.message}`);
|
||||
}
|
||||
|
||||
core.info('');
|
||||
core.info(`Successfully updated game-ci to ${latestVersion}`);
|
||||
} catch (error: any) {
|
||||
core.error(`Update failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default updateCommand;
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const versionCommand: CommandModule = {
|
||||
command: 'version',
|
||||
describe: 'Show version info',
|
||||
builder: {},
|
||||
handler: async () => {
|
||||
try {
|
||||
// Read version from package.json
|
||||
let packageJsonPath = path.join(__dirname, '..', '..', '..', 'package.json');
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
|
||||
}
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
packageJsonPath = path.join(process.cwd(), 'package.json');
|
||||
}
|
||||
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const packageData = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
core.info(`game-ci (unity-builder) v${packageData.version}`);
|
||||
core.info(`Node.js ${process.version}`);
|
||||
core.info(`Platform: ${process.platform} ${process.arch}`);
|
||||
} else {
|
||||
core.info('game-ci (unity-builder)');
|
||||
core.info('Version information unavailable');
|
||||
}
|
||||
} catch (error: any) {
|
||||
core.info('game-ci (unity-builder)');
|
||||
core.error(`Could not read version: ${error.message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default versionCommand;
|
||||
@@ -1,106 +0,0 @@
|
||||
import { Cli } from '../model/cli/cli';
|
||||
import GitHub from '../model/github';
|
||||
|
||||
/**
|
||||
* Maps CLI arguments (kebab-case flags) to the Input/OrchestratorOptions
|
||||
* interface used by the action. This bridges the gap between user-friendly
|
||||
* CLI flags and the camelCase environment/input system unity-builder expects.
|
||||
*
|
||||
* The existing Input class already queries Cli.options, environment variables,
|
||||
* and GitHub Action inputs in priority order. We populate Cli.options so that
|
||||
* the rest of the codebase works unchanged.
|
||||
*/
|
||||
export interface CliArguments {
|
||||
targetPlatform?: string;
|
||||
unityVersion?: string;
|
||||
projectPath?: string;
|
||||
buildProfile?: string;
|
||||
buildName?: string;
|
||||
buildsPath?: string;
|
||||
buildMethod?: string;
|
||||
customParameters?: string;
|
||||
versioning?: string;
|
||||
version?: string;
|
||||
customImage?: string;
|
||||
manualExit?: boolean;
|
||||
enableGpu?: boolean;
|
||||
|
||||
androidVersionCode?: string;
|
||||
androidExportType?: string;
|
||||
androidKeystoreName?: string;
|
||||
androidKeystoreBase64?: string;
|
||||
androidKeystorePass?: string;
|
||||
androidKeyaliasName?: string;
|
||||
androidKeyaliasPass?: string;
|
||||
androidTargetSdkVersion?: string;
|
||||
androidSymbolType?: string;
|
||||
|
||||
dockerCpuLimit?: string;
|
||||
dockerMemoryLimit?: string;
|
||||
dockerIsolationMode?: string;
|
||||
dockerWorkspacePath?: string;
|
||||
containerRegistryRepository?: string;
|
||||
containerRegistryImageVersion?: string;
|
||||
runAsHostUser?: string;
|
||||
chownFilesTo?: string;
|
||||
|
||||
sshAgent?: string;
|
||||
sshPublicKeysDirectoryPath?: string;
|
||||
gitPrivateToken?: string;
|
||||
|
||||
providerStrategy?: string;
|
||||
awsStackName?: string;
|
||||
kubeConfig?: string;
|
||||
kubeVolume?: string;
|
||||
kubeVolumeSize?: string;
|
||||
kubeStorageClass?: string;
|
||||
containerCpu?: string;
|
||||
containerMemory?: string;
|
||||
cacheKey?: string;
|
||||
watchToEnd?: string;
|
||||
allowDirtyBuild?: boolean;
|
||||
skipActivation?: string;
|
||||
cloneDepth?: string;
|
||||
|
||||
readInputFromOverrideList?: string;
|
||||
readInputOverrideCommand?: string;
|
||||
postBuildSteps?: string;
|
||||
preBuildSteps?: string;
|
||||
customJob?: string;
|
||||
|
||||
unityLicensingServer?: string;
|
||||
|
||||
cacheUnityInstallationOnMac?: boolean;
|
||||
unityHubVersionOnMac?: string;
|
||||
|
||||
mode?: string;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts kebab-case CLI flags to camelCase keys matching the Input class
|
||||
* property names, then injects them into Cli.options so the existing
|
||||
* Input.getInput() / OrchestratorOptions.getInput() chain picks them up.
|
||||
*/
|
||||
export function mapCliArgumentsToInput(cliArguments: CliArguments): void {
|
||||
// Disable GitHub Actions input reading when in CLI mode
|
||||
GitHub.githubInputEnabled = false;
|
||||
|
||||
// The existing Cli.options mechanism is used by Input.getInput() to query
|
||||
// CLI-provided values. We set it directly.
|
||||
const mapped: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(cliArguments)) {
|
||||
if (value !== undefined && key !== '_' && key !== '$0') {
|
||||
mapped[key] = typeof value === 'boolean' ? String(value) : value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure mode is set so Cli.isCliMode returns true
|
||||
if (!mapped['mode']) {
|
||||
mapped['mode'] = 'cli';
|
||||
}
|
||||
|
||||
Cli.options = mapped;
|
||||
}
|
||||
Reference in New Issue
Block a user