Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2a47e69
Cover the bootstrap check, reader fallbacks and WooCommerce headers
ahegyes Nov 10, 2026
31bd6d1
Test a boot listener that throws
ahegyes Nov 10, 2026
742143d
Report only an unbuildable composition root as a build error
ahegyes Nov 10, 2026
78c7f75
Test a build without its assets tree
ahegyes Nov 10, 2026
893f3ee
Gate the build on both trees it reads, in consumer-a's form
ahegyes Nov 10, 2026
b6c3782
Test the build-error log's location and frequency
ahegyes Nov 10, 2026
957b579
Log build errors with their location, about once an hour
ahegyes Nov 10, 2026
b83a6bc
Analyse the entry at its oldest runtime, beside the safeguards
ahegyes Nov 10, 2026
f41ab2a
Test that PHPStan checks WordPress calls at the entry's floor
ahegyes Nov 10, 2026
87265e9
Check WordPress calls at the entry's own floor
ahegyes Nov 10, 2026
7dcaf1a
Publish no release from the template itself
ahegyes Nov 10, 2026
5e6e253
Seed every plugin at 1.0.0
ahegyes Nov 10, 2026
bd986e4
Rehearse the fill-in at the longest prefix it accepts
ahegyes Nov 10, 2026
aa1bc87
Lower the prefix cap to the longest that passes PHPCS
ahegyes Nov 10, 2026
84bb421
Fill in with a read-only token and push from a job of its own
ahegyes Nov 10, 2026
a762429
Refuse fill-in inputs the generated plugin cannot carry
ahegyes Nov 10, 2026
13c1025
Fill in the plugin's description with its identity
ahegyes Nov 10, 2026
e626af6
Show the list reader in the Payments recipe instead of shipping it
ahegyes Nov 10, 2026
b1c6bd6
Read the WordPress floor from the entry's header
ahegyes Nov 10, 2026
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
62 changes: 47 additions & 15 deletions .github/scripts/fill-in-scaffold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,48 @@
# generated from the template, and Quality rehearses it on the template with sample inputs.
set -euo pipefail

: "${PLUGIN_NAME:?}" "${SLUG:?}" "${NS:?}" "${PREFIX:?}" "${PORT:?}" "${REPO:?}"
: "${PLUGIN_NAME:?}" "${DESCRIPTION:?}" "${SLUG:?}" "${NS:?}" "${PREFIX:?}" "${PORT:?}" "${REPO:?}"

# The slug names the entry file and the text domain, so it stays clean kebab-case.
grep -Eq '^[a-z][a-z0-9]*(-[a-z0-9]+)*$' <<< "$SLUG" || { echo "::error::plugin-slug must be kebab-case starting with a letter, with no leading, trailing or double hyphen."; exit 1; }
grep -Eq '^[A-Za-z][A-Za-z0-9]*$' <<< "$NS" || { echo "::error::php-namespace must match ^[A-Za-z][A-Za-z0-9]*$."; exit 1; }
# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's, and the
# framework's admin notices refuse a prefix over 32 characters or with a network or hidden segment.
{ grep -Eq '^dws_[a-z0-9]{1,28}$' <<< "$PREFIX" && [ "$PREFIX" != dws_network ] && [ "$PREFIX" != dws_hidden ]; } || { echo "::error::prefix must match ^dws_[a-z0-9]{1,28}$ and must not be dws_network or dws_hidden."; exit 1; }
fail() {
echo "::error::$1"
exit 1
}

# Each input lands on one line of a PHP, JSON or Markdown file, where a control character breaks it, so the checks
# below match whole values.
for input in "$PLUGIN_NAME" "$DESCRIPTION" "$SLUG" "$NS" "$PREFIX" "$PORT"; do
[[ $input != *[[:cntrl:]]* ]] || fail 'No input may contain a line break or another control character.'
done

