Skip to content

feat(recipe): add extra_themes remote/local theme sourcing - #2519

Merged
chubes4 merged 1 commit into
mainfrom
feat/extra-themes-remote-source
Sep 21, 2026
Merged

chubes4 merged 1 commit into
mainfrom
feat/extra-themes-remote-source

Conversation

@chubes4

@chubes4 chubes4 commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Closes Extra-Chill/homeboy-extensions#2857.

The gap

inputs.extra_plugins can source a component from a local directory, local
zip, WordPress.org plugin zip, or generic HTTPS zip URL with sha256
pinning (packages/runtime-core/src/recipe-schema.ts:862-885 extraTheme
mirrors the extraPlugin def at :876-905 of the same file). Themes had no
equivalent: they could only be mounted through the generic inputs.mounts
primitive, which resolves source strictly as a local path
(packages/runtime-core/src/recipe-schema.ts mount def, type/source/
target, no zip/URL/sha256 handling). That blocked release-tag-pinned
theme mounting — the one component class that is not a plugin.

Why loadAs: "theme" was rejected

packages/runtime-core/src/component-contracts.ts:21 resolvePluginEntrypointContract()
resolves a plugin entrypoint: <slug>.php, plugin.php, or a file
carrying a Plugin Name: header (:30-38). A theme has no such file. Its
contract is style.css with a Theme Name header, an optional Template
header naming a parent for child themes, and an entrypoint of index.php,
templates/index.html, or block-templates/index.html. Extending loadAs
to include "theme" would route every theme through a plugin-file resolver
that 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 sibling
function, not a branch inside the plugin resolver.

What the new input does

inputs.extra_themes (schema: packages/runtime-core/src/recipe-schema.ts:203-207
input, :862-885 extraTheme def; types: packages/runtime-core/src/runtime-contracts.ts
WorkspaceRecipeExtraTheme + inputs.extra_themes):

  • source/sourcePath (+ sourceRoot/sourceSubpath/sourceSubdir/
    originalSource) resolve local directory, local zip, or HTTPS zip
    sources, with optional slug/mountSlug and sha256 pinning — same
    shape as extra_plugins minus the plugin-only pluginFile/loadAs/
    composer fields.
  • Each theme mounts to /wordpress/wp-content/themes/<slug>
    (packages/cli/src/recipe-sources.ts:2115 themeTarget).
  • At most one entry may set activate: true
    (packages/cli/src/recipe-sources.ts:352-354 and shape validation at
    packages/cli/src/recipe-validation.ts activeThemeCount); that theme is
    activated with switch_theme(), never activate_plugin()
    (packages/cli/src/commands/recipe-runtime-setup.ts activateExtraThemeCode).
  • A child theme's Template header must name another extra_themes slug,
    and that parent must itself be standalone (no Template of 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.
  • Contract checks (style.css/Theme Name/entrypoint) run after
    materialization — a remote URL cannot be inspected before download. Local
    non-zip directory sources get an additional pre-download semantic check in
    validateWorkspaceRecipeSemantics since those can be inspected
    immediately; 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) is
    called verbatim for themes — same local/https_zip/
    wporg_plugin_zip classification (RecipeSourceType, :39), same
    sha256 verification, same zip-source.ts downloader/extractor, same
    source-policy.ts host-allowlist/size-limit/WP_CODEBOX_ALLOW_NETWORK_DOWNLOADS
    gate. No second downloader, no reimplemented hashing, no shelling to
    curl/wget.
  • The slug/source-root/source-subpath resolution that extra_plugins
    already had (recipeExtraPluginSlug/Source/SourceRoot/SourceSubpath)
    was refactored into shared generic helpers
    (resolveExternalSourceSlug/Ref/Root/Subpath,
    packages/cli/src/recipe-sources.ts above recipeExtraPluginSlug), so
    extra_plugins and extra_themes accessors are both thin wrappers over
    the same logic instead of two independent copies. This refactor is
    behavior-preserving (verified: full existing extra_plugins test suite
    passes unchanged, see below).

Runtime wiring

recipe-runtime-setup.ts: mounts every prepared theme (mount_themes
phase, materialized alongside plugin/input mounts), then — only when exactly
one theme is activate: true — runs a wordpress.run-php step calling
switch_theme(). Themes get no mu-plugin loader, no Composer autoload
install, and no phased/artifact install path; those are plugin-specific
component-contract concerns that don't apply to a theme's style.css
contract. recipe-run.ts wires prepareRecipeExtraThemes/cleanup through
the run lifecycle (cleanupRecipePreparedSources gains a 6th optional
extraThemes param, default [], so existing 5-arg call sites/tests are
unaffected).

Deferred (explicitly, not half-wired)

extra_themes fully works through wp-codebox run (validate → materialize
→ mount → activate). These are secondary UX/reporting/builder-convenience
layers, intentionally out of scope for this slice:

  • 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 —
    a separate component_manifest/"runtime requirements" resolution system
    for agent-task recipes, distinct from inputs.extra_themes
  • recipe-run.ts's deep component-contract/evidence-replay JSON
    (componentContractResults, recipeComponentManifest,
    preparedExtraPluginReceipts) — plugin class-loading/autoload
    diagnostics that don't apply to a theme's style.css contract

