Skip to content

Commit 4870fb5

Browse files
frostebiteclaude
andcommitted
feat(sync): complete incremental sync protocol with storage-pull, state management, and tests (#799)
- Add storage-pull strategy: rclone-based sync from remote storage with overlay and clean modes, URI parsing (storage://remote:bucket/path), transfer parallelism, and automatic rclone availability checking - Add SyncStateManager: persistent state load/save with configurable paths, workspace hash calculation via SHA-256 of key project files, and drift detection for external modification awareness - Add action.yml inputs: syncStrategy, syncInputRef, syncStorageRemote, syncRevertAfter, syncStatePath with sensible defaults - Wire sync into Input (5 getters), BuildParameters (5 fields), index.ts (local build path), and RemoteClient (orchestrator path) with post-job overlay revert when syncRevertAfter is true - Add 42 unit tests covering all strategies, URI parsing, state management, hash calculation, drift detection, error handling, and edge cases (missing rclone, invalid URIs, absent state, empty diffs) - Add root:true to eslintrc to prevent plugin resolution conflicts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3033ee0 commit 4870fb5

File tree

12 files changed

+1633
-74
lines changed

12 files changed

+1633
-74
lines changed

.eslintrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"root": true,
23
"plugins": ["jest", "@typescript-eslint", "prettier", "unicorn"],
34
"extends": ["plugin:unicorn/recommended", "plugin:github/recommended", "plugin:prettier/recommended"],
45
"parser": "@typescript-eslint/parser",

action.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,8 @@ inputs:
182182
required: false
183183
default: ''
184184
description:
185-
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with the
186-
keys image, secrets (name, value object array), command line string)'
185+
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with
186+
the keys image, secrets (name, value object array), command line string)'
187187
awsStackName:
188188
default: 'game-ci'
189189
required: false
@@ -279,6 +279,24 @@ inputs:
279279
description:
280280
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
281281
fixes.'
282+
syncStrategy:
283+
description: 'Workspace sync strategy: full, git-delta, direct-input, storage-pull'
284+
required: false
285+
default: 'full'
286+
syncInputRef:
287+
description: 'URI for direct-input or storage-pull content (storage://remote/path or file path)'
288+
required: false
289+
syncStorageRemote:
290+
description: 'rclone remote name for storage-backed inputs (defaults to rcloneRemote)'
291+
required: false
292+
syncRevertAfter:
293+
description: 'Revert overlaid changes after job completion'
294+
required: false
295+
default: 'true'
296+
syncStatePath:
297+
description: 'Path to sync state file for delta tracking'
298+
required: false
299+
default: '.game-ci/sync-state.json'
282300

283301
outputs:
284302
volume:

dist/index.js

Lines changed: 561 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output
33
import { Cli } from './model/cli/cli';
44
import MacBuilder from './model/mac-builder';
55
import PlatformSetup from './model/platform-setup';
6+
import { IncrementalSyncService } from './model/orchestrator/services/sync';
7+
import { SyncStrategy } from './model/orchestrator/services/sync/sync-state';
68

