diff --git a/src/command-options/build-options.ts b/src/command-options/build-options.ts index e96afe6..a3c3668 100644 --- a/src/command-options/build-options.ts +++ b/src/command-options/build-options.ts @@ -144,6 +144,17 @@ export class BuildOptions implements IOptions { demandOption: false, default: defaultDockerMemoryLimit(), }) + .option('dockerEnv', { + description: String.dedent`Extra environment variables to set inside the build container, as NAME=value. + Repeat the flag, or pass a newline-separated list (a YAML block scalar in GitHub Actions). The container + does not otherwise inherit the surrounding environment, so this is the supported way to reach Unity + settings driven by environment variables - for example IL2CPP_ADDITIONAL_ARGS=--maxcpucount=2 to limit + IL2CPP compile parallelism on a memory-constrained runner. Values containing '=' are preserved; only the + first '=' separates name from value.`, + type: 'array', + demandOption: false, + default: [], + }) .option('dockerShmSize', { description: String.dedent`Size of /dev/shm to assign the docker container, using the format (m or g). Unity 6.6+ editors request 1GiB of shared memory and fail with "Insufficient shared memory diff --git a/src/command-options/docker-test-options.ts b/src/command-options/docker-test-options.ts index 9635936..ea3a339 100644 --- a/src/command-options/docker-test-options.ts +++ b/src/command-options/docker-test-options.ts @@ -156,6 +156,17 @@ export class DockerTestOptions implements IOptions { demandOption: false, default: defaultDockerMemoryLimit(), }) + .option('dockerEnv', { + description: String.dedent`Extra environment variables to set inside the build container, as NAME=value. + Repeat the flag, or pass a newline-separated list (a YAML block scalar in GitHub Actions). The container + does not otherwise inherit the surrounding environment, so this is the supported way to reach Unity + settings driven by environment variables - for example IL2CPP_ADDITIONAL_ARGS=--maxcpucount=2 to limit + IL2CPP compile parallelism on a memory-constrained runner. Values containing '=' are preserved; only the + first '=' separates name from value.`, + type: 'array', + demandOption: false, + default: [], + }) .option('dockerShmSize', { description: String.dedent`Size of /dev/shm to assign the docker container, using the format (m or g). Unity 6.6+ editors request 1GiB of shared memory and fail with "Insufficient shared memory diff --git a/src/model/image-environment-factory.test.ts b/src/model/image-environment-factory.test.ts index 91df922..25a3df8 100644 --- a/src/model/image-environment-factory.test.ts +++ b/src/model/image-environment-factory.test.ts @@ -133,3 +133,73 @@ describe('ImageEnvironmentFactory.getInheritedEnvVars', () => { expect(process.env.UNITY_LICENSE).toBeUndefined(); }); }); + +describe('ImageEnvironmentFactory dockerEnv', () => { + // The container inherits a fixed allowlist rather than the surrounding + // environment, so a user with an env-var-driven Unity setting (most often + // IL2CPP_ADDITIONAL_ARGS) had no supported way to reach it at all. --dockerEnv + // is that way; these cover the shapes it actually arrives in. + it('parses a newline-separated block, as a GitHub Actions block scalar produces', () => { + const parsed = ImageEnvironmentFactory.parseUserEnvironmentVariables( + 'IL2CPP_ADDITIONAL_ARGS=--maxcpucount=2\nFOO=bar\n', + ); + + expect(parsed).toEqual([ + { name: 'IL2CPP_ADDITIONAL_ARGS', value: '--maxcpucount=2' }, + { name: 'FOO', value: 'bar' }, + ] as any); + }); + + it('parses repeated flags', () => { + expect(ImageEnvironmentFactory.parseUserEnvironmentVariables(['A=1', 'B=2'])).toEqual([ + { name: 'A', value: '1' }, + { name: 'B', value: '2' }, + ] as any); + }); + + it('splits on the first = only, so values containing = survive', () => { + // IL2CPP arguments essentially always contain one. + const [parsed] = ImageEnvironmentFactory.parseUserEnvironmentVariables('IL2CPP_ADDITIONAL_ARGS=--maxcpucount=2'); + + expect(parsed.value).toBe('--maxcpucount=2'); + }); + + it('skips blank lines and # comments so a block can be annotated', () => { + expect(ImageEnvironmentFactory.parseUserEnvironmentVariables('# limit workers\n\nA=1\n')).toEqual([ + { name: 'A', value: '1' }, + ] as any); + }); + + it('rejects an entry with no =, rather than silently dropping it', () => { + expect(() => ImageEnvironmentFactory.parseUserEnvironmentVariables('NOEQUALS')).toThrow(/Expected NAME=value/); + }); + + it('is a no-op when unset', () => { + expect(ImageEnvironmentFactory.parseUserEnvironmentVariables(undefined)).toEqual([]); + expect(ImageEnvironmentFactory.parseUserEnvironmentVariables([])).toEqual([]); + }); + + it('appends after the built-ins so a user value wins on collision', () => { + // Docker takes the last --env for a given name. + const vars = ImageEnvironmentFactory.getEnvironmentVariables({ + projectPath: '.', + targetPlatform: 'StandaloneWindows64', + dockerEnv: ['BUILD_TARGET=Overridden'], + } as any); + + const matches = vars.filter((p) => p.name === 'BUILD_TARGET'); + + expect(matches).toHaveLength(2); + expect(matches[matches.length - 1].value).toBe('Overridden'); + }); + + it('reaches the docker command as a real --env flag', () => { + const envString = ImageEnvironmentFactory.getEnvVarString({ + hostOS: 'linux', + projectPath: '.', + dockerEnv: ['IL2CPP_ADDITIONAL_ARGS=--maxcpucount=2'], + } as any); + + expect(envString).toContain('--env IL2CPP_ADDITIONAL_ARGS="--maxcpucount=2"'); + }); +}); diff --git a/src/model/image-environment-factory.ts b/src/model/image-environment-factory.ts index 527e825..4fa9634 100644 --- a/src/model/image-environment-factory.ts +++ b/src/model/image-environment-factory.ts @@ -92,8 +92,80 @@ class ImageEnvironmentFactory { return lines.join(` ${lineContinuation}\n`); } + /** + * Names this factory sets itself. A user-supplied `--dockerEnv` entry that + * collides with one of these still wins (it is appended last, and Docker + * takes the last `--env NAME=value` for a given name), but it is worth + * saying out loud - silently shadowing UNITY_LICENSE or BUILD_TARGET is + * the kind of thing that costs an afternoon to work out from a build log. + */ + private static reservedEnvNames(options: Options, extraVariables: DockerParameter[]): Set { + return new Set( + ImageEnvironmentFactory.builtInEnvironmentVariables(options, extraVariables).map((p) => p.name), + ); + } + + /** + * Parses `--dockerEnv` into env vars to pass into the container. + * + * Accepts repeated flags (`--dockerEnv A=1 --dockerEnv B=2`) and, because a + * GitHub Actions input arrives as one string, newline-separated entries in a + * single value - which is what a YAML block scalar produces: + * + * dockerEnv: | + * IL2CPP_ADDITIONAL_ARGS=--maxcpucount=2 + * FOO=bar + * + * Split on the FIRST `=` only, so values containing `=` (IL2CPP arguments + * invariably do) survive intact. Blank lines and `#` comments are skipped so + * a block scalar can be annotated. + */ + public static parseUserEnvironmentVariables(dockerEnv: unknown): DockerParameter[] { + const entries = (Array.isArray(dockerEnv) ? dockerEnv : [dockerEnv]) + .filter((entry): entry is string => typeof entry === 'string') + .flatMap((entry) => entry.split(/\r?\n/)) + .map((line) => line.trim()) + .filter((line) => line !== '' && !line.startsWith('#')); + + return entries.map((line) => { + const separatorIndex = line.indexOf('='); + + if (separatorIndex <= 0) { + throw new Error( + `Invalid --dockerEnv entry "${line}". Expected NAME=value (for example IL2CPP_ADDITIONAL_ARGS=--maxcpucount=2).`, + ); + } + + return { + name: line.slice(0, separatorIndex).trim(), + value: line.slice(separatorIndex + 1), + } as DockerParameter; + }); + } + /** Engine-agnostic env vars — apply to any engine's Docker build, not Unity-specific. */ public static getEnvironmentVariables(options: Options, extraVariables: DockerParameter[] = []) { + const environmentVariables = ImageEnvironmentFactory.builtInEnvironmentVariables(options, extraVariables); + const userVariables = ImageEnvironmentFactory.parseUserEnvironmentVariables(options.dockerEnv); + + if (userVariables.length > 0) { + const reserved = ImageEnvironmentFactory.reservedEnvNames(options, extraVariables); + + for (const { name } of userVariables) { + if (reserved.has(name)) { + log.warning( + `--dockerEnv ${name} overrides a value game-ci sets itself. The value you provided wins.`, + ); + } + } + } + + // Appended last so a user-supplied value wins over the built-in of the + // same name - Docker uses the last --env for a given name. + return [...environmentVariables, ...userVariables]; + } + + private static builtInEnvironmentVariables(options: Options, extraVariables: DockerParameter[] = []) { const environmentVariables: DockerParameter[] = [ ...extraVariables, { name: 'PROJECT_PATH', value: options.projectPath },