mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-13 09:23:52 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf00694232 | |||
| b7f6ea9194 | |||
| 8ccbfad587 | |||
| 483bab04c7 | |||
| 488c259481 | |||
| 7b8f109334 | |||
| f2e7568d47 |
+15
@@ -387,6 +387,21 @@ inputs:
|
|||||||
description:
|
description:
|
||||||
'Path to an external CLI executable that implements the provider protocol. Enables providers written in any
|
'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.'
|
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:
|
outputs:
|
||||||
volume:
|
volume:
|
||||||
|
|||||||
+450
-7
@@ -101,7 +101,7 @@ async function runMain() {
|
|||||||
await LocalCacheService.restoreLibraryCache(projectFullPath, cacheRoot, cacheKey);
|
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) {
|
if (buildParameters.gitHooksEnabled) {
|
||||||
const { GitHooksService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(9146)));
|
const { GitHooksService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(9146)));
|
||||||
await GitHooksService.installHooks(workspace);
|
await GitHooksService.installHooks(workspace);
|
||||||
@@ -110,10 +110,6 @@ async function runMain() {
|
|||||||
Object.assign(process.env, environment);
|
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);
|
await platform_setup_1.default.setup(buildParameters, actionFolder);
|
||||||
exitCode =
|
exitCode =
|
||||||
process.platform === 'darwin'
|
process.platform === 'darwin'
|
||||||
@@ -476,6 +472,8 @@ class BuildParameters {
|
|||||||
gitHooksSkipList: input_1.default.gitHooksSkipList,
|
gitHooksSkipList: input_1.default.gitHooksSkipList,
|
||||||
gitHooksRunBeforeBuild: input_1.default.gitHooksRunBeforeBuild,
|
gitHooksRunBeforeBuild: input_1.default.gitHooksRunBeforeBuild,
|
||||||
providerExecutable: input_1.default.providerExecutable,
|
providerExecutable: input_1.default.providerExecutable,
|
||||||
|
middleware: orchestrator_options_1.default.middleware,
|
||||||
|
middlewareFiles: orchestrator_options_1.default.middlewareFiles,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
static parseBuildFile(filename, platform, androidExportType) {
|
static parseBuildFile(filename, platform, androidExportType) {
|
||||||
@@ -2015,6 +2013,12 @@ class Input {
|
|||||||
static get providerExecutable() {
|
static get providerExecutable() {
|
||||||
return Input.getInput('providerExecutable') ?? '';
|
return Input.getInput('providerExecutable') ?? '';
|
||||||
}
|
}
|
||||||
|
static get middleware() {
|
||||||
|
return Input.getInput('middleware') ?? '';
|
||||||
|
}
|
||||||
|
static get middlewareFiles() {
|
||||||
|
return Input.getInput('middlewareFiles') ?? '';
|
||||||
|
}
|
||||||
static ToEnvVarFormat(input) {
|
static ToEnvVarFormat(input) {
|
||||||
if (input.toUpperCase() === input) {
|
if (input.toUpperCase() === input) {
|
||||||
return input;
|
return input;
|
||||||
@@ -2414,6 +2418,12 @@ class OrchestratorOptions {
|
|||||||
static get commandHookFiles() {
|
static get commandHookFiles() {
|
||||||
return OrchestratorOptions.getInput('commandHookFiles')?.split(`,`) || [];
|
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
|
// Custom commands from yaml parameters
|
||||||
// ### ### ###
|
// ### ### ###
|
||||||
@@ -10426,6 +10436,24 @@ echo "---${buildParameters.logId}"`;
|
|||||||
exports.CommandHookService = CommandHookService;
|
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:
|
/***/ 80824:
|
||||||
@@ -10796,6 +10824,25 @@ class ContainerHookService {
|
|||||||
exports.ContainerHookService = 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:
|
/***/ 9146:
|
||||||
@@ -11013,6 +11060,360 @@ exports.GitHooksService = GitHooksService;
|
|||||||
GitHooksService.UNITY_GIT_HOOKS_PACKAGE = 'com.frostebite.unitygithooks';
|
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:
|
/***/ 85985:
|
||||||
@@ -11428,29 +11829,67 @@ const command_hook_service_1 = __nccwpck_require__(66604);
|
|||||||
const node_path_1 = __importDefault(__nccwpck_require__(49411));
|
const node_path_1 = __importDefault(__nccwpck_require__(49411));
|
||||||
const orchestrator_1 = __importDefault(__nccwpck_require__(8330));
|
const orchestrator_1 = __importDefault(__nccwpck_require__(8330));
|
||||||
const container_hook_service_1 = __nccwpck_require__(80824);
|
const container_hook_service_1 = __nccwpck_require__(80824);
|
||||||
|
const middleware_service_1 = __nccwpck_require__(78089);
|
||||||
|
const custom_workflow_1 = __nccwpck_require__(19118);
|
||||||
class BuildAutomationWorkflow {
|
class BuildAutomationWorkflow {
|
||||||
async run(orchestratorStepState) {
|
async run(orchestratorStepState) {
|
||||||
return await BuildAutomationWorkflow.standardBuildAutomation(orchestratorStepState.image, orchestratorStepState);
|
return await BuildAutomationWorkflow.standardBuildAutomation(orchestratorStepState.image, orchestratorStepState);
|
||||||
}
|
}
|
||||||
static async standardBuildAutomation(baseImage, 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`);
|
orchestrator_logger_1.default.log(`Orchestrator is running standard build automation`);
|
||||||
let output = '';
|
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);
|
output += await container_hook_service_1.ContainerHookService.RunPreBuildSteps(orchestratorStepState);
|
||||||
orchestrator_logger_1.default.logWithTime('Configurable pre build step(s) time');
|
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.log(baseImage);
|
||||||
orchestrator_logger_1.default.logLine(` `);
|
orchestrator_logger_1.default.logLine(` `);
|
||||||
orchestrator_logger_1.default.logLine('Starting build automation job');
|
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);
|
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');
|
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);
|
output += await container_hook_service_1.ContainerHookService.RunPostBuildSteps(orchestratorStepState);
|
||||||
orchestrator_logger_1.default.logWithTime('Configurable post build step(s) time');
|
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`);
|
orchestrator_logger_1.default.log(`Orchestrator finished running standard build automation`);
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
static get BuildWorkflow() {
|
static get BuildWorkflow() {
|
||||||
const setupHooks = command_hook_service_1.CommandHookService.getHooks(orchestrator_1.default.buildParameters.commandHooks).filter((x) => x.step?.includes(`setup`));
|
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`));
|
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' ||
|
const isContainerized = orchestrator_1.default.buildParameters.providerStrategy === 'aws' ||
|
||||||
orchestrator_1.default.buildParameters.providerStrategy === 'k8s' ||
|
orchestrator_1.default.buildParameters.providerStrategy === 'k8s' ||
|
||||||
orchestrator_1.default.buildParameters.providerStrategy === 'local-docker';
|
orchestrator_1.default.buildParameters.providerStrategy === 'local-docker';
|
||||||
@@ -11465,6 +11904,7 @@ class BuildAutomationWorkflow {
|
|||||||
${isContainerized && orchestrator_1.default.buildParameters.providerStrategy !== 'local-docker'
|
${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'
|
? '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'}
|
: '# 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) || ' '}
|
${setupHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
|
||||||
${orchestrator_1.default.buildParameters.providerStrategy === 'local-docker'
|
${orchestrator_1.default.buildParameters.providerStrategy === 'local-docker'
|
||||||
? `export GITHUB_WORKSPACE="${orchestrator_1.default.buildParameters.dockerWorkspacePath}"
|
? `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'}
|
export LOG_FILE=${isContainerized ? '/home/job-log.txt' : '$(pwd)/temp/job-log.txt'}
|
||||||
${BuildAutomationWorkflow.setupCommands(builderPath, isContainerized)}
|
${BuildAutomationWorkflow.setupCommands(builderPath, isContainerized)}
|
||||||
${setupHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
|
${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) || ' '}
|
${buildHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
|
||||||
${BuildAutomationWorkflow.BuildCommands(builderPath, isContainerized)}
|
${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) {
|
static setupCommands(builderPath, isContainerized) {
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-4
@@ -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) {
|
if (buildParameters.gitHooksEnabled) {
|
||||||
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
|
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
|
||||||
await GitHooksService.installHooks(workspace);
|
await GitHooksService.installHooks(workspace);
|
||||||
@@ -110,9 +110,6 @@ async function runMain() {
|
|||||||
const environment = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
|
const environment = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
|
||||||
Object.assign(process.env, environment);
|
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);
|
await PlatformSetup.setup(buildParameters, actionFolder);
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ class BuildParameters {
|
|||||||
public gitHooksSkipList!: string;
|
public gitHooksSkipList!: string;
|
||||||
public gitHooksRunBeforeBuild!: string;
|
public gitHooksRunBeforeBuild!: string;
|
||||||
public providerExecutable!: string;
|
public providerExecutable!: string;
|
||||||
|
public middleware!: string;
|
||||||
|
public middlewareFiles!: string[];
|
||||||
|
|
||||||
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
|
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
|
||||||
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
|
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
|
||||||
@@ -280,6 +282,8 @@ class BuildParameters {
|
|||||||
gitHooksSkipList: Input.gitHooksSkipList,
|
gitHooksSkipList: Input.gitHooksSkipList,
|
||||||
gitHooksRunBeforeBuild: Input.gitHooksRunBeforeBuild,
|
gitHooksRunBeforeBuild: Input.gitHooksRunBeforeBuild,
|
||||||
providerExecutable: Input.providerExecutable,
|
providerExecutable: Input.providerExecutable,
|
||||||
|
middleware: OrchestratorOptions.middleware,
|
||||||
|
middlewareFiles: OrchestratorOptions.middlewareFiles,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -358,6 +358,14 @@ class Input {
|
|||||||
return Input.getInput('providerExecutable') ?? '';
|
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) {
|
public static ToEnvVarFormat(input: string) {
|
||||||
if (input.toUpperCase() === input) {
|
if (input.toUpperCase() === input) {
|
||||||
return input;
|
return input;
|
||||||
|
|||||||
@@ -166,6 +166,14 @@ class OrchestratorOptions {
|
|||||||
return OrchestratorOptions.getInput('commandHookFiles')?.split(`,`) || [];
|
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
|
// Custom commands from yaml parameters
|
||||||
// ### ### ###
|
// ### ### ###
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ submodules:
|
|||||||
mockedFs.readFileSync.mockImplementation((filePath: any) => {
|
mockedFs.readFileSync.mockImplementation((filePath: any) => {
|
||||||
if (filePath === '/base.yml') return baseYaml;
|
if (filePath === '/base.yml') return baseYaml;
|
||||||
if (filePath === '/variant.yml') return variantYaml;
|
if (filePath === '/variant.yml') return variantYaml;
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,6 +197,7 @@ submodules:
|
|||||||
mockedFs.readFileSync.mockImplementation((filePath: any) => {
|
mockedFs.readFileSync.mockImplementation((filePath: any) => {
|
||||||
if (String(filePath).endsWith('profile.yml')) return profileYaml;
|
if (String(filePath).endsWith('profile.yml')) return profileYaml;
|
||||||
if (String(filePath).endsWith('.gitmodules')) return gitmodulesContent;
|
if (String(filePath).endsWith('.gitmodules')) return gitmodulesContent;
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,6 +251,7 @@ submodules:
|
|||||||
if (p.endsWith('profile.yml')) return profileYaml;
|
if (p.endsWith('profile.yml')) return profileYaml;
|
||||||
if (p.endsWith('variant.yml')) return variantYaml;
|
if (p.endsWith('variant.yml')) return variantYaml;
|
||||||
if (p.endsWith('.gitmodules')) return gitmodulesContent;
|
if (p.endsWith('.gitmodules')) return gitmodulesContent;
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,4 +312,74 @@ submodules:
|
|||||||
expect(mockedSystem.Run).not.toHaveBeenCalledWith(expect.stringContaining('git config url'));
|
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/"');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { CommandHookService } from '../services/hooks/command-hook-service';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import Orchestrator from '../orchestrator';
|
import Orchestrator from '../orchestrator';
|
||||||
import { ContainerHookService } from '../services/hooks/container-hook-service';
|
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 {
|
export class BuildAutomationWorkflow implements WorkflowInterface {
|
||||||
async run(orchestratorStepState: OrchestratorStepParameters) {
|
async run(orchestratorStepState: OrchestratorStepParameters) {
|
||||||
@@ -13,13 +15,41 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async standardBuildAutomation(baseImage: string, orchestratorStepState: OrchestratorStepParameters) {
|
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`);
|
OrchestratorLogger.log(`Orchestrator is running standard build automation`);
|
||||||
|
|
||||||
let output = '';
|
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);
|
output += await ContainerHookService.RunPreBuildSteps(orchestratorStepState);
|
||||||
OrchestratorLogger.logWithTime('Configurable pre build step(s) time');
|
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.log(baseImage);
|
||||||
OrchestratorLogger.logLine(` `);
|
OrchestratorLogger.logLine(` `);
|
||||||
OrchestratorLogger.logLine('Starting build automation job');
|
OrchestratorLogger.logLine('Starting build automation job');
|
||||||
@@ -35,9 +65,33 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
|||||||
);
|
);
|
||||||
OrchestratorLogger.logWithTime('Build time');
|
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);
|
output += await ContainerHookService.RunPostBuildSteps(orchestratorStepState);
|
||||||
OrchestratorLogger.logWithTime('Configurable post build step(s) time');
|
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`);
|
OrchestratorLogger.log(`Orchestrator finished running standard build automation`);
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
@@ -50,6 +104,13 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
|||||||
const buildHooks = CommandHookService.getHooks(Orchestrator.buildParameters.commandHooks).filter((x) =>
|
const buildHooks = CommandHookService.getHooks(Orchestrator.buildParameters.commandHooks).filter((x) =>
|
||||||
x.step?.includes(`build`),
|
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 =
|
const isContainerized =
|
||||||
Orchestrator.buildParameters.providerStrategy === 'aws' ||
|
Orchestrator.buildParameters.providerStrategy === 'aws' ||
|
||||||
Orchestrator.buildParameters.providerStrategy === 'k8s' ||
|
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'
|
? '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'
|
: '# 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) || ' '}
|
${setupHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
|
||||||
${
|
${
|
||||||
Orchestrator.buildParameters.providerStrategy === 'local-docker'
|
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'}
|
export LOG_FILE=${isContainerized ? '/home/job-log.txt' : '$(pwd)/temp/job-log.txt'}
|
||||||
${BuildAutomationWorkflow.setupCommands(builderPath, isContainerized)}
|
${BuildAutomationWorkflow.setupCommands(builderPath, isContainerized)}
|
||||||
${setupHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
|
${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) || ' '}
|
${buildHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
|
||||||
${BuildAutomationWorkflow.BuildCommands(builderPath, isContainerized)}
|
${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) {
|
private static setupCommands(builderPath: string, isContainerized: boolean) {
|
||||||
|
|||||||
Reference in New Issue
Block a user