79
async function runMain() {
810
try {
@@ -23,6 +25,14 @@ async function runMain() {
2325

2426
if (buildParameters.providerStrategy === 'local') {
2527
core.info('Building locally');
28+
29+
// Apply incremental sync strategy before build
30+
const syncStrategy = buildParameters.syncStrategy as SyncStrategy;
31+
if (syncStrategy !== 'full') {
32+
core.info(`[Sync] Applying sync strategy: ${syncStrategy}`);
33+
await applySyncStrategy(buildParameters, workspace);
34+
}
35+
2636
await PlatformSetup.setup(buildParameters, actionFolder);
2737
exitCode =
2838
process.platform === 'darwin'
@@ -32,6 +42,16 @@ async function runMain() {
3242
actionFolder,
3343
...buildParameters,
3444
});
45+
46+
// Revert overlays after job completion if configured
47+
if (buildParameters.syncRevertAfter && syncStrategy !== 'full') {
48+
core.info('[Sync] Reverting overlay changes after job completion');
49+
try {
50+
await IncrementalSyncService.revertOverlays(workspace, buildParameters.syncStatePath);
51+
} catch (revertError) {
52+
core.warning(`[Sync] Overlay revert failed: ${(revertError as Error).message}`);
53+
}
54+
}
3555
} else {
3656
await Orchestrator.run(buildParameters, baseImage.toString());
3757
exitCode = 0;
@@ -50,4 +70,58 @@ async function runMain() {
5070
}
5171
}
5272

73+
/**
74+
* Apply the configured sync strategy to the workspace before build.
75+
*/
76+
async function applySyncStrategy(buildParameters: BuildParameters, workspace: string): Promise<void> {
77+
const strategy = buildParameters.syncStrategy as SyncStrategy;
78+
const resolvedStrategy = IncrementalSyncService.resolveStrategy(strategy, workspace, buildParameters.syncStatePath);
79+
80+
if (resolvedStrategy === 'full') {
81+
core.info('[Sync] Resolved to full sync (no incremental state available)');
82+
83+
return;
84+
}
85+
86+
switch (resolvedStrategy) {
87+
case 'git-delta': {
88+
const targetReference = buildParameters.gitSha || buildParameters.branch;
89+
const changedFiles = await IncrementalSyncService.syncGitDelta(
90+
workspace,
91+
targetReference,
92+
buildParameters.syncStatePath,
93+
);
94+
core.info(`[Sync] Git delta sync applied: ${changedFiles} file(s) changed`);
95+
break;
96+
}
97+
case 'direct-input': {
98+
if (!buildParameters.syncInputRef) {
99+
throw new Error('[Sync] direct-input strategy requires syncInputRef to be set');
100+
}
101+
const overlays = await IncrementalSyncService.applyDirectInput(
102+
workspace,
103+
buildParameters.syncInputRef,
104+
buildParameters.syncStorageRemote || undefined,
105+
buildParameters.syncStatePath,
106+
);
107+
core.info(`[Sync] Direct input applied: ${overlays.length} overlay(s)`);
108+
break;
109+
}
110+
case 'storage-pull': {
111+
if (!buildParameters.syncInputRef) {
112+
throw new Error('[Sync] storage-pull strategy requires syncInputRef to be set');
113+
}
114+
const pulledFiles = await IncrementalSyncService.syncStoragePull(workspace, buildParameters.syncInputRef, {
115+
rcloneRemote: buildParameters.syncStorageRemote || undefined,
116+
syncRevertAfter: buildParameters.syncRevertAfter,
117+
statePath: buildParameters.syncStatePath,
118+
});
119+
core.info(`[Sync] Storage pull complete: ${pulledFiles.length} file(s)`);
120+
break;
121+
}
122+
default:
123+
core.warning(`[Sync] Unknown sync strategy: ${resolvedStrategy}`);
124+
}
125+
}
126+
53127
runMain();

src/model/build-parameters.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ class BuildParameters {
106106
public cacheUnityInstallationOnMac!: boolean;
107107
public unityHubVersionOnMac!: string;
108108
public dockerWorkspacePath!: string;
109+
public syncStrategy!: string;
110+
public syncInputRef!: string;
111+
public syncStorageRemote!: string;
112+
public syncRevertAfter!: boolean;
113+
public syncStatePath!: string;
109114

110115
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
111116
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -242,6 +247,11 @@ class BuildParameters {
242247
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
243248
unityHubVersionOnMac: Input.unityHubVersionOnMac,
244249
dockerWorkspacePath: Input.dockerWorkspacePath,
250+
syncStrategy: Input.syncStrategy,
251+
syncInputRef: Input.syncInputRef,
252+
syncStorageRemote: Input.syncStorageRemote,
253+
syncRevertAfter: Input.syncRevertAfter,
254+
syncStatePath: Input.syncStatePath,
245255
};
246256
}
247257

src/model/input.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,28 @@ class Input {
241241
return Input.getInput('dockerWorkspacePath') ?? '/github/workspace';
242242
}
243243

244+
static get syncStrategy(): string {
245+
return Input.getInput('syncStrategy') ?? 'full';
246+
}
247+
248+
static get syncInputRef(): string {
249+
return Input.getInput('syncInputRef') ?? '';
250+
}
251+
252+
static get syncStorageRemote(): string {
253+
return Input.getInput('syncStorageRemote') ?? '';
254+
}
255+
256+
static get syncRevertAfter(): boolean {
257+
const input = Input.getInput('syncRevertAfter') ?? 'true';
258+
259+
return input === 'true';
260+
}
261+
262+
static get syncStatePath(): string {
263+
return Input.getInput('syncStatePath') ?? '.game-ci/sync-state.json';
264+
}
265+
244266
static get dockerCpuLimit(): string {
245267
return Input.getInput('dockerCpuLimit') ?? os.cpus().length.toString();
246268
}

src/model/orchestrator/remote-client/index.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,24 @@ import BuildParameters from '../../build-parameters';
1515
import { Cli } from '../../cli/cli';
1616
import OrchestratorOptions from '../options/orchestrator-options';
1717
import ResourceTracking from '../services/core/resource-tracking';
18+
import { IncrementalSyncService } from '../services/sync';
19+
import { SyncStrategy } from '../services/sync/sync-state';
1820

1921
export class RemoteClient {
2022
@CliFunction(`remote-cli-pre-build`, `sets up a repository, usually before a game-ci build`)
2123
static async setupRemoteClient() {
2224
OrchestratorLogger.log(`bootstrap game ci orchestrator...`);
2325
await ResourceTracking.logDiskUsageSnapshot('remote-cli-pre-build (start)');
24-
if (!(await RemoteClient.handleRetainedWorkspace())) {
26+
27+
const syncStrategy = (Orchestrator.buildParameters.syncStrategy || 'full') as SyncStrategy;
28+
29+
if (syncStrategy !== 'full') {
30+
OrchestratorLogger.log(`[Sync] Using incremental sync strategy: ${syncStrategy}`);
31+
await RemoteClient.handleIncrementalSync(syncStrategy);
32+
} else if (!(await RemoteClient.handleRetainedWorkspace())) {
2533
await RemoteClient.bootstrapRepository();
2634
}
35+
2736
await RemoteClient.replaceLargePackageReferencesWithSharedReferences();
2837
await RemoteClient.runCustomHookFiles(`before-build`);
2938
}
@@ -157,6 +166,20 @@ export class RemoteClient {
157166

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

169+
// Revert sync overlays if configured
170+
const syncStrategy = (Orchestrator.buildParameters.syncStrategy || 'full') as SyncStrategy;
171+
if (Orchestrator.buildParameters.syncRevertAfter && syncStrategy !== 'full') {
172+
try {
173+
OrchestratorLogger.log('[Sync] Reverting overlay changes after job completion');
174+
await IncrementalSyncService.revertOverlays(
175+
OrchestratorFolders.repoPathAbsolute,
176+
Orchestrator.buildParameters.syncStatePath,
177+
);
178+
} catch (revertError: any) {
179+
RemoteClientLogger.logWarning(`[Sync] Overlay revert failed: ${revertError.message}`);
180+
}
181+
}
182+
160183
// WIP - need to give the pod permissions to create config map
161184
await RemoteClientLogger.handleLogManagementPostJob();
162185
} catch (error: any) {
@@ -229,6 +252,78 @@ export class RemoteClient {
229252
RemoteClientLogger.log(JSON.stringify(error, undefined, 4));
230253
}
231254
}
255+
256+
/**
257+
* Handle incremental sync strategies (git-delta, direct-input, storage-pull).
258+
*
259+
* For git-delta: requires an existing workspace with sync state; fetches and applies
260+
* only changed files.
261+
*
262+
* For direct-input and storage-pull: requires an existing workspace; applies overlay
263+
* content on top.
264+
*
265+
* Falls back to full bootstrapRepository() if incremental sync cannot proceed.
266+
*/
267+
private static async handleIncrementalSync(strategy: SyncStrategy): Promise<void> {
268+
const buildParameters = Orchestrator.buildParameters;
269+
const workspacePath = OrchestratorFolders.repoPathAbsolute;
270+
const statePath = buildParameters.syncStatePath;
271+
272+
// Resolve strategy — may fall back to 'full' if no state exists
273+
const resolvedStrategy = IncrementalSyncService.resolveStrategy(strategy, workspacePath, statePath);
274+
275+
if (resolvedStrategy === 'full') {
276+
OrchestratorLogger.log('[Sync] Falling back to full bootstrap');
277+
if (!(await RemoteClient.handleRetainedWorkspace())) {
278+
await RemoteClient.bootstrapRepository();
279+
}
280+
281+
return;
282+
}
283+
284+
switch (resolvedStrategy) {
285+
case 'git-delta': {
286+
const targetReference = buildParameters.gitSha || buildParameters.branch;
287+
OrchestratorLogger.log(`[Sync] Git delta sync to ${targetReference}`);
288+
const changedFiles = await IncrementalSyncService.syncGitDelta(workspacePath, targetReference, statePath);
289+
OrchestratorLogger.log(`[Sync] Git delta complete: ${changedFiles} file(s) updated`);
290+
break;
291+
}
292+
case 'direct-input': {
293+
const inputReference = buildParameters.syncInputRef;
294+
if (!inputReference) {
295+
throw new Error('[Sync] direct-input strategy requires syncInputRef');
296+
}
297+
OrchestratorLogger.log(`[Sync] Applying direct input: ${inputReference}`);
298+
await IncrementalSyncService.applyDirectInput(
299+
workspacePath,
300+
inputReference,
301+
buildParameters.syncStorageRemote || undefined,
302+
statePath,
303+
);
304+
break;
305+
}
306+
case 'storage-pull': {
307+
const storageUri = buildParameters.syncInputRef;
308+
if (!storageUri) {
309+
throw new Error('[Sync] storage-pull strategy requires syncInputRef');
310+
}
311+
OrchestratorLogger.log(`[Sync] Storage pull from: ${storageUri}`);
312+
await IncrementalSyncService.syncStoragePull(workspacePath, storageUri, {
313+
rcloneRemote: buildParameters.syncStorageRemote || undefined,
314+
syncRevertAfter: buildParameters.syncRevertAfter,
315+
statePath,
316+
});
317+
break;
318+
}
319+
default:
320+
OrchestratorLogger.logWarning(`[Sync] Unknown strategy: ${resolvedStrategy}, falling back to full`);
321+
if (!(await RemoteClient.handleRetainedWorkspace())) {
322+
await RemoteClient.bootstrapRepository();
323+
}
324+
}
325+
}
326+
232327
public static async bootstrapRepository() {
233328
await OrchestratorSystem.Run(
234329
`mkdir -p ${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute)}`,

0 commit comments

Comments
 (0)