Compare commits

...

7 Commits

Author SHA1 Message Date
frostebite
cf00694232 revert: restore build-tests-mac.yml to match main
Stop modifying the macOS build workflow — leave it identical to main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:54:08 +00:00
frostebite
b7f6ea9194 ci: use step-level continue-on-error for macOS builds
GitHub deprecated external check run conclusion updates (Feb 2025),
so the Checks API approach to mark builds as neutral no longer works.

Instead, move continue-on-error from the job level to the build step.
The job always succeeds (green check), failed builds emit a warning
annotation, and upload is skipped on failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:52:49 +00:00
frostebite
8ccbfad587 ci: mark failed macOS builds as neutral instead of failure
Use the Checks API to flip failed macOS build conclusions to neutral
(gray dash) so unstable builds don't show red X marks on PRs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:17:15 +00:00
frostebite
483bab04c7 feat(orchestrator): middleware pipeline — composable trigger-aware hooks
Add a middleware system that wraps around build pipeline phases with
before/after semantics, built on the existing command hook and container
hook fundamentals. Middleware supports rich trigger conditions (phase,
provider, platform, environment expressions), priority-based ordering
with wrapping semantics, and both inline YAML and file-based definitions
from game-ci/middleware/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:57:20 +00:00
frostebite
488c259481 test: add integration wiring and input parsing tests for enterprise features
Add three test files covering the two highest-priority gaps in PR #777:

1. src/index-enterprise-features.test.ts (21 tests) - Integration wiring
   tests for index.ts that verify conditional gating of all enterprise
   services (GitHooks, LocalCache, ChildWorkspace, SubmoduleProfile,
   LfsAgent). Tests that disabled features (default) are never invoked,
   enabled features call the correct service methods, and the order of
   operations is correct (restore before build, save after build).
   Also tests non-local provider strategy skips all enterprise features.

2. src/model/enterprise-inputs.test.ts (103 tests) - Input/BuildParameters
   wiring tests for all 20 new enterprise properties. Covers defaults,
   explicit values, and boolean string parsing edge cases (the #1 source
   of bugs: 'false' as truthy, 'TRUE' case sensitivity, '1', 'yes').
   Verifies BuildParameters.create() correctly maps all Input getters.

3. src/model/orchestrator/services/submodule/submodule-profile-service.test.ts
   (5 new tests) - Command construction safety tests for execute(),
   documenting how paths, branches, and tokens are passed into git
   commands and verifying the expected command strings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:29:10 +00:00
frostebite
7b8f109334 fix: make git hooks opt-in only — do not modify hooks when disabled
Remove the else branch that actively called GitHooksService.disableHooks()
for every user where gitHooksEnabled was false (the default). This was a
breaking change that silently modified core.hooksPath to point at an empty
directory, disabling any existing git hooks (husky, lefthook, pre-commit, etc.).

When gitHooksEnabled is false (default), the action now does nothing
regarding hooks — exactly matching the behavior on main before the hooks
feature was added. The hooks feature only activates when users explicitly
set gitHooksEnabled: true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:53:15 +00:00
frostebite
f2e7568d47 ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:34:12 +00:00
14 changed files with 2578 additions and 14 deletions

View File

@@ -387,6 +387,21 @@ inputs:
description:
'Path to an external CLI executable that implements the provider protocol. Enables providers written in any
language (Go, Python, Rust, shell). Uses JSON-over-stdin/stdout communication.'
middleware:
required: false
default: ''
description:
'[Orchestrator] Inline YAML middleware pipeline definitions. Middleware wraps around build phases with
before/after semantics, supports trigger conditions (phase, provider, platform, environment expressions), and
resolves to either command hooks (inline in build container) or container hooks (separate Docker containers).
Middleware executes in priority order — lower priority values run first for before phases and last for after
phases (wrapping pattern). See documentation for full YAML format.'
middlewareFiles:
required: false
default: ''
description:
'[Orchestrator] Comma-separated list of middleware file names (without extension) to load from the
game-ci/middleware/ directory in your repository. Each file should contain YAML middleware definitions.'
outputs:
volume:

457
dist/index.js generated vendored
View File