# The slug names the entry file, the text domain and the block namespace, which WordPress refuses unless it starts
# with a letter, and the workflows check the slug they read from composer.json with the same pattern.
[[ $SLUG =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]] || fail 'plugin-slug must be kebab-case starting with a letter, with no leading, trailing or double hyphen.'
# A namespace that is a reserved word, such as List, parses from PHP 8.0 only, which the fill-in job's PHP 7.4 syntax
# check refuses.
[[ $NS =~ ^[A-Za-z][A-Za-z0-9]*$ ]] || fail 'php-namespace must match ^[A-Za-z][A-Za-z0-9]*$.'
# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's; past 23 characters
# the template's longest option constants fail PHPCS's array alignment, which Quality's rehearsal at the cap checks,
# and the framework's admin notices refuse a network or hidden segment.
[[ $PREFIX =~ ^dws_[a-z0-9]{1,23}$ && $PREFIX != dws_network && $PREFIX != dws_hidden ]] || fail 'prefix must match ^dws_[a-z0-9]{1,23}$ and must not be dws_network or dws_hidden.'
# The ports land in JSON numbers, where a leading zero is invalid, and the tests and multisite environments take the
# next two; none may be the template's own 8811, 8812 or 8813, which the leftover check below refuses.
{ grep -Eq '^[1-9][0-9]{0,4}$' <<< "$PORT" && [ "$PORT" -le 65533 ] && { [ "$PORT" -lt 8809 ] || [ "$PORT" -gt 8813 ]; }; } || { echo "::error::wp-env-port must be 1-65533 with no leading zero, and neither it nor the next two ports may be 8811, 8812 or 8813."; exit 1; }
# The display name lands in single-quoted PHP and JavaScript strings, and in JSX text.
if printf '%s' "$PLUGIN_NAME" | grep -q "['\\\$/<>{}]"; then echo "::error::plugin-name must not contain \\, \$, /, ', <, >, { or }."; exit 1; fi
[[ $PORT =~ ^[1-9][0-9]{0,4}$ && $PORT -le 65533 && ( $PORT -lt 8809 || $PORT -gt 8813 ) ]] ||
fail 'wp-env-port must be 1-65533 with no leading zero, and neither it nor the next two ports may be 8811, 8812 or 8813.'
# WordPress skips a plugin whose name is blank, and the name lands in single-quoted PHP and JavaScript strings, and in
# JSX text.
name_breakers="['\\\$/<>{}]"
[[ $PLUGIN_NAME =~ [^[:space:]] && ! $PLUGIN_NAME =~ $name_breakers ]] || fail "plugin-name must not be blank or contain \\, \$, /, ', <, >, { or }."
# The description lands in the entry's header docblock, which */ would close.
[[ $DESCRIPTION =~ [^[:space:]] && $DESCRIPTION != *'*/'* ]] || fail 'description must not be blank or contain */.'

export SCREAMING_PREFIX="${PREFIX^^}"
export TESTS_PORT=$(( PORT + 1 ))
export MULTISITE_PORT=$(( PORT + 2 ))
export REPO_NAME="${REPO#*/}"
# Composer and npm accept only lowercase package names, which the sweep takes from the repository.
export REPO="${REPO,,}"
TEMPLATE_DESCRIPTION=$(php -r 'echo json_decode( (string) file_get_contents( "composer.json" ), false, 512, JSON_THROW_ON_ERROR )->description;')
export TEMPLATE_DESCRIPTION

git mv --force README.scaffold.md README.md
# The scaffold README becomes the plugin's README.md, which .distignore already excludes.
Expand Down Expand Up @@ -52,11 +74,17 @@ git ls-files -z -- . ':!.github/workflows/' ':!.github/scripts/fill-in-scaffold.
git ls-files -z -- . ':!.github/workflows/' ':!.github/scripts/fill-in-scaffold.sh' ':!composer.lock' ':!package-lock.json' | xargs -0 perl -pi -e '
BEGIN { @ARGV = grep { ! -l $_ } @ARGV }
s/\b(881[123])\b/8811 == $1 ? $ENV{PORT} : 8812 == $1 ? $ENV{TESTS_PORT} : $ENV{MULTISITE_PORT}/ge;
s/^(\s*\*\s*\@(?:since|version)\s+)2\.0\.0$/${1}0.1.0/;
'

