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.
- DDEV runs the consumer site for steps 6 to 8, and step 8 needs
npxwith a Chrome driver. The verifier reads files only and needsjq.
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:
package_path: string # the exported package, recipes/<machine_name> in the scratch
# codebase; absolute, or relative to where the verifier runs
consumer_root: string # the consumer codebase's project root, given the same way
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. Create a new distro codebase at
verify.consumer_root; 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. Runddev drush site:install recipes/<name> --yesinverify.consumer_rootand record its exit code, which must be 0. Render-check everyverify.pagesroute (200s, brand-string count 0, expected sections present). For each page, fetch it from the consumer's DDEV URL withcurl -sS -o <file> -w '%{http_code}', then count eachbrand.string_replacementsfromstring in it withgrep -cF. Record the HTTP status, the brand count and whether the expected sections are present, per page. -
Run the scaffold's PHPUnit tests. With drupal/core-dev in the consumer codebase and
SIMPLETEST_*env set, copyweb/core/phpunit.xml.disttophpunit.xmlat the consumer's project root, rewrite its paths for the docroot, and runvendor/bin/phpunit -c phpunit.xml recipes/<name>/tests— seedrupal/tdd/phpunit-configuration;-c web/coreruns core's own contrib-wide suite instead. 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. Record the PHPUnit status line verbatim; only a line startingOK (passes. Skip this step whenverify.run_scaffold_testsisfalse. -
axe-core WCAG scan. Run an axe-core scan over the rendered
verify.pages; zero critical and zero serious violations is the bar. Per page, runnpx --yes @axe-core/cli <url> --stdoutand count the violations whoseimpactiscriticalorserious. A run that returns no results is a failure, not a zero. Skip this step whenverify.run_axe_scanisfalse. -
Emit summary. Package identity, export warnings captured, post-export patches applied, compliance-grep counts, install exit code, per-page HTTP status, brand count and expected sections present, the PHPUnit status line, per-page axe critical and serious counts, and confirmation that the seed/theme source repos are zero-diff. Report a skipped step 7 or 8 as skipped.
Data flow
input: seed (recipe_path, distro, required_theme)
package (machine_name, composer_name, label, license)
brand (neutral_identity, string_replacements, brand_assets)
verify (package_path, consumer_root, 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, static reads of the package):
structure · compliance · brand-count 0 · post-export patches
reports (steps 6-8, run by the agent):
fresh consumer site:install exit 0 · pages 200 · brand-count 0 ·
PHPUnit InstallTest/ValidationTest/RequirementsTest green ·
axe 0 critical / 0 serious
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
Each entry is one command, run from the project root after the recipe ran. It is split on spaces and never run through a shell. A non-zero exit fails the entry, whatever pass says. stdout empty reads standard output only. Every entry calls .aida/site-template-publishing/verify.sh, the script in ## Files. It prints one line per violation and exits non-zero when it printed any.
verifier: - id: package-structure kind: config-assert run: bash .aida/site-template-publishing/verify.sh structure {verify.package_path} {package.machine_name} pass: stdout empty - id: compliance kind: config-assert run: bash .aida/site-template-publishing/verify.sh compliance {verify.package_path} pass: stdout empty - id: no-brand-strings kind: config-assert run: bash .aida/site-template-publishing/verify.sh brand {verify.package_path} {brand.string_replacements:json} pass: stdout empty - id: post-export-patches kind: config-assert run: bash .aida/site-template-publishing/verify.sh patches {verify.package_path} {brand.brand_assets:json} pass: stdout empty
What the entries do not prove, and where the proof is:
- The entries read the exported package's files at
verify.package_pathand nothing else. They install nothing and need no DDEV. A missing path prints a violation. - No entry installs the package, renders a page, runs the scaffold tests or scans accessibility. A verifier entry is always a command, with no kind for an instruction the agent follows. Sequence steps 6 to 8 carry those checks, and step 9 reports their results.
package-structurereads the prefix rule from the name in the generatedcomposer.jsonand frompackage.machine_name.compliancefails apatchesorpatches-filekey anywhere incomposer.json, and anyrequirevalue that does not start with^. It does not check "no install-profile dependency": the recipe names no file or key that would carry one.no-brand-stringsscansrecipe.yml,composer.json,config/andcontent/for eachfromstring, case-sensitive, skipping binary files. Canvasdefault_valuestrings re-seeded from the theme's SDCexamples:live inconfig/, so this entry covers them too. An absent or emptybrand.string_replacementsfails it, because an empty scan proves nothing.post-export-patcheschecks that eachschemadotorg.schemadotorg_mapping.<entity>.*.ymllistsschemadotorg.schemadotorg_mapping_type.<entity>independencies.config. It reads the default theme fromconfig/system.theme.yml, or else from thesystem.themeaction inrecipe.yml. That theme's settings must carryuse_default: falseunderlogofor alogo-lightorlogo-darkasset, and underfaviconfor afaviconasset. An absent or emptybrand.brand_assetsskips the theme check. The script reads the YAML as Drupal exports it, by indentation, not with a YAML parser.- No entry checks that the seed and theme repos are unchanged. Rule 7 bars a
git statuscheck in a recipe that ships## Files, and the Input contract has no path for the theme repo. Sequence step 9's summary carries that confirmation.
Files
One script, which the consumer writes before the verifier runs and removes after it. Do not edit it or commit it. bash runs it from the project root.
```sh .aida/site-template-publishing/verify.sh
Verifier checks for site-template-publishing.
bash verify.sh structure
| compliance
| brand
| patches
is the exported package directory. Reads files only. Prints one
line per violation and exits 1 when it printed any; exits 2 on a usage error.
Needs jq; uses the grep on PATH with POSIX ERE only.
check=${1:-} a=${2:-} b=${3:-} v=0 say() { printf '%s\n' "$*"; v=1; }
Prints each line of $1 as a violation.
each() { [ -n "$1" ] || return 0 while IFS= read -r line; do say "$check: $line"; done <<EOF $1 EOF } finish() { [ $v -eq 0 ] || exit 1; exit 0; }
case $check in
structure|compliance|brand|patches) ;;
*) echo "usage: verify.sh structure|compliance|brand|patches
[ -n "$a" ] || { say "$check: verify.package_path is missing"; finish; } [ -d "$a" ] || { say "$check: verify.package_path $a is not a directory"; finish; }
Sets $froms to the from strings of $1, one per line.
read_froms() { case $1 in ''|null) say "$check: brand.string_replacements is missing"; return 1 ;; esac froms=$(printf '%s' "$1" | jq -r '.[] | .from // empty') || exit 2 [ -n "$froms" ] || { say "$check: brand.string_replacements is empty, so there is no old identity to scan for"; return 1; } }
case $check in structure) case $b in '') say "structure: package.machine_name is missing" ;; drupal_cms_|drupal-cms-) say "structure: package.machine_name $b starts with a drupal_cms_ prefix" ;; esac if [ -f "$a/recipe.yml" ]; then grep -Eq "^type:[[:space:]][\"']?Site[\"']?[[:space:]]$" "$a/recipe.yml" || say "structure: $a/recipe.yml has no top-level type: Site" else say "structure: $a/recipe.yml is missing" fi out=$(jq -r ' (if .type != "drupal-recipe" then "composer.json type is (.type), not drupal-recipe" else empty end), (if .license != "GPL-2.0-or-later" then "composer.json license is (.license), not GPL-2.0-or-later" else empty end), ((.name // "") | split("/") | last | if startswith("drupal_cms_") or startswith("drupal-cms-") then "composer.json name (.) starts with a drupal_cms_ prefix" else empty end) ' "$a/composer.json" 2>/dev/null) || say "structure: $a/composer.json is missing or not JSON" each "$out" ;;
compliance) out=$(jq -r ' (paths | map(tostring) | select(.[-1] == "patches" or .[-1] == "patches-file") | "composer.json carries a (.[-1]) key at (join("."))"), ((.require // {}) | to_entries[] | select(.value | tostring | startswith("^") | not) | "composer.json requires (.key) at (.value), not a caret range") ' "$a/composer.json" 2>/dev/null) || say "compliance: $a/composer.json is missing or not JSON" each "$out" ;;
brand) read_froms "$b" || finish while IFS= read -r from; do out=$(cd "$a" && grep -rlIF -e "$from" -- recipe.yml composer.json config content 2>/dev/null) [ -n "$out" ] || continue while IFS= read -r file; do say "brand: $a/$file carries '$from'"; done <<EOT $out EOT done <<EOF $froms EOF ;;
patches) for f in "$a"/config/schemadotorg.schemadotorg_mapping..yml; do [ -e "$f" ] || continue rest=${f##/schemadotorg.schemadotorg_mapping.} want=schemadotorg.schemadotorg_mapping_type.${rest%%.} awk -v want="$want" ' /^dependencies:/ { d = 1; next } d && /^[^[:space:]]/ { d = 0 } d && /^ config:/ { c = 1; next } d && c && /^ [a-z]/ { c = 0 } d && c && /^ - / { item = $0; sub(/^ - /, "", item); gsub(/["\047[:space:]]/, "", item); if (item == want) found = 1 } END { exit !found } ' "$f" || say "patches: $f does not list $want in dependencies.config" done case $b in '') say "patches: brand.brand_assets is missing"; finish ;; null) finish ;; esac roles=$(printf '%s' "$b" | jq -r '.[].role') || exit 2 [ -n "$roles" ] || finish theme= if [ -f "$a/config/system.theme.yml" ]; then theme=$(sed -n "s/^default:[[:space:]][\"']{0,1}([a-z0-9_])./\1/p" "$a/config/system.theme.yml") elif [ -f "$a/recipe.yml" ]; then theme=$(awk ' /^[[:space:]]+system.theme:[[:space:]]$/ { match($0, /^[[:space:]]+/); ind = RLENGTH; inb = 1; next } inb && /[^[:space:]]/ { match($0, /^[[:space:]]/); if (RLENGTH <= ind) inb = 0 } inb && /^[[:space:]]+default:/ { t = $0; sub(/^[[:space:]]+default:[[:space:]]/, "", t); gsub(/["\047[:space:]]/, "", t); print t; exit } ' "$a/recipe.yml") fi [ -n "$theme" ] || { say "patches: neither config/system.theme.yml nor a system.theme action in recipe.yml names the default theme"; finish; } settings=$a/config/$theme.settings.yml [ -f "$settings" ] || { say "patches: $settings is missing, so no brand asset overrides the theme's"; finish; } for role in $roles; do case $role in logo-light|logo-dark) key=logo ;; favicon) key=favicon ;; ) say "patches: brand asset role $role is not logo-light, logo-dark or favicon"; continue ;; esac awk -v k="$key" ' $0 ~ "^" k ":" { b = 1; next } b && /^[^[:space:]]/ { b = 0 } b && /^[[:space:]]+use_default:[[:space:]]false[[:space:]]*$/ { f = 1 } END { exit !f } ' "$settings" || say "patches: $settings $key does not carry use_default: false" done ;; esac
finish ```
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 |
drupal/tdd/phpunit-configuration |
The consumer's phpunit.xml at the project root, its rewritten paths, and why -c web/core is wrong |
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 |