@@ -101,7 +101,7 @@ async function runMain() {
await LocalCacheService.restoreLibraryCache(projectFullPath, cacheRoot, cacheKey);
}
}
// Git hooks
// Git hooks — opt-in only. When disabled (default), do not touch hooks at all.
if (buildParameters.gitHooksEnabled) {
const { GitHooksService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(9146)));
await GitHooksService.installHooks(workspace);
@@ -110,10 +110,6 @@ async function runMain() {
Object.assign(process.env, environment);
}
}
else {
const { GitHooksService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(9146)));
await GitHooksService.disableHooks(workspace);
}
await platform_setup_1.default.setup(buildParameters, actionFolder);
exitCode =
process.platform === 'darwin'
@@ -476,6 +472,8 @@ class BuildParameters {
gitHooksSkipList: input_1.default.gitHooksSkipList,
gitHooksRunBeforeBuild: input_1.default.gitHooksRunBeforeBuild,
providerExecutable: input_1.default.providerExecutable,
middleware: orchestrator_options_1.default.middleware,
middlewareFiles: orchestrator_options_1.default.middlewareFiles,
};
}
static parseBuildFile(filename, platform, androidExportType) {
@@ -2015,6 +2013,12 @@ class Input {
static get providerExecutable() {
return Input.getInput('providerExecutable') ?? '';
}
static get middleware() {
return Input.getInput('middleware') ?? '';
}
static get middlewareFiles() {
return Input.getInput('middlewareFiles') ?? '';
}
static ToEnvVarFormat(input) {
if (input.toUpperCase() === input) {
return input;
@@ -2414,6 +2418,12 @@ class OrchestratorOptions {
static get commandHookFiles() {
return OrchestratorOptions.getInput('commandHookFiles')?.split(`,`) || [];
}
static get middleware() {
return OrchestratorOptions.getInput('middleware') || '';
}
static get middlewareFiles() {
return OrchestratorOptions.getInput('middlewareFiles')?.split(',') || [];
}
// ### ### ###
// Custom commands from yaml parameters
// ### ### ###
@@ -10426,6 +10436,24 @@ echo "---${buildParameters.logId}"`;
exports.CommandHookService = CommandHookService;
/***/ }),
/***/ 59619:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.CommandHook = void 0;
class CommandHook {
constructor() {
this.commands = new Array();
this.secrets = new Array();
}
}
exports.CommandHook = CommandHook;
/***/ }),
/***/ 80824:
@@ -10796,6 +10824,25 @@ class ContainerHookService {
exports.ContainerHookService = ContainerHookService;
/***/ }),
/***/ 54667:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.ContainerHook = void 0;
class ContainerHook {
constructor() {
this.secrets = new Array();
this.image = `ubuntu`;
this.allowFailure = false; // If true, hook failures won't stop the build
}
}
exports.ContainerHook = ContainerHook;
/***/ }),
/***/ 9146:
@@ -11013,6 +11060,360 @@ exports.GitHooksService = GitHooksService;
GitHooksService.UNITY_GIT_HOOKS_PACKAGE = 'com.frostebite.unitygithooks';
/***/ }),
/***/ 78089:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.MiddlewareService = void 0;
const yaml_1 = __importDefault(__nccwpck_require__(44083));
const node_fs_1 = __importDefault(__nccwpck_require__(87561));
const node_path_1 = __importDefault(__nccwpck_require__(49411));
const middleware_1 = __nccwpck_require__(86786);
const container_hook_1 = __nccwpck_require__(54667);
const command_hook_1 = __nccwpck_require__(59619);
const orchestrator_options_1 = __importDefault(__nccwpck_require__(82473));
const orchestrator_1 = __importDefault(__nccwpck_require__(8330));
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
const input_1 = __importDefault(__nccwpck_require__(91933));
/**
* Service for loading, evaluating, and resolving middleware into hooks.
*
* Middleware is a higher-level composable abstraction over the existing
* command hook and container hook systems. Each middleware:
* - Wraps around pipeline phases with before/after semantics
* - Has trigger conditions (phase, provider, platform, expression)
* - Resolves to either CommandHooks or ContainerHooks at execution time
* - Executes in priority order (before: ascending, after: descending)
*/
class MiddlewareService {
/**
* Load all active middleware from inline YAML + file-based definitions.
* Returns them sorted by priority (ascending).
*/
static getMiddleware(inlineYaml) {
const middleware = [];
// Parse inline YAML definitions
if (inlineYaml && inlineYaml !== '') {
middleware.push(...MiddlewareService.parseMiddleware(inlineYaml));
}
// Load file-based definitions from game-ci/middleware/
middleware.push(...MiddlewareService.getMiddlewareFromFiles());
// Sort by priority (lower = earlier)
middleware.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
orchestrator_logger_1.default.log(`Middleware: loaded ${middleware.length} definition(s)`);
return middleware;
}
/**
* Resolve middleware to CommandHooks for a given phase and timing.
* Filters by trigger conditions and converts to hooks.
*
* Before hooks: ascending priority (lowest priority runs first, closest to core phase).
* After hooks: descending priority (highest priority runs first, closest to core phase).
* This produces the wrapping pattern: outermost middleware's before runs first and after runs last.
*/
static resolveCommandHooks(middleware, phase, timing) {
const applicable = middleware
.filter((m) => m.type === 'command')
.filter((m) => MiddlewareService.evaluateTrigger(m.trigger, phase));
// before: ascending priority; after: descending (wrapping order)
if (timing === 'after') {
applicable.reverse();
}
const hooks = applicable
.filter((m) => (timing === 'before' ? m.before : m.after))
.map((m) => {
const mPhase = timing === 'before' ? m.before : m.after;
const hook = new command_hook_1.CommandHook();
hook.name = `middleware:${m.name}:${timing}`;
hook.commands = typeof mPhase.commands === 'string' ? [mPhase.commands] : [mPhase.commands];
hook.hook = [timing];
hook.step = [phase];
hook.secrets = m.secrets || [];
return hook;
});
if (hooks.length > 0) {
orchestrator_logger_1.default.log(`Middleware: resolved ${hooks.length} command hook(s) for ${phase}:${timing}${hooks
.map((h) => h.name)
.join(', ')}`);
}
return hooks;
}
/**
* Resolve middleware to ContainerHooks for a given phase and timing.
* Same ordering logic as resolveCommandHooks.
*/
static resolveContainerHooks(middleware, phase, timing) {
const applicable = middleware
.filter((m) => m.type === 'container')
.filter((m) => MiddlewareService.evaluateTrigger(m.trigger, phase));
// before: ascending priority; after: descending (wrapping order)
if (timing === 'after') {
applicable.reverse();
}
const hooks = applicable
.filter((m) => (timing === 'before' ? m.before : m.after))
.map((m) => {
const mPhase = timing === 'before' ? m.before : m.after;
const hook = new container_hook_1.ContainerHook();
hook.name = `middleware:${m.name}:${timing}`;
hook.commands = typeof mPhase.commands === 'string' ? mPhase.commands : mPhase.commands;
hook.image = mPhase.image || m.image || 'ubuntu';
hook.hook = timing === 'before' ? 'before' : 'after';
hook.secrets = m.secrets || [];
hook.allowFailure = m.allowFailure ?? false;
return hook;
});
if (hooks.length > 0) {
orchestrator_logger_1.default.log(`Middleware: resolved ${hooks.length} container hook(s) for ${phase}:${timing}${hooks
.map((h) => h.name)
.join(', ')}`);
}
return hooks;
}
/**
* Evaluate whether a middleware's trigger conditions are met.
* All specified conditions must pass (AND logic).
*/
static evaluateTrigger(trigger, currentPhase) {
// Phase must match
if (!trigger.phase || !trigger.phase.includes(currentPhase)) {
return false;
}
// Provider filter (if specified)
if (trigger.provider && trigger.provider.length > 0) {
const currentProvider = orchestrator_1.default.buildParameters?.providerStrategy || orchestrator_options_1.default.providerStrategy;
if (!trigger.provider.includes(currentProvider)) {
return false;
}
}
// Platform filter (if specified)
if (trigger.platform && trigger.platform.length > 0) {
const currentPlatform = orchestrator_1.default.buildParameters?.targetPlatform || '';
if (!trigger.platform.includes(currentPlatform)) {
return false;
}
}
// Expression-based condition
if (trigger.when) {
if (!MiddlewareService.evaluateExpression(trigger.when)) {
return false;
}
}
return true;
}
/**
* Evaluate a simple expression string against environment variables.
*
* Supported formats:
* - env.VAR_NAME == 'value' equality check
* - env.VAR_NAME != 'value' inequality check
* - env.VAR_NAME truthy check (defined, non-empty, not 'false')
* - !env.VAR_NAME falsy check
*/
static evaluateExpression(expression) {
const trimmed = expression.trim();
// Match: env.VAR == 'value' or env.VAR != 'value'
const comparisonMatch = trimmed.match(/^env\.(\w+)\s*(==|!=)\s*['"](.*)['"]$/);
if (comparisonMatch) {
const [, varName, operator, value] = comparisonMatch;
const envValue = process.env[varName] || '';
return operator === '==' ? envValue === value : envValue !== value;
}
// Match: !env.VAR (falsy check)
const falsyMatch = trimmed.match(/^!env\.(\w+)$/);
if (falsyMatch) {
const [, varName] = falsyMatch;
const envValue = process.env[varName];
return envValue === undefined || envValue === '' || envValue === 'false';
}
// Match: env.VAR (truthy check)
const truthyMatch = trimmed.match(/^env\.(\w+)$/);
if (truthyMatch) {
const [, varName] = truthyMatch;
const envValue = process.env[varName];
return envValue !== undefined && envValue !== '' && envValue !== 'false';
}
// Unknown expression format — log warning, default to true
orchestrator_logger_1.default.logWarning(`Middleware: unknown expression format "${expression}", defaulting to true`);
return true;
}
/**
* Parse middleware definitions from a YAML string.
* Accepts both single-object and array format.
*/
static parseMiddleware(yamlString) {
if (!yamlString || yamlString.trim() === '') {
return [];
}
try {
const isArray = yamlString.replace(/\s/g, '')[0] === '-';
const parsed = isArray ? yaml_1.default.parse(yamlString) : [yaml_1.default.parse(yamlString)];
if (!parsed || !Array.isArray(parsed)) {
return [];
}
return parsed.map((m) => MiddlewareService.hydrateMiddleware(m));
}
catch (error) {
orchestrator_logger_1.default.logWarning(`Middleware: failed to parse YAML — ${error.message}`);
return [];
}
}
/**
* Hydrate a raw parsed YAML object into a Middleware instance.
*/
static hydrateMiddleware(m) {
const middleware = new middleware_1.Middleware();
middleware.name = m.name || 'unnamed';
middleware.description = m.description;
middleware.type = m.type || 'command';
middleware.priority = m.priority ?? 100;
middleware.image = m.image || 'ubuntu';
middleware.allowFailure = m.allowFailure ?? false;
middleware.outputs = m.outputs;
// Parse trigger — normalize scalar values to arrays
middleware.trigger = {
phase: MiddlewareService.toStringArray(m.trigger?.phase),
provider: m.trigger?.provider ? MiddlewareService.toStringArray(m.trigger.provider) : undefined,
platform: m.trigger?.platform ? MiddlewareService.toStringArray(m.trigger.platform) : undefined,
when: m.trigger?.when,
};
// Parse before/after phases — accept string shorthand or object format
if (m.before) {
middleware.before = {
commands: typeof m.before === 'string' ? m.before : m.before.commands || '',
image: typeof m.before === 'string' ? undefined : m.before.image,
};
}
if (m.after) {
middleware.after = {
commands: typeof m.after === 'string' ? m.after : m.after.commands || '',
image: typeof m.after === 'string' ? undefined : m.after.image,
};
}
// Parse secrets
if (m.secrets && Array.isArray(m.secrets)) {
middleware.secrets = m.secrets.map((s) => ({
ParameterKey: s.name,
EnvironmentVariable: input_1.default.ToEnvVarFormat(s.name),
ParameterValue: s.value ?? process.env[s.name] ?? process.env[input_1.default.ToEnvVarFormat(s.name)] ?? '',
}));
}
return middleware;
}
/**
* Load middleware definitions from game-ci/middleware/ directory files.
* Only files whose base name appears in the middlewareFiles allowlist are loaded.
*/
static getMiddlewareFromFiles() {
const results = [];
const allowedFiles = orchestrator_options_1.default.middlewareFiles;
if (!allowedFiles || allowedFiles.length === 0) {
return results;
}
try {
const middlewarePath = node_path_1.default.join(process.cwd(), 'game-ci', 'middleware');
if (!node_fs_1.default.existsSync(middlewarePath)) {
return results;
}
const files = node_fs_1.default.readdirSync(middlewarePath);
for (const file of files) {
const baseName = file.replace(/\.ya?ml$/, '');
if (!allowedFiles.includes(baseName)) {
continue;
}
try {
const contents = node_fs_1.default.readFileSync(node_path_1.default.join(middlewarePath, file), 'utf8');
results.push(...MiddlewareService.parseMiddleware(contents));
}
catch (error) {
orchestrator_logger_1.default.logWarning(`Middleware: failed to parse file ${file}${error.message}`);
}
}
}
catch {
// Directory doesn't exist or can't be read — not an error
}
return results;
}
/**
* Normalize a value to a string array. Accepts string, string[], or undefined.
*/
static toStringArray(value) {
if (!value)
return [];
if (Array.isArray(value))
return value;
return [value];
}
}
exports.MiddlewareService = MiddlewareService;
/***/ }),
/***/ 86786:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.Middleware = void 0;
/**
* Middleware a composable, trigger-aware pipeline unit built on hooks.
*
* Middleware wraps around pipeline phases with before/after semantics.
* Each middleware resolves to either CommandHooks (inline in build container)
* or ContainerHooks (separate Docker containers) at execution time.
*
* Execution order: before phases run in ascending priority order,
* after phases run in descending priority order (wrapping pattern).
*
* Example YAML:
* ```yaml
* name: code-signing
* description: Signs build artifacts after successful build
* type: container
* priority: 50
* image: ubuntu:22.04
* trigger:
* phase: [build]
* provider: [aws, k8s]
* platform: [StandaloneWindows64]
* when: "env.SIGN_BUILDS == 'true'"
* before:
* commands: |
* echo "Preparing signing environment..."
* after:
* commands: |
* echo "Signing build artifacts..."
* secrets:
* - name: SIGNING_KEY
* - name: SIGNING_CERT
* allowFailure: false
* ```
*/
class Middleware {
constructor() {
/** Execution priority. Lower values run first for before, last for after (wrapping order). Default: 100 */
this.priority = 100;
/** Default Docker image for container type middleware */
this.image = 'ubuntu';
/** Secrets injected as environment variables */
this.secrets = [];
/** If true, failures warn but don't stop the build. Default: false */
this.allowFailure = false;
}
}
exports.Middleware = Middleware;
/***/ }),
/***/ 85985:
@@ -11428,29 +11829,67 @@ const command_hook_service_1 = __nccwpck_require__(66604);
const node_path_1 = __importDefault(__nccwpck_require__(49411));
const orchestrator_1 = __importDefault(__nccwpck_require__(8330));
const container_hook_service_1 = __nccwpck_require__(80824);
const middleware_service_1 = __nccwpck_require__(78089);
const custom_workflow_1 = __nccwpck_require__(19118);
class BuildAutomationWorkflow {
async run(orchestratorStepState) {
return await BuildAutomationWorkflow.standardBuildAutomation(orchestratorStepState.image, orchestratorStepState);
}
static async standardBuildAutomation(baseImage, orchestratorStepState) {
// TODO accept post and pre build steps as yaml files in the repo
orchestrator_logger_1.default.log(`Orchestrator is running standard build automation`);
let output = '';
// Load middleware pipeline
const middleware = middleware_service_1.MiddlewareService.getMiddleware(orchestrator_1.default.buildParameters.middleware);
// --- Pre-build phase ---
// Middleware container hooks: pre-build before
const preBuildMiddlewareBefore = middleware_service_1.MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
if (preBuildMiddlewareBefore.length > 0) {
output += await custom_workflow_1.CustomWorkflow.runContainerJob(preBuildMiddlewareBefore, orchestratorStepState.environment, orchestratorStepState.secrets);
orchestrator_logger_1.default.logWithTime('Middleware pre-build:before container step(s) time');
}
// Standard pre-build container hooks
output += await container_hook_service_1.ContainerHookService.RunPreBuildSteps(orchestratorStepState);
orchestrator_logger_1.default.logWithTime('Configurable pre build step(s) time');
// Middleware container hooks: pre-build after
const preBuildMiddlewareAfter = middleware_service_1.MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'after');
if (preBuildMiddlewareAfter.length > 0) {
output += await custom_workflow_1.CustomWorkflow.runContainerJob(preBuildMiddlewareAfter, orchestratorStepState.environment, orchestratorStepState.secrets);
orchestrator_logger_1.default.logWithTime('Middleware pre-build:after container step(s) time');
}
// --- Build phase ---
orchestrator_logger_1.default.log(baseImage);
orchestrator_logger_1.default.logLine(` `);
orchestrator_logger_1.default.logLine('Starting build automation job');
output += await orchestrator_1.default.Provider.runTaskInWorkflow(orchestrator_1.default.buildParameters.buildGuid, baseImage.toString(), BuildAutomationWorkflow.BuildWorkflow, `/${orchestrator_folders_1.OrchestratorFolders.buildVolumeFolder}`, `/${orchestrator_folders_1.OrchestratorFolders.buildVolumeFolder}/`, orchestratorStepState.environment, orchestratorStepState.secrets);
orchestrator_logger_1.default.logWithTime('Build time');
// --- Post-build phase ---
// Middleware container hooks: post-build before
const postBuildMiddlewareBefore = middleware_service_1.MiddlewareService.resolveContainerHooks(middleware, 'post-build', 'before');
if (postBuildMiddlewareBefore.length > 0) {
output += await custom_workflow_1.CustomWorkflow.runContainerJob(postBuildMiddlewareBefore, orchestratorStepState.environment, orchestratorStepState.secrets);
orchestrator_logger_1.default.logWithTime('Middleware post-build:before container step(s) time');
}
// Standard post-build container hooks
output += await container_hook_service_1.ContainerHookService.RunPostBuildSteps(orchestratorStepState);
orchestrator_logger_1.default.logWithTime('Configurable post build step(s) time');
// Middleware container hooks: post-build after
const postBuildMiddlewareAfter = middleware_service_1.MiddlewareService.resolveContainerHooks(middleware, 'post-build', 'after');
if (postBuildMiddlewareAfter.length > 0) {
output += await custom_workflow_1.CustomWorkflow.runContainerJob(postBuildMiddlewareAfter, orchestratorStepState.environment, orchestratorStepState.secrets);
orchestrator_logger_1.default.logWithTime('Middleware post-build:after container step(s) time');
}
orchestrator_logger_1.default.log(`Orchestrator finished running standard build automation`);
return output;
}
static get BuildWorkflow() {
const setupHooks = command_hook_service_1.CommandHookService.getHooks(orchestrator_1.default.buildParameters.commandHooks).filter((x) => x.step?.includes(`setup`));
const buildHooks = command_hook_service_1.CommandHookService.getHooks(orchestrator_1.default.buildParameters.commandHooks).filter((x) => x.step?.includes(`build`));
// Resolve middleware command hooks for setup and build phases
const middleware = middleware_service_1.MiddlewareService.getMiddleware(orchestrator_1.default.buildParameters.middleware);
const middlewareSetupBefore = middleware_service_1.MiddlewareService.resolveCommandHooks(middleware, 'setup', 'before');
const middlewareSetupAfter = middleware_service_1.MiddlewareService.resolveCommandHooks(middleware, 'setup', 'after');
const middlewareBuildBefore = middleware_service_1.MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
const middlewareBuildAfter = middleware_service_1.MiddlewareService.resolveCommandHooks(middleware, 'build', 'after');
const isContainerized = orchestrator_1.default.buildParameters.providerStrategy === 'aws' ||
orchestrator_1.default.buildParameters.providerStrategy === 'k8s' ||
orchestrator_1.default.buildParameters.providerStrategy === 'local-docker';
@@ -11465,6 +11904,7 @@ class BuildAutomationWorkflow {
${isContainerized && orchestrator_1.default.buildParameters.providerStrategy !== 'local-docker'
? 'apt-get install -y curl tar tree npm git-lfs jq git > /dev/null || true\n npm --version || true\n npm i -g n > /dev/null || true\n npm i -g semver > /dev/null || true\n npm install --global yarn > /dev/null || true\n n 20.8.0 || true\n node --version || true'
: '# skipping toolchain setup in local-docker or non-container provider'}
${middlewareSetupBefore.map((x) => x.commands).join('\n ') || ' '}
${setupHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
${orchestrator_1.default.buildParameters.providerStrategy === 'local-docker'
? `export GITHUB_WORKSPACE="${orchestrator_1.default.buildParameters.dockerWorkspacePath}"
@@ -11474,9 +11914,12 @@ class BuildAutomationWorkflow {
export LOG_FILE=${isContainerized ? '/home/job-log.txt' : '$(pwd)/temp/job-log.txt'}
${BuildAutomationWorkflow.setupCommands(builderPath, isContainerized)}
${setupHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
${middlewareSetupAfter.map((x) => x.commands).join('\n ') || ' '}
${middlewareBuildBefore.map((x) => x.commands).join('\n ') || ' '}
${buildHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
${BuildAutomationWorkflow.BuildCommands(builderPath, isContainerized)}
${buildHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}`;
${buildHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
${middlewareBuildAfter.map((x) => x.commands).join('\n ') || ' '}`;
}
static setupCommands(builderPath, isContainerized) {
// prettier-ignore

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,606 @@
/**
* Integration wiring tests for enterprise features in index.ts
*
* These tests verify the conditional gating logic in runMain():
* - Each enterprise feature is only invoked when its gate condition is met
* - Services are NOT called when their feature is disabled (the default)
* - The order of operations is correct (restore before build, save after build)
*/
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
import { BuildParameters } from './model';
// ---------------------------------------------------------------------------
// Service mocks — must be declared before importing index.ts (jest hoists them)
// ---------------------------------------------------------------------------
const mockChildWorkspaceService = {
buildConfig: jest.fn().mockReturnValue({ enabled: true, workspaceName: 'Test' }),
initializeWorkspace: jest.fn().mockReturnValue(false),
getWorkspaceSize: jest.fn().mockReturnValue('0 B'),
saveWorkspace: jest.fn(),
};
const mockSubmoduleProfileService = {
createInitPlan: jest.fn().mockResolvedValue([]),
execute: jest.fn().mockResolvedValue(''),
};
const mockLfsAgentService = {
configure: jest.fn().mockResolvedValue(''),
};
const mockLocalCacheService = {
resolveCacheRoot: jest.fn().mockReturnValue('/cache'),
generateCacheKey: jest.fn().mockReturnValue('key-1'),
restoreLfsCache: jest.fn().mockResolvedValue(true),
restoreLibraryCache: jest.fn().mockResolvedValue(true),
saveLibraryCache: jest.fn().mockResolvedValue(''),
saveLfsCache: jest.fn().mockResolvedValue(''),
};
const mockGitHooksService = {
installHooks: jest.fn().mockResolvedValue(''),
configureSkipList: jest.fn().mockReturnValue({ LEFTHOOK_EXCLUDE: 'pre-commit' }),
};
// Mock the dynamic import() targets — jest.mock with factory functions.
// The services are imported dynamically via `await import(...)` in index.ts,
// so we mock the module path and return the mock objects as named exports.
jest.mock('./model/orchestrator/services/cache/child-workspace-service', () => ({
ChildWorkspaceService: mockChildWorkspaceService,
}));
jest.mock('./model/orchestrator/services/submodule/submodule-profile-service', () => ({
SubmoduleProfileService: mockSubmoduleProfileService,
}));
jest.mock('./model/orchestrator/services/lfs/lfs-agent-service', () => ({
LfsAgentService: mockLfsAgentService,
}));
jest.mock('./model/orchestrator/services/cache/local-cache-service', () => ({
LocalCacheService: mockLocalCacheService,
}));
jest.mock('./model/orchestrator/services/hooks/git-hooks-service', () => ({
GitHooksService: mockGitHooksService,
}));
// Mock all non-enterprise dependencies to isolate the wiring logic
jest.mock('@actions/core');
jest.mock('./model', () => ({
Action: {
checkCompatibility: jest.fn(),
workspace: '/workspace',
actionFolder: '/action',
},
BuildParameters: {
create: jest.fn(),
},
Cache: {
verify: jest.fn(),
},
Orchestrator: {
run: jest.fn().mockResolvedValue(''),
},
Docker: {
run: jest.fn().mockResolvedValue(0),
},
ImageTag: jest.fn().mockImplementation(() => ({
toString: () => 'mock-image:latest',
})),
Output: {
setBuildVersion: jest.fn().mockResolvedValue(''),
setAndroidVersionCode: jest.fn().mockResolvedValue(''),
setEngineExitCode: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('./model/cli/cli', () => ({
Cli: {
InitCliMode: jest.fn().mockReturnValue(false),
},
}));
jest.mock('./model/mac-builder', () => ({
__esModule: true,
default: {
run: jest.fn().mockResolvedValue(0),
},
}));
jest.mock('./model/platform-setup', () => ({
__esModule: true,
default: {
setup: jest.fn().mockResolvedValue(''),
},
}));
const mockedBuildParametersCreate = BuildParameters.create as jest.Mock;
interface EnterpriseBuildParametersOverrides {
providerStrategy?: string;
childWorkspacesEnabled?: boolean;
childWorkspaceName?: string;
childWorkspaceCacheRoot?: string;
childWorkspacePreserveGit?: boolean;
childWorkspaceSeparateLibrary?: boolean;
submoduleProfilePath?: string;
submoduleVariantPath?: string;
submoduleToken?: string;
gitPrivateToken?: string;
lfsTransferAgent?: string;
lfsTransferAgentArgs?: string;
lfsStoragePaths?: string;
localCacheEnabled?: boolean;
localCacheRoot?: string;
localCacheLibrary?: boolean;
localCacheLfs?: boolean;
gitHooksEnabled?: boolean;
gitHooksSkipList?: string;
gitHooksRunBeforeBuild?: string;
}
function createMockBuildParameters(overrides: EnterpriseBuildParametersOverrides = {}) {
return {
// Required base properties
providerStrategy: 'local',
targetPlatform: 'StandaloneLinux64',
editorVersion: '2021.3.1f1',
buildVersion: '1.0.0',
androidVersionCode: '1',
projectPath: '.',
branch: 'main',
runnerTempPath: '/tmp',
// Enterprise features - all disabled by default
childWorkspacesEnabled: false,
childWorkspaceName: '',
childWorkspaceCacheRoot: '',
childWorkspacePreserveGit: true,
childWorkspaceSeparateLibrary: true,
submoduleProfilePath: '',
submoduleVariantPath: '',
submoduleToken: '',
gitPrivateToken: '',
lfsTransferAgent: '',
lfsTransferAgentArgs: '',
lfsStoragePaths: '',
localCacheEnabled: false,
localCacheRoot: '',
localCacheLibrary: true,
localCacheLfs: false,
gitHooksEnabled: false,
gitHooksSkipList: '',
gitHooksRunBeforeBuild: '',
...overrides,
};
}
/**
* The entry point (runMain) is invoked by importing index.ts.
* Since it calls `runMain()` at module scope, we need to re-import it
* for each test. jest.isolateModules() handles this.
*/
async function runIndex(overrides: EnterpriseBuildParametersOverrides = {}): Promise<void> {
mockedBuildParametersCreate.mockResolvedValue(createMockBuildParameters(overrides));
return new Promise<void>((resolve) => {
jest.isolateModules(() => {
require('./index');
// runMain() is async; give it a tick to complete
// We use setImmediate to ensure all microtasks from the dynamic imports resolve
});
// Allow all promises and microtasks to settle
setTimeout(resolve, 100);
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('index.ts enterprise feature wiring', () => {
const originalPlatform = process.platform;
const originalEnvironment = { ...process.env };
beforeEach(() => {
jest.clearAllMocks();
process.env.GITHUB_WORKSPACE = '/workspace';
// Force linux platform so Docker.run is used (not MacBuilder)
Object.defineProperty(process, 'platform', { value: 'linux' });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
process.env = { ...originalEnvironment };
});
// -----------------------------------------------------------------------
// GitHooksService gating
// -----------------------------------------------------------------------
describe('GitHooksService gating', () => {
it('should NOT call GitHooksService when gitHooksEnabled is false (default)', async () => {
await runIndex({ gitHooksEnabled: false });
expect(mockGitHooksService.installHooks).not.toHaveBeenCalled();
expect(mockGitHooksService.configureSkipList).not.toHaveBeenCalled();
});
it('should call installHooks when gitHooksEnabled is true', async () => {
await runIndex({ gitHooksEnabled: true });
expect(mockGitHooksService.installHooks).toHaveBeenCalledWith('/workspace');
});
it('should call configureSkipList when gitHooksEnabled and gitHooksSkipList is set', async () => {
await runIndex({
gitHooksEnabled: true,
gitHooksSkipList: 'pre-commit,pre-push',
});
expect(mockGitHooksService.configureSkipList).toHaveBeenCalledWith(['pre-commit', 'pre-push']);
});
it('should NOT call configureSkipList when gitHooksSkipList is empty', async () => {
await runIndex({
gitHooksEnabled: true,
gitHooksSkipList: '',
});
expect(mockGitHooksService.installHooks).toHaveBeenCalled();
expect(mockGitHooksService.configureSkipList).not.toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// LocalCacheService gating
// -----------------------------------------------------------------------
describe('LocalCacheService gating', () => {
it('should NOT call LocalCacheService when localCacheEnabled is false (default)', async () => {
await runIndex({ localCacheEnabled: false });
expect(mockLocalCacheService.resolveCacheRoot).not.toHaveBeenCalled();
expect(mockLocalCacheService.generateCacheKey).not.toHaveBeenCalled();
expect(mockLocalCacheService.restoreLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).not.toHaveBeenCalled();
});
it('should call restore and save operations when localCacheEnabled is true', async () => {
await runIndex({
localCacheEnabled: true,
localCacheLibrary: true,
localCacheLfs: true,
});
expect(mockLocalCacheService.resolveCacheRoot).toHaveBeenCalled();
expect(mockLocalCacheService.generateCacheKey).toHaveBeenCalled();
expect(mockLocalCacheService.restoreLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).toHaveBeenCalled();
});
it('should only cache Library when localCacheLibrary is true and localCacheLfs is false', async () => {
await runIndex({
localCacheEnabled: true,
localCacheLibrary: true,
localCacheLfs: false,
});
expect(mockLocalCacheService.restoreLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).not.toHaveBeenCalled();
});
it('should only cache LFS when localCacheLfs is true and localCacheLibrary is false', async () => {
await runIndex({
localCacheEnabled: true,
localCacheLibrary: false,
localCacheLfs: true,
});
expect(mockLocalCacheService.restoreLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// ChildWorkspaceService gating
// -----------------------------------------------------------------------
describe('ChildWorkspaceService gating', () => {
it('should NOT call ChildWorkspaceService when childWorkspacesEnabled is false (default)', async () => {
await runIndex({ childWorkspacesEnabled: false });
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
expect(mockChildWorkspaceService.initializeWorkspace).not.toHaveBeenCalled();
expect(mockChildWorkspaceService.saveWorkspace).not.toHaveBeenCalled();
});
it('should NOT call ChildWorkspaceService when childWorkspacesEnabled is true but childWorkspaceName is empty', async () => {
await runIndex({
childWorkspacesEnabled: true,
childWorkspaceName: '',
});
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
});
it('should call buildConfig, initializeWorkspace, and saveWorkspace when enabled with a name', async () => {
mockChildWorkspaceService.buildConfig.mockReturnValue({ enabled: true, workspaceName: 'TurnOfWar' });
await runIndex({
childWorkspacesEnabled: true,
childWorkspaceName: 'TurnOfWar',
childWorkspaceCacheRoot: '/cache/workspaces',
});
expect(mockChildWorkspaceService.buildConfig).toHaveBeenCalledWith(
expect.objectContaining({
childWorkspacesEnabled: true,
childWorkspaceName: 'TurnOfWar',
}),
);
expect(mockChildWorkspaceService.initializeWorkspace).toHaveBeenCalled();
expect(mockChildWorkspaceService.getWorkspaceSize).toHaveBeenCalled();
expect(mockChildWorkspaceService.saveWorkspace).toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// SubmoduleProfileService gating
// -----------------------------------------------------------------------
describe('SubmoduleProfileService gating', () => {
it('should NOT call SubmoduleProfileService when submoduleProfilePath is empty (default)', async () => {
await runIndex({ submoduleProfilePath: '' });
expect(mockSubmoduleProfileService.createInitPlan).not.toHaveBeenCalled();
expect(mockSubmoduleProfileService.execute).not.toHaveBeenCalled();
});
it('should call createInitPlan and execute when submoduleProfilePath is set', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleVariantPath: '',
submoduleToken: 'my-token',
});
expect(mockSubmoduleProfileService.createInitPlan).toHaveBeenCalledWith('/path/to/profile.yml', '', '/workspace');
expect(mockSubmoduleProfileService.execute).toHaveBeenCalled();
});
it('should pass variant path when provided', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleVariantPath: '/path/to/variant.yml',
});
expect(mockSubmoduleProfileService.createInitPlan).toHaveBeenCalledWith(
'/path/to/profile.yml',
'/path/to/variant.yml',
'/workspace',
);
});
it('should use submoduleToken for auth, falling back to gitPrivateToken', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleToken: '',
gitPrivateToken: 'fallback-token',
});
expect(mockSubmoduleProfileService.execute).toHaveBeenCalledWith(
expect.anything(),
'/workspace',
'fallback-token',
);
});
it('should prefer submoduleToken over gitPrivateToken', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleToken: 'specific-token',
gitPrivateToken: 'fallback-token',
});
expect(mockSubmoduleProfileService.execute).toHaveBeenCalledWith(
expect.anything(),
'/workspace',
'specific-token',
);
});
});
// -----------------------------------------------------------------------
// LfsAgentService gating
// -----------------------------------------------------------------------
describe('LfsAgentService gating', () => {
it('should NOT call LfsAgentService when lfsTransferAgent is empty (default)', async () => {
await runIndex({ lfsTransferAgent: '' });
expect(mockLfsAgentService.configure).not.toHaveBeenCalled();
});
it('should call configure when lfsTransferAgent is set', async () => {
await runIndex({
lfsTransferAgent: '/tools/elastic-git-storage',
lfsTransferAgentArgs: '--verbose',
lfsStoragePaths: '/path/a;/path/b',
});
expect(mockLfsAgentService.configure).toHaveBeenCalledWith(
'/tools/elastic-git-storage',
'--verbose',
['/path/a', '/path/b'],
'/workspace',
);
});
it('should pass empty array when lfsStoragePaths is empty', async () => {
await runIndex({
lfsTransferAgent: '/tools/agent',
lfsStoragePaths: '',
});
expect(mockLfsAgentService.configure).toHaveBeenCalledWith('/tools/agent', '', [], '/workspace');
});
});
// -----------------------------------------------------------------------
// Order of operations (restore before build, save after build)
// -----------------------------------------------------------------------
describe('order of operations', () => {
it('should execute restore operations before build and save operations after build', async () => {
const callOrder: string[] = [];
// Track call order for each relevant operation
mockChildWorkspaceService.buildConfig.mockReturnValue({ enabled: true, workspaceName: 'Test' });
mockChildWorkspaceService.initializeWorkspace.mockImplementation(() => {
callOrder.push('child-workspace-restore');
return false;
});
mockChildWorkspaceService.getWorkspaceSize.mockImplementation(() => {
callOrder.push('child-workspace-size');
return '0 B';
});
mockSubmoduleProfileService.createInitPlan.mockImplementation(async () => {
callOrder.push('submodule-profile-plan');
return [];
});
mockSubmoduleProfileService.execute.mockImplementation(async () => {
callOrder.push('submodule-profile-execute');
});
mockLfsAgentService.configure.mockImplementation(async () => {
callOrder.push('lfs-agent-configure');
});
mockLocalCacheService.resolveCacheRoot.mockImplementation(() => {
callOrder.push('local-cache-resolve');
return '/cache';
});
mockLocalCacheService.generateCacheKey.mockImplementation(() => {
callOrder.push('local-cache-keygen');
return 'key-1';
});
mockLocalCacheService.restoreLfsCache.mockImplementation(async () => {
callOrder.push('local-cache-restore-lfs');
return true;
});
mockLocalCacheService.restoreLibraryCache.mockImplementation(async () => {
callOrder.push('local-cache-restore-library');
return true;
});
mockGitHooksService.installHooks.mockImplementation(async () => {
callOrder.push('git-hooks-install');
});
mockLocalCacheService.saveLibraryCache.mockImplementation(async () => {
callOrder.push('local-cache-save-library');
});
mockLocalCacheService.saveLfsCache.mockImplementation(async () => {
callOrder.push('local-cache-save-lfs');
});
mockChildWorkspaceService.saveWorkspace.mockImplementation(() => {
callOrder.push('child-workspace-save');
});
await runIndex({
childWorkspacesEnabled: true,
childWorkspaceName: 'TurnOfWar',
submoduleProfilePath: '/profile.yml',
lfsTransferAgent: '/tools/agent',
localCacheEnabled: true,
localCacheLfs: true,
localCacheLibrary: true,
gitHooksEnabled: true,
});
// Verify restore operations happen before save operations.
// The expected order from index.ts is:
// 1. Child workspace restore
// 2. Submodule profile init
// 3. LFS agent configure
// 4. Local cache restore (LFS then Library)
// 5. Git hooks install
// 6. [BUILD happens here - Docker.run or MacBuilder.run]
// 7. Local cache save (Library then LFS)
// 8. Child workspace save
const restoreOps = [
'child-workspace-restore',
'submodule-profile-plan',
'submodule-profile-execute',
'lfs-agent-configure',
'local-cache-restore-lfs',
'local-cache-restore-library',
'git-hooks-install',
];
const saveOps = ['local-cache-save-library', 'local-cache-save-lfs', 'child-workspace-save'];
// All restore ops should appear before all save ops
for (const restoreOp of restoreOps) {
if (!callOrder.includes(restoreOp)) continue; // Skip if the operation wasn't called
for (const saveOp of saveOps) {
if (!callOrder.includes(saveOp)) continue;
expect(callOrder.indexOf(restoreOp)).toBeLessThan(callOrder.indexOf(saveOp));
}
}
// Child workspace save should be last
if (callOrder.includes('child-workspace-save') && callOrder.includes('local-cache-save-lfs')) {
expect(callOrder.indexOf('local-cache-save-lfs')).toBeLessThan(callOrder.indexOf('child-workspace-save'));
}
});
});
// -----------------------------------------------------------------------
// Non-local provider strategy
// -----------------------------------------------------------------------
describe('non-local provider strategy', () => {
it('should skip all enterprise features when providerStrategy is not local', async () => {
await runIndex({
providerStrategy: 'aws',
childWorkspacesEnabled: true,
childWorkspaceName: 'Test',
submoduleProfilePath: '/profile.yml',
lfsTransferAgent: '/tools/agent',
localCacheEnabled: true,
gitHooksEnabled: true,
});
// None of the enterprise services should be called because
// they are inside the `if (providerStrategy === 'local')` block
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
expect(mockSubmoduleProfileService.createInitPlan).not.toHaveBeenCalled();
expect(mockLfsAgentService.configure).not.toHaveBeenCalled();
expect(mockLocalCacheService.resolveCacheRoot).not.toHaveBeenCalled();
expect(mockGitHooksService.installHooks).not.toHaveBeenCalled();
});
});
});

View File

@@ -102,7 +102,7 @@ async function runMain() {
}
}
// Git hooks
// Git hooks — opt-in only. When disabled (default), do not touch hooks at all.
if (buildParameters.gitHooksEnabled) {
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
await GitHooksService.installHooks(workspace);
@@ -110,9 +110,6 @@ async function runMain() {
const environment = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
Object.assign(process.env, environment);
}
} else {
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
await GitHooksService.disableHooks(workspace);
}
await PlatformSetup.setup(buildParameters, actionFolder);

View File

@@ -125,6 +125,8 @@ class BuildParameters {
public gitHooksSkipList!: string;
public gitHooksRunBeforeBuild!: string;
public providerExecutable!: string;
public middleware!: string;
public middlewareFiles!: string[];
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -280,6 +282,8 @@ class BuildParameters {
gitHooksSkipList: Input.gitHooksSkipList,
gitHooksRunBeforeBuild: Input.gitHooksRunBeforeBuild,
providerExecutable: Input.providerExecutable,
middleware: OrchestratorOptions.middleware,
middlewareFiles: OrchestratorOptions.middlewareFiles,
};
}

View File

@@ -0,0 +1,509 @@
/**
* Tests for enterprise input properties and their wiring into BuildParameters.
*
* Covers all 20 new input properties added for enterprise features:
* - Boolean inputs: localCacheEnabled, childWorkspacesEnabled, gitHooksEnabled,
* localCacheLibrary, localCacheLfs, childWorkspacePreserveGit, childWorkspaceSeparateLibrary
* - String inputs: submoduleProfilePath, submoduleVariantPath, submoduleToken,
* localCacheRoot, childWorkspaceName, childWorkspaceCacheRoot, lfsTransferAgent,
* lfsTransferAgentArgs, lfsStoragePaths, providerExecutable, gitHooksSkipList,
* gitHooksRunBeforeBuild
*
* Special attention to boolean inputs: GitHub Actions always passes inputs as strings,
* so 'false' must NOT evaluate as truthy (the #1 source of bugs).
*/
import * as core from '@actions/core';
import Input from './input';
import Versioning from './versioning';
import BuildParameters from './build-parameters';
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
afterEach(() => {
jest.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Part 1: Input getters — defaults and explicit values
// ---------------------------------------------------------------------------
describe('Enterprise Input properties', () => {
// -----------------------------------------------------------------------
// Boolean inputs — default and string parsing
// -----------------------------------------------------------------------
describe('localCacheEnabled', () => {
it('returns false by default', () => {
expect(Input.localCacheEnabled).toBe(false);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.localCacheEnabled).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.localCacheEnabled).toBe(false);
});
it('returns false when empty string is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('');
expect(Input.localCacheEnabled).toBe(false);
});
});
describe('localCacheLibrary', () => {
it('returns true by default (library caching on by default when cache enabled)', () => {
expect(Input.localCacheLibrary).toBe(true);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.localCacheLibrary).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.localCacheLibrary).toBe(false);
});
});
describe('localCacheLfs', () => {
it('returns false by default', () => {
expect(Input.localCacheLfs).toBe(false);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.localCacheLfs).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.localCacheLfs).toBe(false);
});
});
describe('childWorkspacesEnabled', () => {
it('returns false by default', () => {
expect(Input.childWorkspacesEnabled).toBe(false);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.childWorkspacesEnabled).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.childWorkspacesEnabled).toBe(false);
});
it('returns false when empty string is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('');
expect(Input.childWorkspacesEnabled).toBe(false);
});
});
describe('childWorkspacePreserveGit', () => {
it('returns true by default', () => {
expect(Input.childWorkspacePreserveGit).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.childWorkspacePreserveGit).toBe(false);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.childWorkspacePreserveGit).toBe(true);
});
});
describe('childWorkspaceSeparateLibrary', () => {
it('returns true by default', () => {
expect(Input.childWorkspaceSeparateLibrary).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.childWorkspaceSeparateLibrary).toBe(false);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.childWorkspaceSeparateLibrary).toBe(true);
});
});
describe('gitHooksEnabled', () => {
it('returns false by default', () => {
expect(Input.gitHooksEnabled).toBe(false);
});
it('returns true when string "true" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(Input.gitHooksEnabled).toBe(true);
});
it('returns false when string "false" is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(Input.gitHooksEnabled).toBe(false);
});
it('returns false when empty string is passed', () => {
jest.spyOn(core, 'getInput').mockReturnValue('');
expect(Input.gitHooksEnabled).toBe(false);
});
});
// -----------------------------------------------------------------------
// Boolean truthiness edge cases — the #1 source of bugs
// -----------------------------------------------------------------------
describe('boolean input string handling (edge cases)', () => {
// These tests verify that the === 'true' comparison is correct.
// In JavaScript, 'false' is truthy when used in a boolean context,
// but the Input class correctly uses === 'true' comparison.
const booleanInputs: Array<{
name: string;
getter: () => boolean;
defaultValue: boolean;
}> = [
{ name: 'localCacheEnabled', getter: () => Input.localCacheEnabled, defaultValue: false },
{ name: 'localCacheLfs', getter: () => Input.localCacheLfs, defaultValue: false },
{ name: 'childWorkspacesEnabled', getter: () => Input.childWorkspacesEnabled, defaultValue: false },
{ name: 'gitHooksEnabled', getter: () => Input.gitHooksEnabled, defaultValue: false },
// These default to true:
{ name: 'localCacheLibrary', getter: () => Input.localCacheLibrary, defaultValue: true },
{ name: 'childWorkspacePreserveGit', getter: () => Input.childWorkspacePreserveGit, defaultValue: true },
{ name: 'childWorkspaceSeparateLibrary', getter: () => Input.childWorkspaceSeparateLibrary, defaultValue: true },
];
test.each(booleanInputs)('$name: "false" string does NOT evaluate as truthy', ({ getter }) => {
jest.spyOn(core, 'getInput').mockReturnValue('false');
expect(getter()).toBe(false);
});
test.each(booleanInputs)('$name: "true" string evaluates as truthy', ({ getter }) => {
jest.spyOn(core, 'getInput').mockReturnValue('true');
expect(getter()).toBe(true);
});
test.each(booleanInputs)('$name: "TRUE" (uppercase) does NOT evaluate as true (case sensitive)', ({ getter }) => {
jest.spyOn(core, 'getInput').mockReturnValue('TRUE');
expect(getter()).toBe(false);
});
test.each(booleanInputs)('$name: "1" does NOT evaluate as true', ({ getter }) => {
jest.spyOn(core, 'getInput').mockReturnValue('1');
expect(getter()).toBe(false);
});
test.each(booleanInputs)('$name: "yes" does NOT evaluate as true', ({ getter }) => {
jest.spyOn(core, 'getInput').mockReturnValue('yes');
expect(getter()).toBe(false);
});
});
// -----------------------------------------------------------------------
// String inputs — defaults and explicit values
// -----------------------------------------------------------------------
describe('submoduleProfilePath', () => {
it('returns empty string by default', () => {
expect(Input.submoduleProfilePath).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('config/submodule-profiles/tow/ec/profile.yml');
expect(Input.submoduleProfilePath).toBe('config/submodule-profiles/tow/ec/profile.yml');
});
});
describe('submoduleVariantPath', () => {
it('returns empty string by default', () => {
expect(Input.submoduleVariantPath).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('config/submodule-profiles/tow/ec/server.yml');
expect(Input.submoduleVariantPath).toBe('config/submodule-profiles/tow/ec/server.yml');
});
});
describe('submoduleToken', () => {
it('returns empty string by default', () => {
expect(Input.submoduleToken).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('ghp_abc123');
expect(Input.submoduleToken).toBe('ghp_abc123');
});
});
describe('localCacheRoot', () => {
it('returns empty string by default', () => {
expect(Input.localCacheRoot).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('/d/cache/game-ci');
expect(Input.localCacheRoot).toBe('/d/cache/game-ci');
});
});
describe('childWorkspaceName', () => {
it('returns empty string by default', () => {
expect(Input.childWorkspaceName).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('TurnOfWarEndlessCrusade');
expect(Input.childWorkspaceName).toBe('TurnOfWarEndlessCrusade');
});
});
describe('childWorkspaceCacheRoot', () => {
it('returns empty string by default', () => {
expect(Input.childWorkspaceCacheRoot).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('/d/workspaces');
expect(Input.childWorkspaceCacheRoot).toBe('/d/workspaces');
});
});
describe('lfsTransferAgent', () => {
it('returns empty string by default', () => {
expect(Input.lfsTransferAgent).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('/tools/elastic-git-storage');
expect(Input.lfsTransferAgent).toBe('/tools/elastic-git-storage');
});
});
describe('lfsTransferAgentArgs', () => {
it('returns empty string by default', () => {
expect(Input.lfsTransferAgentArgs).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('--verbose --timeout=60');
expect(Input.lfsTransferAgentArgs).toBe('--verbose --timeout=60');
});
});
describe('lfsStoragePaths', () => {
it('returns empty string by default', () => {
expect(Input.lfsStoragePaths).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('/storage/primary;/storage/secondary');
expect(Input.lfsStoragePaths).toBe('/storage/primary;/storage/secondary');
});
});
describe('providerExecutable', () => {
it('returns empty string by default', () => {
expect(Input.providerExecutable).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('/usr/local/bin/custom-provider');
expect(Input.providerExecutable).toBe('/usr/local/bin/custom-provider');
});
});
describe('gitHooksSkipList', () => {
it('returns empty string by default', () => {
expect(Input.gitHooksSkipList).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('pre-commit,pre-push');
expect(Input.gitHooksSkipList).toBe('pre-commit,pre-push');
});
});
describe('gitHooksRunBeforeBuild', () => {
it('returns empty string by default', () => {
expect(Input.gitHooksRunBeforeBuild).toBe('');
});
it('takes input from workflow', () => {
jest.spyOn(core, 'getInput').mockReturnValue('pre-commit');
expect(Input.gitHooksRunBeforeBuild).toBe('pre-commit');
});
});
});
// ---------------------------------------------------------------------------
// Part 2: BuildParameters.create() maps new inputs to properties
// ---------------------------------------------------------------------------
const testLicense =
'<?xml version="1.0" encoding="UTF-8"?><root>\n <License id="Terms">\n <MachineBindings>\n <Binding Key="1" Value="576562626572264761624c65526f7578"/>\n <Binding Key="2" Value="576562626572264761624c65526f7578"/>\n </MachineBindings>\n <MachineID Value="D7nTUnjNAmtsUMcnoyrqkgIbYdM="/>\n <SerialHash Value="2033b8ac3e6faa3742ca9f0bfae44d18f2a96b80"/>\n <Features>\n <Feature Value="33"/>\n <Feature Value="1"/>\n <Feature Value="12"/>\n <Feature Value="2"/>\n <Feature Value="24"/>\n <Feature Value="3"/>\n <Feature Value="36"/>\n <Feature Value="17"/>\n <Feature Value="19"/>\n <Feature Value="62"/>\n </Features>\n <DeveloperData Value="AQAAAEY0LUJHUlgtWEQ0RS1aQ1dWLUM1SlctR0RIQg=="/>\n <SerialMasked Value="F4-BGRX-XD4E-ZCWV-C5JW-XXXX"/>\n <StartDate Value="2021-02-08T00:00:00"/>\n <UpdateDate Value="2021-02-09T00:34:57"/>\n <InitialActivationDate Value="2021-02-08T00:34:56"/>\n <LicenseVersion Value="6.x"/>\n <ClientProvidedVersion Value="2018.4.30f1"/>\n <AlwaysOnline Value="false"/>\n <Entitlements>\n <Entitlement Ns="unity_editor" Tag="UnityPersonal" Type="EDITOR" ValidTo="9999-12-31T00:00:00"/>\n <Entitlement Ns="unity_editor" Tag="DarkSkin" Type="EDITOR_FEATURE" ValidTo="9999-12-31T00:00:00"/>\n </Entitlements>\n </License>\n<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#Terms"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>m0Db8UK+ktnOLJBtHybkfetpcKo=</DigestValue></Reference></SignedInfo><SignatureValue>o/pUbSQAukz7+ZYAWhnA0AJbIlyyCPL7bKVEM2lVqbrXt7cyey+umkCXamuOgsWPVUKBMkXtMH8L\n5etLmD0getWIhTGhzOnDCk+gtIPfL4jMo9tkEuOCROQAXCci23VFscKcrkB+3X6h4wEOtA2APhOY\nB+wvC794o8/82ffjP79aVAi57rp3Wmzx+9pe9yMwoJuljAy2sc2tIMgdQGWVmOGBpQm3JqsidyzI\nJWG2kjnc7pDXK9pwYzXoKiqUqqrut90d+kQqRyv7MSZXR50HFqD/LI69h68b7P8Bjo3bPXOhNXGR\n9YCoemH6EkfCJxp2gIjzjWW+l2Hj2EsFQi8YXw==</SignatureValue></Signature></root>';
describe('BuildParameters.create() enterprise property mapping', () => {
beforeEach(() => {
jest.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
process.env.UNITY_LICENSE = testLicense;
});
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('maps submoduleProfilePath from Input', async () => {
jest.spyOn(Input, 'submoduleProfilePath', 'get').mockReturnValue('/path/to/profile.yml');
const parameters = await BuildParameters.create();
expect(parameters.submoduleProfilePath).toBe('/path/to/profile.yml');
});
it('maps submoduleVariantPath from Input', async () => {
jest.spyOn(Input, 'submoduleVariantPath', 'get').mockReturnValue('/path/to/variant.yml');
const parameters = await BuildParameters.create();
expect(parameters.submoduleVariantPath).toBe('/path/to/variant.yml');
});
it('maps submoduleToken from Input', async () => {
jest.spyOn(Input, 'submoduleToken', 'get').mockReturnValue('ghp_token123');
const parameters = await BuildParameters.create();
expect(parameters.submoduleToken).toBe('ghp_token123');
});
it('maps localCacheEnabled from Input', async () => {
jest.spyOn(Input, 'localCacheEnabled', 'get').mockReturnValue(true);
const parameters = await BuildParameters.create();
expect(parameters.localCacheEnabled).toBe(true);
});
it('maps localCacheRoot from Input', async () => {
jest.spyOn(Input, 'localCacheRoot', 'get').mockReturnValue('/d/cache');
const parameters = await BuildParameters.create();
expect(parameters.localCacheRoot).toBe('/d/cache');
});
it('maps localCacheLibrary from Input', async () => {
jest.spyOn(Input, 'localCacheLibrary', 'get').mockReturnValue(false);
const parameters = await BuildParameters.create();
expect(parameters.localCacheLibrary).toBe(false);
});
it('maps localCacheLfs from Input', async () => {
jest.spyOn(Input, 'localCacheLfs', 'get').mockReturnValue(true);
const parameters = await BuildParameters.create();
expect(parameters.localCacheLfs).toBe(true);
});
it('maps childWorkspacesEnabled from Input', async () => {
jest.spyOn(Input, 'childWorkspacesEnabled', 'get').mockReturnValue(true);
const parameters = await BuildParameters.create();
expect(parameters.childWorkspacesEnabled).toBe(true);
});
it('maps childWorkspaceName from Input', async () => {
jest.spyOn(Input, 'childWorkspaceName', 'get').mockReturnValue('TurnOfWar');
const parameters = await BuildParameters.create();
expect(parameters.childWorkspaceName).toBe('TurnOfWar');
});
it('maps childWorkspaceCacheRoot from Input', async () => {
jest.spyOn(Input, 'childWorkspaceCacheRoot', 'get').mockReturnValue('/cache/workspaces');
const parameters = await BuildParameters.create();
expect(parameters.childWorkspaceCacheRoot).toBe('/cache/workspaces');
});
it('maps childWorkspacePreserveGit from Input', async () => {
jest.spyOn(Input, 'childWorkspacePreserveGit', 'get').mockReturnValue(false);
const parameters = await BuildParameters.create();
expect(parameters.childWorkspacePreserveGit).toBe(false);
});
it('maps childWorkspaceSeparateLibrary from Input', async () => {
jest.spyOn(Input, 'childWorkspaceSeparateLibrary', 'get').mockReturnValue(false);
const parameters = await BuildParameters.create();
expect(parameters.childWorkspaceSeparateLibrary).toBe(false);
});
it('maps lfsTransferAgent from Input', async () => {
jest.spyOn(Input, 'lfsTransferAgent', 'get').mockReturnValue('/tools/elastic-git-storage');
const parameters = await BuildParameters.create();
expect(parameters.lfsTransferAgent).toBe('/tools/elastic-git-storage');
});
it('maps lfsTransferAgentArgs from Input', async () => {
jest.spyOn(Input, 'lfsTransferAgentArgs', 'get').mockReturnValue('--verbose');
const parameters = await BuildParameters.create();
expect(parameters.lfsTransferAgentArgs).toBe('--verbose');
});
it('maps lfsStoragePaths from Input', async () => {
jest.spyOn(Input, 'lfsStoragePaths', 'get').mockReturnValue('/path/a;/path/b');
const parameters = await BuildParameters.create();
expect(parameters.lfsStoragePaths).toBe('/path/a;/path/b');
});
it('maps gitHooksEnabled from Input', async () => {
jest.spyOn(Input, 'gitHooksEnabled', 'get').mockReturnValue(true);
const parameters = await BuildParameters.create();
expect(parameters.gitHooksEnabled).toBe(true);
});
it('maps gitHooksSkipList from Input', async () => {
jest.spyOn(Input, 'gitHooksSkipList', 'get').mockReturnValue('pre-commit,pre-push');
const parameters = await BuildParameters.create();
expect(parameters.gitHooksSkipList).toBe('pre-commit,pre-push');
});
it('maps gitHooksRunBeforeBuild from Input', async () => {
jest.spyOn(Input, 'gitHooksRunBeforeBuild', 'get').mockReturnValue('pre-commit');
const parameters = await BuildParameters.create();
expect(parameters.gitHooksRunBeforeBuild).toBe('pre-commit');
});
it('maps providerExecutable from Input', async () => {
jest.spyOn(Input, 'providerExecutable', 'get').mockReturnValue('/usr/local/bin/provider');
const parameters = await BuildParameters.create();
expect(parameters.providerExecutable).toBe('/usr/local/bin/provider');
});
// Test that all enterprise properties have correct defaults when not explicitly set
it('has correct defaults for all enterprise properties', async () => {
const parameters = await BuildParameters.create();
expect(parameters.submoduleProfilePath).toBe('');
expect(parameters.submoduleVariantPath).toBe('');
expect(parameters.submoduleToken).toBe('');
expect(parameters.localCacheEnabled).toBe(false);
expect(parameters.localCacheRoot).toBe('');
expect(parameters.localCacheLibrary).toBe(true);
expect(parameters.localCacheLfs).toBe(false);
expect(parameters.childWorkspacesEnabled).toBe(false);
expect(parameters.childWorkspaceName).toBe('');
expect(parameters.childWorkspaceCacheRoot).toBe('');
expect(parameters.childWorkspacePreserveGit).toBe(true);
expect(parameters.childWorkspaceSeparateLibrary).toBe(true);
expect(parameters.lfsTransferAgent).toBe('');
expect(parameters.lfsTransferAgentArgs).toBe('');
expect(parameters.lfsStoragePaths).toBe('');
expect(parameters.gitHooksEnabled).toBe(false);
expect(parameters.gitHooksSkipList).toBe('');
expect(parameters.gitHooksRunBeforeBuild).toBe('');
expect(parameters.providerExecutable).toBe('');
});
});

View File

@@ -358,6 +358,14 @@ class Input {
return Input.getInput('providerExecutable') ?? '';
}
static get middleware(): string {
return Input.getInput('middleware') ?? '';
}
static get middlewareFiles(): string {
return Input.getInput('middlewareFiles') ?? '';
}
public static ToEnvVarFormat(input: string) {
if (input.toUpperCase() === input) {
return input;

View File

@@ -166,6 +166,14 @@ class OrchestratorOptions {
return OrchestratorOptions.getInput('commandHookFiles')?.split(`,`) || [];
}
static get middleware(): string {
return OrchestratorOptions.getInput('middleware') || '';
}
static get middlewareFiles(): string[] {
return OrchestratorOptions.getInput('middlewareFiles')?.split(',') || [];
}
// ### ### ###
// Custom commands from yaml parameters
// ### ### ###

View File

@@ -0,0 +1,422 @@
import { MiddlewareService } from './middleware-service';
import { Middleware } from './middleware';
// Mock dependencies
jest.mock('../../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
providerStrategy: 'aws',
targetPlatform: 'StandaloneLinux64',
},
},
}));
jest.mock('../../options/orchestrator-options', () => ({
__esModule: true,
default: {
providerStrategy: 'aws',
middlewareFiles: ['code-signing', 'cache-optimizer'],
},
}));
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
},
}));
describe('MiddlewareService', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
describe('parseMiddleware', () => {
it('should parse a single middleware definition', () => {
const yaml = `
name: test-middleware
type: command
priority: 50
trigger:
phase: [build]
before:
commands: echo "before"
after:
commands: echo "after"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('test-middleware');
expect(result[0].type).toBe('command');
expect(result[0].priority).toBe(50);
expect(result[0].trigger.phase).toEqual(['build']);
expect(result[0].before?.commands).toBe('echo "before"');
expect(result[0].after?.commands).toBe('echo "after"');
});
it('should parse an array of middleware definitions', () => {
const yaml = `
- name: first
type: command
trigger:
phase: [setup]
before:
commands: echo "first"
- name: second
type: container
image: node:20
trigger:
phase: [build]
after:
commands: echo "second"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('first');
expect(result[0].type).toBe('command');
expect(result[1].name).toBe('second');
expect(result[1].type).toBe('container');
expect(result[1].image).toBe('node:20');
});
it('should handle string shorthand for before/after', () => {
const yaml = `
name: shorthand
type: command
trigger:
phase: [build]
before: echo "shorthand before"
after: echo "shorthand after"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result).toHaveLength(1);
expect(result[0].before?.commands).toBe('echo "shorthand before"');
expect(result[0].after?.commands).toBe('echo "shorthand after"');
});
it('should parse secrets', () => {
const yaml = `
name: with-secrets
type: container
trigger:
phase: [build]
secrets:
- name: MY_SECRET
value: secret-value
- name: ANOTHER_SECRET
after:
commands: echo "done"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result[0].secrets).toHaveLength(2);
expect(result[0].secrets[0].ParameterKey).toBe('MY_SECRET');
expect(result[0].secrets[0].ParameterValue).toBe('secret-value');
expect(result[0].secrets[1].ParameterKey).toBe('ANOTHER_SECRET');
});
it('should default priority to 100', () => {
const yaml = `
name: no-priority
type: command
trigger:
phase: [build]
before: echo "test"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result[0].priority).toBe(100);
});
it('should default type to command', () => {
const yaml = `
name: no-type
trigger:
phase: [build]
before: echo "test"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result[0].type).toBe('command');
});
it('should return empty array for empty input', () => {
expect(MiddlewareService.parseMiddleware('')).toEqual([]);
expect(MiddlewareService.parseMiddleware(' ')).toEqual([]);
});
it('should normalize scalar trigger values to arrays', () => {
const yaml = `
name: scalar-triggers
type: command
trigger:
phase: build
provider: aws
platform: StandaloneLinux64
before: echo "test"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result[0].trigger.phase).toEqual(['build']);
expect(result[0].trigger.provider).toEqual(['aws']);
expect(result[0].trigger.platform).toEqual(['StandaloneLinux64']);
});
it('should handle per-phase image override for container type', () => {
const yaml = `
name: multi-image
type: container
image: ubuntu
trigger:
phase: [build]
before:
image: node:20
commands: npm install
after:
image: python:3
commands: python verify.py
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result[0].image).toBe('ubuntu');
expect(result[0].before?.image).toBe('node:20');
expect(result[0].after?.image).toBe('python:3');
});
});
describe('evaluateExpression', () => {
it('should evaluate equality expressions', () => {
process.env.TEST_VAR = 'hello';
expect(MiddlewareService.evaluateExpression("env.TEST_VAR == 'hello'")).toBe(true);
expect(MiddlewareService.evaluateExpression("env.TEST_VAR == 'world'")).toBe(false);
});
it('should evaluate inequality expressions', () => {
process.env.TEST_VAR = 'hello';
expect(MiddlewareService.evaluateExpression("env.TEST_VAR != 'world'")).toBe(true);
expect(MiddlewareService.evaluateExpression("env.TEST_VAR != 'hello'")).toBe(false);
});
it('should evaluate truthy expressions', () => {
process.env.DEFINED_VAR = 'yes';
expect(MiddlewareService.evaluateExpression('env.DEFINED_VAR')).toBe(true);
process.env.FALSE_VAR = 'false';
expect(MiddlewareService.evaluateExpression('env.FALSE_VAR')).toBe(false);
process.env.EMPTY_VAR = '';
expect(MiddlewareService.evaluateExpression('env.EMPTY_VAR')).toBe(false);
delete process.env.MISSING_VAR;
expect(MiddlewareService.evaluateExpression('env.MISSING_VAR')).toBe(false);
});
it('should evaluate falsy expressions', () => {
delete process.env.MISSING_VAR;
expect(MiddlewareService.evaluateExpression('!env.MISSING_VAR')).toBe(true);
process.env.DEFINED_VAR = 'yes';
expect(MiddlewareService.evaluateExpression('!env.DEFINED_VAR')).toBe(false);
});
it('should default to true for unknown expressions', () => {
expect(MiddlewareService.evaluateExpression('some unknown expression')).toBe(true);
});
it('should handle double-quoted values', () => {
process.env.TEST_VAR = 'hello';
expect(MiddlewareService.evaluateExpression('env.TEST_VAR == "hello"')).toBe(true);
});
});
describe('evaluateTrigger', () => {
it('should match when phase matches', () => {
const trigger = { phase: ['build'] };
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(true);
expect(MiddlewareService.evaluateTrigger(trigger, 'setup')).toBe(false);
});
it('should match multiple phases', () => {
const trigger = { phase: ['setup', 'build'] };
expect(MiddlewareService.evaluateTrigger(trigger, 'setup')).toBe(true);
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(true);
expect(MiddlewareService.evaluateTrigger(trigger, 'pre-build')).toBe(false);
});
it('should filter by provider', () => {
const trigger = { phase: ['build'], provider: ['k8s'] };
// Mock provider is 'aws', so this should not match
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
const matchingTrigger = { phase: ['build'], provider: ['aws', 'k8s'] };
expect(MiddlewareService.evaluateTrigger(matchingTrigger, 'build')).toBe(true);
});
it('should filter by platform', () => {
const trigger = { phase: ['build'], platform: ['StandaloneWindows64'] };
// Mock platform is 'StandaloneLinux64', so this should not match
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
const matchingTrigger = { phase: ['build'], platform: ['StandaloneLinux64'] };
expect(MiddlewareService.evaluateTrigger(matchingTrigger, 'build')).toBe(true);
});
it('should evaluate when expressions', () => {
process.env.FEATURE_FLAG = 'true';
const trigger = { phase: ['build'], when: "env.FEATURE_FLAG == 'true'" };
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(true);
process.env.FEATURE_FLAG = 'false';
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
});
it('should require all conditions to pass (AND logic)', () => {
process.env.FEATURE_FLAG = 'true';
const trigger = {
phase: ['build'],
provider: ['k8s'], // won't match (mock provider is 'aws')
when: "env.FEATURE_FLAG == 'true'", // would match
};
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
});
});
describe('resolveCommandHooks', () => {
const middleware: Middleware[] = [
{
name: 'low-priority',
type: 'command',
priority: 10,
trigger: { phase: ['build'] },
image: 'ubuntu',
before: { commands: 'echo "low-before"' },
after: { commands: 'echo "low-after"' },
secrets: [],
allowFailure: false,
},
{
name: 'high-priority',
type: 'command',
priority: 90,
trigger: { phase: ['build'] },
image: 'ubuntu',
before: { commands: 'echo "high-before"' },
after: { commands: 'echo "high-after"' },
secrets: [],
allowFailure: false,
},
{
name: 'container-type',
type: 'container',
priority: 50,
trigger: { phase: ['build'] },
image: 'node:20',
before: { commands: 'echo "container"' },
secrets: [],
allowFailure: false,
},
];
it('should only return command type middleware', () => {
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
expect(hooks).toHaveLength(2);
expect(hooks.every((h) => h.name.startsWith('middleware:'))).toBe(true);
expect(hooks.find((h) => h.name.includes('container-type'))).toBeUndefined();
});
it('should order before hooks by ascending priority', () => {
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
expect(hooks[0].name).toBe('middleware:low-priority:before');
expect(hooks[1].name).toBe('middleware:high-priority:before');
});
it('should order after hooks by descending priority (wrapping)', () => {
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'build', 'after');
expect(hooks[0].name).toBe('middleware:high-priority:after');
expect(hooks[1].name).toBe('middleware:low-priority:after');
});
it('should filter by phase', () => {
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'setup', 'before');
expect(hooks).toHaveLength(0);
});
});
describe('resolveContainerHooks', () => {
const middleware: Middleware[] = [
{
name: 'container-a',
type: 'container',
priority: 20,
trigger: { phase: ['pre-build'] },
image: 'ubuntu',
before: { commands: 'echo "a-before"' },
after: { commands: 'echo "a-after"' },
secrets: [],
allowFailure: false,
},
{
name: 'container-b',
type: 'container',
priority: 80,
trigger: { phase: ['pre-build'] },
image: 'node:20',
before: { commands: 'echo "b-before"', image: 'python:3' },
secrets: [],
allowFailure: true,
},
];
it('should resolve container hooks with correct images', () => {
const hooks = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
expect(hooks).toHaveLength(2);
expect(hooks[0].image).toBe('ubuntu'); // default image from middleware
expect(hooks[1].image).toBe('python:3'); // per-phase override
});
it('should propagate allowFailure', () => {
const hooks = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
expect(hooks[0].allowFailure).toBe(false);
expect(hooks[1].allowFailure).toBe(true);
});
it('should only include phases that have definitions', () => {
// container-b has no after phase
const hooks = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'after');
expect(hooks).toHaveLength(1);
expect(hooks[0].name).toBe('middleware:container-a:after');
});
});
describe('getMiddleware', () => {
it('should sort by priority ascending', () => {
const yaml = `
- name: high
type: command
priority: 200
trigger:
phase: [build]
before: echo "high"
- name: low
type: command
priority: 5
trigger:
phase: [build]
before: echo "low"
- name: medium
type: command
priority: 50
trigger:
phase: [build]
before: echo "medium"
`;
const result = MiddlewareService.getMiddleware(yaml);
expect(result[0].name).toBe('low');
expect(result[1].name).toBe('medium');
expect(result[2].name).toBe('high');
});
});
});