# The description goes in after the port sweep, which would rewrite a port number in it.
# shellcheck disable=SC2016
perl -pi -e 's/\Q$ENV{TEMPLATE_DESCRIPTION}\E/$ENV{DESCRIPTION}/' "$SLUG.php"
# The manifests hold the description in JSON strings, so their copy escapes quotes and backslashes.
# shellcheck disable=SC2016
perl -pi -e 's/^(\s*\*\s*Version:\s*)2\.0\.0$/${1}0.1.0/' "$SLUG.php"
perl -pi -e 's/^## 2\.0\.0 - unreleased$/## 0.1.0 - unreleased/' CHANGELOG.md
perl -pi -e 'BEGIN { ( $description = $ENV{DESCRIPTION} ) =~ s/(["\\])/\\$1/g } s/\Q$ENV{TEMPLATE_DESCRIPTION}\E/$description/' composer.json package.json
# shellcheck disable=SC2016
perl -pi -e 's/^TODO: one sentence saying why this plugin exists.*$/$ENV{DESCRIPTION}/' README.md

find changelog -type f ! -name '.gitkeep' -delete

# The sweep renamed the package, so the lock's content hash follows, or composer validate --strict fails.
Expand All @@ -71,7 +99,7 @@ for swept_json in .wp-env.json .wp-env.tests.json .wp-env.multisite.json compose
done

# A name or slug shorter than the template's leaves wrapped lines that Prettier joins, so the swept scripts are formatted
# again. Install scripts stay off, because the fill-in job holds a token that can push.
# again. Install scripts stay off, since formatting needs none of them.
npm ci --ignore-scripts --no-audit --no-fund
node_modules/.bin/wp-scripts format '{blocks/src,assets/src,tests/EndToEnd}/**/*.{js,jsx,ts,tsx}'

