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
+6 -6
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' });
+4 -4
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);
+8 -122
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);
});
});
+2 -73
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
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
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);
}
+7 -7
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: {