View File

@@ -0,0 +1,329 @@
import YAML from 'yaml';
import fs from 'node:fs';
import path from 'node:path';
import { Middleware, MiddlewareTrigger } from './middleware';
import { ContainerHook } from './container-hook';
import { CommandHook } from './command-hook';
import OrchestratorOptions from '../../options/orchestrator-options';
import Orchestrator from '../../orchestrator';
import OrchestratorLogger from '../core/orchestrator-logger';
import Input from '../../../input';
/**
* Service for loading, evaluating, and resolving middleware into hooks.
*
* Middleware is a higher-level composable abstraction over the existing
* command hook and container hook systems. Each middleware:
* - Wraps around pipeline phases with before/after semantics
* - Has trigger conditions (phase, provider, platform, expression)
* - Resolves to either CommandHooks or ContainerHooks at execution time
* - Executes in priority order (before: ascending, after: descending)
*/
export class MiddlewareService {
/**
* Load all active middleware from inline YAML + file-based definitions.
* Returns them sorted by priority (ascending).
*/
static getMiddleware(inlineYaml: string): Middleware[] {
const middleware: Middleware[] = [];
// Parse inline YAML definitions
if (inlineYaml && inlineYaml !== '') {
middleware.push(...MiddlewareService.parseMiddleware(inlineYaml));
}
// Load file-based definitions from game-ci/middleware/
middleware.push(...MiddlewareService.getMiddlewareFromFiles());
// Sort by priority (lower = earlier)
middleware.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
OrchestratorLogger.log(`Middleware: loaded ${middleware.length} definition(s)`);
return middleware;
}
/**
* Resolve middleware to CommandHooks for a given phase and timing.
* Filters by trigger conditions and converts to hooks.
*
* Before hooks: ascending priority (lowest priority runs first, closest to core phase).
* After hooks: descending priority (highest priority runs first, closest to core phase).
* This produces the wrapping pattern: outermost middleware's before runs first and after runs last.
*/
static resolveCommandHooks(middleware: Middleware[], phase: string, timing: 'before' | 'after'): CommandHook[] {
const applicable = middleware
.filter((m) => m.type === 'command')
.filter((m) => MiddlewareService.evaluateTrigger(m.trigger, phase));
// before: ascending priority; after: descending (wrapping order)
if (timing === 'after') {
applicable.reverse();
}
const hooks = applicable
.filter((m) => (timing === 'before' ? m.before : m.after))
.map((m) => {
const mPhase = timing === 'before' ? m.before! : m.after!;
const hook = new CommandHook();
hook.name = `middleware:${m.name}:${timing}`;
hook.commands = typeof mPhase.commands === 'string' ? [mPhase.commands] : [mPhase.commands];
hook.hook = [timing];
hook.step = [phase];
hook.secrets = m.secrets || [];
return hook;
});
if (hooks.length > 0) {
OrchestratorLogger.log(
`Middleware: resolved ${hooks.length} command hook(s) for ${phase}:${timing}${hooks
.map((h) => h.name)
.join(', ')}`,
);
}
return hooks;
}
/**
* Resolve middleware to ContainerHooks for a given phase and timing.
* Same ordering logic as resolveCommandHooks.
*/
static resolveContainerHooks(middleware: Middleware[], phase: string, timing: 'before' | 'after'): ContainerHook[] {
const applicable = middleware
.filter((m) => m.type === 'container')
.filter((m) => MiddlewareService.evaluateTrigger(m.trigger, phase));
// before: ascending priority; after: descending (wrapping order)
if (timing === 'after') {
applicable.reverse();
}
const hooks = applicable
.filter((m) => (timing === 'before' ? m.before : m.after))
.map((m) => {
const mPhase = timing === 'before' ? m.before! : m.after!;
const hook = new ContainerHook();
hook.name = `middleware:${m.name}:${timing}`;
hook.commands = typeof mPhase.commands === 'string' ? mPhase.commands : mPhase.commands;
hook.image = mPhase.image || m.image || 'ubuntu';
hook.hook = timing === 'before' ? 'before' : 'after';
hook.secrets = m.secrets || [];
hook.allowFailure = m.allowFailure ?? false;
return hook;
});
if (hooks.length > 0) {
OrchestratorLogger.log(
`Middleware: resolved ${hooks.length} container hook(s) for ${phase}:${timing}${hooks
.map((h) => h.name)
.join(', ')}`,
);
}
return hooks;
}
/**
* Evaluate whether a middleware's trigger conditions are met.
* All specified conditions must pass (AND logic).
*/
static evaluateTrigger(trigger: MiddlewareTrigger, currentPhase: string): boolean {
// Phase must match
if (!trigger.phase || !trigger.phase.includes(currentPhase)) {
return false;
}
// Provider filter (if specified)
if (trigger.provider && trigger.provider.length > 0) {
const currentProvider = Orchestrator.buildParameters?.providerStrategy || OrchestratorOptions.providerStrategy;
if (!trigger.provider.includes(currentProvider)) {
return false;
}
}
// Platform filter (if specified)
if (trigger.platform && trigger.platform.length > 0) {
const currentPlatform = Orchestrator.buildParameters?.targetPlatform || '';
if (!trigger.platform.includes(currentPlatform)) {
return false;
}
}
// Expression-based condition
if (trigger.when) {
if (!MiddlewareService.evaluateExpression(trigger.when)) {
return false;
}
}
return true;
}
/**
* Evaluate a simple expression string against environment variables.
*
* Supported formats:
* - env.VAR_NAME == 'value' — equality check
* - env.VAR_NAME != 'value' — inequality check
* - env.VAR_NAME — truthy check (defined, non-empty, not 'false')
* - !env.VAR_NAME — falsy check
*/
static evaluateExpression(expression: string): boolean {
const trimmed = expression.trim();
// Match: env.VAR == 'value' or env.VAR != 'value'
const comparisonMatch = trimmed.match(/^env\.(\w+)\s*(==|!=)\s*['"](.*)['"]$/);
if (comparisonMatch) {
const [, varName, operator, value] = comparisonMatch;
const envValue = process.env[varName] || '';
return operator === '==' ? envValue === value : envValue !== value;
}
// Match: !env.VAR (falsy check)
const falsyMatch = trimmed.match(/^!env\.(\w+)$/);
if (falsyMatch) {
const [, varName] = falsyMatch;
const envValue = process.env[varName];
return envValue === undefined || envValue === '' || envValue === 'false';
}
// Match: env.VAR (truthy check)
const truthyMatch = trimmed.match(/^env\.(\w+)$/);
if (truthyMatch) {
const [, varName] = truthyMatch;
const envValue = process.env[varName];
return envValue !== undefined && envValue !== '' && envValue !== 'false';
}
// Unknown expression format — log warning, default to true
OrchestratorLogger.logWarning(`Middleware: unknown expression format "${expression}", defaulting to true`);
return true;
}
/**
* Parse middleware definitions from a YAML string.
* Accepts both single-object and array format.
*/
static parseMiddleware(yamlString: string): Middleware[] {
if (!yamlString || yamlString.trim() === '') {
return [];
}
try {
const isArray = yamlString.replace(/\s/g, '')[0] === '-';
const parsed = isArray ? YAML.parse(yamlString) : [YAML.parse(yamlString)];
if (!parsed || !Array.isArray(parsed)) {
return [];
}
return parsed.map((m: any) => MiddlewareService.hydrateMiddleware(m));
} catch (error: any) {
OrchestratorLogger.logWarning(`Middleware: failed to parse YAML — ${error.message}`);
return [];
}
}
/**
* Hydrate a raw parsed YAML object into a Middleware instance.
*/
private static hydrateMiddleware(m: any): Middleware {
const middleware = new Middleware();
middleware.name = m.name || 'unnamed';
middleware.description = m.description;
middleware.type = m.type || 'command';
middleware.priority = m.priority ?? 100;
middleware.image = m.image || 'ubuntu';
middleware.allowFailure = m.allowFailure ?? false;
middleware.outputs = m.outputs;
// Parse trigger — normalize scalar values to arrays
middleware.trigger = {
phase: MiddlewareService.toStringArray(m.trigger?.phase),
provider: m.trigger?.provider ? MiddlewareService.toStringArray(m.trigger.provider) : undefined,
platform: m.trigger?.platform ? MiddlewareService.toStringArray(m.trigger.platform) : undefined,
when: m.trigger?.when,
};
// Parse before/after phases — accept string shorthand or object format
if (m.before) {
middleware.before = {
commands: typeof m.before === 'string' ? m.before : m.before.commands || '',
image: typeof m.before === 'string' ? undefined : m.before.image,
};
}
if (m.after) {
middleware.after = {
commands: typeof m.after === 'string' ? m.after : m.after.commands || '',
image: typeof m.after === 'string' ? undefined : m.after.image,
};
}
// Parse secrets
if (m.secrets && Array.isArray(m.secrets)) {
middleware.secrets = m.secrets.map((s: any) => ({
ParameterKey: s.name,
EnvironmentVariable: Input.ToEnvVarFormat(s.name),
ParameterValue: s.value ?? process.env[s.name] ?? process.env[Input.ToEnvVarFormat(s.name)] ?? '',
}));
}
return middleware;
}
/**
* Load middleware definitions from game-ci/middleware/ directory files.
* Only files whose base name appears in the middlewareFiles allowlist are loaded.
*/
static getMiddlewareFromFiles(): Middleware[] {
const results: Middleware[] = [];
const allowedFiles = OrchestratorOptions.middlewareFiles;
if (!allowedFiles || allowedFiles.length === 0) {
return results;
}
try {
const middlewarePath = path.join(process.cwd(), 'game-ci', 'middleware');
if (!fs.existsSync(middlewarePath)) {
return results;
}
const files = fs.readdirSync(middlewarePath);
for (const file of files) {
const baseName = file.replace(/\.ya?ml$/, '');
if (!allowedFiles.includes(baseName)) {
continue;
}
try {
const contents = fs.readFileSync(path.join(middlewarePath, file), 'utf8');
results.push(...MiddlewareService.parseMiddleware(contents));
} catch (error: any) {
OrchestratorLogger.logWarning(`Middleware: failed to parse file ${file}${error.message}`);
}
}
} catch {
// Directory doesn't exist or can't be read — not an error
}
return results;
}
/**
* Normalize a value to a string array. Accepts string, string[], or undefined.
*/
private static toStringArray(value: any): string[] {
if (!value) return [];
if (Array.isArray(value)) return value;
return [value];
}
}

View File

@@ -0,0 +1,85 @@
import OrchestratorSecret from '../../options/orchestrator-secret';
/**
* Trigger conditions that determine when a middleware activates.
* All specified conditions must be true (AND logic).
*/
export interface MiddlewareTrigger {
/** Pipeline phases this middleware applies to: 'setup', 'build', 'pre-build', 'post-build' */
phase: string[];
/** Restrict to specific providers. If omitted, applies to all providers. */
provider?: string[];
/** Restrict to specific build target platforms. If omitted, applies to all platforms. */
platform?: string[];
/** Expression-based condition. Supports: env.VAR == 'value', env.VAR != 'value', env.VAR (truthy) */
when?: string;
}
/**
* A single phase (before or after) of a middleware definition.
*/
export interface MiddlewarePhase {
/** Shell commands to execute */
commands: string;
/** Override image for this phase (container type only) */
image?: string;
}
/**
* Middleware — a composable, trigger-aware pipeline unit built on hooks.
*
* Middleware wraps around pipeline phases with before/after semantics.
* Each middleware resolves to either CommandHooks (inline in build container)
* or ContainerHooks (separate Docker containers) at execution time.
*
* Execution order: before phases run in ascending priority order,
* after phases run in descending priority order (wrapping pattern).
*
* Example YAML:
* ```yaml
* name: code-signing
* description: Signs build artifacts after successful build
* type: container
* priority: 50
* image: ubuntu:22.04
* trigger:
* phase: [build]
* provider: [aws, k8s]
* platform: [StandaloneWindows64]
* when: "env.SIGN_BUILDS == 'true'"
* before:
* commands: |
* echo "Preparing signing environment..."
* after:
* commands: |
* echo "Signing build artifacts..."
* secrets:
* - name: SIGNING_KEY
* - name: SIGNING_CERT
* allowFailure: false
* ```
*/
export class Middleware {
/** Unique name identifying this middleware */
public name!: string;
/** Human-readable description */
public description?: string;
/** Hook type: 'command' (inline in build container) or 'container' (separate Docker container) */
public type!: 'command' | 'container';
/** Execution priority. Lower values run first for before, last for after (wrapping order). Default: 100 */
public priority: number = 100;
/** Conditions that determine when this middleware activates */
public trigger!: MiddlewareTrigger;
/** Default Docker image for container type middleware */
public image: string = 'ubuntu';
/** Commands to run before the target phase */
public before?: MiddlewarePhase;
/** Commands to run after the target phase */
public after?: MiddlewarePhase;
/** Secrets injected as environment variables */
public secrets: OrchestratorSecret[] = [];
/** If true, failures warn but don't stop the build. Default: false */
public allowFailure: boolean = false;
/** Environment variable names this middleware exports to downstream middleware */
public outputs?: string[];
}

View File

@@ -83,6 +83,7 @@ submodules:
mockedFs.readFileSync.mockImplementation((filePath: any) => {
if (filePath === '/base.yml') return baseYaml;
if (filePath === '/variant.yml') return variantYaml;
return '';
});
@@ -196,6 +197,7 @@ submodules:
mockedFs.readFileSync.mockImplementation((filePath: any) => {
if (String(filePath).endsWith('profile.yml')) return profileYaml;
if (String(filePath).endsWith('.gitmodules')) return gitmodulesContent;
return '';
});
@@ -249,6 +251,7 @@ submodules:
if (p.endsWith('profile.yml')) return profileYaml;
if (p.endsWith('variant.yml')) return variantYaml;
if (p.endsWith('.gitmodules')) return gitmodulesContent;
return '';
});
@@ -309,4 +312,74 @@ submodules:
expect(mockedSystem.Run).not.toHaveBeenCalledWith(expect.stringContaining('git config url'));
});
});
describe('execute — command construction safety', () => {
it('constructs expected git commands for a standard path', async () => {
mockedSystem.Run.mockResolvedValue('');
const plan = [
{
name: 'Assets/_Game/Submodules/TurnOfWar',
path: 'Assets/_Game/Submodules/TurnOfWar',
branch: 'main',
action: 'init' as const,
},
];
await SubmoduleProfileService.execute(plan, '/repo');
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/_Game/Submodules/TurnOfWar');
});
it('passes path directly into git commands (paths with spaces are not quoted)', async () => {
mockedSystem.Run.mockResolvedValue('');
// This test documents current behavior: paths are passed as-is.
// If a path contained shell metacharacters, they would be passed through.
// This is acceptable because submodule paths come from .gitmodules (trusted source),
// not from user input. Still, this test documents the behavior for awareness.
const plan = [
{ name: 'Module With Spaces', path: 'Assets/Module With Spaces', branch: 'main', action: 'init' as const },
];
await SubmoduleProfileService.execute(plan, '/repo');
// The current implementation passes the path directly — no shell quoting
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/Module With Spaces');
});
it('passes branch name directly into git checkout command', async () => {
mockedSystem.Run.mockResolvedValue('');
// Document that branch names are passed as-is into shell commands.
// Branch names come from the trusted profile YAML, not user input.
const plan = [{ name: 'ModuleX', path: 'Assets/ModuleX', branch: 'feature/my-branch', action: 'init' as const }];
await SubmoduleProfileService.execute(plan, '/repo');
expect(mockedSystem.Run).toHaveBeenCalledWith('git -C Assets/ModuleX checkout feature/my-branch');
});
it('constructs deinit command with error suppression for skip actions', async () => {
mockedSystem.Run.mockResolvedValue('');
const plan = [{ name: 'Unused', path: 'Assets/Unused', branch: 'empty', action: 'skip' as const }];
await SubmoduleProfileService.execute(plan, '/repo');
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule deinit -f Assets/Unused 2>/dev/null || true');
});
it('injects token into git config URL insteadOf pattern', async () => {
mockedSystem.Run.mockResolvedValue('');
// The token is embedded directly into the URL pattern.
// This is the standard Git credential approach for CI.
await SubmoduleProfileService.execute([], '/repo', 'ghp_abc123xyz');
const configCall = mockedSystem.Run.mock.calls.find((call: any[]) => String(call[0]).includes('git config url'));
expect(configCall).toBeDefined();
expect(configCall![0]).toBe('git config url."https://ghp_abc123xyz@github.com/".insteadOf "https://github.com/"');
});
});
});