Expand All @@ -80,7 +108,11 @@ if git grep -n -I -E 'dws-plugin-template|dws_tpl|DWS_TPL|PluginTemplate|DWS Plu
echo "::error::The fill-in left the template's names above."
status=1
fi
if git grep -n -I -E '\b881[123]\b' -- . ':!.github/scripts/fill-in-scaffold.sh' ':!composer.lock' ':!package-lock.json'; then
if git grep -n -I -F -e "$TEMPLATE_DESCRIPTION" -- . ':!.github/scripts/fill-in-scaffold.sh'; then
echo "::error::The fill-in left the template's description above."
status=1
fi
if git grep -n -I -w -E '881[123]' -- . ':!.github/scripts/fill-in-scaffold.sh' ':!composer.lock' ':!package-lock.json'; then
echo "::error::The fill-in left the template's ports above."
status=1
fi
Expand Down
58 changes: 53 additions & 5 deletions .github/workflows/fill-in-scaffold.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ on:
description: 'Display name, such as "Acme Orders"'
required: true
type: string
description:
description: 'One sentence saying why the plugin exists, such as "Acme Orders keeps every order a store takes in one place."'
required: true
type: string
plugin-slug:
description: 'Kebab-case slug, also the text domain and the entry file name, such as "acme-orders"'
required: true
Expand Down Expand Up @@ -34,14 +38,12 @@ jobs:
if: ${{ !github.event.repository.is_template }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
# The job pushes the filled-in plugin back, so the checkout keeps its credentials.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: true
persist-credentials: false

# setup-php hands its token to Composer on disk, where the npm packages the fill-in runs can read it, so this job's token is read-only.
- uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '8.5'
Expand All @@ -55,19 +57,65 @@ jobs:
- name: Fill in the placeholders
env:
PLUGIN_NAME: ${{ inputs.plugin-name }}
DESCRIPTION: ${{ inputs.description }}
SLUG: ${{ inputs.plugin-slug }}
NS: ${{ inputs.php-namespace }}
PREFIX: ${{ inputs.prefix }}
PORT: ${{ inputs.wp-env-port }}
REPO: ${{ github.repository }}
run: .github/scripts/fill-in-scaffold.sh

# The entry runs before the floor gate and names the namespace, so one PHP 7.4 cannot parse stops the fill-in here, on the files Quality parses on PHP 7.4.
- uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '7.4'
coverage: none
tools: none

- name: Check the PHP 7.4 syntax
env:
SLUG: ${{ inputs.plugin-slug }}
run: |
for file in "$SLUG.php" uninstall.php includes/*.php; do
php -l "$file"
done

- name: Save the filled-in plugin as a patch
run: |
git add -A
git diff --cached --binary > "$RUNNER_TEMP/fill-in.patch"

- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: fill-in
path: ${{ runner.temp }}/fill-in.patch
if-no-files-found: error
retention-days: 1

push:
name: Commit and push
needs: fill-in
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write # Pushes the filled-in plugin.
steps:
# Only git runs in this job, so the checkout keeps the credentials the push uses.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: true

- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: fill-in
path: ${{ runner.temp }}

- name: Commit and push
env:
SLUG: ${{ inputs.plugin-slug }}
run: |
git apply --index "$RUNNER_TEMP/fill-in.patch"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "Fill in scaffold: $SLUG"
git push
18 changes: 16 additions & 2 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
id: slug
run: |
slug=$(jq -r '.extra["text-domain"] // empty' composer.json)
if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
if ! [[ "$slug" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]]; then
echo "::error::composer.json's extra.text-domain must be the plugin's kebab-case slug — got '$slug'."
exit 1
fi
Expand Down Expand Up @@ -83,9 +83,12 @@ jobs:
- name: Fill in the scaffold
env:
PLUGIN_NAME: Acme Orders
# The quotes and the backslash check that the manifests' JSON strings escape the description.
DESCRIPTION: 'Acme Orders keeps a store''s "orders" in one place, C:\ included.'
SLUG: acme-orders
NS: AcmeOrders
PREFIX: dws_ao
# The rehearsal fills in the longest prefix the script accepts, so its PHPCS run fails once the cap stops passing.
PREFIX: dws_acmeextraordinaryorders
PORT: '8821'
REPO: acme/acme-orders
run: .github/scripts/fill-in-scaffold.sh
Expand All @@ -101,3 +104,14 @@ jobs:
npm run build
npm run lint:scripts
npm run lint:types
# Setting up PHP 7.4 replaces the PHP 8.5 the steps above need, so the fill-in job's last check runs last here too.
- uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '7.4'
coverage: none
tools: none
- name: Check the PHP 7.4 syntax
run: |
for file in acme-orders.php uninstall.php includes/*.php; do
php -l "$file"
done
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
id: slug
run: |
slug=$(jq -r '.extra["text-domain"] // empty' composer.json)
if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
if ! [[ "$slug" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]]; then
echo "::error::composer.json's extra.text-domain must be the plugin's kebab-case slug — got '$slug'."
exit 1
fi
Expand All @@ -51,5 +51,5 @@ jobs:
wp-env-config-file: '.wp-env.tests.json'
# Both parse every PHP file with php-parser 4, which stops at PHP 8.4 syntax such as protected(set).
exclude-checks: 'prefixing,direct_file_access'
# A pull request or a dispatch on a branch rehearses the release without publishing it.
publish: ${{ github.ref_type == 'tag' }}
# A pull request, a dispatch on a branch or any tag on the template rehearses the release without publishing it.
publish: ${{ github.ref_type == 'tag' && !github.event.repository.is_template }}
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru
6. **Options and data.** Options are read only through `includes/options.php`'s typed readers, safeguards excepted. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else its own entry.
7. **Safeguards** live in `includes/safeguards.php`, call only WordPress and that file, and relax only through their own filter, through `dws_tpl_is_relaxed()` (`docs/recipes.md`). The entry loads them before the floor check, so they parse on PHP 7.4 and call only WordPress APIs present at the oldest WordPress the bootstrap package runs on, or guard them, which `phpstan.safeguards.neon` checks.
8. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails.
9. **WordPress floor.** The plugin calls only APIs its `Requires at least` has, PHP's and JavaScript's alike: PHPStan's wp-compat reads the floor from that header, and CI runs the end-to-end suite on the floor's WordPress, which supplies the `@wordpress/*` scripts at runtime.

## Style

Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ All notable changes to this plugin are documented in this file. Format follows [

Pending entries live in [`changelog/`](./changelog) — add via `composer changelog:add`. Aggregate into a release with `composer changelog:write`.

## 2.0.0 - unreleased
## 1.0.0 - unreleased

### Added
- Initial release.
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,22 @@ Create the plugin's repository from this template with GitHub's **Use this templ
| Input | Example | What it sets |
| --- | --- | --- |
| `plugin-name` | `Acme Orders for WooCommerce` | the display name in the plugin header, the strings and the docs |
| `description` | `Acme Orders keeps every order a store takes in one place.` | the entry's `Description` header, `composer.json`'s and `package.json`'s `description` and the README's opening sentence; set the repository's GitHub description to the same sentence |
| `plugin-slug` | `acme-orders-for-woocommerce` | the text domain and the names of the entry file and the release zip |
| `php-namespace` | `AcmeOrders` | the namespace segment after `DeepWebSolutions\` |
| `prefix` | `dws_aowc` | the prefix of every global function, constant, option and hook |
| `wp-env-port` | `8821` | the development site's port; the tests site takes the next one |

The workflow:
- renames the entry file and replaces the template's names, prefix and ports in every tracked file outside `.github/workflows/`, which read the slug from `composer.json` instead, since GitHub refuses the workflow's own token for workflow changes;
- resets the version to 0.1.0 and the changelog to one unreleased entry;
- renames the entry file and replaces the template's names, description, prefix and ports in every tracked file outside `.github/workflows/`, which read the slug from `composer.json` instead, since GitHub refuses the workflow's own token for workflow changes;
- makes `README.scaffold.md` the plugin's README, which lists the template's demo and what removing each part takes;
- deletes the disabled example include and refreshes the Composer lock's content hash;
- fails when any name or port of the template is left, and otherwise pushes one commit to the branch it ran on.
- deletes the disabled example include and any changelog fragments, and refreshes the Composer lock's content hash;
- fails when any name, description or port of the template is left, and otherwise pushes one commit to the branch it ran on.

## Naming

- A WooCommerce plugin's display name reads `X for WooCommerce`, as WooCommerce's trademark guidelines ask, and its slug ends in `-for-woocommerce`.
- The prefix is `dws_` and one segment of up to 28 lowercase letters and digits, such as `dws_lpmwc`, so no plugin's prefix starts with another's; the framework's admin notices refuse `dws_network` and `dws_hidden`; a WooCommerce plugin's segment ends in `wc`.
- The prefix is `dws_` and one segment of up to 23 lowercase letters and digits, such as `dws_lpmwc`, so no plugin's prefix starts with another's and the template's longest constants still pass PHPCS; the framework's admin notices refuse `dws_network` and `dws_hidden`; a WooCommerce plugin's segment ends in `wc`.
- The namespace is flat: `DeepWebSolutions\<Name>\`, with no segment in between.
- The repository is `wc-<name>` for a WooCommerce plugin and `wp-<name>` otherwise, as the plugin mirrors are named, and the fill-in names the Composer package `<owner>/<repository>` and the npm package `@<owner>/<repository>` after it.
- Each plugin takes its own three consecutive wp-env ports, for its development, tests and multisite sites, apart from 8888 and 8889, which wp-env uses by default, and the template's own 8811, 8812 and 8813.
Expand Down Expand Up @@ -60,7 +60,13 @@ Lint the scripts, styles and types:
npm run lint
```

Every pull request also rehearses the fill-in with sample inputs and gates the filled-in plugin, and rehearses the release on the built zip. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the changelog, and `SECURITY.md` how to report a vulnerability.
Update the dependencies within their constraints, and `@wordpress/*` to npm's latest rather than the WordPress floor's versions, since WordPress supplies those packages at runtime and PHPStan and the end-to-end suite on the floor's WordPress keep the plugin to its floor's APIs:

```sh
npm run packages-update:all
```

Every pull request also rehearses the fill-in with sample inputs and gates the filled-in plugin, and rehearses the release on the built zip. The template itself publishes no release, even on a tag, and takes no changelog fragments, which the fill-in deletes. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the changelog, and `SECURITY.md` how to report a vulnerability.

## License

Expand Down
Loading
Loading