Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"root": true,
"plugins": ["jest", "@typescript-eslint", "prettier", "unicorn"],
"extends": ["plugin:unicorn/recommended", "plugin:github/recommended", "plugin:prettier/recommended"],
"parser": "@typescript-eslint/parser",
Expand Down
22 changes: 20 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,8 @@ inputs:
required: false
default: ''
description:
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with the
keys image, secrets (name, value object array), command line string)'
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with
the keys image, secrets (name, value object array), command line string)'
awsStackName:
default: 'game-ci'
required: false
Expand Down Expand Up @@ -279,6 +279,24 @@ inputs:
description:
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.'
syncStrategy:
description: 'Workspace sync strategy: full, git-delta, direct-input, storage-pull'
required: false
default: 'full'
syncInputRef:
description: 'URI for direct-input or storage-pull content (storage://remote/path or file path)'
required: false
syncStorageRemote:
description: 'rclone remote name for storage-backed inputs (defaults to rcloneRemote)'
required: false
syncRevertAfter:
description: 'Revert overlaid changes after job completion'
required: false
default: 'true'
syncStatePath:
description: 'Path to sync state file for delta tracking'
required: false
default: '.game-ci/sync-state.json'

outputs:
volume:
Expand Down
562 changes: 561 additions & 1 deletion dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output
import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup';
import { IncrementalSyncService } from './model/orchestrator/services/sync';
import { SyncStrategy } from './model/orchestrator/services/sync/sync-state';