View File

@@ -6,6 +6,8 @@ import { CommandHookService } from '../services/hooks/command-hook-service';
import path from 'node:path';
import Orchestrator from '../orchestrator';
import { ContainerHookService } from '../services/hooks/container-hook-service';
import { MiddlewareService } from '../services/hooks/middleware-service';
import { CustomWorkflow } from './custom-workflow';
export class BuildAutomationWorkflow implements WorkflowInterface {
async run(orchestratorStepState: OrchestratorStepParameters) {
@@ -13,13 +15,41 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
}
private static async standardBuildAutomation(baseImage: string, orchestratorStepState: OrchestratorStepParameters) {
// TODO accept post and pre build steps as yaml files in the repo
OrchestratorLogger.log(`Orchestrator is running standard build automation`);
let output = '';
// Load middleware pipeline
const middleware = MiddlewareService.getMiddleware(Orchestrator.buildParameters.middleware);
// --- Pre-build phase ---
// Middleware container hooks: pre-build before
const preBuildMiddlewareBefore = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
if (preBuildMiddlewareBefore.length > 0) {
output += await CustomWorkflow.runContainerJob(
preBuildMiddlewareBefore,
orchestratorStepState.environment,
orchestratorStepState.secrets,
);
OrchestratorLogger.logWithTime('Middleware pre-build:before container step(s) time');
}
// Standard pre-build container hooks
output += await ContainerHookService.RunPreBuildSteps(orchestratorStepState);
OrchestratorLogger.logWithTime('Configurable pre build step(s) time');
// Middleware container hooks: pre-build after
const preBuildMiddlewareAfter = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'after');
if (preBuildMiddlewareAfter.length > 0) {
output += await CustomWorkflow.runContainerJob(
preBuildMiddlewareAfter,
orchestratorStepState.environment,
orchestratorStepState.secrets,
);
OrchestratorLogger.logWithTime('Middleware pre-build:after container step(s) time');
}
// --- Build phase ---
OrchestratorLogger.log(baseImage);
OrchestratorLogger.logLine(` `);
OrchestratorLogger.logLine('Starting build automation job');
@@ -35,9 +65,33 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
);
OrchestratorLogger.logWithTime('Build time');
// --- Post-build phase ---
// Middleware container hooks: post-build before
const postBuildMiddlewareBefore = MiddlewareService.resolveContainerHooks(middleware, 'post-build', 'before');
if (postBuildMiddlewareBefore.length > 0) {
output += await CustomWorkflow.runContainerJob(
postBuildMiddlewareBefore,
orchestratorStepState.environment,
orchestratorStepState.secrets,
);
OrchestratorLogger.logWithTime('Middleware post-build:before container step(s) time');
}
// Standard post-build container hooks
output += await ContainerHookService.RunPostBuildSteps(orchestratorStepState);
OrchestratorLogger.logWithTime('Configurable post build step(s) time');
// Middleware container hooks: post-build after
const postBuildMiddlewareAfter = MiddlewareService.resolveContainerHooks(middleware, 'post-build', 'after');
if (postBuildMiddlewareAfter.length > 0) {
output += await CustomWorkflow.runContainerJob(
postBuildMiddlewareAfter,
orchestratorStepState.environment,
orchestratorStepState.secrets,
);
OrchestratorLogger.logWithTime('Middleware post-build:after container step(s) time');
}
OrchestratorLogger.log(`Orchestrator finished running standard build automation`);
return output;
@@ -50,6 +104,13 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
const buildHooks = CommandHookService.getHooks(Orchestrator.buildParameters.commandHooks).filter((x) =>
x.step?.includes(`build`),
);
// Resolve middleware command hooks for setup and build phases
const middleware = MiddlewareService.getMiddleware(Orchestrator.buildParameters.middleware);
const middlewareSetupBefore = MiddlewareService.resolveCommandHooks(middleware, 'setup', 'before');
const middlewareSetupAfter = MiddlewareService.resolveCommandHooks(middleware, 'setup', 'after');
const middlewareBuildBefore = MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
const middlewareBuildAfter = MiddlewareService.resolveCommandHooks(middleware, 'build', 'after');
const isContainerized =
Orchestrator.buildParameters.providerStrategy === 'aws' ||
Orchestrator.buildParameters.providerStrategy === 'k8s' ||
@@ -71,6 +132,7 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
? 'apt-get install -y curl tar tree npm git-lfs jq git > /dev/null || true\n npm --version || true\n npm i -g n > /dev/null || true\n npm i -g semver > /dev/null || true\n npm install --global yarn > /dev/null || true\n n 20.8.0 || true\n node --version || true'
: '# skipping toolchain setup in local-docker or non-container provider'
}
${middlewareSetupBefore.map((x) => x.commands).join('\n ') || ' '}
${setupHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
${
Orchestrator.buildParameters.providerStrategy === 'local-docker'
@@ -82,9 +144,12 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
export LOG_FILE=${isContainerized ? '/home/job-log.txt' : '$(pwd)/temp/job-log.txt'}
${BuildAutomationWorkflow.setupCommands(builderPath, isContainerized)}
${setupHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
${middlewareSetupAfter.map((x) => x.commands).join('\n ') || ' '}
${middlewareBuildBefore.map((x) => x.commands).join('\n ') || ' '}
${buildHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
${BuildAutomationWorkflow.BuildCommands(builderPath, isContainerized)}
${buildHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}`;
${buildHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
${middlewareBuildAfter.map((x) => x.commands).join('\n ') || ' '}`;
}
private static setupCommands(builderPath: string, isContainerized: boolean) {