Tests (behavioral, execute the real path)

New:

  • tests/recipe-extra-theme-local-source.test.ts — local directory source
    materializes/mounts/activates; regression check that the pre-existing
    generic inputs.mounts local-theme path is unaffected; local-zip
    sha256 pinning + mismatch rejection; missing style.css/empty Theme Name/missing entrypoint rejected (via the real prepareExtraThemes, not
    a schema-only check); a remote https:// source is not rejected for
    missing content at validation time (contract deferred past materialization);
    orphan child theme (Template parent not listed) rejected; grandchild
    theme (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 — an https:// theme
    source 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 prepareExtraThemes in a child Node
    process (NODE_EXTRA_CA_CERTS is read only at process startup, so the
    actual network round trip has to run out-of-process), verifies
    provenance.kind === "https_zip" and digest.verified === true, and
    separately verifies a mismatched sha256 is rejected against the same
    live download. Also verifies the exact same
    WP_CODEBOX_ALLOW_NETWORK_DOWNLOADS=1 policy gate extra_plugins uses is
    enforced by prepareExtraThemes before any network call.
  • tests/recipe-runtime-setup-extra-themes.test.ts — exercises
    applyRecipeRuntimeSetup with a fake Runtime: theme mounts to
    wp-content/themes/<slug>; no activation call when activate is unset;
    switch_theme (not activate_plugin, not the plugin preload/lifecycle-
    replay machinery) is invoked for the one activate: true theme.

While writing the local-source test I hit a real bug (\s* in the header
regex crossed newlines and matched */ as the theme name on the next line
instead of rejecting an empty Theme Name:) — fixed in
component-contracts.ts before these tests were green, which is the kind
of thing a shape-only test would not have caught.

Full output of the individual new/regression runs:

=== tests/recipe-extra-plugin-local-zip.test.ts ===
recipe extra plugin local ZIP sources ok
=== tests/recipe-extra-plugin-nested-source.test.ts ===
recipe extra plugin nested source ok
=== tests/recipe-extra-plugin-composer-autoloaders.test.ts ===
recipe extra plugin composer autoloaders ok
=== tests/recipe-extra-plugin-composer-preparation.test.ts ===
recipe extra plugin Composer preparation ok
=== tests/recipe-phased-plugin-input.test.ts ===
recipe phased plugin input contract ok
=== tests/recipe-runtime-setup-empty-plugins.test.ts ===
recipe-runtime-setup empty extra plugins materialize phase ok
=== tests/recipe-runtime-setup-staged-materialization.test.ts ===
recipe runtime setup staged materialization ok
=== tests/prepared-extra-plugin-receipts.test.ts ===
prepared extra plugin receipts ok
=== tests/wordpress-plugin-public-runtime-abilities.test.ts ===
wordpress plugin public runtime abilities contract ok
=== tests/runtime-sources-materialization.test.ts ===
runtime sources materialization ok
=== tests/recipe-extra-theme-local-source.test.ts ===
recipe extra theme local/zip source ok
=== tests/recipe-extra-theme-remote-source.test.ts ===
recipe extra theme https zip remote source ok
=== tests/recipe-runtime-setup-extra-themes.test.ts ===
recipe runtime setup extra_themes mount+activate wiring ok

Build / full check-lane output

npm ci succeeded (postinstall patches + build:release ran clean).
npm run build (tsc -b across runtime-core/runtime-playground/cli —
this repo's typecheck boundary; there is no separate lint/typecheck
script) is clean:

> node ./node_modules/typescript/bin/tsc -b packages/runtime-core packages/runtime-playground packages/cli && node scripts/ensure-cli-bin-executable.mjs && tsx scripts/write-cli-build-provenance.ts
WP Codebox CLI build provenance: 0.26.12 a7d8d7699f3d703b92fbd3e61bd124d13a7ad75d 66a41f6cc6ac2c87c762c9927672cddc9951c0c84996bf7d7c9767da7d1ea606

npm run check (build + test:generic-primitives + test:runtime-services

  • the 319-file discovered fast-lane test suite, this repo's closest
    equivalent to a combined typecheck+lint+test gate) passes with one
    pre-existing, unrelated failure
    :
[smoke] FAIL (183/319) tests/execute-native-agent-task-lifecycle.test.mjs
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ '/home/opencode/.profile: line 29: /mnt/extrachill-workspace/tmp/opencode/cargo-home-9579/env: No such file or directory\n' +
+   'verification stderr\n'
- 'verification stderr\n'

I verified this is not caused by this change: git stash, re-ran the same
file against unmodified origin/main in this same sandbox, identical
failure (a sandbox .profile/cargo-home path issue, unrelated to
wp-codebox). git stash pop restored this branch's changes before
continuing. schema-parity.test.ts (which checks docs/recipe-contract.md's
field list against the runtime-core schema) initially failed after the
schema addition and is now green — docs/recipe-contract.md gained an
extra_themes entry in the fields list plus a full "Extra Themes" section.

Explicitly not done

  • Did not merge. Did not release or deploy.
  • Did not touch CHANGELOG.md or hand-bump any version.

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
chubes4 merged commit b94b900 into main Sep 21, 2026
6 of 7 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: wp_codebox_extra_themes cannot source from an HTTPS zip URL (extra_plugins already can)

1 participant