async function runMain() {
try {
Expand All @@ -23,6 +25,14 @@ async function runMain() {

if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');

// Apply incremental sync strategy before build
const syncStrategy = buildParameters.syncStrategy as SyncStrategy;
if (syncStrategy !== 'full') {
core.info(`[Sync] Applying sync strategy: ${syncStrategy}`);
await applySyncStrategy(buildParameters, workspace);
}

await PlatformSetup.setup(buildParameters, actionFolder);
exitCode =
process.platform === 'darwin'
Expand All @@ -32,6 +42,16 @@ async function runMain() {
actionFolder,
...buildParameters,
});

// Revert overlays after job completion if configured
if (buildParameters.syncRevertAfter && syncStrategy !== 'full') {
core.info('[Sync] Reverting overlay changes after job completion');
try {
await IncrementalSyncService.revertOverlays(workspace, buildParameters.syncStatePath);
} catch (revertError) {
core.warning(`[Sync] Overlay revert failed: ${(revertError as Error).message}`);
}
}
} else {
await Orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0;
Expand All @@ -50,4 +70,58 @@ async function runMain() {
}
}

/**
* Apply the configured sync strategy to the workspace before build.
*/
async function applySyncStrategy(buildParameters: BuildParameters, workspace: string): Promise<void> {
const strategy = buildParameters.syncStrategy as SyncStrategy;
const resolvedStrategy = IncrementalSyncService.resolveStrategy(strategy, workspace, buildParameters.syncStatePath);

if (resolvedStrategy === 'full') {
core.info('[Sync] Resolved to full sync (no incremental state available)');

return;
}

switch (resolvedStrategy) {
case 'git-delta': {
const targetReference = buildParameters.gitSha || buildParameters.branch;
const changedFiles = await IncrementalSyncService.syncGitDelta(
workspace,
targetReference,
buildParameters.syncStatePath,
);
core.info(`[Sync] Git delta sync applied: ${changedFiles} file(s) changed`);
break;
}
case 'direct-input': {
if (!buildParameters.syncInputRef) {
throw new Error('[Sync] direct-input strategy requires syncInputRef to be set');
}
const overlays = await IncrementalSyncService.applyDirectInput(
workspace,
buildParameters.syncInputRef,
buildParameters.syncStorageRemote || undefined,
buildParameters.syncStatePath,
);
core.info(`[Sync] Direct input applied: ${overlays.length} overlay(s)`);
break;
}
case 'storage-pull': {
if (!buildParameters.syncInputRef) {
throw new Error('[Sync] storage-pull strategy requires syncInputRef to be set');
}
const pulledFiles = await IncrementalSyncService.syncStoragePull(workspace, buildParameters.syncInputRef, {
rcloneRemote: buildParameters.syncStorageRemote || undefined,
syncRevertAfter: buildParameters.syncRevertAfter,
statePath: buildParameters.syncStatePath,
});
core.info(`[Sync] Storage pull complete: ${pulledFiles.length} file(s)`);
break;
}
default:
core.warning(`[Sync] Unknown sync strategy: ${resolvedStrategy}`);
}
}

runMain();
10 changes: 10 additions & 0 deletions src/model/build-parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ class BuildParameters {
public cacheUnityInstallationOnMac!: boolean;
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string;
public syncStrategy!: string;
public syncInputRef!: string;
public syncStorageRemote!: string;
public syncRevertAfter!: boolean;
public syncStatePath!: string;

public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
Expand Down Expand Up @@ -242,6 +247,11 @@ class BuildParameters {
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
syncStrategy: Input.syncStrategy,
syncInputRef: Input.syncInputRef,
syncStorageRemote: Input.syncStorageRemote,
syncRevertAfter: Input.syncRevertAfter,
syncStatePath: Input.syncStatePath,
};
}

Expand Down
22 changes: 22 additions & 0 deletions src/model/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,28 @@ class Input {
return Input.getInput('dockerWorkspacePath') ?? '/github/workspace';
}

static get syncStrategy(): string {
return Input.getInput('syncStrategy') ?? 'full';
}

static get syncInputRef(): string {
return Input.getInput('syncInputRef') ?? '';
}

static get syncStorageRemote(): string {
return Input.getInput('syncStorageRemote') ?? '';
}

static get syncRevertAfter(): boolean {
const input = Input.getInput('syncRevertAfter') ?? 'true';

return input === 'true';
}
Comment on lines +256 to +260
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Parse syncRevertAfter case-insensitively.

Values like TRUE / True currently resolve to false.

Proposed fix
   static get syncRevertAfter(): boolean {
     const input = Input.getInput('syncRevertAfter') ?? 'true';
 
-    return input === 'true';
+    return input.toLowerCase() === 'true';
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static get syncRevertAfter(): boolean {
const input = Input.getInput('syncRevertAfter') ?? 'true';
return input === 'true';
}
static get syncRevertAfter(): boolean {
const input = Input.getInput('syncRevertAfter') ?? 'true';
return input.toLowerCase() === 'true';
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/model/input.ts` around lines 256 - 260, The syncRevertAfter getter on
class Input currently compares the raw string exactly to 'true', causing values
like 'TRUE' or 'True' to be treated as false; change the comparison in static
get syncRevertAfter() to normalize the value (e.g., call .toLowerCase() on the
result of Input.getInput('syncRevertAfter') ?? 'true') and then compare to
'true' so the check is case-insensitive while preserving the existing default
behavior.


static get syncStatePath(): string {
return Input.getInput('syncStatePath') ?? '.game-ci/sync-state.json';
}

static get dockerCpuLimit(): string {
return Input.getInput('dockerCpuLimit') ?? os.cpus().length.toString();
}
Expand Down
97 changes: 96 additions & 1 deletion src/model/orchestrator/remote-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,24 @@ import BuildParameters from '../../build-parameters';
import { Cli } from '../../cli/cli';
import OrchestratorOptions from '../options/orchestrator-options';
import ResourceTracking from '../services/core/resource-tracking';
import { IncrementalSyncService } from '../services/sync';
import { SyncStrategy } from '../services/sync/sync-state';

export class RemoteClient {
@CliFunction(`remote-cli-pre-build`, `sets up a repository, usually before a game-ci build`)
static async setupRemoteClient() {
OrchestratorLogger.log(`bootstrap game ci orchestrator...`);
await ResourceTracking.logDiskUsageSnapshot('remote-cli-pre-build (start)');
if (!(await RemoteClient.handleRetainedWorkspace())) {

const syncStrategy = (Orchestrator.buildParameters.syncStrategy || 'full') as SyncStrategy;

if (syncStrategy !== 'full') {
OrchestratorLogger.log(`[Sync] Using incremental sync strategy: ${syncStrategy}`);
await RemoteClient.handleIncrementalSync(syncStrategy);
} else if (!(await RemoteClient.handleRetainedWorkspace())) {
await RemoteClient.bootstrapRepository();
}

await RemoteClient.replaceLargePackageReferencesWithSharedReferences();
await RemoteClient.runCustomHookFiles(`before-build`);
}
Expand Down Expand Up @@ -157,6 +166,20 @@ export class RemoteClient {

await RemoteClient.runCustomHookFiles(`after-build`);

// Revert sync overlays if configured
const syncStrategy = (Orchestrator.buildParameters.syncStrategy || 'full') as SyncStrategy;
if (Orchestrator.buildParameters.syncRevertAfter && syncStrategy !== 'full') {
try {
OrchestratorLogger.log('[Sync] Reverting overlay changes after job completion');
await IncrementalSyncService.revertOverlays(
OrchestratorFolders.repoPathAbsolute,
Orchestrator.buildParameters.syncStatePath,
);
} catch (revertError: any) {
RemoteClientLogger.logWarning(`[Sync] Overlay revert failed: ${revertError.message}`);
}
}

// WIP - need to give the pod permissions to create config map
await RemoteClientLogger.handleLogManagementPostJob();
} catch (error: any) {
Expand Down Expand Up @@ -229,6 +252,78 @@ export class RemoteClient {
RemoteClientLogger.log(JSON.stringify(error, undefined, 4));
}
}

/**
* Handle incremental sync strategies (git-delta, direct-input, storage-pull).
*
* For git-delta: requires an existing workspace with sync state; fetches and applies
* only changed files.
*
* For direct-input and storage-pull: requires an existing workspace; applies overlay
* content on top.
*
* Falls back to full bootstrapRepository() if incremental sync cannot proceed.
*/
private static async handleIncrementalSync(strategy: SyncStrategy): Promise<void> {
const buildParameters = Orchestrator.buildParameters;
const workspacePath = OrchestratorFolders.repoPathAbsolute;
const statePath = buildParameters.syncStatePath;

// Resolve strategy — may fall back to 'full' if no state exists
const resolvedStrategy = IncrementalSyncService.resolveStrategy(strategy, workspacePath, statePath);

if (resolvedStrategy === 'full') {
OrchestratorLogger.log('[Sync] Falling back to full bootstrap');
if (!(await RemoteClient.handleRetainedWorkspace())) {
await RemoteClient.bootstrapRepository();
}

return;
}

switch (resolvedStrategy) {
case 'git-delta': {
const targetReference = buildParameters.gitSha || buildParameters.branch;
OrchestratorLogger.log(`[Sync] Git delta sync to ${targetReference}`);
const changedFiles = await IncrementalSyncService.syncGitDelta(workspacePath, targetReference, statePath);
OrchestratorLogger.log(`[Sync] Git delta complete: ${changedFiles} file(s) updated`);
break;
}
case 'direct-input': {
const inputReference = buildParameters.syncInputRef;
if (!inputReference) {
throw new Error('[Sync] direct-input strategy requires syncInputRef');
}
OrchestratorLogger.log(`[Sync] Applying direct input: ${inputReference}`);
await IncrementalSyncService.applyDirectInput(
workspacePath,
inputReference,
buildParameters.syncStorageRemote || undefined,
statePath,
);
break;
}
case 'storage-pull': {
const storageUri = buildParameters.syncInputRef;
if (!storageUri) {
throw new Error('[Sync] storage-pull strategy requires syncInputRef');
}
OrchestratorLogger.log(`[Sync] Storage pull from: ${storageUri}`);
await IncrementalSyncService.syncStoragePull(workspacePath, storageUri, {
rcloneRemote: buildParameters.syncStorageRemote || undefined,
syncRevertAfter: buildParameters.syncRevertAfter,
statePath,
});
break;
}
default:
OrchestratorLogger.logWarning(`[Sync] Unknown strategy: ${resolvedStrategy}, falling back to full`);
if (!(await RemoteClient.handleRetainedWorkspace())) {
await RemoteClient.bootstrapRepository();
}
}
}

public static async bootstrapRepository() {
await OrchestratorSystem.Run(
`mkdir -p ${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute)}`,
Expand Down
Loading
Loading