Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/recipe-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ top-level fields:
- `workspaces`
- `workspace_preloads`
- `extra_plugins`
- `extra_themes`
- `component_manifest`
- `dependency_overlays`
- `runtimeEnv`
Expand Down Expand Up @@ -298,6 +299,46 @@ Supported `loadAs` values:
External HTTPS zip downloads are gated by `WP_CODEBOX_ALLOW_NETWORK_DOWNLOADS=1`.
Local paths are resolved relative to the recipe file.

## Extra Themes

`inputs.extra_themes` mounts additional WordPress themes before workflow steps
run, resolved through the same local/local-zip/HTTPS-zip source layer and
`sha256` pinning as `inputs.extra_plugins`. Each entry requires `source` or
`sourcePath` and may include `sourceSubdir`, `mountSlug`, `activate`, and
`sha256`. Themes have no plugin-style entrypoint; the contract is `style.css`
carrying a non-empty `Theme Name` header, an optional `Template` header naming
a parent theme slug, and one of `index.php`, `templates/index.html`, or
`block-templates/index.html`. A remote source cannot be inspected before it is
downloaded, so this contract is checked after materialization, not at recipe
validation time.

```json
{
"inputs": {
"extra_themes": [
{
"source": "https://github.com/example/theme/releases/download/v1.0.0/theme.zip",
"slug": "example-theme",
"sha256": "…",
"activate": true
},
{
"source": "../child-theme",
"slug": "example-theme-child"
}
]
}
}
```

Each theme mounts below `/wordpress/wp-content/themes/<slug>`. At most one
entry may set `activate: true`; that theme is activated with `switch_theme`,
not `activate_plugin`. A child theme's `Template` header must name another
`extra_themes` entry's slug, and that parent must itself be a standalone theme
(no `Template` header of its own). External HTTPS zip downloads are gated by
`WP_CODEBOX_ALLOW_NETWORK_DOWNLOADS=1`. Local paths are resolved relative to
the recipe file.

## Source Packages

`inputs.sourcePackages` materializes local recipe source directories below the
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/recipe-run-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ export interface RecipeDiagnosticArtifactRef {

export type RecipePhasedPluginInputStage = "collect" | "project" | "resolve" | "mount" | "activate" | "readiness"

export type RecipePhaseName = "provision_runtime_services" | "runtime_startup" | "mount_plugins" | "materialize_runtime_inputs" | "activate_plugins" | "collect_phased_plugin_input" | "project_phased_plugin_input" | "resolve_phased_plugin_input" | "mount_phased_plugins" | "activate_phased_plugins" | "phased_plugin_readiness" | "run_blueprint_steps" | "apply_distribution" | "import_fixture_databases" | "run_distribution_setup_artifacts" | "run_distribution_startup_probes" | "run_workloads" | "run_adversarial_campaigns" | "run_probes" | "collect_artifacts"
export type RecipePhaseName = "provision_runtime_services" | "runtime_startup" | "mount_plugins" | "mount_themes" | "materialize_runtime_inputs" | "activate_plugins" | "activate_theme" | "collect_phased_plugin_input" | "project_phased_plugin_input" | "resolve_phased_plugin_input" | "mount_phased_plugins" | "activate_phased_plugins" | "phased_plugin_readiness" | "run_blueprint_steps" | "apply_distribution" | "import_fixture_databases" | "run_distribution_setup_artifacts" | "run_distribution_startup_probes" | "run_workloads" | "run_adversarial_campaigns" | "run_probes" | "collect_artifacts"

export interface RecipePhaseEvidence {
schema: "wp-codebox/recipe-phase-evidence/v1"
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/commands/recipe-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { appendRecipeRuntimeEvidence, collectAndFinalizeFailedRecipeArtifacts, c
import { recipeExternalServiceBoundarySummaries } from "../recipe-external-services.js"
import { mergeRecipeSecretEnvSummary, resolveRecipeSecretEnv } from "../recipe-secret-env.js"
import type { PreparedRuntimeBackendPackage } from "../recipe-backend-package.js"
import { cleanupRecipePreparedSources, recipeBlueprintWithBootActivePlugins, recipeExtraPlugins, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js"
import { cleanupRecipePreparedSources, recipeBlueprintWithBootActivePlugins, recipeExtraPlugins, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedExtraTheme, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js"
import { loadWorkspaceRecipe, recipePolicy, recipeWorkflowSteps, validateRecipeRuntimePolicy, validateWorkspaceRecipe, type RecipeWorkflowPhase } from "../recipe-validation.js"
import { resolveCliRuntimeBackend } from "../runtime-backends.js"
import { previewSpec, releaseRuntime, runtimeMetadata, type RunOutput } from "../runtime-command-wrappers.js"
Expand Down Expand Up @@ -206,6 +206,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
let effectivePolicy = Object.keys(secretEnv).length > 0 ? { ...policy, secrets: "connector-scoped" as const } : policy
let workspaceMounts: PreparedWorkspaceMount[] = []
let extraPlugins: PreparedExtraPlugin[] = []
let extraThemes: PreparedExtraTheme[] = []
let dependencyOverlays: PreparedDependencyOverlay[] = []
let stagedFiles: PreparedStagedFile[] = []
let overlays: PreparedRuntimeOverlay[] = []
Expand Down Expand Up @@ -250,7 +251,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe

try {
const preparedRuntimeSetup = await prepareRecipeRuntimeSetup(recipe, recipeDirectory, plan.runtime.backend)
;({ workspaceMounts, extraPlugins, dependencyOverlays, stagedFiles, overlays, inputMountBaselinePaths, inputMountPathMap, backendPackage } = preparedRuntimeSetup)
;({ workspaceMounts, extraPlugins, extraThemes = [], dependencyOverlays, stagedFiles, overlays, inputMountBaselinePaths, inputMountPathMap, backendPackage } = preparedRuntimeSetup)
interruption?.throwIfInterrupted()

runRecord = await runRegistry.update(runRecord.runId, { status: "booting" })
Expand Down Expand Up @@ -505,7 +506,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
}
})
await releaseManagedServices()
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays)
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays, extraThemes)
await cleanupInputMountBaselines(inputMountBaselinePaths)
})
runRecord = await runRegistry.read(runRecord.runId)
Expand Down Expand Up @@ -646,7 +647,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe

