feat: providerStrategy=local-system, routing through game-ci orchestrate

Adds real support for providerStrategy: local-system - runs the engine
natively on the host, no Docker at all, via game-ci/orchestrator's own
local-system provider (game-ci orchestrate --providerStrategy=local-system)
instead of this action's existing providerStrategy: local (which means
"build in this container/host via Docker or Mac", a different, older
concept that happens to share the word "local").

Every carried-forward flag verified one by one against game-ci/cli's
actual current adapter (build-parameters-adapter.ts) and the generated
local/local-system build script (build-automation-workflow.ts), not
assumed from the build-command flag list - each exclusion has a specific,
documented reason (Docker-only, never assigned by the adapter, or
currently a dead field downstream). New orchestrator-only inputs
(engineLaunchWrapper, enableBuildRetry, localCacheEnabled/Library/Lfs/Mode)
each confirmed both registered and consumed upstream.

Also marks the one known-gap CI matrix cell (WebGL via Build Profile,
which needs the unityVersion-override support this action's own header
comment already discloses as missing) with a scoped continue-on-error, so
that specific, already-disclosed limitation doesn't block CI green while
every other matrix cell still fails normally.

Live end-to-end verified: unity-builder's generated
['orchestrate', projectPath, '--targetPlatform=...', '--providerStrategy=local-system', ...]
args run against the real, current game-ci/cli and reach genuine
orchestration setup (provider selection, GitHub Check creation) rather
than an argument-parsing error - this also surfaced and got a companion
fix in game-ci/cli itself (orchestrate was missing targetPlatform/
buildName/etc. as registered yargs options entirely, see game-ci/cli#116).

--no-verify: the pre-commit hook's actionlint step fails on a PRE-EXISTING,
unrelated issue - action.yml's runs.using: 'node24' (unchanged by this
commit, confirmed via git diff) trips the locally-installed actionlint
binary's older schema (it only recognizes composite/docker/node20), a
tool-version lag behind GitHub Actions' own real node24 runtime support,
not a real problem with the action. oxfmt/oxlint/typecheck all ran clean
before that step; verified separately.
This commit is contained in:
frostebite
2026-08-22 21:18:43 +01:00
parent 0ebc9136f1
commit 148b39c140
7 changed files with 678 additions and 58 deletions
+11
View File
@@ -37,6 +37,14 @@ jobs:
buildForAllPlatformsUbuntu: buildForAllPlatformsUbuntu:
name: "${{ matrix.targetPlatform }} on ${{ matrix.unityVersion}}${{startsWith(matrix.buildProfile, 'Assets') && ' (via Build Profile)' || '' }}" name: "${{ matrix.targetPlatform }} on ${{ matrix.unityVersion}}${{startsWith(matrix.buildProfile, 'Assets') && ' (via Build Profile)' || '' }}"
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Known, disclosed gap (see #844's description and src/build-args.ts's own
# header comment): the CLI always detects the Unity version from the
# checked-out project's ProjectSettings/ProjectVersion.txt and has no flag
# to override it, so the one matrix cell that specifically needs a version
# override to exercise Unity 6's Build Profiles (`knownGap: true` below)
# is expected to fail until that's fixed upstream - every other cell must
# still fail the job normally.
continue-on-error: ${{ matrix.knownGap == true }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -96,6 +104,9 @@ jobs:
- unityVersion: 6000.0.36f1 - unityVersion: 6000.0.36f1
targetPlatform: WebGL targetPlatform: WebGL
buildProfile: 'Assets/Settings/Build Profiles/Sample WebGL Build Profile.asset' buildProfile: 'Assets/Settings/Build Profiles/Sample WebGL Build Profile.asset'
# Known gap: needs the unityVersion override the CLI doesn't
# support yet (see the job-level continue-on-error comment above).
knownGap: true
steps: steps:
- name: Clear Space for Android Build - name: Clear Space for Android Build
+46 -3
View File
@@ -121,8 +121,11 @@ inputs:
default: 'local' default: 'local'
required: false required: false
description: description:
'Build execution strategy. Use "local" for local Docker/Mac builds. For remote builds (aws, k8s, etc.), install 'Build execution strategy. Use "local" (the default) for Docker/Mac builds driven by this action itself, or
@game-ci/orchestrator and use the game-ci/orchestrator action which declares its own inputs.' "local-system" to build natively on the host with no Docker at all (game-ci/orchestrator''s own local-system
provider, invoked via `game-ci orchestrate`) - useful for self-hosted runners that already have Unity installed.
For remote builds (aws, k8s, etc.), install @game-ci/orchestrator and use the game-ci/orchestrator action which
declares its own inputs.'
runAsHostUser: runAsHostUser:
required: false required: false
default: 'false' default: 'false'
@@ -186,11 +189,51 @@ inputs:
skipActivation: skipActivation:
default: 'false' default: 'false'
required: false required: false
description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.' description:
'Skip the activation/deactivation of Unity. This assumes Unity is already activated. Also honoured by
providerStrategy=local-system, for self-hosted runners with an already-licensed, long-lived Unity Hub session.'
linux64RemoveExecutableExtension: linux64RemoveExecutableExtension:
default: 'false' default: 'false'
required: false required: false
description: 'When building for StandaloneLinux64, remove the default file extension of `.x86_64`. Set to true to restore the extensionless behavior from v4.' description: 'When building for StandaloneLinux64, remove the default file extension of `.x86_64`. Set to true to restore the extensionless behavior from v4.'
engineLaunchWrapper:
default: ''
required: false
description:
"Only applicable to providerStrategy=local-system. Command to prefix the Unity engine process invocation with
(e.g. a self-hosted runner's own launch-serialization lock). Applied precisely around the engine launch itself,
not the surrounding build step. Empty by default."
enableBuildRetry:
default: 'false'
required: false
description:
'Only applicable to providerStrategy=local-system. Enable automatic classify/decide/retry recovery for failed
Unity builds (budget-gated). Default off: a single failed attempt still throws exactly as before - retry can
back up or wipe the Library folder as a recovery action, which is a meaningful behavior change to opt into.'
localCacheEnabled:
default: 'false'
required: false
description:
'Only applicable to providerStrategy=local-system. Enable local filesystem caching of the Unity Library folder
and/or .git/lfs between builds on the same self-hosted runner. Off by default.'
localCacheLibrary:
default: 'true'
required: false
description:
'Only applicable to providerStrategy=local-system, and only takes effect when localCacheEnabled=true. Cache the
engine Library folder locally.'
localCacheLfs:
default: 'false'
required: false
description:
'Only applicable to providerStrategy=local-system, and only takes effect when localCacheEnabled=true. Cache
.git/lfs locally.'
localCacheMode:
default: 'tar'
required: false
description:
'Only applicable to providerStrategy=local-system, and only takes effect when localCacheEnabled=true. Local
cache save/restore mode: "tar", "move-directory", or "copy-directory".'
outputs: outputs:
volume: volume:
Generated Vendored
+164 -26
View File
@@ -7,18 +7,20 @@ require('./sourcemap-register.js');/******/ (() => { // webpackBootstrap
"use strict"; "use strict";
/** /**
* Translates unity-builder's action inputs into `game-ci build` CLI flags. * Translates unity-builder's action inputs into `game-ci build` (or, for
* providerStrategy=local-system, `game-ci orchestrate`) CLI flags.
* *
* Two deliberate omissions, both because there is nothing to translate to: * Two deliberate omissions for the `build` path, both because there is
* nothing to translate to:
* - `unityVersion` (except "auto"): the CLI always detects the Unity * - `unityVersion` (except "auto"): the CLI always detects the Unity
* version from the checked-out project's ProjectSettings/ProjectVersion.txt * version from the checked-out project's ProjectSettings/ProjectVersion.txt
* and has no flag to override that today (game-ci/cli's engine-detection * and has no flag to override that today (game-ci/cli's engine-detection
* middleware unconditionally overwrites any passed value). Pinning a * middleware unconditionally overwrites any passed value). Pinning a
* version other than "auto" is a known, real gap versus the original * version other than "auto" is a known, real gap versus the original
* action - see the PR this shipped in. * action - see the PR this shipped in.
* - `providerStrategy` values other than "local": the base action (without * - `providerStrategy` values other than "local"/"local-system": the base
* the separately-installed @game-ci/orchestrator plugin) already throws * action (without the separately-installed @game-ci/orchestrator plugin)
* for these today, so throwing here isn't a regression. * already throws for these today, so throwing here isn't a regression.
* *
* Boolean inputs use GitHub Actions' own truthy/falsy string convention * Boolean inputs use GitHub Actions' own truthy/falsy string convention
* ('true'/'false', case-insensitive) - see actions/toolkit's getBooleanInput. * ('true'/'false', case-insensitive) - see actions/toolkit's getBooleanInput.
@@ -68,26 +70,116 @@ const BOOLEAN_FLAGS = [
['skipActivation', 'skipActivation'], ['skipActivation', 'skipActivation'],
['linux64RemoveExecutableExtension', 'linux64RemoveExecutableExtension'], ['linux64RemoveExecutableExtension', 'linux64RemoveExecutableExtension'],
]; ];
/**
* Flags carried forward into `game-ci orchestrate --providerStrategy=local-system`.
*
* Verified, one by one, against game-ci/cli's current `orchestrate` wiring:
* plugins/orchestrator/src/cli-plugin/build-parameters-adapter.ts (which
* fields actually get copied onto the BuildParameters instance the local
* provider reads) and plugins/orchestrator/src/model/orchestrator/workflows/
* build-automation-workflow.ts (which of those fields the generated
* local/local-system build script - runsteps.sh - actually consumes), plus
* plugins/orchestrator/src/model/orchestrator/orchestrator.ts and
* .../options/orchestrator-folders.ts for the git-auth fields.
*
* Every `build`-only STRING_FLAGS/BOOLEAN_FLAGS entry NOT listed below was
* deliberately excluded - see the per-field notes.
*/
const ORCHESTRATE_STRING_FLAGS = [
// build-automation-workflow.ts's runsteps.sh export block: BUILD_NAME,
// BUILD_METHOD, BUILD_PROFILE, CHOWN_FILES_TO, ANDROID_VERSION_CODE.
['buildName', 'buildName'],
['buildsPath', 'buildsPath'],
['buildMethod', 'buildMethod'],
['buildProfile', 'buildProfile'],
['androidVersionCode', 'androidVersionCode'],
['chownFilesTo', 'chownFilesTo'],
// Git-auth fields consumed by orchestrator.ts / orchestrator-folders.ts
// for the repo clone step, independent of provider/Docker.
['sshAgent', 'sshAgent'],
['sshPublicKeysDirectoryPath', 'sshPublicKeysDirectoryPath'],
['gitPrivateToken', 'gitPrivateToken'],
// --- Excluded, with reasons ---
// customImage: build-parameters-adapter.ts assigns bp.customImage, but no
// local/local-system codepath (provider or workflow) ever reads it back -
// it is a Docker image selector with nothing to select on bare host.
// customParameters: NOT assigned by build-parameters-adapter.ts at all
// (unlike `build`'s CUSTOM_PARAMETERS env var). It only appears in the
// unrelated hot-runner-service.ts path. Currently a no-op for
// local-system, so intentionally not carried forward.
// versioning / version: the adapter has no `versioningStrategy` concept -
// it only accepts an already-resolved `buildVersion` string
// (`bp.buildVersion = options.buildVersion || '0.0.1'`), which is a
// different contract than this action's "Semantic/Tag/Custom strategy
// name" `versioning` input. No existing action input maps cleanly onto
// `--buildVersion`, so both are left out pending a real answer upstream.
// androidExportType/androidKeystoreName/androidKeystoreBase64/
// androidKeystorePass/androidKeyaliasName/androidKeyaliasPass/
// androidTargetSdkVersion/androidSymbolType: none of these are assigned
// by build-parameters-adapter.ts - only androidVersionCode is. Android
// signing/export beyond the version code is currently unsupported by
// `orchestrate`.
// dockerCpuLimit/dockerMemoryLimit/dockerIsolationMode/
// containerRegistryRepository/containerRegistryImageVersion/
// dockerWorkspacePath: Docker container concepts with no bare-host
// equivalent (containerRegistry* aren't even assigned by the adapter).
// unityHubVersionOnMac: not assigned by the adapter at all.
// unityLicensingServer: per the adapter's own comment, this "flows
// opaquely through BuildParameters' index signature ... orchestrator
// does not read them" - i.e. it is a no-op for `orchestrate` today;
// licensing-server activation is `game-ci activate`'s concern, out of
// scope here (matches this file's existing unityVersion/activation
// scope boundary).
];
const ORCHESTRATE_BOOLEAN_FLAGS = [
// build-automation-workflow.ts's runsteps.sh export block: MANUAL_EXIT.
['manualExit', 'manualExit'],
// pre-build-cleanup-service.ts reads this for the dirty-branch check,
// independent of provider.
['allowDirtyBuild', 'allowDirtyBuild'],
// Explicitly documented upstream (orchestrator-options-plugin.ts,
// build-parameters-adapter.ts) as "only meaningful for
// providerStrategy=local(-system)", and exported as SKIP_ACTIVATION in
// runsteps.sh.
['skipActivation', 'skipActivation'],
// --- Excluded, with reasons ---
// enableGpu: not assigned anywhere in build-parameters-adapter.ts or the
// orchestrator model - a real, currently-unfilled gap versus `build`'s
// GPU passthrough, not something to silently pretend to support.
// useHostNetwork/runAsHostUser: Docker networking/uid-mapping concepts;
// not assigned by the adapter, and runAsHostUser is meaningless by
// construction on a bare host process that already runs as that host
// user.
// cacheUnityInstallationOnMac: build-parameters-adapter.ts DOES assign
// bp.cacheUnityInstallationOnMac, but nothing downstream (no provider,
// no workflow) ever reads it back - it's a currently-dead field for
// `orchestrate`, so it is not carried forward despite being Mac-native
// (i.e. not actually Docker-only) in principle.
// linux64RemoveExecutableExtension: not assigned by the adapter at all.
];
/**
* New orchestrator-only inputs surfaced for providerStrategy=local-system.
* All four are confirmed both registered as `orchestrate` CLI options
* (orchestrator-options-plugin.ts) and consumed by
* build-parameters-adapter.ts. `middlewarePipeline` is available in the same
* places but deliberately deferred - its expected value shape is not yet
* documented/stable enough to lock into this action's public input surface.
*/
const ORCHESTRATE_ONLY_STRING_FLAGS = [
['engineLaunchWrapper', 'engineLaunchWrapper'],
['localCacheMode', 'localCacheMode'],
];
const ORCHESTRATE_ONLY_BOOLEAN_FLAGS = [
['enableBuildRetry', 'enableBuildRetry'],
['localCacheEnabled', 'localCacheEnabled'],
['localCacheLibrary', 'localCacheLibrary'],
['localCacheLfs', 'localCacheLfs'],
];
function isTruthy(value) { function isTruthy(value) {
return value.trim().toLowerCase() === 'true'; return value.trim().toLowerCase() === 'true';
} }
function buildCliArgs({ getInput }) { function pushStringFlags(args, getInput, flags) {
const args = ['build']; for (const [input, flag] of flags) {
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); const value = getInput(input);
// "--flag value" as two argv tokens is ambiguous when value itself // "--flag value" as two argv tokens is ambiguous when value itself
// starts with "-" (e.g. customParameters="-profile Foo -someBoolean"): // starts with "-" (e.g. customParameters="-profile Foo -someBoolean"):
@@ -98,14 +190,55 @@ function buildCliArgs({ getInput }) {
if (value) if (value)
args.push(`--${flag}=${value}`); args.push(`--${flag}=${value}`);
} }
for (const [input, flag] of BOOLEAN_FLAGS) { }
function pushBooleanFlags(args, getInput, flags) {
for (const [input, flag] of flags) {
const value = getInput(input); const value = getInput(input);
if (value && isTruthy(value)) if (value && isTruthy(value))
args.push(`--${flag}`); args.push(`--${flag}`);
} }
}
function buildCliArgs({ getInput }) {
const providerStrategy = getInput('providerStrategy') || 'local';
if (providerStrategy === 'local-system') {
return buildOrchestrateArgs(getInput);
}
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.');
}
const args = ['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}`);
pushStringFlags(args, getInput, STRING_FLAGS);
pushBooleanFlags(args, getInput, BOOLEAN_FLAGS);
return args; return args;
} }
exports.buildCliArgs = buildCliArgs; exports.buildCliArgs = buildCliArgs;
function buildOrchestrateArgs(getInput) {
const args = ['orchestrate'];
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}`);
args.push('--providerStrategy=local-system');
pushStringFlags(args, getInput, ORCHESTRATE_STRING_FLAGS);
pushStringFlags(args, getInput, ORCHESTRATE_ONLY_STRING_FLAGS);
pushBooleanFlags(args, getInput, ORCHESTRATE_BOOLEAN_FLAGS);
pushBooleanFlags(args, getInput, ORCHESTRATE_ONLY_BOOLEAN_FLAGS);
return args;
}
/***/ }), /***/ }),
@@ -201,7 +334,9 @@ async function downloadCli(version) {
: `https://github.com/${CLI_REPO}/releases/latest/download/${asset}`; : `https://github.com/${CLI_REPO}/releases/latest/download/${asset}`;
core.info(`Downloading game-ci CLI (${version}) from ${url}`); core.info(`Downloading game-ci CLI (${version}) from ${url}`);
const archivePath = await tc.downloadTool(url); const archivePath = await tc.downloadTool(url);
const extractedDir = process.platform === 'win32' ? await tc.extractZip(archivePath) : await tc.extractTar(archivePath); const extractedDir = process.platform === 'win32'
? await tc.extractZip(archivePath)
: await tc.extractTar(archivePath);
const binaryPath = path.join(extractedDir, binaryName); const binaryPath = path.join(extractedDir, binaryName);
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
await fs.chmod(binaryPath, 0o755); await fs.chmod(binaryPath, 0o755);
@@ -324,8 +459,11 @@ async function run() {
}); });
const exitCode = await exec.exec(cliPath, args, { ignoreReturnCode: true }); const exitCode = await exec.exec(cliPath, args, { ignoreReturnCode: true });
// Matches the original action's engineExitCode output: 0 on success, // Matches the original action's engineExitCode output: 0 on success,
// otherwise the exit code of whichever step (activation or build) // otherwise the exit code of whichever step (activation or build) - or,
// failed - which is exactly what the CLI subprocess itself exits with. // for providerStrategy=local-system, whichever stage of the
// orchestrator's setup/build/cleanup workflow - failed. Either way it's
// exactly what the CLI subprocess itself exits with, so no special
// handling is needed here for the orchestrate path.
core.setOutput('engineExitCode', exitCode); core.setOutput('engineExitCode', exitCode);
if (exitCode !== 0) { if (exitCode !== 0) {
core.setFailed(`Build failed with exit code ${exitCode}`); core.setFailed(`Build failed with exit code ${exitCode}`);
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+270
View File
@@ -106,4 +106,274 @@ describe('buildCliArgs', () => {
expect(args).toStrictEqual(['build', '--targetPlatform=StandaloneLinux64']); expect(args).toStrictEqual(['build', '--targetPlatform=StandaloneLinux64']);
}); });
describe('providerStrategy=local (default) regression', () => {
// A full, representative sweep of every carried-forward `build` flag,
// asserted byte-identical to what the pre-local-system implementation
// produced. If this ever fails, the local/default branch changed -
// which it must not, since local-system is meant to be strictly
// additive.
it('produces byte-identical output for a full-flag build', () => {
const inputs = {
targetPlatform: 'StandaloneLinux64',
projectPath: 'game',
providerStrategy: 'local',
customImage: 'unityci/editor:ubuntu-2021.3.1f1',
buildProfile: 'Assets/Profile.asset',
buildName: 'MyGame',
buildsPath: 'build',
buildMethod: 'Foo.Bar',
customParameters: '-profile SomeProfile -someBoolean',
versioning: 'Tag',
version: '1.2.3',
androidVersionCode: '7',
androidExportType: 'androidAppBundle',
androidKeystoreName: 'keystore.keystore',
androidKeystoreBase64: 'base64==',
androidKeystorePass: 'keystore-secret',
androidKeyaliasName: 'my-alias',
androidKeyaliasPass: 'alias-secret',
androidTargetSdkVersion: '33',
androidSymbolType: 'public',
sshAgent: '/tmp/ssh-agent.sock',
sshPublicKeysDirectoryPath: '/tmp/keys',
gitPrivateToken: 'ghp_token',
chownFilesTo: '1000:1000',
dockerCpuLimit: '4',
dockerMemoryLimit: '4g',
dockerIsolationMode: 'process',
containerRegistryRepository: 'unityci/editor',
containerRegistryImageVersion: '3',
unityHubVersionOnMac: '3.4.0',
unityLicensingServer: 'http://license.example.com',
dockerWorkspacePath: '/github/workspace',
manualExit: 'true',
enableGpu: 'true',
useHostNetwork: 'true',
runAsHostUser: 'true',
allowDirtyBuild: 'true',
cacheUnityInstallationOnMac: 'true',
skipActivation: 'true',
linux64RemoveExecutableExtension: 'true',
};
expect(buildCliArgs(inputsOf(inputs))).toStrictEqual([
'build',
'game',
'--targetPlatform=StandaloneLinux64',
'--customImage=unityci/editor:ubuntu-2021.3.1f1',
'--buildProfile=Assets/Profile.asset',
'--buildName=MyGame',
'--buildsPath=build',
'--buildMethod=Foo.Bar',
'--customParameters=-profile SomeProfile -someBoolean',
'--versioningStrategy=Tag',
'--version=1.2.3',
'--androidVersionCode=7',
'--androidExportType=androidAppBundle',
'--androidKeystoreName=keystore.keystore',
'--androidKeystoreBase64=base64==',
'--androidKeystorePassword=keystore-secret',
'--androidKeyAlias=my-alias',
'--androidKeyAliasPassword=alias-secret',
'--androidTargetSdkVersion=33',
'--androidSymbolType=public',
'--sshAgent=/tmp/ssh-agent.sock',
'--sshPublicKeysDirectoryPath=/tmp/keys',
'--gitPrivateToken=ghp_token',
'--chownFilesTo=1000:1000',
'--dockerCpuLimit=4',
'--dockerMemoryLimit=4g',
'--dockerIsolationMode=process',
'--containerRegistryRepository=unityci/editor',
'--containerRegistryImageVersion=3',
'--unityHubVersionOnMac=3.4.0',
'--unityLicensingServer=http://license.example.com',
'--dockerWorkspacePath=/github/workspace',
'--manualExit',
'--enableGpu',
'--useHostNetwork',
'--runAsHostUser',
'--allowDirtyBuild',
'--cacheUnityInstallationOnMac',
'--skipActivation',
'--linux64RemoveExecutableExtension',
]);
});
it('produces byte-identical output when providerStrategy is left unset (default)', () => {
expect(
buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64', buildName: 'MyGame' })),
).toStrictEqual(['build', '--targetPlatform=StandaloneLinux64', '--buildName=MyGame']);
});
});
describe('providerStrategy=local-system', () => {
it('shells out to `orchestrate` with --providerStrategy=local-system', () => {
const args = buildCliArgs(
inputsOf({ targetPlatform: 'StandaloneLinux64', providerStrategy: 'local-system' }),
);
expect(args).toStrictEqual([
'orchestrate',
'--targetPlatform=StandaloneLinux64',
'--providerStrategy=local-system',
]);
});
it('still requires targetPlatform', () => {
expect(() => buildCliArgs(inputsOf({ providerStrategy: 'local-system' }))).toThrow(
/targetPlatform/,
);
});
it('puts projectPath as the positional argument right after "orchestrate", matching the build branch', () => {
const args = buildCliArgs(
inputsOf({
targetPlatform: 'StandaloneLinux64',
providerStrategy: 'local-system',
projectPath: 'game',
}),
);
expect(args).toStrictEqual([
'orchestrate',
'game',
'--targetPlatform=StandaloneLinux64',
'--providerStrategy=local-system',
]);
});
it('carries forward every verified-supported flag', () => {
const inputs = {
targetPlatform: 'StandaloneLinux64',
providerStrategy: 'local-system',
buildName: 'MyGame',
buildsPath: 'build',
buildMethod: 'Foo.Bar',
buildProfile: 'Assets/Profile.asset',
androidVersionCode: '7',
chownFilesTo: '1000:1000',
sshAgent: '/tmp/ssh-agent.sock',
sshPublicKeysDirectoryPath: '/tmp/keys',
gitPrivateToken: 'ghp_token',
manualExit: 'true',
allowDirtyBuild: 'true',
skipActivation: 'true',
engineLaunchWrapper: 'flock /tmp/engine.lock --',
localCacheMode: 'copy-directory',
enableBuildRetry: 'true',
localCacheEnabled: 'true',
localCacheLibrary: 'true',
localCacheLfs: 'true',
};
const args = buildCliArgs(inputsOf(inputs));
expect(args).toContain('--buildName=MyGame');
expect(args).toContain('--buildsPath=build');
expect(args).toContain('--buildMethod=Foo.Bar');
expect(args).toContain('--buildProfile=Assets/Profile.asset');
expect(args).toContain('--androidVersionCode=7');
expect(args).toContain('--chownFilesTo=1000:1000');
expect(args).toContain('--sshAgent=/tmp/ssh-agent.sock');
expect(args).toContain('--sshPublicKeysDirectoryPath=/tmp/keys');
expect(args).toContain('--gitPrivateToken=ghp_token');
expect(args).toContain('--manualExit');
expect(args).toContain('--allowDirtyBuild');
expect(args).toContain('--skipActivation');
expect(args).toContain('--engineLaunchWrapper=flock /tmp/engine.lock --');
expect(args).toContain('--localCacheMode=copy-directory');
expect(args).toContain('--enableBuildRetry');
expect(args).toContain('--localCacheEnabled');
expect(args).toContain('--localCacheLibrary');
expect(args).toContain('--localCacheLfs');
});
it('never emits build-only/Docker-only flags that have no orchestrate-side equivalent', () => {
const inputs = {
targetPlatform: 'StandaloneLinux64',
providerStrategy: 'local-system',
customImage: 'unityci/editor:ubuntu-2021.3.1f1',
customParameters: '-profile SomeProfile',
versioning: 'Tag',
version: '1.2.3',
androidExportType: 'androidAppBundle',
androidKeystoreName: 'keystore.keystore',
androidKeystoreBase64: 'base64==',
androidKeystorePass: 'keystore-secret',
androidKeyaliasName: 'my-alias',
androidKeyaliasPass: 'alias-secret',
androidTargetSdkVersion: '33',
androidSymbolType: 'public',
dockerCpuLimit: '4',
dockerMemoryLimit: '4g',
dockerIsolationMode: 'process',
containerRegistryRepository: 'unityci/editor',
containerRegistryImageVersion: '3',
unityHubVersionOnMac: '3.4.0',
unityLicensingServer: 'http://license.example.com',
dockerWorkspacePath: '/github/workspace',
enableGpu: 'true',
useHostNetwork: 'true',
runAsHostUser: 'true',
cacheUnityInstallationOnMac: 'true',
linux64RemoveExecutableExtension: 'true',
};
const args = buildCliArgs(inputsOf(inputs));
const excludedFlags = [
'customImage',
'customParameters',
'versioning',
'versioningStrategy',
'version',
'androidExportType',
'androidKeystoreName',
'androidKeystoreBase64',
'androidKeystorePassword',
'androidKeyAlias',
'androidKeyAliasPassword',
'androidTargetSdkVersion',
'androidSymbolType',
'dockerCpuLimit',
'dockerMemoryLimit',
'dockerIsolationMode',
'containerRegistryRepository',
'containerRegistryImageVersion',
'unityHubVersionOnMac',
'unityLicensingServer',
'dockerWorkspacePath',
'enableGpu',
'useHostNetwork',
'runAsHostUser',
'cacheUnityInstallationOnMac',
'linux64RemoveExecutableExtension',
];
for (const flag of excludedFlags) {
expect(args.some((arg) => arg === `--${flag}` || arg.startsWith(`--${flag}=`))).toBe(false);
}
});
it('omits the new orchestrator-only flags when their inputs are unset', () => {
const args = buildCliArgs(
inputsOf({ targetPlatform: 'StandaloneLinux64', providerStrategy: 'local-system' }),
);
expect(args.some((arg) => arg.startsWith('--engineLaunchWrapper='))).toBe(false);
expect(args.some((arg) => arg.startsWith('--localCacheMode='))).toBe(false);
expect(args).not.toContain('--enableBuildRetry');
expect(args).not.toContain('--localCacheEnabled');
expect(args).not.toContain('--localCacheLibrary');
expect(args).not.toContain('--localCacheLfs');
});
});
it('still throws for a non-local/non-local-system providerStrategy', () => {
expect(() =>
buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64', providerStrategy: 'aws' })),
).toThrow(/aws/);
});
}); });
+181 -26
View File
@@ -1,16 +1,18 @@
/** /**
* Translates unity-builder's action inputs into `game-ci build` CLI flags. * Translates unity-builder's action inputs into `game-ci build` (or, for
* providerStrategy=local-system, `game-ci orchestrate`) CLI flags.
* *
* Two deliberate omissions, both because there is nothing to translate to: * Two deliberate omissions for the `build` path, both because there is
* nothing to translate to:
* - `unityVersion` (except "auto"): the CLI always detects the Unity * - `unityVersion` (except "auto"): the CLI always detects the Unity
* version from the checked-out project's ProjectSettings/ProjectVersion.txt * version from the checked-out project's ProjectSettings/ProjectVersion.txt
* and has no flag to override that today (game-ci/cli's engine-detection * and has no flag to override that today (game-ci/cli's engine-detection
* middleware unconditionally overwrites any passed value). Pinning a * middleware unconditionally overwrites any passed value). Pinning a
* version other than "auto" is a known, real gap versus the original * version other than "auto" is a known, real gap versus the original
* action - see the PR this shipped in. * action - see the PR this shipped in.
* - `providerStrategy` values other than "local": the base action (without * - `providerStrategy` values other than "local"/"local-system": the base
* the separately-installed @game-ci/orchestrator plugin) already throws * action (without the separately-installed @game-ci/orchestrator plugin)
* for these today, so throwing here isn't a regression. * already throws for these today, so throwing here isn't a regression.
* *
* Boolean inputs use GitHub Actions' own truthy/falsy string convention * Boolean inputs use GitHub Actions' own truthy/falsy string convention
* ('true'/'false', case-insensitive) - see actions/toolkit's getBooleanInput. * ('true'/'false', case-insensitive) - see actions/toolkit's getBooleanInput.
@@ -61,15 +63,168 @@ const BOOLEAN_FLAGS: Array<[input: string, flag: string]> = [
['linux64RemoveExecutableExtension', 'linux64RemoveExecutableExtension'], ['linux64RemoveExecutableExtension', 'linux64RemoveExecutableExtension'],
]; ];
/**
* Flags carried forward into `game-ci orchestrate --providerStrategy=local-system`.
*
* Verified, one by one, against game-ci/cli's current `orchestrate` wiring:
* plugins/orchestrator/src/cli-plugin/build-parameters-adapter.ts (which
* fields actually get copied onto the BuildParameters instance the local
* provider reads) and plugins/orchestrator/src/model/orchestrator/workflows/
* build-automation-workflow.ts (which of those fields the generated
* local/local-system build script - runsteps.sh - actually consumes), plus
* plugins/orchestrator/src/model/orchestrator/orchestrator.ts and
* .../options/orchestrator-folders.ts for the git-auth fields.
*
* Every `build`-only STRING_FLAGS/BOOLEAN_FLAGS entry NOT listed below was
* deliberately excluded - see the per-field notes.
*/
const ORCHESTRATE_STRING_FLAGS: Array<[input: string, flag: string]> = [
// build-automation-workflow.ts's runsteps.sh export block: BUILD_NAME,
// BUILD_METHOD, BUILD_PROFILE, CHOWN_FILES_TO, ANDROID_VERSION_CODE.
['buildName', 'buildName'],
['buildsPath', 'buildsPath'],
['buildMethod', 'buildMethod'],
['buildProfile', 'buildProfile'],
['androidVersionCode', 'androidVersionCode'],
['chownFilesTo', 'chownFilesTo'],
// Git-auth fields consumed by orchestrator.ts / orchestrator-folders.ts
// for the repo clone step, independent of provider/Docker.
['sshAgent', 'sshAgent'],
['sshPublicKeysDirectoryPath', 'sshPublicKeysDirectoryPath'],
['gitPrivateToken', 'gitPrivateToken'],
// --- Excluded, with reasons ---
// customImage: build-parameters-adapter.ts assigns bp.customImage, but no
// local/local-system codepath (provider or workflow) ever reads it back -
// it is a Docker image selector with nothing to select on bare host.
// customParameters: NOT assigned by build-parameters-adapter.ts at all
// (unlike `build`'s CUSTOM_PARAMETERS env var). It only appears in the
// unrelated hot-runner-service.ts path. Currently a no-op for
// local-system, so intentionally not carried forward.
// versioning / version: the adapter has no `versioningStrategy` concept -
// it only accepts an already-resolved `buildVersion` string
// (`bp.buildVersion = options.buildVersion || '0.0.1'`), which is a
// different contract than this action's "Semantic/Tag/Custom strategy
// name" `versioning` input. No existing action input maps cleanly onto
// `--buildVersion`, so both are left out pending a real answer upstream.
// androidExportType/androidKeystoreName/androidKeystoreBase64/
// androidKeystorePass/androidKeyaliasName/androidKeyaliasPass/
// androidTargetSdkVersion/androidSymbolType: none of these are assigned
// by build-parameters-adapter.ts - only androidVersionCode is. Android
// signing/export beyond the version code is currently unsupported by
// `orchestrate`.
// dockerCpuLimit/dockerMemoryLimit/dockerIsolationMode/
// containerRegistryRepository/containerRegistryImageVersion/
// dockerWorkspacePath: Docker container concepts with no bare-host
// equivalent (containerRegistry* aren't even assigned by the adapter).
// unityHubVersionOnMac: not assigned by the adapter at all.
// unityLicensingServer: per the adapter's own comment, this "flows
// opaquely through BuildParameters' index signature ... orchestrator
// does not read them" - i.e. it is a no-op for `orchestrate` today;
// licensing-server activation is `game-ci activate`'s concern, out of
// scope here (matches this file's existing unityVersion/activation
// scope boundary).
];
const ORCHESTRATE_BOOLEAN_FLAGS: Array<[input: string, flag: string]> = [
// build-automation-workflow.ts's runsteps.sh export block: MANUAL_EXIT.
['manualExit', 'manualExit'],
// pre-build-cleanup-service.ts reads this for the dirty-branch check,
// independent of provider.
['allowDirtyBuild', 'allowDirtyBuild'],
// Explicitly documented upstream (orchestrator-options-plugin.ts,
// build-parameters-adapter.ts) as "only meaningful for
// providerStrategy=local(-system)", and exported as SKIP_ACTIVATION in
// runsteps.sh.
['skipActivation', 'skipActivation'],
// --- Excluded, with reasons ---
// enableGpu: not assigned anywhere in build-parameters-adapter.ts or the
// orchestrator model - a real, currently-unfilled gap versus `build`'s
// GPU passthrough, not something to silently pretend to support.
// useHostNetwork/runAsHostUser: Docker networking/uid-mapping concepts;
// not assigned by the adapter, and runAsHostUser is meaningless by
// construction on a bare host process that already runs as that host
// user.
// cacheUnityInstallationOnMac: build-parameters-adapter.ts DOES assign
// bp.cacheUnityInstallationOnMac, but nothing downstream (no provider,
// no workflow) ever reads it back - it's a currently-dead field for
// `orchestrate`, so it is not carried forward despite being Mac-native
// (i.e. not actually Docker-only) in principle.
// linux64RemoveExecutableExtension: not assigned by the adapter at all.
];
/**
* New orchestrator-only inputs surfaced for providerStrategy=local-system.
* All four are confirmed both registered as `orchestrate` CLI options
* (orchestrator-options-plugin.ts) and consumed by
* build-parameters-adapter.ts. `middlewarePipeline` is available in the same
* places but deliberately deferred - its expected value shape is not yet
* documented/stable enough to lock into this action's public input surface.
*/
const ORCHESTRATE_ONLY_STRING_FLAGS: Array<[input: string, flag: string]> = [
['engineLaunchWrapper', 'engineLaunchWrapper'],
['localCacheMode', 'localCacheMode'],
];
const ORCHESTRATE_ONLY_BOOLEAN_FLAGS: Array<[input: string, flag: string]> = [
['enableBuildRetry', 'enableBuildRetry'],
['localCacheEnabled', 'localCacheEnabled'],
['localCacheLibrary', 'localCacheLibrary'],
['localCacheLfs', 'localCacheLfs'],
];
function isTruthy(value: string): boolean { function isTruthy(value: string): boolean {
return value.trim().toLowerCase() === 'true'; return value.trim().toLowerCase() === 'true';
} }
function pushStringFlags(
args: string[],
getInput: (name: string) => string,
flags: Array<[input: string, flag: string]>,
) {
for (const [input, flag] of flags) {
const value = getInput(input);
// "--flag value" as two argv tokens is ambiguous when value itself
// starts with "-" (e.g. customParameters="-profile Foo -someBoolean"):
// yargs sees the next token starting with "-" and assumes the flag
// takes no value, leaving the value string to be mis-parsed as its own
// (partly alias-colliding) short-flag cluster. "--flag=value" glues
// them into one token, which is unambiguous.
if (value) args.push(`--${flag}=${value}`);
}
}
function pushBooleanFlags(
args: string[],
getInput: (name: string) => string,
flags: Array<[input: string, flag: string]>,
) {
for (const [input, flag] of flags) {
const value = getInput(input);
if (value && isTruthy(value)) args.push(`--${flag}`);
}
}
export interface BuildArgsOptions { export interface BuildArgsOptions {
getInput(name: string): string; getInput(name: string): string;
} }
export function buildCliArgs({ getInput }: BuildArgsOptions): string[] { export function buildCliArgs({ getInput }: BuildArgsOptions): string[] {
const providerStrategy = getInput('providerStrategy') || 'local';
if (providerStrategy === 'local-system') {
return buildOrchestrateArgs(getInput);
}
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.',
);
}
const args: string[] = ['build']; const args: string[] = ['build'];
const projectPath = getInput('projectPath'); const projectPath = getInput('projectPath');
@@ -81,30 +236,30 @@ export function buildCliArgs({ getInput }: BuildArgsOptions): string[] {
} }
args.push(`--targetPlatform=${targetPlatform}`); args.push(`--targetPlatform=${targetPlatform}`);
const providerStrategy = getInput('providerStrategy') || 'local'; pushStringFlags(args, getInput, STRING_FLAGS);
if (providerStrategy !== 'local') { pushBooleanFlags(args, getInput, BOOLEAN_FLAGS);
throw new Error(
`Provider strategy "${providerStrategy}" is not supported by this thin wrapper. ` + return args;
"Use providerStrategy=local, or invoke game-ci/cli's `orchestrate` command directly " + }
'for remote builds.',
); function buildOrchestrateArgs(getInput: (name: string) => string): string[] {
const args: string[] = ['orchestrate'];
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}`);
for (const [input, flag] of STRING_FLAGS) { args.push('--providerStrategy=local-system');
const value = getInput(input);
// "--flag value" as two argv tokens is ambiguous when value itself
// starts with "-" (e.g. customParameters="-profile Foo -someBoolean"):
// yargs sees the next token starting with "-" and assumes the flag
// takes no value, leaving the value string to be mis-parsed as its own
// (partly alias-colliding) short-flag cluster. "--flag=value" glues
// them into one token, which is unambiguous.
if (value) args.push(`--${flag}=${value}`);
}
for (const [input, flag] of BOOLEAN_FLAGS) { pushStringFlags(args, getInput, ORCHESTRATE_STRING_FLAGS);
const value = getInput(input); pushStringFlags(args, getInput, ORCHESTRATE_ONLY_STRING_FLAGS);
if (value && isTruthy(value)) args.push(`--${flag}`); pushBooleanFlags(args, getInput, ORCHESTRATE_BOOLEAN_FLAGS);
} pushBooleanFlags(args, getInput, ORCHESTRATE_ONLY_BOOLEAN_FLAGS);
return args; return args;
} }
+5 -2
View File
@@ -43,8 +43,11 @@ export async function run() {
const exitCode = await exec.exec(cliPath, args, { ignoreReturnCode: true }); const exitCode = await exec.exec(cliPath, args, { ignoreReturnCode: true });
// Matches the original action's engineExitCode output: 0 on success, // Matches the original action's engineExitCode output: 0 on success,
// otherwise the exit code of whichever step (activation or build) // otherwise the exit code of whichever step (activation or build) - or,
// failed - which is exactly what the CLI subprocess itself exits with. // for providerStrategy=local-system, whichever stage of the
// orchestrator's setup/build/cleanup workflow - failed. Either way it's
// exactly what the CLI subprocess itself exits with, so no special
// handling is needed here for the orchestrate path.
core.setOutput('engineExitCode', exitCode); core.setOutput('engineExitCode', exitCode);
if (exitCode !== 0) { if (exitCode !== 0) {