Generalize unity-builder plugin contract (#832)

* refactor: generalize unity-builder plugin contract

* test: preserve orchestrator plugin compatibility target

* refactor: rename build plugin to plugin
This commit is contained in:
Frostebite
2026-05-06 17:11:37 +01:00
committed by GitHub
parent 365bdb5e12
commit 821ba97789
9 changed files with 436 additions and 434 deletions

438
dist/index.js generated vendored
View File

@@ -38,7 +38,7 @@ const model_1 = __nccwpck_require__(41359);
const cli_1 = __nccwpck_require__(55651);
const mac_builder_1 = __importDefault(__nccwpck_require__(39364));
const platform_setup_1 = __importDefault(__nccwpck_require__(64423));
const orchestrator_plugin_1 = __nccwpck_require__(2075);
const plugin_1 = __nccwpck_require__(67270);
async function runMain() {
try {
if (cli_1.Cli.InitCliMode()) {
@@ -50,8 +50,8 @@ async function runMain() {
const { workspace, actionFolder } = model_1.Action;
const buildParameters = await model_1.BuildParameters.create();
const baseImage = new model_1.ImageTag(buildParameters);
// Load orchestrator plugin (optional — only needed for remote builds and plugin features)
const plugin = await (0, orchestrator_plugin_1.loadOrchestratorPlugin)();
// Load optional plugin. The default implementation is @game-ci/orchestrator.
const plugin = await (0, plugin_1.loadPlugin)();
await plugin?.initialize(buildParameters, workspace);
let exitCode = -1;
if (plugin?.canHandleBuild()) {
@@ -1522,72 +1522,6 @@ class MacBuilder {
exports["default"] = MacBuilder;
/***/ }),
/***/ 2075:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.loadOrchestratorPlugin = void 0;
const core = __importStar(__nccwpck_require__(42186));
/**
* Attempt to load the orchestrator plugin.
* Returns undefined if @game-ci/orchestrator is not installed.
*/
async function loadOrchestratorPlugin() {
try {
// eslint-disable-next-line import/no-unresolved
const orchestratorModule = await Promise.resolve().then(() => __importStar(__nccwpck_require__(70776)));
if (typeof orchestratorModule.createPlugin !== 'function') {
core.warning('Orchestrator package found but does not export createPlugin(). ' +
'Update @game-ci/orchestrator to the latest version.');
return;
}
return orchestratorModule.createPlugin();
}
catch (error) {
if (!isModuleNotFoundError(error)) {
throw error;
}
}
}
exports.loadOrchestratorPlugin = loadOrchestratorPlugin;
function isModuleNotFoundError(error) {
if (error && typeof error === 'object' && 'code' in error) {
const code = error.code;
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
return true;
}
}
return typeof error?.message === 'string' && /cannot find module/i.test(error.message);
}
/***/ }),
/***/ 85487:
@@ -2092,6 +2026,76 @@ class Platform {
exports["default"] = Platform;
/***/ }),
/***/ 67270:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.loadPlugin = void 0;
const core = __importStar(__nccwpck_require__(42186));
const DEFAULT_PLUGIN_MODULE = '@game-ci/orchestrator';
/**
* Attempt to load the default optional plugin.
*
* Today the default implementation is @game-ci/orchestrator. The loader is
* intentionally named after the generic plugin contract so additional plugin
* implementations can be added without making orchestrator part of the core
* abstraction.
*/
async function loadPlugin(moduleName = DEFAULT_PLUGIN_MODULE) {
try {
const pluginModule = await Promise.resolve().then(() => __importStar(require(/* webpackIgnore: true */ moduleName)));
if (typeof pluginModule.createPlugin !== 'function') {
core.warning(`Plugin package "${moduleName}" found but does not export createPlugin(). ` +
'Update the plugin package to the latest version.');
return;
}
return pluginModule.createPlugin();
}
catch (error) {
if (!isModuleNotFoundError(error)) {
throw error;
}
}
}
exports.loadPlugin = loadPlugin;
function isModuleNotFoundError(error) {
if (error && typeof error === 'object' && 'code' in error) {
const code = error.code;
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
return true;
}
}
return typeof error?.message === 'string' && /cannot find module/i.test(error.message);
}
/***/ }),
/***/ 88666:
@@ -76007,14 +76011,6 @@ module.exports = {
}
/***/ }),
/***/ 70776:
/***/ ((module) => {
module.exports = eval("require")("@game-ci/orchestrator");
/***/ }),
/***/ 22877:
@@ -91074,6 +91070,24 @@ exports.StorageContextClient = StorageContextClient;
/***/ }),
/***/ 39241:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.KnownEncryptionAlgorithmType = void 0;
/** Known values of {@link EncryptionAlgorithmType} that the service accepts. */
var KnownEncryptionAlgorithmType;
(function (KnownEncryptionAlgorithmType) {
KnownEncryptionAlgorithmType["AES256"] = "AES256";
})(KnownEncryptionAlgorithmType || (exports.KnownEncryptionAlgorithmType = KnownEncryptionAlgorithmType = {}));
//# sourceMappingURL=generatedModels.js.map
/***/ }),
/***/ 57955:
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
@@ -101400,6 +101414,132 @@ exports.listType = {
/***/ }),
/***/ 24763:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
//# sourceMappingURL=appendBlob.js.map
/***/ }),
/***/ 57427:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
//# sourceMappingURL=blob.js.map
/***/ }),
/***/ 56945:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
//# sourceMappingURL=blockBlob.js.map
/***/ }),
/***/ 43634:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
//# sourceMappingURL=container.js.map
/***/ }),
/***/ 68529:
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
const tslib_1 = __nccwpck_require__(4351);
tslib_1.__exportStar(__nccwpck_require__(75650), exports);
tslib_1.__exportStar(__nccwpck_require__(43634), exports);
tslib_1.__exportStar(__nccwpck_require__(57427), exports);
tslib_1.__exportStar(__nccwpck_require__(76425), exports);
tslib_1.__exportStar(__nccwpck_require__(24763), exports);
tslib_1.__exportStar(__nccwpck_require__(56945), exports);
//# sourceMappingURL=index.js.map
/***/ }),
/***/ 76425:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
//# sourceMappingURL=pageBlob.js.map
/***/ }),
/***/ 75650:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
//# sourceMappingURL=service.js.map
/***/ }),
/***/ 80313:
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
@@ -104615,132 +104755,6 @@ const filterBlobsOperationSpec = {
/***/ }),
/***/ 24763:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
//# sourceMappingURL=appendBlob.js.map
/***/ }),
/***/ 57427:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
//# sourceMappingURL=blob.js.map
/***/ }),
/***/ 56945:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
//# sourceMappingURL=blockBlob.js.map
/***/ }),
/***/ 43634:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
//# sourceMappingURL=container.js.map
/***/ }),
/***/ 68529:
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
const tslib_1 = __nccwpck_require__(4351);
tslib_1.__exportStar(__nccwpck_require__(75650), exports);
tslib_1.__exportStar(__nccwpck_require__(43634), exports);
tslib_1.__exportStar(__nccwpck_require__(57427), exports);
tslib_1.__exportStar(__nccwpck_require__(76425), exports);
tslib_1.__exportStar(__nccwpck_require__(24763), exports);
tslib_1.__exportStar(__nccwpck_require__(56945), exports);
//# sourceMappingURL=index.js.map
/***/ }),
/***/ 76425:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
//# sourceMappingURL=pageBlob.js.map
/***/ }),
/***/ 75650:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
/*
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* Code generated by Microsoft (R) AutoRest Code Generator.
* Changes may cause incorrect behavior and will be lost if the code is regenerated.
*/
Object.defineProperty(exports, "__esModule", ({ value: true }));
//# sourceMappingURL=service.js.map
/***/ }),
/***/ 50166:
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
@@ -104814,24 +104828,6 @@ exports.StorageClient = StorageClient;
/***/ }),
/***/ 39241:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.KnownEncryptionAlgorithmType = void 0;
/** Known values of {@link EncryptionAlgorithmType} that the service accepts. */
var KnownEncryptionAlgorithmType;
(function (KnownEncryptionAlgorithmType) {
KnownEncryptionAlgorithmType["AES256"] = "AES256";
})(KnownEncryptionAlgorithmType || (exports.KnownEncryptionAlgorithmType = KnownEncryptionAlgorithmType = {}));
//# sourceMappingURL=generatedModels.js.map
/***/ }),
/***/ 37168:
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -24,10 +24,10 @@ const mockPlugin = {
handlePostBuild: jest.fn().mockResolvedValue(undefined),
};
const mockLoadOrchestratorPlugin = jest.fn().mockResolvedValue(mockPlugin);
const mockLoadPlugin = jest.fn().mockResolvedValue(mockPlugin);
jest.mock('./model/orchestrator-plugin', () => ({
loadOrchestratorPlugin: mockLoadOrchestratorPlugin,
jest.mock('./model/plugin', () => ({
loadPlugin: mockLoadPlugin,
}));
jest.mock('@actions/core');
@@ -119,7 +119,7 @@ describe('index.ts plugin lifecycle wiring', () => {
// Reset plugin to default behavior
mockPlugin.canHandleBuild.mockReturnValue(false);
mockPlugin.handleBuild.mockResolvedValue({ exitCode: 0 });
mockLoadOrchestratorPlugin.mockResolvedValue(mockPlugin);
mockLoadPlugin.mockResolvedValue(mockPlugin);
});
afterEach(() => {
@@ -226,7 +226,7 @@ describe('index.ts plugin lifecycle wiring', () => {
describe('no plugin installed', () => {
it('should build locally without errors when providerStrategy is local', async () => {
const { Docker } = require('./model');
mockLoadOrchestratorPlugin.mockResolvedValue(undefined);
mockLoadPlugin.mockResolvedValue(undefined);
await runIndex({ providerStrategy: 'local' });
@@ -235,7 +235,7 @@ describe('index.ts plugin lifecycle wiring', () => {
it('should error when providerStrategy is non-local and no plugin', async () => {
const core = require('@actions/core');
mockLoadOrchestratorPlugin.mockResolvedValue(undefined);
mockLoadPlugin.mockResolvedValue(undefined);
await runIndex({ providerStrategy: 'aws' });

View File

@@ -3,7 +3,7 @@ import { Action, BuildParameters, Cache, Docker, ImageTag, Output } from './mode
import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup';
import { loadOrchestratorPlugin, OrchestratorPlugin } from './model/orchestrator-plugin';
import { Plugin, loadPlugin } from './model/plugin';
async function runMain() {
try {
@@ -19,8 +19,8 @@ async function runMain() {
const buildParameters = await BuildParameters.create();
const baseImage = new ImageTag(buildParameters);
// Load orchestrator plugin (optional — only needed for remote builds and plugin features)
const plugin = await loadOrchestratorPlugin();
// Load optional plugin. The default implementation is @game-ci/orchestrator.
const plugin = await loadPlugin();
await plugin?.initialize(buildParameters, workspace);
let exitCode = -1;
@@ -62,7 +62,7 @@ async function runLocalBuild(
baseImage: ImageTag,
workspace: string,
actionFolder: string,
plugin?: OrchestratorPlugin,
plugin?: Plugin,
): Promise<number> {
await plugin?.beforeLocalBuild(workspace);

View File

@@ -1,129 +1,15 @@
/**
* Tests for the orchestrator plugin loader (orchestrator-plugin.ts).
* Compatibility tests for the legacy orchestrator-plugin module name.
*
* The plugin loader dynamically imports @game-ci/orchestrator and calls
* createPlugin(). Two scenarios:
*
* 1. Package NOT installed — loadOrchestratorPlugin() returns undefined.
* 2. Package IS installed — returns the plugin from createPlugin().
* CI targets this file pattern directly, and consumers may still import this
* module while migrating to the generic plugin API.
*/
const mockWarning = jest.fn();
const mockInfo = jest.fn();
jest.mock('@actions/core', () => ({
warning: mockWarning,
info: mockInfo,
}));
describe('orchestrator-plugin compatibility exports', () => {
it('keeps loadOrchestratorPlugin as an alias for loadPlugin', async () => {
const plugin = await import('./plugin');
const compatibility = await import('./orchestrator-plugin');
beforeEach(() => {
jest.resetModules();
mockWarning.mockClear();
mockInfo.mockClear();
});
// ---------------------------------------------------------------------------
// Part 1: Package NOT installed
// ---------------------------------------------------------------------------
describe('orchestrator-plugin (package not installed)', () => {
it('loadOrchestratorPlugin() returns undefined', async () => {
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
const result = await loadOrchestratorPlugin();
expect(result).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Part 2: Package IS installed (mocked)
// ---------------------------------------------------------------------------
describe('orchestrator-plugin (package installed)', () => {
const fakePlugin = {
initialize: jest.fn(),
canHandleBuild: jest.fn().mockReturnValue(false),
handleBuild: jest.fn().mockResolvedValue({ exitCode: 0 }),
beforeLocalBuild: jest.fn(),
afterLocalBuild: jest.fn(),
handlePostBuild: jest.fn(),
};
const mockCreatePlugin = jest.fn().mockReturnValue(fakePlugin);
function installOrchestratorMock(overrides: Record<string, unknown> = {}) {
jest.doMock(
'@game-ci/orchestrator',
() => ({
createPlugin: mockCreatePlugin,
...overrides,
}),
{ virtual: true },
);
}
beforeEach(() => {
mockCreatePlugin.mockClear();
fakePlugin.initialize.mockClear();
fakePlugin.canHandleBuild.mockClear();
fakePlugin.handleBuild.mockClear();
fakePlugin.beforeLocalBuild.mockClear();
fakePlugin.afterLocalBuild.mockClear();
fakePlugin.handlePostBuild.mockClear();
});
it('returns the plugin from createPlugin()', async () => {
installOrchestratorMock();
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
const plugin = await loadOrchestratorPlugin();
expect(plugin).toBeDefined();
expect(mockCreatePlugin).toHaveBeenCalledTimes(1);
expect(plugin).toBe(fakePlugin);
});
it('returns the plugin with all lifecycle methods', async () => {
installOrchestratorMock();
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
const plugin = await loadOrchestratorPlugin();
expect(typeof plugin!.initialize).toBe('function');
expect(typeof plugin!.canHandleBuild).toBe('function');
expect(typeof plugin!.handleBuild).toBe('function');
expect(typeof plugin!.beforeLocalBuild).toBe('function');
expect(typeof plugin!.afterLocalBuild).toBe('function');
expect(typeof plugin!.handlePostBuild).toBe('function');
});
it('returns undefined and warns when createPlugin is not a function', async () => {
installOrchestratorMock({ createPlugin: undefined });
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
const plugin = await loadOrchestratorPlugin();
expect(plugin).toBeUndefined();
expect(mockWarning).toHaveBeenCalledWith(expect.stringContaining('does not export createPlugin'));
});
// -----------------------------------------------------------------------
// Error handling
// -----------------------------------------------------------------------
describe('error handling', () => {
it('propagates non-MODULE_NOT_FOUND errors', async () => {
const importError = new Error('Syntax error in module');
jest.doMock(
'@game-ci/orchestrator',
() => {
throw importError;
},
{ virtual: true },
);
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
await expect(loadOrchestratorPlugin()).rejects.toThrow('Syntax error in module');
});
expect(compatibility.loadOrchestratorPlugin).toBe(plugin.loadPlugin);
});
});

View File

@@ -1,73 +1,2 @@
import * as core from '@actions/core';
/**
* Lifecycle interface for the orchestrator plugin.
*
* The orchestrator reads its own configuration from environment variables
* and GitHub Actions inputs. Unity-builder only calls these lifecycle hooks
* at the appropriate times — it never needs to know individual plugin params.
*/
export interface OrchestratorPlugin {
// eslint-disable-next-line no-unused-vars
initialize(coreParameters: Record<string, any>, workspace: string): Promise<void>;
/** Whether the plugin wants to handle the entire build (remote, hot runner, test workflow). */
canHandleBuild(): boolean;
/**
* Execute the build when canHandleBuild() returns true.
* If the plugin needs to fall back to a local build (e.g. hot runner failure),
* it returns { exitCode: -1, fallbackToLocal: true }.
*/
// eslint-disable-next-line no-unused-vars
handleBuild(baseImage: string): Promise<{ exitCode: number; fallbackToLocal?: boolean }>;
/** Pre-build hook for local builds (cache restore, git hooks, sync, etc.). */
// eslint-disable-next-line no-unused-vars
beforeLocalBuild(workspace: string): Promise<void>;
/** Post-build hook for local builds (cache save, workspace save, etc.). */
// eslint-disable-next-line no-unused-vars
afterLocalBuild(workspace: string, exitCode: number): Promise<void>;
/** Post-build hook for all build types (archiving, artifacts, etc.). */
// eslint-disable-next-line no-unused-vars
handlePostBuild(exitCode: number): Promise<void>;
}
/**
* Attempt to load the orchestrator plugin.
* Returns undefined if @game-ci/orchestrator is not installed.
*/
export async function loadOrchestratorPlugin(): Promise<OrchestratorPlugin | undefined> {
try {
// eslint-disable-next-line import/no-unresolved
const orchestratorModule = await import('@game-ci/orchestrator');
if (typeof orchestratorModule.createPlugin !== 'function') {
core.warning(
'Orchestrator package found but does not export createPlugin(). ' +
'Update @game-ci/orchestrator to the latest version.',
);
return;
}
return orchestratorModule.createPlugin();
} catch (error) {
if (!isModuleNotFoundError(error)) {
throw error;
}
}
}
function isModuleNotFoundError(error: unknown): boolean {
if (error && typeof error === 'object' && 'code' in error) {
const code = (error as { code: string }).code;
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
return true;
}
}
return typeof (error as Error)?.message === 'string' && /cannot find module/i.test((error as Error).message);
}
export { loadPlugin as loadOrchestratorPlugin } from './plugin';
export type { Plugin as OrchestratorPlugin } from './plugin';

113
src/model/plugin.test.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* Tests for the generic plugin loader (plugin.ts).
*
* The default plugin implementation is currently @game-ci/orchestrator, but
* unity-builder depends on the generic Plugin lifecycle rather than an
* orchestrator-specific type.
*/
const mockWarning = jest.fn();
const mockInfo = jest.fn();
jest.mock('@actions/core', () => ({
warning: mockWarning,
info: mockInfo,
}));
beforeEach(() => {
jest.resetModules();
mockWarning.mockClear();
mockInfo.mockClear();
});
describe('plugin (default package not installed)', () => {
it('loadPlugin() returns undefined', async () => {
const { loadPlugin } = await import('./plugin');
const result = await loadPlugin();
expect(result).toBeUndefined();
});
});
describe('plugin (default package installed)', () => {
const fakePlugin = {
initialize: jest.fn(),
canHandleBuild: jest.fn().mockReturnValue(false),
handleBuild: jest.fn().mockResolvedValue({ exitCode: 0 }),
beforeLocalBuild: jest.fn(),
afterLocalBuild: jest.fn(),
handlePostBuild: jest.fn(),
};
const mockCreatePlugin = jest.fn().mockReturnValue(fakePlugin);
function installDefaultPluginMock(overrides: Record<string, unknown> = {}) {
jest.doMock(
'@game-ci/orchestrator',
() => ({
createPlugin: mockCreatePlugin,
...overrides,
}),
{ virtual: true },
);
}
beforeEach(() => {
mockCreatePlugin.mockClear();
fakePlugin.initialize.mockClear();
fakePlugin.canHandleBuild.mockClear();
fakePlugin.handleBuild.mockClear();
fakePlugin.beforeLocalBuild.mockClear();
fakePlugin.afterLocalBuild.mockClear();
fakePlugin.handlePostBuild.mockClear();
});
it('returns the plugin from createPlugin()', async () => {
installDefaultPluginMock();
const { loadPlugin } = await import('./plugin');
const plugin = await loadPlugin();
expect(plugin).toBeDefined();
expect(mockCreatePlugin).toHaveBeenCalledTimes(1);
expect(plugin).toBe(fakePlugin);
});
it('returns a plugin with all lifecycle methods', async () => {
installDefaultPluginMock();
const { loadPlugin } = await import('./plugin');
const plugin = await loadPlugin();
expect(typeof plugin!.initialize).toBe('function');
expect(typeof plugin!.canHandleBuild).toBe('function');
expect(typeof plugin!.handleBuild).toBe('function');
expect(typeof plugin!.beforeLocalBuild).toBe('function');
expect(typeof plugin!.afterLocalBuild).toBe('function');
expect(typeof plugin!.handlePostBuild).toBe('function');
});
it('returns undefined and warns when createPlugin is not a function', async () => {
installDefaultPluginMock({ createPlugin: undefined });
const { loadPlugin } = await import('./plugin');
const plugin = await loadPlugin();
expect(plugin).toBeUndefined();
expect(mockWarning).toHaveBeenCalledWith(expect.stringContaining('does not export createPlugin'));
});
it('propagates non-MODULE_NOT_FOUND errors', async () => {
const importError = new Error('Syntax error in module');
jest.doMock(
'@game-ci/orchestrator',
() => {
throw importError;
},
{ virtual: true },
);
const { loadPlugin } = await import('./plugin');
await expect(loadPlugin()).rejects.toThrow('Syntax error in module');
});
});

78
src/model/plugin.ts Normal file
View File

@@ -0,0 +1,78 @@
import * as core from '@actions/core';
const DEFAULT_PLUGIN_MODULE = '@game-ci/orchestrator';
/**
* Generic lifecycle contract for optional unity-builder plugins.
*
* Plugins read their own configuration from environment variables and GitHub
* Actions inputs. Unity-builder only calls lifecycle hooks at the points where
* an external implementation can extend or replace the local build flow.
*/
export interface Plugin {
// eslint-disable-next-line no-unused-vars
initialize(coreParameters: Record<string, any>, workspace: string): Promise<void>;
/** Whether the plugin wants to handle the entire build. */
canHandleBuild(): boolean;
/**
* Execute the build when canHandleBuild() returns true.
* If the plugin needs to fall back to a local build, it returns
* { exitCode: -1, fallbackToLocal: true }.
*/
// eslint-disable-next-line no-unused-vars
handleBuild(baseImage: string): Promise<{ exitCode: number; fallbackToLocal?: boolean }>;
/** Pre-build hook for local builds. */
// eslint-disable-next-line no-unused-vars
beforeLocalBuild(workspace: string): Promise<void>;
/** Post-build hook for local builds. */
// eslint-disable-next-line no-unused-vars
afterLocalBuild(workspace: string, exitCode: number): Promise<void>;
/** Post-build hook for all build types. */
// eslint-disable-next-line no-unused-vars
handlePostBuild(exitCode: number): Promise<void>;
}
/**
* Attempt to load the default optional plugin.
*
* Today the default implementation is @game-ci/orchestrator. The loader is
* intentionally named after the generic plugin contract so additional plugin
* implementations can be added without making orchestrator part of the core
* abstraction.
*/
export async function loadPlugin(moduleName = DEFAULT_PLUGIN_MODULE): Promise<Plugin | undefined> {
try {
const pluginModule = await import(/* webpackIgnore: true */ moduleName);
if (typeof pluginModule.createPlugin !== 'function') {
core.warning(
`Plugin package "${moduleName}" found but does not export createPlugin(). ` +
'Update the plugin package to the latest version.',
);
return;
}
return pluginModule.createPlugin();
} catch (error) {
if (!isModuleNotFoundError(error)) {
throw error;
}
}
}
function isModuleNotFoundError(error: unknown): boolean {
if (error && typeof error === 'object' && 'code' in error) {
const code = (error as { code: string }).code;
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
return true;
}
}
return typeof (error as Error)?.message === 'string' && /cannot find module/i.test((error as Error).message);
}

View File

@@ -1,12 +1,12 @@
/**
* Type declarations for @game-ci/orchestrator.
*
* This optional dependency provides remote build orchestration and plugin
* services. When installed, the plugin loader in orchestrator-plugin.ts
* This optional dependency is one implementation of unity-builder's generic
* plugin lifecycle. When installed, the plugin loader in plugin.ts
* dynamically imports it.
*/
declare module '@game-ci/orchestrator' {
interface OrchestratorPlugin {
interface Plugin {
initialize(coreParams: Record<string, any>, workspace: string): Promise<void>;
canHandleBuild(): boolean;
handleBuild(baseImage: string): Promise<{ exitCode: number; fallbackToLocal?: boolean }>;
@@ -16,11 +16,11 @@ declare module '@game-ci/orchestrator' {
}
/**
* Create an orchestrator plugin instance.
* The plugin reads its own configuration from environment variables
* and GitHub Actions inputs unity-builder does not need to proxy them.
* Create a unity-builder plugin instance.
* The plugin reads its own configuration from environment variables and
* GitHub Actions inputs; unity-builder does not need to proxy them.
*/
export function createPlugin(): OrchestratorPlugin;
export function createPlugin(): Plugin;
// Legacy export — kept for backward compatibility with CLI and direct consumers
export const Orchestrator: {