Compare commits

..

2 Commits

Author SHA1 Message Date
frostebite
71a0700bfa fix: stop uploading empty placeholder cache tars to S3
The build workflow created empty tar files (via `tar --files-from /dev/null`)
as placeholders before remote-cli-post-build runs. When the container is
OOM-killed, only these ~10KB empty tars survive and get uploaded to S3.
On the next build, the pull-cache hook downloads them and extracts an empty
Library, providing zero caching benefit.

Changes:
- Remove empty placeholder tar creation in build-automation-workflow.ts
- Keep mkdir -p for cache directories (hooks need them)
- Add size check in aws-s3-upload-cache hook to delete tar files < 1KB
  before uploading, as a safety net against stale stubs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:55:38 +00:00
Frostebite
ce7ce7a416 fix: pass Unity license secrets to AWS ECS container via RunTask overrides (#821)
* fix: pass Unity license secrets to AWS ECS container via RunTask overrides

The AWS provider was not passing UNITY_EMAIL, UNITY_PASSWORD, and
UNITY_SERIAL to the ECS container as environment variables. These
secrets were only sent to CloudFormation Secrets Manager, but the
template generation produced duplicate YAML Secrets keys (one per
secret), causing only the last secret to survive. The activate.sh
script requires all three to be present simultaneously.

This fix merges secrets into the ECS RunTask containerOverrides
environment array, matching how the docker and k8s providers already
handle secrets. The CloudFormation Secrets Manager path is preserved
as a secondary mechanism.

Fixes license activation failure when using providerStrategy: aws.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Pin LocalStack to 4.4.0 (pre-auth-token requirement)

As of 2026-03-23, localstack/localstack:latest requires an auth token
even for community features. Pin to 4.4.0 (last community release
before the single-image migration) to restore CI.

See: https://blog.localstack.cloud/localstack-single-image-next-steps/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 19:49:23 +00:00
17 changed files with 72 additions and 351 deletions

View File

@@ -12,7 +12,6 @@ jobs:
buildForAllPlatformsMacOS:
name: ${{ matrix.targetPlatform }} on ${{ matrix.unityVersion }}
runs-on: macos-latest
continue-on-error: true
strategy:
fail-fast: false
matrix:

View File

@@ -54,7 +54,7 @@ jobs:
# AWS_STACK_NAME: game-ci-github-pipelines
CHECKS_UPDATE: ${{ github.event.inputs.checksObject }}
run: |
git clone -b main https://github.com/game-ci/unity-builder
git clone -b orchestrator-develop https://github.com/game-ci/unity-builder
cd unity-builder
yarn
ls

View File

@@ -91,7 +91,7 @@ jobs:
-e SERVICES=s3,cloudformation,ecs,kinesis,cloudwatch,logs,efs,ec2,iam,elasticfilesystem,secretsmanager,lambda,events,sts \
-e DEBUG=0 \
-e HOSTNAME_EXTERNAL=localstack-main \
localstack/localstack:latest || true
localstack/localstack:4.4.0 || true
# Wait for LocalStack to be ready - check both health endpoint and S3 service
echo "Waiting for LocalStack to be ready..."
MAX_ATTEMPTS=60

View File

@@ -105,12 +105,6 @@ inputs:
required: false
default: ''
description: '[Orchestrator] Github private token to pull from github'
gitAuthMode:
required: false
default: 'header'
description:
'[Orchestrator] How git authentication is configured. "header" (default) uses http.extraHeader so the token
never appears in clone URLs or git config. "url" embeds the token in clone URLs (legacy behavior).'
githubOwner:
required: false
default: ''

136
dist/index.js generated vendored
View File

@@ -327,7 +327,6 @@ class BuildParameters {
containerRegistryRepository: input_1.default.containerRegistryRepository,
containerRegistryImageVersion: input_1.default.containerRegistryImageVersion,
providerStrategy: orchestrator_options_1.default.providerStrategy,
gitAuthMode: orchestrator_options_1.default.gitAuthMode,
buildPlatform: orchestrator_options_1.default.buildPlatform,
kubeConfig: orchestrator_options_1.default.kubeConfig,
containerMemory: orchestrator_options_1.default.containerMemory,
@@ -1945,29 +1944,6 @@ exports["default"] = OrchestratorEnvironmentVariable;
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
@@ -2022,57 +1998,12 @@ class OrchestratorFolders {
static get libraryCacheFolderFull() {
return node_path_1.default.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `Library`);
}
/**
* Whether to use http.extraHeader for git authentication (secure, default)
* instead of embedding the token in clone URLs (legacy).
*/
static get useHeaderAuth() {
return orchestrator_1.default.buildParameters.gitAuthMode !== 'url';
}
static get unityBuilderRepoUrl() {
if (OrchestratorFolders.useHeaderAuth) {
return `https://github.com/${orchestrator_1.default.buildParameters.orchestratorRepoName}.git`;
}
return `https://${orchestrator_1.default.buildParameters.gitPrivateToken}@github.com/${orchestrator_1.default.buildParameters.orchestratorRepoName}.git`;
}
static get targetBuildRepoUrl() {
if (OrchestratorFolders.useHeaderAuth) {
return `https://github.com/${orchestrator_1.default.buildParameters.githubRepo}.git`;
}
return `https://${orchestrator_1.default.buildParameters.gitPrivateToken}@github.com/${orchestrator_1.default.buildParameters.githubRepo}.git`;
}
/**
* Shell commands to configure git authentication via http.extraHeader.
* Uses GIT_PRIVATE_TOKEN env var so the token never appears in clone URLs or git config output.
* This is the same mechanism used by actions/checkout.
*
* Only emits commands when gitAuthMode is 'header' (default). In 'url' mode,
* returns a no-op comment since the token is already in the URL.
*/
static get gitAuthConfigScript() {
if (!OrchestratorFolders.useHeaderAuth) {
return `# git auth: using token-in-URL mode (legacy)`;
}
return `# git auth: configuring http.extraHeader (secure mode)
if [ -n "$GIT_PRIVATE_TOKEN" ]; then
git config --global http.https://github.com/.extraHeader "Authorization: Basic $(printf '%s' "x-access-token:$GIT_PRIVATE_TOKEN" | base64 -w 0)"
fi`;
}
/**
* Configure git authentication via http.extraHeader in the current Node process.
* For use in the remote-client where shell scripts aren't used.
* Only configures when gitAuthMode is 'header' (default).
*/
static async configureGitAuth() {
if (!OrchestratorFolders.useHeaderAuth)
return;
const token = orchestrator_1.default.buildParameters.gitPrivateToken || process.env.GIT_PRIVATE_TOKEN || '';
if (!token)
return;
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
const { OrchestratorSystem } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(9744)));
await OrchestratorSystem.Run(`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`);
}
static get buildVolumeFolder() {
return 'data';
}
@@ -2273,9 +2204,6 @@ class OrchestratorOptions {
}
return provider || 'local';
}
static get gitAuthMode() {
return OrchestratorOptions.getInput('gitAuthMode') || 'header';
}
static get containerCpu() {
return OrchestratorOptions.getInput('containerCpu') || `1024`;
}
@@ -3470,7 +3398,7 @@ class AWSTaskRunner {
return { name: x.name, value };
});
}
static async runTask(taskDef, environment, commands) {
static async runTask(taskDef, environment, secrets, commands) {
const cluster = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ECSCluster')?.PhysicalResourceId || '';
const taskDefinition = taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'TaskDefinition')?.PhysicalResourceId || '';
const SubnetOne = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'PublicSubnetOne')?.PhysicalResourceId || '';
@@ -3479,6 +3407,11 @@ class AWSTaskRunner {
const streamName = taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'KinesisStream')?.PhysicalResourceId || '';
// Transform localhost endpoints for container environment
const transformedEnvironment = AWSTaskRunner.transformEndpointsForContainer(environment);
// Merge secrets into environment as plain env vars, matching docker and k8s provider behavior.
// This ensures UNITY_EMAIL, UNITY_PASSWORD, UNITY_SERIAL reach the container reliably
// without depending on CloudFormation Secrets Manager resolution.
const secretsAsEnvironment = secrets.map((s) => ({ name: s.EnvironmentVariable, value: s.ParameterValue }));
const mergedEnvironment = [...transformedEnvironment, ...secretsAsEnvironment];
const runParameters = {
cluster,
taskDefinition,
@@ -3487,7 +3420,7 @@ class AWSTaskRunner {
containerOverrides: [
{
name: taskDef.taskDefStackName,
environment: transformedEnvironment,
environment: mergedEnvironment,
command: ['-c', command_hook_service_1.CommandHookService.ApplyHooksToCommands(commands, orchestrator_1.default.buildParameters)],
},
],
@@ -4521,7 +4454,7 @@ class AWSBuildEnvironment {
try {
const postSetupStacksTimeMs = Date.now();
orchestrator_logger_1.default.log(`Setup job time: ${Math.floor((postSetupStacksTimeMs - startTimeMs) / 1000)}s`);
const { output, shouldCleanup } = await aws_task_runner_1.default.runTask(taskDef, environment, commands);
const { output, shouldCleanup } = await aws_task_runner_1.default.runTask(taskDef, environment, secrets, commands);
postRunTaskTimeMs = Date.now();
orchestrator_logger_1.default.log(`Run job time: ${Math.floor((postRunTaskTimeMs - postSetupStacksTimeMs) / 1000)}s`);
if (shouldCleanup) {
@@ -8057,7 +7990,6 @@ class RemoteClient {
}
remote_client_logger_1.RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
await orchestrator_folders_1.OrchestratorFolders.configureGitAuth();
remote_client_logger_1.RemoteClientLogger.log(`Cloning the repository being built:`);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
@@ -8162,7 +8094,10 @@ class RemoteClient {
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
if (gitPrivateToken) {
remote_client_logger_1.RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
await RemoteClient.configureTokenAuth(gitPrivateToken);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global url."https://${gitPrivateToken}@github.com/".insteadOf "https://github.com/"`);
await orchestrator_system_1.OrchestratorSystem.Run(`git lfs pull`, true);
await orchestrator_system_1.OrchestratorSystem.Run(`git lfs checkout || true`, true);
remote_client_logger_1.RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
@@ -8177,7 +8112,10 @@ class RemoteClient {
const githubToken = process.env.GITHUB_TOKEN;
if (githubToken) {
remote_client_logger_1.RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
await RemoteClient.configureTokenAuth(githubToken);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global url."https://${githubToken}@github.com/".insteadOf "https://github.com/"`);
await orchestrator_system_1.OrchestratorSystem.Run(`git lfs pull`, true);
await orchestrator_system_1.OrchestratorSystem.Run(`git lfs checkout || true`, true);
remote_client_logger_1.RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
@@ -8234,23 +8172,6 @@ class RemoteClient {
}
return false;
}
/**
* Configure git authentication for a token. In header mode (default), uses
* http.extraHeader so the token never appears in URLs or git config output.
* In url mode (legacy), uses url.insteadOf to embed the token in URLs.
*/
static async configureTokenAuth(token) {
if (orchestrator_folders_1.OrchestratorFolders.useHeaderAuth) {
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`);
}
else {
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"`);
}
}
}
__decorate([
(0, cli_functions_repository_1.CliFunction)(`remote-cli-pre-build`, `sets up a repository, usually before a game-ci build`)
@@ -9498,6 +9419,10 @@ class ContainerHookService {
fi
ENDPOINT_ARGS=""
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
# Skip uploading empty or near-empty tar files (< 1KB) these are leftover
# stubs with no real cache data and would poison the cache for the next build.
find /data/cache/$CACHE_KEY/lfs -name "*.tar*" -size -1k -delete 2>/dev/null || true
find /data/cache/$CACHE_KEY/Library -name "*.tar*" -size -1k -delete 2>/dev/null || true
aws $ENDPOINT_ARGS s3 cp --recursive /data/cache/$CACHE_KEY/lfs s3://${orchestrator_1.default.buildParameters.awsStackName}/orchestrator-cache/$CACHE_KEY/lfs || true
rm -r /data/cache/$CACHE_KEY/lfs || true
aws $ENDPOINT_ARGS s3 cp --recursive /data/cache/$CACHE_KEY/Library s3://${orchestrator_1.default.buildParameters.awsStackName}/orchestrator-cache/$CACHE_KEY/Library || true
@@ -9809,14 +9734,14 @@ printenv
git config --global advice.detachedHead false
git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"
git config --global filter.lfs.process "git-lfs filter-process --skip"
${orchestrator_folders_1.OrchestratorFolders.gitAuthConfigScript}
BRANCH="${orchestrator_1.default.buildParameters.orchestratorBranch}"
REPO="${orchestrator_folders_1.OrchestratorFolders.unityBuilderRepoUrl}"
if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" /builder
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b main "$REPO" /builder \
git clone -q -b orchestrator-develop "$REPO" /builder \
|| git clone -q -b main "$REPO" /builder \
|| git clone -q "$REPO" /builder
fi
git clone -q -b ${orchestrator_1.default.buildParameters.branch} ${orchestrator_folders_1.OrchestratorFolders.targetBuildRepoUrl} /repo
@@ -9926,7 +9851,6 @@ class BuildAutomationWorkflow {
static setupCommands(builderPath, isContainerized) {
// prettier-ignore
const commands = `mkdir -p ${orchestrator_folders_1.OrchestratorFolders.ToLinuxFolder(orchestrator_folders_1.OrchestratorFolders.builderPathAbsolute)}
${orchestrator_folders_1.OrchestratorFolders.gitAuthConfigScript}
BRANCH="${orchestrator_1.default.buildParameters.orchestratorBranch}"
REPO="${orchestrator_folders_1.OrchestratorFolders.unityBuilderRepoUrl}"
DEST="${orchestrator_folders_1.OrchestratorFolders.ToLinuxFolder(orchestrator_folders_1.OrchestratorFolders.builderPathAbsolute)}"
@@ -9934,7 +9858,8 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" "$DEST"
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b main "$REPO" "$DEST" \
git clone -q -b orchestrator-develop "$REPO" "$DEST" \
|| git clone -q -b main "$REPO" "$DEST" \
|| git clone -q "$REPO" "$DEST"
fi
chmod +x ${builderPath}`;
@@ -9991,13 +9916,14 @@ echo "CACHE_KEY=$CACHE_KEY"`;
if ! command -v yarn > /dev/null 2>&1; then printf '#!/bin/sh\nexit 0\n' > /usr/local/bin/yarn && chmod +x /usr/local/bin/yarn; fi
# Pipe entrypoint.sh output through log stream to capture Unity build output (including "Build succeeded")
{ echo "game ci start"; echo "game ci start" >> /home/job-log.txt; echo "CACHE_KEY=$CACHE_KEY"; echo "$CACHE_KEY"; if [ -n "$LOCKED_WORKSPACE" ]; then echo "Retained Workspace: true"; fi; if [ -n "$LOCKED_WORKSPACE" ] && [ -d "$GITHUB_WORKSPACE/.git" ]; then echo "Retained Workspace Already Exists!"; fi; /entrypoint.sh; } | node ${builderPath} -m remote-cli-log-stream --logFile /home/job-log.txt
# Ensure cache directories exist for post-build and S3 upload hooks.
# Do NOT create empty placeholder tars they waste S3 storage and on next
# build the pull-cache hook downloads them, giving Unity an empty Library
# (no caching benefit). The real tars are created by remote-cli-post-build
# via Caching.PushToCache(), and the S3 upload hooks use || true so missing
# files are handled gracefully.
mkdir -p "/data/cache/$CACHE_KEY/Library"
if [ ! -f "/data/cache/$CACHE_KEY/Library/lib-$BUILD_GUID.tar" ] && [ ! -f "/data/cache/$CACHE_KEY/Library/lib-$BUILD_GUID.tar.lz4" ]; then
tar -cf "/data/cache/$CACHE_KEY/Library/lib-$BUILD_GUID.tar" --files-from /dev/null || touch "/data/cache/$CACHE_KEY/Library/lib-$BUILD_GUID.tar"
fi
if [ ! -f "/data/cache/$CACHE_KEY/build/build-$BUILD_GUID.tar" ] && [ ! -f "/data/cache/$CACHE_KEY/build/build-$BUILD_GUID.tar.lz4" ]; then
tar -cf "/data/cache/$CACHE_KEY/build/build-$BUILD_GUID.tar" --files-from /dev/null || touch "/data/cache/$CACHE_KEY/build/build-$BUILD_GUID.tar"
fi
mkdir -p "/data/cache/$CACHE_KEY/build"
# Run post-build tasks and capture output
# Note: Post-build may clean up the builder directory, so we write output directly to log file
# Use set +e to allow the command to fail without exiting the script

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -54,7 +54,6 @@ class BuildParameters {
public sshAgent!: string;
public sshPublicKeysDirectoryPath!: string;
public providerStrategy!: string;
public gitAuthMode!: string;
public gitPrivateToken!: string;
public awsStackName!: string;
public awsEndpoint?: string;
@@ -195,7 +194,6 @@ class BuildParameters {
containerRegistryRepository: Input.containerRegistryRepository,
containerRegistryImageVersion: Input.containerRegistryImageVersion,
providerStrategy: OrchestratorOptions.providerStrategy,
gitAuthMode: OrchestratorOptions.gitAuthMode,
buildPlatform: OrchestratorOptions.buildPlatform,
kubeConfig: OrchestratorOptions.kubeConfig,
containerMemory: OrchestratorOptions.containerMemory,

View File

@@ -1,140 +0,0 @@
import { OrchestratorFolders } from './orchestrator-folders';
jest.mock('../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
orchestratorRepoName: 'game-ci/unity-builder',
githubRepo: 'myorg/myrepo',
gitPrivateToken: 'ghp_test123',
gitAuthMode: 'header',
buildGuid: 'test-guid',
projectPath: '',
buildPath: 'Builds',
cacheKey: 'test-cache',
},
lockedWorkspace: '',
},
}));
jest.mock('./orchestrator-options', () => ({
__esModule: true,
default: {
useSharedBuilder: false,
},
}));
jest.mock('../services/core/orchestrator-system', () => ({
OrchestratorSystem: {
Run: jest.fn().mockResolvedValue(''),
},
}));
const mockOrchestrator = require('../orchestrator').default;
describe('OrchestratorFolders git auth', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('useHeaderAuth', () => {
it('should return true when gitAuthMode is header', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'header';
expect(OrchestratorFolders.useHeaderAuth).toBe(true);
});
it('should return true when gitAuthMode is undefined (default)', () => {
mockOrchestrator.buildParameters.gitAuthMode = undefined;
expect(OrchestratorFolders.useHeaderAuth).toBe(true);
});
it('should return false when gitAuthMode is url', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'url';
expect(OrchestratorFolders.useHeaderAuth).toBe(false);
});
});
describe('unityBuilderRepoUrl', () => {
it('should not include token in URL when using header auth', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'header';
const url = OrchestratorFolders.unityBuilderRepoUrl;
expect(url).toBe('https://github.com/game-ci/unity-builder.git');
expect(url).not.toContain('ghp_test123');
});
it('should include token in URL when using url auth (legacy)', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'url';
const url = OrchestratorFolders.unityBuilderRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/game-ci/unity-builder.git');
});
});
describe('targetBuildRepoUrl', () => {
it('should not include token in URL when using header auth', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'header';
const url = OrchestratorFolders.targetBuildRepoUrl;
expect(url).toBe('https://github.com/myorg/myrepo.git');
expect(url).not.toContain('ghp_test123');
});
it('should include token in URL when using url auth (legacy)', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'url';
const url = OrchestratorFolders.targetBuildRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/myorg/myrepo.git');
});
});
describe('gitAuthConfigScript', () => {
it('should emit http.extraHeader commands in header mode', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'header';
const script = OrchestratorFolders.gitAuthConfigScript;
expect(script).toContain('http.extraHeader');
expect(script).toContain('GIT_PRIVATE_TOKEN');
expect(script).toContain('Authorization: Basic');
});
it('should emit no-op comment in url mode', () => {
mockOrchestrator.buildParameters.gitAuthMode = 'url';
const script = OrchestratorFolders.gitAuthConfigScript;
expect(script).toContain('legacy');
expect(script).not.toContain('http.extraHeader');
});
});
describe('configureGitAuth', () => {
it('should run git config with http.extraHeader in header mode', async () => {
mockOrchestrator.buildParameters.gitAuthMode = 'header';
mockOrchestrator.buildParameters.gitPrivateToken = 'ghp_test123';
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
await OrchestratorFolders.configureGitAuth();
// Verify the base64 encoding and extraHeader config are correct
const expectedEncoded = Buffer.from('x-access-token:ghp_test123').toString('base64');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining(expectedEncoded));
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('.extraHeader'));
});
it('should not run git config in url mode', async () => {
mockOrchestrator.buildParameters.gitAuthMode = 'url';
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
await OrchestratorFolders.configureGitAuth();
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
});
it('should not run git config when no token is available', async () => {
mockOrchestrator.buildParameters.gitAuthMode = 'header';
mockOrchestrator.buildParameters.gitPrivateToken = '';
const originalEnv = process.env.GIT_PRIVATE_TOKEN;
delete process.env.GIT_PRIVATE_TOKEN;
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
await OrchestratorFolders.configureGitAuth();
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
if (originalEnv !== undefined) process.env.GIT_PRIVATE_TOKEN = originalEnv;
});
});
});

View File

@@ -72,67 +72,14 @@ export class OrchestratorFolders {
return path.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `Library`);
}
/**
* Whether to use http.extraHeader for git authentication (secure, default)
* instead of embedding the token in clone URLs (legacy).
*/
public static get useHeaderAuth(): boolean {
return Orchestrator.buildParameters.gitAuthMode !== 'url';
}
public static get unityBuilderRepoUrl(): string {
if (OrchestratorFolders.useHeaderAuth) {
return `https://github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
}
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
}
public static get targetBuildRepoUrl(): string {
if (OrchestratorFolders.useHeaderAuth) {
return `https://github.com/${Orchestrator.buildParameters.githubRepo}.git`;
}
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.githubRepo}.git`;
}
/**
* Shell commands to configure git authentication via http.extraHeader.
* Uses GIT_PRIVATE_TOKEN env var so the token never appears in clone URLs or git config output.
* This is the same mechanism used by actions/checkout.
*
* Only emits commands when gitAuthMode is 'header' (default). In 'url' mode,
* returns a no-op comment since the token is already in the URL.
*/
public static get gitAuthConfigScript(): string {
if (!OrchestratorFolders.useHeaderAuth) {
return `# git auth: using token-in-URL mode (legacy)`;
}
return `# git auth: configuring http.extraHeader (secure mode)
if [ -n "$GIT_PRIVATE_TOKEN" ]; then
git config --global http.https://github.com/.extraHeader "Authorization: Basic $(printf '%s' "x-access-token:$GIT_PRIVATE_TOKEN" | base64 -w 0)"
fi`;
}
/**
* Configure git authentication via http.extraHeader in the current Node process.
* For use in the remote-client where shell scripts aren't used.
* Only configures when gitAuthMode is 'header' (default).
*/
public static async configureGitAuth(): Promise<void> {
if (!OrchestratorFolders.useHeaderAuth) return;
const token = Orchestrator.buildParameters.gitPrivateToken || process.env.GIT_PRIVATE_TOKEN || '';
if (!token) return;
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
const { OrchestratorSystem } = await import('../services/core/orchestrator-system');
await OrchestratorSystem.Run(
`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`,
);
}
public static get buildVolumeFolder() {
return 'data';
}

View File

@@ -138,10 +138,6 @@ class OrchestratorOptions {
return provider || 'local';
}
static get gitAuthMode(): string {
return OrchestratorOptions.getInput('gitAuthMode') || 'header';
}
static get containerCpu(): string {
return OrchestratorOptions.getInput('containerCpu') || `1024`;
}

View File

@@ -1,6 +1,7 @@
import { DescribeTasksCommand, RunTaskCommand, waitUntilTasksRunning } from '@aws-sdk/client-ecs';
import { DescribeStreamCommand, GetRecordsCommand, GetShardIteratorCommand } from '@aws-sdk/client-kinesis';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorSecret from '../../options/orchestrator-secret';
import * as core from '@actions/core';
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def';
import * as zlib from 'node:zlib';
@@ -56,6 +57,7 @@ class AWSTaskRunner {
static async runTask(
taskDef: OrchestratorAWSTaskDef,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
commands: string,
): Promise<{ output: string; shouldCleanup: boolean }> {
const cluster = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ECSCluster')?.PhysicalResourceId || '';
@@ -73,6 +75,12 @@ class AWSTaskRunner {
// Transform localhost endpoints for container environment
const transformedEnvironment = AWSTaskRunner.transformEndpointsForContainer(environment);
// Merge secrets into environment as plain env vars, matching docker and k8s provider behavior.
// This ensures UNITY_EMAIL, UNITY_PASSWORD, UNITY_SERIAL reach the container reliably
// without depending on CloudFormation Secrets Manager resolution.
const secretsAsEnvironment = secrets.map((s) => ({ name: s.EnvironmentVariable, value: s.ParameterValue }));
const mergedEnvironment = [...transformedEnvironment, ...secretsAsEnvironment];
const runParameters = {
cluster,
taskDefinition,
@@ -81,7 +89,7 @@ class AWSTaskRunner {
containerOverrides: [
{
name: taskDef.taskDefStackName,
environment: transformedEnvironment,
environment: mergedEnvironment,
command: ['-c', CommandHookService.ApplyHooksToCommands(commands, Orchestrator.buildParameters)],
},
],

View File

@@ -125,7 +125,7 @@ class AWSBuildEnvironment implements ProviderInterface {
try {
const postSetupStacksTimeMs = Date.now();
OrchestratorLogger.log(`Setup job time: ${Math.floor((postSetupStacksTimeMs - startTimeMs) / 1000)}s`);
const { output, shouldCleanup } = await AwsTaskRunner.runTask(taskDef, environment, commands);
const { output, shouldCleanup } = await AwsTaskRunner.runTask(taskDef, environment, secrets, commands);
postRunTaskTimeMs = Date.now();
OrchestratorLogger.log(`Run job time: ${Math.floor((postRunTaskTimeMs - postSetupStacksTimeMs) / 1000)}s`);
if (shouldCleanup) {

View File

@@ -302,7 +302,6 @@ export class RemoteClient {
RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
await OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
await OrchestratorFolders.configureGitAuth();
RemoteClientLogger.log(`Cloning the repository being built:`);
await OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
await OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
@@ -412,7 +411,12 @@ export class RemoteClient {
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
if (gitPrivateToken) {
RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
await RemoteClient.configureTokenAuth(gitPrivateToken);
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await OrchestratorSystem.Run(
`git config --global url."https://${gitPrivateToken}@github.com/".insteadOf "https://github.com/"`,
);
await OrchestratorSystem.Run(`git lfs pull`, true);
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
@@ -428,7 +432,12 @@ export class RemoteClient {
const githubToken = process.env.GITHUB_TOKEN;
if (githubToken) {
RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
await RemoteClient.configureTokenAuth(githubToken);
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await OrchestratorSystem.Run(
`git config --global url."https://${githubToken}@github.com/".insteadOf "https://github.com/"`,
);
await OrchestratorSystem.Run(`git lfs pull`, true);
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
@@ -492,25 +501,4 @@ export class RemoteClient {
return false;
}
/**
* Configure git authentication for a token. In header mode (default), uses
* http.extraHeader so the token never appears in URLs or git config output.
* In url mode (legacy), uses url.insteadOf to embed the token in URLs.
*/
private static async configureTokenAuth(token: string): Promise<void> {
if (OrchestratorFolders.useHeaderAuth) {
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
await OrchestratorSystem.Run(
`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`,
);
} else {
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await OrchestratorSystem.Run(
`git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"`,
);
}
}
}

View File

@@ -155,6 +155,10 @@ export class ContainerHookService {
fi
ENDPOINT_ARGS=""
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
# Skip uploading empty or near-empty tar files (< 1KB) — these are leftover
# stubs with no real cache data and would poison the cache for the next build.
find /data/cache/$CACHE_KEY/lfs -name "*.tar*" -size -1k -delete 2>/dev/null || true
find /data/cache/$CACHE_KEY/Library -name "*.tar*" -size -1k -delete 2>/dev/null || true
aws $ENDPOINT_ARGS s3 cp --recursive /data/cache/$CACHE_KEY/lfs s3://${
Orchestrator.buildParameters.awsStackName
}/orchestrator-cache/$CACHE_KEY/lfs || true

View File

@@ -30,7 +30,7 @@ describe('Orchestrator Caching', () => {
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
containerHookFiles: `debug-cache`,
orchestratorBranch: `main`,
orchestratorBranch: `orchestrator-develop`,
orchestratorDebug: true,
};

View File

@@ -27,14 +27,14 @@ printenv
git config --global advice.detachedHead false
git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"
git config --global filter.lfs.process "git-lfs filter-process --skip"
${OrchestratorFolders.gitAuthConfigScript}
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" /builder
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b main "$REPO" /builder \
git clone -q -b orchestrator-develop "$REPO" /builder \
|| git clone -q -b main "$REPO" /builder \
|| git clone -q "$REPO" /builder
fi
git clone -q -b ${Orchestrator.buildParameters.branch} ${OrchestratorFolders.targetBuildRepoUrl} /repo

View File

@@ -92,7 +92,6 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
const commands = `mkdir -p ${OrchestratorFolders.ToLinuxFolder(
OrchestratorFolders.builderPathAbsolute,
)}
${OrchestratorFolders.gitAuthConfigScript}
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
DEST="${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.builderPathAbsolute)}"
@@ -100,7 +99,8 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" "$DEST"
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b main "$REPO" "$DEST" \
git clone -q -b orchestrator-develop "$REPO" "$DEST" \
|| git clone -q -b main "$REPO" "$DEST" \
|| git clone -q "$REPO" "$DEST"
fi
chmod +x ${builderPath}`;
@@ -170,13 +170,14 @@ echo "CACHE_KEY=$CACHE_KEY"`;
if ! command -v yarn > /dev/null 2>&1; then printf '#!/bin/sh\nexit 0\n' > /usr/local/bin/yarn && chmod +x /usr/local/bin/yarn; fi
# Pipe entrypoint.sh output through log stream to capture Unity build output (including "Build succeeded")
{ echo "game ci start"; echo "game ci start" >> /home/job-log.txt; echo "CACHE_KEY=$CACHE_KEY"; echo "$CACHE_KEY"; if [ -n "$LOCKED_WORKSPACE" ]; then echo "Retained Workspace: true"; fi; if [ -n "$LOCKED_WORKSPACE" ] && [ -d "$GITHUB_WORKSPACE/.git" ]; then echo "Retained Workspace Already Exists!"; fi; /entrypoint.sh; } | node ${builderPath} -m remote-cli-log-stream --logFile /home/job-log.txt
# Ensure cache directories exist for post-build and S3 upload hooks.
# Do NOT create empty placeholder tars — they waste S3 storage and on next
# build the pull-cache hook downloads them, giving Unity an empty Library
# (no caching benefit). The real tars are created by remote-cli-post-build
# via Caching.PushToCache(), and the S3 upload hooks use || true so missing
# files are handled gracefully.
mkdir -p "/data/cache/$CACHE_KEY/Library"
if [ ! -f "/data/cache/$CACHE_KEY/Library/lib-$BUILD_GUID.tar" ] && [ ! -f "/data/cache/$CACHE_KEY/Library/lib-$BUILD_GUID.tar.lz4" ]; then
tar -cf "/data/cache/$CACHE_KEY/Library/lib-$BUILD_GUID.tar" --files-from /dev/null || touch "/data/cache/$CACHE_KEY/Library/lib-$BUILD_GUID.tar"
fi
if [ ! -f "/data/cache/$CACHE_KEY/build/build-$BUILD_GUID.tar" ] && [ ! -f "/data/cache/$CACHE_KEY/build/build-$BUILD_GUID.tar.lz4" ]; then
tar -cf "/data/cache/$CACHE_KEY/build/build-$BUILD_GUID.tar" --files-from /dev/null || touch "/data/cache/$CACHE_KEY/build/build-$BUILD_GUID.tar"
fi
mkdir -p "/data/cache/$CACHE_KEY/build"
# Run post-build tasks and capture output
# Note: Post-build may clean up the builder directory, so we write output directly to log file
# Use set +e to allow the command to fail without exiting the script