Site template publishing
Goal
Produce a standalone, installable, marketplace-compliant Drupal CMS site template — a type: Site recipe with a type: drupal-recipe composer manifest — from a working seed site, such that a fresh consumer can install it from scratch into a running branded site with demo content, no patches, no version pins, no install-profile dependency, no brand leaks, and proven-clean install + accessibility. The template is produced by a pipeline, not hand-authored: a disposable scratch install is genericized in place and exported; the export is the artifact.
Opinion
Export from a disposable scratch install, never a long-lived upgraded site. drush site:export reads the LIVE database, so its output is exactly the current state on the current schema. An in-place-upgraded site exports its accumulated cruft — every extension it ever installed lands in the install:/require lists. Provision a fresh, throwaway drupal/cms install of the seed and export from that. Evidence: the exporter reads the DB against current core/CMS schema by construction; hand-copied YAML goes stale and skips the config→action rewrite.
Install the seed recipe with drush site:install <recipe-path>, not recipe:apply on a minimal install. Core installs Drupal from a Site recipe — the canonical path the scaffold's InstallTest exercises (it sets parameters.recipe and installs from the recipe). Applying a Site recipe onto a bare minimal install is not that supported path.
Genericize in place, before export — not on the exported YAML. Because export reads the DB, brand and vertical strings are edited on the running scratch site (content edits + config-level edits + brand-asset overrides). The export inherits a clean state; you never post-edit hundreds of exported files.
uid1 hygiene: neutralize, don't cancel. Cancelling uid1 mid-build removes the admin access later steps need. Set uid1 name/mail to neutral placeholders and verify no user entities land in content/. (Tugboat cancels uid1 after install for previews — that is a preview concern, not an export concern.)
Rebrand a required theme at the template level, never by forking it. Ship neutral brand assets as managed files and override via theme-settings config; keep the theme a byte-identical caret dependency. Source: play drupal/best-practices/camoa/rebrand-required-theme-without-forking.
Never raw-edit versioned Canvas component configs. Their active_version is a hash of their settings; a raw edit desyncs it. site:install masks the break; the recipe CLI validator fails. Source: play drupal/best-practices/camoa/canvas-versioned-config-raw-edit-trap.
Prefer a flat exported recipe over a composed one. site:export emits a flat install: list plus full active config, which sidesteps sub-recipe import-ordering traps (e.g. the search view-mode crash). Source: play drupal/best-practices/camoa/ship-search-index-view-mode-defensively.
Post-export patches are standing steps, not one-time edits. Anything site:export regenerates from the DB (schemadotorg mapping deps, Canvas-reseeded brand strings) must be re-applied every export and documented in AGENTS.md.
Preconditions
- Drupal 11.2+ (Canvas requires ^11.2); a drupal/cms
^2.1-class distro withdrupal_cms_helperprovidingdrush site:export. - A working seed (config + default content) that installs cleanly from its own Site recipe on the current core/CMS release — provisioned as a fresh, disposable scratch install, never the long-lived upgraded site.
- The site-template scaffold (
drupal_cms_site_template_base) pre-placed at the export destinationrecipes/<name>/(it ships unpacked inside the drupal/cms project template; record its provenance from the distro lock).drush site:exportmergesrecipe.yml/composer.jsonon top of the pre-placed scaffold — there is no--baseflag. - DDEV (or equivalent) for the scratch and verify codebases; host Composer available for path-repo require ops (see play
drupal/best-practices/camoa/ddev-composer-path-repo-drupal-recipe). - Rights to all shipped imagery (marketplace rule) — provenance documented.
- A consumer codebase for verification, plus drupal/core-dev for the scaffold's PHPUnit tests.
Input contract
Generic, source-agnostic, supplied by the caller (adapter skill, human operator, or orchestrator).
seed:
recipe_path: string # path to the seed Site recipe to install FROM
distro: string # e.g. drupal/cms:^2.1 (install base)
required_theme: string # e.g. vendor/my_theme (reused unmodified)
package:
machine_name: string # e.g. my_site_template — must NOT be prefixed drupal_cms_ / drupal-cms-
composer_name: string # vendor/name — any Packagist-valid vendor is allowed
label: string # human recipe.yml name
description: string
license: string # must be GPL-2.0-or-later
brand:
neutral_identity: string # the demo brand to genericize TO (name, vertical, copy tone)
string_replacements: # old → new map for content + config leaks
- from: string
to: string
brand_assets: # neutral logo/favicon to ship as managed files
- role: string # logo-light | logo-dark | favicon
path: string
verify:
run_scaffold_tests: boolean # InstallTest / ValidationTest / RequirementsTest (default true)
run_axe_scan: boolean # axe-core WCAG scan on rendered pages (default true)
pages: [string] # routes to render-check (home, listings, full pages)
Sequence
If invoked in dry-run mode, perform all reads and derivations but emit a preview (planned genericization diff, expected export manifest, compliance-grep plan) instead of mutating any site. Dry-run is required.
-
Provision the disposable scratch. Fresh distro codebase (
composer create-project <distro>), DDEV up. Require the seed sources and the scaffold; on a path repo forcesymlink: falseand run require on host Composer (the DDEV container can't resolve a../siblingpath repo). See playdrupal/best-practices/camoa/ddev-composer-path-repo-drupal-recipe. Install FROM the seed recipe:drush site:install <recipe-path>(neverrecipe:applyon minimal). If the seed appliesdrupal_cms_searchon core 11.4, expect and pre-empt thesearch_indexview-mode crash — see playdrupal/best-practices/camoa/ship-search-index-view-mode-defensively. -
Genericize in place on the live scratch site. Apply the brand
string_replacementsacross content (nodes, media names/alt, taxonomy terms, Canvas component-tree prop strings) AND config-level leaks (footer region strings, componentdefault_valueprops). For a versioned Canvas config, make the change so Canvas recomputes itsactive_versionhash — never raw-edit the hashed settings (playdrupal/best-practices/camoa/canvas-versioned-config-raw-edit-trap). Ship neutral brand assets as permanent managed files and set theme-settings overrides (playdrupal/best-practices/camoa/rebrand-required-theme-without-forking). Neutralize uid1 (set name/mail, do not cancel). Resolve anycanvas.*config carryingdependencies.content(export throws otherwise). -
Export. With the scaffold already pre-placed at
recipes/<name>/, rundrush site:export --destination=recipes/<name>(the command takes only--destination; there is no--base). The exporter merges on top of the pre-placed scaffold — its CI/Tugboat/tests/AGENTS.mdare already at the destination — and regeneratesrecipe.yml(flatinstall:list, config→actions, deletescore.extension),composer.json(^<installed>caret constraints,type: drupal-recipe, WARNS on non-stable deps),config/(full active config as config + actions), andcontent/(core Default Content YAML + binaries, including the shipped brand-asset files). Capture the non-stable-dep warnings verbatim. Canvas guardrail: export throws on anycanvas.*dependencies.contentand skipscanvas.folder.*. -
Post-export patches (standing, documented in
AGENTS.md). Re-apply what export regenerates without: (a) schemadotorgmapping_typedeps on each mapping — playdrupal/best-practices/camoa/schemadotorg-mapping-type-deps-post-export; (b) any Canvas componentdefault_valuere-seeded from the theme's SDCexamples:(playdrupal/best-practices/camoa/rebrand-required-theme-without-forking); (c) recipe/composer identity fields (label, description,finish_url, composer name/homepage, remove exporter's_comment/version). Note the exporter drops brand-asset override flags into config as expected — confirm theuse_default: falseoverrides landed. -
Compliance greps on the GENERATED files. Fail-closed checks on the export output: no
patcheskey; everyrequirea caret range (no==/exact pins); no install-profile dependency;type: Site+type: drupal-recipe+license: GPL-2.0-or-later; the package name is not prefixeddrupal_cms_/drupal-cms-; zero brand/vertical strings acrossconfig/+content/+ manifests (recursive grep of the old identity). -
Prove via a fresh consumer. New distro codebase; add the package via a path repo (
symlink: false); because the template carries pre-stable deps, setcomposer config minimum-stability dev+prefer-stable truefirst — playdrupal/best-practices/camoa/pre-stable-template-consumer-minimum-stability.drush site:install <recipes/<name>>must exit 0; render-check everyverify.pagesroute (200s, brand-string count 0, expected sections present). -
Run the scaffold's PHPUnit tests. With drupal/core-dev in the consumer codebase and
SIMPLETEST_*env set, runvendor/bin/phpunit --configuration=web/core recipes/<name>/tests. InstallTest (installs from the recipe), ValidationTest (applies via the recipe CLI AND asserts every Canvas component used by shipped content exists ascanvas.component.*config), RequirementsTest. Handle pre-stable minimum-stability for the test codebase the same way as the consumer. Note: PHPUnit 11 boolean flags (--fail-on-warning,--display-deprecations) take no value. The recipe CLI path (notsite:install) is what catches a desynced Canvasactive_version— treat a ValidationTest hash failure as the Canvas raw-edit trap. -
axe-core WCAG scan. Run an axe-core scan over the rendered
verify.pages; zero critical and zero serious violations is the bar. -
Emit summary. Package identity, export warnings captured, post-export patches applied, compliance-grep counts, install exit code, per-page render/brand-count results, PHPUnit tallies, axe results, and confirmation that the seed/theme source repos are zero-diff.
Data flow
input: seed (recipe_path, distro, required_theme)
package (machine_name, composer_name, label, license)
brand (neutral_identity, string_replacements, brand_assets)
verify (run_scaffold_tests, run_axe_scan, pages)
reads / mutates scratch state:
live DB of a DISPOSABLE scratch install (content + config)
theme-settings overrides + permanent managed brand files
uid1 name/mail (neutralized, not cancelled)
applies opinion (plays):
ddev-composer-path-repo · ship-search-index-view-mode ·
schemadotorg-mapping-type-deps · pre-stable-consumer-stability ·
rebrand-theme-without-forking · canvas-versioned-config-raw-edit-trap
references atomic detail (guides):
drupal/recipes · drupal/config-management
emits (the shipped package under recipes/<name>/):
recipe.yml (type: Site, flat install:, config actions)
composer.json (type: drupal-recipe, GPL-2.0-or-later, caret deps)
config/ (full active config + actions, brand-neutral)
content/ (core Default Content YAML + brand-asset binaries)
tests/ + CI + Tugboat + AGENTS.md (from the pre-placed scaffold)
screenshot.webp (neutral demo)
proves (verifier):
fresh consumer site:install exit 0 · pages 200 · brand-count 0 ·
PHPUnit InstallTest/ValidationTest/RequirementsTest green ·
axe 0 critical / 0 serious · source repos zero-diff
State-awareness contract
The scratch and consumer sites are disposable — the recipe mutates them freely and tears them down; it must NOT mutate the seed/theme source repos (zero-diff guarantee — verified by git status at the end). The shipped package is regenerated wholesale by each export, so genericization is idempotent at the source (same replacements on the same seed state produce the same export), while the post-export patches (step 4) are not captured by the DB and MUST be re-applied every export — the recipe treats them as standing, documented steps, not one-shot edits. A re-export followed by re-patch + re-verify is the supported update loop.
Verifier
Every check is falsifiable and agent-runnable against the generated package and a live consumer install:
- Structure (config-assert, no served site): generated
recipe.ymlhastype: Site; generatedcomposer.jsonhastype: drupal-recipeandlicense: GPL-2.0-or-later; package name not prefixeddrupal_cms_/drupal-cms-. - Compliance greps = 0 (config-assert): no
patcheskey; zero exact/==version pins (all caret); no install-profile dependency; zero occurrences of the old brand/vertical strings acrossconfig/+content/+ manifests. - Post-export patches present (config-assert): each schemadotorg mapping declares its
mapping_typeindependencies.config; brand-asset overrides carryuse_default: false; no theme-examples:-derived brand string survives. - Install (live-site): on a fresh consumer with
minimum-stability dev+prefer-stable,drush site:install <recipes/<name>>exits 0. - Render (live-site): every
verify.pagesroute returns 200, shows its expected sections, and a brand-string scan of the rendered HTML counts 0. - Scaffold tests (live-site): InstallTest, ValidationTest, RequirementsTest all pass — a ValidationTest
active_version … does not match hash of settingsfailure is a fail-closed signal of the Canvas raw-edit trap, not a skip. - Accessibility (live-site): axe-core reports 0 critical and 0 serious violations across
verify.pages. - Source integrity (config-assert):
git statuson the seed and theme repos is clean.
The recipe ships no verifier script, but every check is a runnable method: checks 1–3 and 8 are config-assert; checks 4–7 are live-site and are fail-closed, not skipped, when no served site is available.
References
Atomic guides cited
| Guide | Used for |
|---|---|
drupal/recipes |
Recipe structure, type: Site, flat install: vs composed recipes:, config actions, import ordering |
drupal/config-management |
Active config export, config→action rewrite, dependencies, core.extension handling |
Plays applied
| Play | Source |
|---|---|
| DDEV + Composer path repo for drupal-recipe packages | drupal/best-practices/camoa/ddev-composer-path-repo-drupal-recipe |
Ship search_index view mode defensively (Haven pattern) |
drupal/best-practices/camoa/ship-search-index-view-mode-defensively |
schemadotorg mapping_type dependency enrichment post-export |
drupal/best-practices/camoa/schemadotorg-mapping-type-deps-post-export |
Pre-stable template deps require consumer minimum-stability |
drupal/best-practices/camoa/pre-stable-template-consumer-minimum-stability |
| Rebrand a required theme without forking | drupal/best-practices/camoa/rebrand-required-theme-without-forking |
| Canvas versioned-config raw-edit trap | drupal/best-practices/camoa/canvas-versioned-config-raw-edit-trap |