Repository navigation
feat(recipe): add extra_themes remote/local theme sourcing - #2519
Merged
Merged
Conversation
Adds a first-class inputs.extra_themes recipe input so themes can be mounted from a local directory, local zip, or generic HTTPS zip URL (e.g. a GitHub Release asset) with sha256 pinning, the way inputs.extra_plugins already supports. Closes the gap where themes could only be mounted through the generic `mounts` primitive, which resolves `source` strictly as a local path. - Reuses prepareRecipeSource (recipe-sources.ts) verbatim for local/ https_zip/sha256 resolution -- no second downloader, no reimplemented hashing. The plugin/theme slug, sourceRoot, and sourceSubpath accessors are refactored into a shared generic resolver so both inputs share that logic instead of duplicating it. - Adds a theme-specific contract (component-contracts.ts resolveThemeEntrypointContract): style.css with a non-empty Theme Name header, an optional Template parent header, and an entrypoint of index.php, templates/index.html, or block-templates/index.html. This does not extend resolvePluginEntrypointContract -- a theme has no plugin-style entrypoint file to resolve. - Contract checks run after materialization (prepareExtraThemes), not at recipe-build time, since a remote URL cannot be inspected before download. - Enforces at most one active theme and that a child theme's Template parent is itself listed and standalone. - Wires mounting into wp-content/themes/<slug> and activation via switch_theme (not activate_plugin) into recipe-runtime-setup.ts and recipe-run.ts. - Adds inputs.extra_themes to the recipe JSON schema, runtime-core types, and docs/recipe-contract.md (schema-parity coverage). Deferred (not wired in this change, noted for follow-up): - recipe-dry-run.ts preview/step-index reporting - output.ts human-readable summary counts - recipe-builders.ts programmatic recipe-builder convenience - agent-task-recipe.ts / wordpress-runtime.ts / php-bootstrap.ts component_manifest/runtime-requirements resolution - recipe-run.ts's deep component-contract/evidence-replay JSON (componentContractResults, recipeComponentManifest, preparedExtraPluginReceipts) -- plugin class-loading/autoload diagnostics that do not apply to a theme's style.css contract These are secondary UX/reporting/builder-convenience layers; a recipe run through `wp-codebox run` materializes, validates, mounts, and activates inputs.extra_themes end-to-end without them.
chubes4
added a commit
to Extra-Chill/extrachill-network
that referenced
this pull request
Sep 23, 2026
Wires `homeboy rig up extrachill-network` into deploy.yml as a pre-deploy gate (extrachill-network#264), the wiring #223 explicitly left out of scope. - Resolve step (id: resolve) computes the exact release set Plan's command would touch, always as a dry run, independent of HOMEBOY_OUTPUT_DIR so it never pollutes Deploy/Verify's own result aggregation. - Build network gate release set (id: release_set) turns that resolution into the rig's extrachill_release_set / extrachill_theme_source settings and a component_count used to skip the gate entirely when nothing is outdated (the normal state on most cron ticks). - Network rig gate (id: gate) boots the rig on the GitHub runner via a pinned wp-codebox v0.27.0 build (Automattic/wp-codebox#2519 theme-zip support) and a pinned homeboy CLI binary, gated on component_count and the new skip_network_gate bypass input. - Summarize network gate result extracts pass/fail, failing pipeline steps, and site-naming assertion errors into the run summary; evidence uploads as network-gate-evidence-<run_id>. - Report to Discord gets a distinct "Network gate failed" red, separate from the existing misconfigured-component red, plus a bypass notice. Race note: the release set is resolved once, before the gate's ~3 minute boot; Deploy re-resolves --outdated afterward rather than being pinned to the exact gated refs, because homeboy's --release-set requires local Git checkouts (conflicts with this workflow's checkout-less design). This narrows the gate/deploy race, it does not close it — closing it needs a checkout-less bulk version-pinned deploy primitive upstream in homeboy. Not deployed, not released, not merged: ship to PR only.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes Extra-Chill/homeboy-extensions#2857.
The gap
inputs.extra_pluginscan source a component from a local directory, localzip, WordPress.org plugin zip, or generic HTTPS zip URL with
sha256pinning (
packages/runtime-core/src/recipe-schema.ts:862-885extraThememirrors the
extraPlugindef at:876-905of the same file). Themes had noequivalent: they could only be mounted through the generic
inputs.mountsprimitive, which resolves
sourcestrictly as a local path(
packages/runtime-core/src/recipe-schema.tsmountdef,type/source/target, no zip/URL/sha256 handling). That blocked release-tag-pinnedtheme mounting — the one component class that is not a plugin.
Why
loadAs: "theme"was rejectedpackages/runtime-core/src/component-contracts.ts:21resolvePluginEntrypointContract()resolves a plugin entrypoint:
<slug>.php,plugin.php, or a filecarrying a
Plugin Name:header (:30-38). A theme has no such file. Itscontract is
style.csswith aTheme Nameheader, an optionalTemplateheader naming a parent for child themes, and an entrypoint of
index.php,templates/index.html, orblock-templates/index.html. ExtendingloadAsto include
"theme"would route every theme through a plugin-file resolverthat cannot find one. So this PR shares the source layer, not the
component-contract layer: a new
resolveThemeEntrypointContract()(
packages/runtime-core/src/component-contracts.ts:118-141) is a siblingfunction, not a branch inside the plugin resolver.
What the new input does
inputs.extra_themes(schema:packages/runtime-core/src/recipe-schema.ts:203-207input,
:862-885extraThemedef; types:packages/runtime-core/src/runtime-contracts.tsWorkspaceRecipeExtraTheme+inputs.extra_themes):source/sourcePath(+sourceRoot/sourceSubpath/sourceSubdir/originalSource) resolve local directory, local zip, or HTTPS zipsources, with optional
slug/mountSlugandsha256pinning — sameshape as
extra_pluginsminus the plugin-onlypluginFile/loadAs/composerfields./wordpress/wp-content/themes/<slug>(
packages/cli/src/recipe-sources.ts:2115themeTarget).activate: true(
packages/cli/src/recipe-sources.ts:352-354and shape validation atpackages/cli/src/recipe-validation.tsactiveThemeCount); that theme isactivated with
switch_theme(), neveractivate_plugin()(
packages/cli/src/commands/recipe-runtime-setup.tsactivateExtraThemeCode).Templateheader must name anotherextra_themesslug,and that parent must itself be standalone (no
Templateof its own) —enforced in
prepareExtraThemes(packages/cli/src/recipe-sources.ts:345-416)after every theme in the batch is materialized, so cross-theme checks run
once, not per-theme.
style.css/Theme Name/entrypoint) run aftermaterialization — a remote URL cannot be inspected before download. Local
non-zip directory sources get an additional pre-download semantic check in
validateWorkspaceRecipeSemanticssince those can be inspectedimmediately; local zip and remote sources defer entirely to
prepareExtraThemes.Exactly which existing source-layer code is reused, not duplicated
prepareRecipeSource()(packages/cli/src/recipe-sources.ts:1720) iscalled verbatim for themes — same
local/https_zip/wporg_plugin_zipclassification (RecipeSourceType,:39), samesha256verification, samezip-source.tsdownloader/extractor, samesource-policy.tshost-allowlist/size-limit/WP_CODEBOX_ALLOW_NETWORK_DOWNLOADSgate. No second downloader, no reimplemented hashing, no shelling to
curl/wget.
extra_pluginsalready had (
recipeExtraPluginSlug/Source/SourceRoot/SourceSubpath)was refactored into shared generic helpers
(
resolveExternalSourceSlug/Ref/Root/Subpath,packages/cli/src/recipe-sources.tsaboverecipeExtraPluginSlug), soextra_pluginsandextra_themesaccessors are both thin wrappers overthe same logic instead of two independent copies. This refactor is
behavior-preserving (verified: full existing
extra_pluginstest suitepasses unchanged, see below).
Runtime wiring
recipe-runtime-setup.ts: mounts every prepared theme (mount_themesphase, materialized alongside plugin/input mounts), then — only when exactly
one theme is
activate: true— runs awordpress.run-phpstep callingswitch_theme(). Themes get no mu-plugin loader, no Composer autoloadinstall, and no phased/artifact install path; those are plugin-specific
component-contract concerns that don't apply to a theme's
style.csscontract.
recipe-run.tswiresprepareRecipeExtraThemes/cleanup throughthe run lifecycle (
cleanupRecipePreparedSourcesgains a 6th optionalextraThemesparam, default[], so existing 5-arg call sites/tests areunaffected).
Deferred (explicitly, not half-wired)
extra_themesfully works throughwp-codebox run(validate → materialize→ mount → activate). These are secondary UX/reporting/builder-convenience
layers, intentionally out of scope for this slice:
recipe-dry-run.tspreview/step-index reportingoutput.tshuman-readable summary countsrecipe-builders.tsprogrammatic recipe-builder convenienceagent-task-recipe.ts/wordpress-runtime.ts/php-bootstrap.ts—a separate
component_manifest/"runtime requirements" resolution systemfor agent-task recipes, distinct from
inputs.extra_themesrecipe-run.ts's deep component-contract/evidence-replay JSON(
componentContractResults,recipeComponentManifest,preparedExtraPluginReceipts) — plugin class-loading/autoloaddiagnostics that don't apply to a theme's
style.csscontractTests (behavioral, execute the real path)
New:
tests/recipe-extra-theme-local-source.test.ts— local directory sourcematerializes/mounts/activates; regression check that the pre-existing
generic
inputs.mountslocal-theme path is unaffected; local-zipsha256pinning + mismatch rejection; missingstyle.css/emptyTheme Name/missing entrypoint rejected (via the realprepareExtraThemes, nota schema-only check); a remote
https://source is not rejected formissing content at validation time (contract deferred past materialization);
orphan child theme (
Templateparent not listed) rejected; grandchildtheme (parent is itself a child) rejected; valid parent+child pair
materializes; at-most-one-active-theme enforced both at shape-validation
time and inside
prepareExtraThemes.tests/recipe-extra-theme-remote-source.test.ts— anhttps://themesource resolves through the same source layer as
extra_plugins:spins up a real local HTTPS server with an openssl-generated self-signed
cert, downloads a real zip through
prepareExtraThemesin a child Nodeprocess (
NODE_EXTRA_CA_CERTSis read only at process startup, so theactual network round trip has to run out-of-process), verifies
provenance.kind === "https_zip"anddigest.verified === true, andseparately verifies a mismatched
sha256is rejected against the samelive download. Also verifies the exact same
WP_CODEBOX_ALLOW_NETWORK_DOWNLOADS=1policy gateextra_pluginsuses isenforced by
prepareExtraThemesbefore any network call.tests/recipe-runtime-setup-extra-themes.test.ts— exercisesapplyRecipeRuntimeSetupwith a fakeRuntime: theme mounts towp-content/themes/<slug>; no activation call whenactivateis unset;switch_theme(notactivate_plugin, not the plugin preload/lifecycle-replay machinery) is invoked for the one
activate: truetheme.While writing the local-source test I hit a real bug (
\s*in the headerregex crossed newlines and matched
*/as the theme name on the next lineinstead of rejecting an empty
Theme Name:) — fixed incomponent-contracts.tsbefore these tests were green, which is the kindof thing a shape-only test would not have caught.
Full output of the individual new/regression runs:
Build / full check-lane output
npm cisucceeded (postinstall patches +build:releaseran clean).npm run build(tsc -bacrossruntime-core/runtime-playground/cli—this repo's typecheck boundary; there is no separate
lint/typecheckscript) is clean:
npm run check(build+test:generic-primitives+test:runtime-servicesequivalent to a combined typecheck+lint+test gate) passes with one
pre-existing, unrelated failure:
I verified this is not caused by this change:
git stash, re-ran the samefile against unmodified
origin/mainin this same sandbox, identicalfailure (a sandbox
.profile/cargo-homepath issue, unrelated towp-codebox).
git stash poprestored this branch's changes beforecontinuing.
schema-parity.test.ts(which checksdocs/recipe-contract.md'sfield list against the runtime-core schema) initially failed after the
schema addition and is now green —
docs/recipe-contract.mdgained anextra_themesentry in the fields list plus a full "Extra Themes" section.Explicitly not done
CHANGELOG.mdor hand-bump any version.