cleanupEvidence = await runManagedServiceCleanup(runRegistry, runRecord, serviceEvidence, true, async () => {
await releaseManagedServices()
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays)
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays, extraThemes)
await cleanupInputMountBaselines(inputMountBaselinePaths)
})
runRecord = await runRegistry.read(runRecord.runId)
Expand Down
103 changes: 101 additions & 2 deletions packages/cli/src/commands/recipe-runtime-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"
import { join, posix, resolve } from "node:path"
import { booleanCommandArg, phpRuntimeRecipePluginPreloadFunction, type ExecutionResult, type MountSpec, type Runtime, type RuntimeCreateSpec, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntimeHealthProbe } from "@automattic/wp-codebox-core"
import { requiresManagedMultisitePreinstall } from "@automattic/wp-codebox-playground"
import { installMuPluginsCode, installPluginComposerAutoloadersCode, prepareExtraPlugins, prepareRecipeDependencyOverlays, prepareRecipeExtraPlugins, prepareRecipeRuntimeOverlays, prepareRecipeStagedFiles, prepareRecipeWorkspacePreloads, prepareRecipeWorkspaces, recipeMountType, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js"
import { installMuPluginsCode, installPluginComposerAutoloadersCode, prepareExtraPlugins, prepareRecipeDependencyOverlays, prepareRecipeExtraPlugins, prepareRecipeExtraThemes, prepareRecipeRuntimeOverlays, prepareRecipeStagedFiles, prepareRecipeWorkspacePreloads, prepareRecipeWorkspaces, recipeMountType, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedExtraTheme, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js"
import { pluginRuntimeHealthProbeStep, type RecipeWorkflowPhase } from "../recipe-validation.js"
import { pluginRuntimeHealthProbeStepIndex, pluginRuntimeSetupStepIndex } from "../recipe-dry-run.js"
import { prepareRecipeRuntimeBackendPackage, type PreparedRuntimeBackendPackage } from "../recipe-backend-package.js"
Expand All @@ -17,6 +17,7 @@ export { assertResolvedInputMountPathArgs, recipeInputMountPathMap, rewriteInput
export interface PreparedRecipeRuntimeSetup {
workspaceMounts: PreparedWorkspaceMount[]
extraPlugins: PreparedExtraPlugin[]
extraThemes?: PreparedExtraTheme[]
dependencyOverlays: PreparedDependencyOverlay[]
stagedFiles: PreparedStagedFile[]
overlays: PreparedRuntimeOverlay[]
Expand Down Expand Up @@ -75,13 +76,15 @@ export async function applyPhasedRecipePlugins(args: {

export async function prepareRecipeRuntimeSetup(recipe: WorkspaceRecipe, recipeDirectory: string, runtimeBackend: string): Promise<PreparedRecipeRuntimeSetup> {
const extraPlugins = await prepareRecipeExtraPlugins(recipe, recipeDirectory)
const extraThemes = await prepareRecipeExtraThemes(recipe, recipeDirectory)
const workspaceMounts = [
...await prepareRecipeWorkspaces(recipe, recipeDirectory),
...await prepareRecipeWorkspacePreloads(recipe),
]
return {
workspaceMounts,
extraPlugins,
extraThemes,
dependencyOverlays: await prepareRecipeDependencyOverlays(recipe, recipeDirectory, extraPlugins),
stagedFiles: await prepareRecipeStagedFiles(recipe, recipeDirectory),
overlays: await prepareRecipeRuntimeOverlaysForRun(recipe, recipeDirectory),
Expand All @@ -101,7 +104,7 @@ export async function applyRecipeRuntimeSetup(args: {
interruption?: RecipeInterruptionController
}): Promise<RecipeRuntimeSetupResult> {
const { recipe, recipeDirectory, runtime, runtimeSpec, prepared, phaseExecutor, interruption } = args
const { workspaceMounts, extraPlugins, dependencyOverlays, stagedFiles, overlays, inputMountBaselinePaths, inputMountPathMap } = prepared
const { workspaceMounts, extraPlugins, extraThemes = [], dependencyOverlays, stagedFiles, overlays, inputMountBaselinePaths, inputMountPathMap } = prepared
const executions: RecipeExecutionResult[] = []
const phaseTracker = phaseExecutor.tracker
const awaitRecipe = <T>(operation: string, promiseOrFactory: Promise<T> | (() => Promise<T>), timeoutMs?: number): Promise<T> => phaseExecutor.operation(operation, promiseOrFactory, timeoutMs)
Expand Down Expand Up @@ -176,6 +179,9 @@ export async function applyRecipeRuntimeSetup(args: {
const extraPluginMounts = preparedExtraPluginMounts(extraPlugins)
await mountPreparedExtraPlugins(runtime, extraPlugins, extraPluginMounts, phaseExecutor, interruption, "mount_plugins")

const extraThemeMounts = preparedExtraThemeMounts(extraThemes)
await mountPreparedExtraThemes(runtime, extraThemes, extraThemeMounts, phaseExecutor, interruption, "mount_themes")

await phaseTracker.run("materialize_runtime_inputs", phaseRuntimeInputData(recipe, extraPlugins, stagedFiles, dependencyOverlays), async () => {
for (const overlay of overlayCopies) {
executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(copyRuntimeOverlayCode(overlay.source, overlay.target)) }), "setup", -3, `runtime.overlay.copy:${overlay.target}`))
Expand Down Expand Up @@ -225,6 +231,7 @@ export async function applyRecipeRuntimeSetup(args: {

const materializableMounts: MountSpec[] = [
...extraPluginMounts,
...extraThemeMounts,
...inputMounts,
...stagedFiles.map((stagedFile) => ({
type: stagedFile.type,
Expand Down Expand Up @@ -259,6 +266,16 @@ export async function applyRecipeRuntimeSetup(args: {
activationPhase: "activate_plugins",
}))

executions.push(...await installPreparedExtraThemeRuntime({
runtime,
themes: extraThemes,
phaseExecutor,
interruption,
recipePhase: "setup",
activationStepIndex: -1,
activationPhase: "activate_theme",
}))

for (const [index, setupStep] of (recipe.inputs?.pluginRuntime?.setup ?? []).entries()) {
executions.push(await awaitRecipe(`plugin-runtime.setup[${index}]`, executeRecipePluginRuntimeStep(runtime, setupStep, recipeDirectory, "setup", index, inputMountPathMap)))
interruption?.throwIfInterrupted()
Expand Down Expand Up @@ -480,6 +497,36 @@ async function mountPreparedExtraPlugins(runtime: Runtime, plugins: PreparedExtr
})
}

function preparedExtraThemeMounts(themes: PreparedExtraTheme[]): MountSpec[] {
return themes.map((theme) => ({
type: "directory",
source: theme.source,
target: theme.target,
mode: "readonly",
metadata: {
kind: "extra-theme",
slug: theme.slug,
source: theme.provenance,
},
}))
}

async function mountPreparedExtraThemes(runtime: Runtime, themes: PreparedExtraTheme[], mounts: MountSpec[], phaseExecutor: RecipeRunPhaseExecutor, interruption: RecipeInterruptionController | undefined, phaseName: RecipePhaseName): Promise<void> {
await phaseExecutor.tracker.run(phaseName, phaseThemeMountData(themes), async () => {
for (const [index, theme] of themes.entries()) {
await phaseExecutor.operation(`extra-theme.mount:${theme.slug}`, runtime.mount(mounts[index]))
interruption?.throwIfInterrupted()
}
})
}

function phaseThemeMountData(themes: PreparedExtraTheme[]): Record<string, unknown> {
return {
count: themes.length,
themes: themes.map((theme) => ({ slug: theme.slug, target: theme.target, activate: theme.activate })),
}
}

function canMaterializeMounts(runtime: Runtime): boolean {
return typeof runtime.materializeStagedInputs === "function" || typeof runtime.materializeMounts === "function"
}
Expand Down Expand Up @@ -532,6 +579,40 @@ function phasePluginMountData(extraPlugins: PreparedExtraPlugin[]): Record<strin
}
}

/**
* Themes have no mu-plugin loader, no Composer autoload, and no phased/
* artifact-based install path -- those are plugin-specific component-
* contract concerns (component-contracts.ts resolvePluginEntrypointContract)
* that do not apply to a theme's style.css contract. At most one extra_themes
* entry may declare activate:true (enforced in prepareExtraThemes), so this
* mounts every prepared theme and activates that single theme via
* switch_theme, not activate_plugin.
*/
async function installPreparedExtraThemeRuntime(args: {
runtime: Runtime
themes: PreparedExtraTheme[]
phaseExecutor: RecipeRunPhaseExecutor
interruption?: RecipeInterruptionController
recipePhase: RecipeWorkflowPhase
activationStepIndex: number
activationPhase: RecipePhaseName
}): Promise<RecipeExecutionResult[]> {
const { runtime, themes, phaseExecutor, interruption, recipePhase, activationStepIndex, activationPhase } = args
const executions: RecipeExecutionResult[] = []
const activeTheme = themes.find((theme) => theme.activate)
if (!activeTheme) return executions

await phaseExecutor.tracker.run(activationPhase, phaseThemeActivationData(activeTheme), async () => {
executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(activateExtraThemeCode(activeTheme)) }), recipePhase, activationStepIndex, `extra-theme.activate:${activeTheme.slug}`))
interruption?.throwIfInterrupted()
})
return executions
}

function phaseThemeActivationData(theme: PreparedExtraTheme): Record<string, unknown> {
return { slug: theme.slug, target: theme.target, themeName: theme.themeName }
}

function phaseRuntimeInputData(recipe: WorkspaceRecipe, extraPlugins: PreparedExtraPlugin[], stagedFiles: PreparedStagedFile[], dependencyOverlays: PreparedDependencyOverlay[]): Record<string, unknown> {
const inputMounts = recipe.inputs?.mounts ?? []
return {
Expand Down Expand Up @@ -694,3 +775,21 @@ if (is_wp_error($result)) {
do_action('wp_codebox_runtime_plugin_activated', $plugin_file);
echo wp_json_encode(array('activated' => $plugin_file));`
}

function activateExtraThemeCode(theme: PreparedExtraTheme): string {
const stylesheet = theme.slug
return `$stylesheet = ${JSON.stringify(stylesheet)};
$theme = wp_get_theme($stylesheet);
if (!$theme->exists()) {
throw new RuntimeException('Recipe extra theme is not available for activation: ' . $stylesheet);
}
if ($theme->errors()) {
throw new RuntimeException('Recipe extra theme has errors: ' . $stylesheet . ': ' . $theme->errors()->get_error_message());
}
switch_theme($stylesheet);
if (get_stylesheet() !== $stylesheet) {
throw new RuntimeException('Failed to activate extra theme: ' . $stylesheet);
}
do_action('wp_codebox_runtime_theme_activated', $stylesheet);
echo wp_json_encode(array('activated' => $stylesheet));`
}
Loading
Loading