mammothwp / wp-release
Prepare, verify and ship a Mammoth WP plugin release to wordpress.org and GitHub.
Requires
- php: >=8.1
- ext-json: *
- symfony/console: ^6.4 || ^7.0
- symfony/filesystem: ^6.4 || ^7.0
- symfony/process: ^6.4 || ^7.0
Requires (Dev)
- phpstan/phpstan: ^1.11 || ^2.0
- phpunit/phpunit: ^10.5 || ^11.0
- squizlabs/php_codesniffer: ^3.10
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Prepare, verify and ship a Mammoth WP plugin release to wordpress.org (SVN) and GitHub Releases.
One PHP CLI, mmwp-release, that behaves identically locally and in CI.
Release model: a version bump on main is the release. There are no git tags for plugins. Every command compares the
plugin's own Version header with what is already published and does nothing when it is already out, so the
whole pipeline is safe to run on every push to main.
How it works
Install
composer require --dev mammothwp/wp-release vendor/bin/mmwp-release --help
This library is released as tagged versions (vX.Y.Z) on Packagist and mirrored to
https://github.com/mammothwp/wp-release. Requires PHP >= 8.1 and these tools on PATH:
composer, rsync, zip, unzip (build); wp (WP-CLI) and curl (check, HTTP); svn (deploy-wporg).
For check with the default SQLite database PHP also needs pdo_sqlite.
In CI (see docs/ci.md) everything runs on the composer:2 image (Alpine) plus apk packages, e.g.
apk add --no-cache rsync zip unzip curl bash, and subversion in the job that runs deploy-wporg.
Commands
All commands accept the global flags below. Results go to stdout (table or JSON); logs, including every external command that is run, go to stderr.
| Command | What it does |
|---|---|
build |
Fresh composer install --no-dev --optimize-autoloader in a temp copy (the working tree's vendor/ is never touched), requires .distignore, rsyncs to dist/<slug>/, zips to <slug>.zip, validates headers/readme/forbidden files, prints zip size and file count. |
deploy-freemius |
Builds the full tree (as build --full) from tracked files in a clean temp dir, adds the generated Freemius config file, runs the upload-zip guard, uploads it to Freemius as a pending version (the release endpoint is never called) and writes freemius.env. Unless upload-only, downloads Freemius's free zip (is_premium=false) and runs the strict Plugin Check on it. See Freemius upload. |
check-free <zip|dir> |
Leak check of a wordpress.org free build: fails (exit 11, naming every file) on the Freemius SDK, Pro code or the plugin's own forbidden patterns. See Free build. |
check |
Throwaway WordPress in a temp dir (SQLite drop-in, or MySQL), installs Plugin Check, unzips the built zip (builds first if the zip is missing or older than the sources), activates it, runs wp plugin check, writes plugin-check.json and plugin-check.txt, removes the site. Fails on any ERROR (and any WARNING with --strict). |
deploy-wporg |
Needs a passing check. Skips (exit 0) if tags/<version> already exists on plugins.svn.wordpress.org, or if the plugin has no SVN repo yet. Otherwise syncs trunk (+ .wordpress-org/ → assets), svn cp trunk tags/<version> and commits. |
release-github |
Skips (exit 0) if release v<version> exists in mammothwp/<slug>. Otherwise creates it with the matching readme.txt changelog section as notes and uploads <slug>.zip. |
status |
Prints slug, header version, readme Stable tag, whether the version exists on wordpress.org and GitHub, and what each deploy command would do. |
all |
build → check → deploy-wporg → release-github, stopping at the first failure. |
Global flags
| Flag | Meaning |
|---|---|
--dry-run |
Publish nothing. deploy-wporg prints the svn status diff and stops before commit; release-github prints the planned release. |
--debug-mode |
Verbose: stream command output, also print the Plugin Check table. |
--force |
Skip the "passing check required" gate of deploy-wporg. Never overwrites a published version. |
--path=<dir> |
Plugin repository root (default: current directory). |
--format=table|json |
Result format on stdout (default table). |
--slug=<slug> |
Plugin slug (see configuration). |
Command specific: check / all: --strict, --deps=<slugs> (see Plugin dependencies), --self-test, --keep, --db=sqlite|mysql;
build: --full (see Free build); deploy-freemius: --upload-only;
deploy-wporg / all: --fail-if-unapproved; deploy-wporg: --strict, --zip=<path> (deploy that zip instead of dist/<slug>/: it is extracted, its only top folder must be the slug, and the header Version must equal the readme Stable tag; the idempotency check and the passing-check gate use the zip's version);
check / all / deploy-wporg: --allow=<regex> (repeatable, see Plugin Check allow-list).
Configuration
Precedence: CLI flag > MMWP_* environment > mmwp-release.json > auto-detection.
Slug: --slug, MMWP_SLUG, slug in mmwp-release.json, else the single root *.php file with a
Plugin Name: header (file name without .php). The main file must be <slug>.php.
mmwp-release.json (optional, plugin root):
{
"slug": "my-plugin",
"php_min": "7.4",
"wp_version": "6.8",
"strict": false,
"allow": [],
"excludes": ["*.psd", "docs/*"],
"github_repo": "my-plugin"
}
php_min: the Requires PHP header must be at least this. wp_version: WordPress version of the check site
(overridden by WP_VERSION). strict: same as MMWP_PCP_STRICT. allow: same as MMWP_PCP_ALLOW (array of regexes). excludes: extra forbidden-file patterns
(fnmatch, matched against each path and file name in the zip).
Plugin dependencies
Plugins with a Requires Plugins: header cannot be activated alone. Set MMWP_BROWSER_DEPS="woocommerce" (or --deps=woocommerce,
which overrides the variable) and check runs wp plugin install <slug> --activate for each slug before activating the plugin under
test. Plugin Check still checks only the plugin under test. Unset/empty keeps the old behaviour; if the header is present and no
dependencies are given, check fails (exit 2) naming MMWP_BROWSER_DEPS.
Plugin Check allow-list
Strict mode fails on any Plugin Check warning. A narrow allow-list lets known, reviewer-approved warnings through:
--allow=<regex> (repeatable), MMWP_PCP_ALLOW (newline- or ||-separated) and allow in mmwp-release.json are merged.
Nothing is allowed by default.
- A finding is allowed only if it is a WARNING and the regex matches the string
<code>: <message>. ERRORs can never be allowed. - Regexes are bare PCRE bodies without delimiters, matched with the
uflag; use inline flags such as(?i). An invalid regex exits with code 2. - Allowed findings are listed as
ALLOWED: <code>: <message> (rule: <regex>)inplugin-check.txtand on the console, the counts readN error(s), M warning(s), K allowed, andplugin-check.jsongetsallowedandallowed_findings.warningsexcludes allowed findings, so the strict exit-20 decision and thedeploy-wporggate ignore them.
Recommended rule for the "MammothWP" brand: wordpress.org reviewers approve "BrandWP" names, but Plugin Check warns
trademarked_term for the "wp" in "MammothWP". This allows only that term, only when the quoted name/slug contains mammothwp
(not wordpress, woocommerce, or other brands):
(?i)^trademarked_term: (?=.*(?<![a-z0-9])mammothwp)(?=.*restricted term \W?wp\W)
mmwp-release check --strict --allow='(?i)^trademarked_term: (?=.*(?<![a-z0-9])mammothwp)(?=.*restricted term \W?wp\W)'
Environment variables
| Variable | Used by | Meaning |
|---|---|---|
MMWP_SLUG |
all | Plugin slug. |
MMWP_DEBUG |
all | true = same as --debug-mode. |
MMWP_PCP_STRICT |
check, deploy-wporg | true = fail on Plugin Check warnings too. |
MMWP_PCP_ALLOW |
check, deploy-wporg | Plugin Check allow-list regexes, newline- or ` |
MMWP_BROWSER_DEPS |
check | wordpress.org slugs (space/comma separated) installed and activated in the temp site before the plugin; --deps overrides it. |
MMWP_SELF_TEST |
check | true = same as --self-test. |
MMWP_DB |
check | sqlite (default) or mysql. |
MMWP_DB_HOST / MMWP_DB_USER / MMWP_DB_PASS / MMWP_DB_NAME |
check | MySQL connection (name defaults to a random mmwp_check_*, dropped afterwards). |
MMWP_FAIL_IF_UNAPPROVED |
deploy-wporg | true = exit 32 instead of 0 when the plugin has no SVN repo yet. |
MMWP_GITHUB_OWNER |
release-github, status | GitHub owner (default mammothwp). |
MMWP_GITHUB_REPO |
release-github, status | GitHub repo (default: the slug). |
MMWP_FREE_BUILD |
build, check, all | ours (default when unset) or freemius: which free zip a plugin with a Pro side ships to wordpress.org (see Free build). |
MMWP_FREEMIUS_PRODUCT_ID / MMWP_FREEMIUS_PUBLIC_KEY |
deploy-freemius | Freemius product id (numeric) and public key ([A-Za-z0-9_]+). |
MMWP_FREEMIUS_SECRET_KEY |
deploy-freemius | API bearer token (masked + protected). Not needed for --dry-run. Never printed; passed to curl through a 0600 header file, never on a command line. |
MMWP_FREEMIUS_API |
deploy-freemius | API base URL (default https://api.freemius.com/v1). |
MMWP_FREEMIUS_UPLOAD_VERSION |
deploy-freemius | Version written into the header Version: and readme Stable tag: inside the upload zip only (2.0.0, 2.0.0-beta, 2.0.0-rc.1). |
MMWP_FREEMIUS_UPLOAD_ONLY |
deploy-freemius | true = same as --upload-only. |
WP_VERSION |
check | WordPress version, default latest. |
WPORG_USERNAME / WPORG_PASSWORD |
deploy-wporg | SVN credentials. Masked in all output. |
GITHUB_TOKEN |
release-github | Token with contents: write on the repo. Masked in all output. |
Free build (plugins with a Pro side)
A plugin has a Pro side when its main file has an @fs_premium_only header, any __premium_only symbol or
*__premium_only.php file exists outside vendor/, or composer.json requires freemius/wordpress-sdk. Plugins without a Pro
side are built exactly as before, in every mode.
MMWP_FREE_BUILD decides what build writes for a plugin with a Pro side:
| Value | build writes |
Used for |
|---|---|---|
ours (default) |
<slug>.zip = the wordpress.org free zip, stripped by this package and leak-checked |
check, test:browser, deploy-wporg as usual |
freemius |
<slug>.zip = the full tree as today (Freemius generates the free zip from the upload) |
the Freemius flow |
mmwp-release build --full always builds the full tree, whatever the variable says (needed by the Freemius upload).
The ours build, in the staged copy only (the working tree is never touched):
- removes the paths of the
@fs_premium_onlyheader,*__premium_only.phpfiles,__premium_onlyfunctions/methods (with their docblock) andif ( ...__premium_only()... ) { ... }blocks (noelse, as in Freemius);<fs_premium_only>blocks ofreadme.txtare dropped and<fs_free_only>tags unwrapped.vendor/is never touched; - replaces the region between
// @mmwp-sdk-beginand// @mmwp-sdk-endlines in root PHP files by the configured replacement (default: nothing) and deletes the configured SDK wrapper files; - runs
composer remove freemius/wordpress-sdk --update-no-dev(SDK out ofcomposer.json, the lock and Composer's autoloadfiles; other packages keep their locked versions), or a normal production install when the SDK is not required; - deletes
freemius-config.phpandmmwp-free.json, then applies.distignore, dropscomposer.lockand zips as usual.composer.jsonandvendor/(which carries runtime packages such asmammothwp/wp-kit) stay; the lock is dropped only in this free build because it lists dev packages that are not shipped and wordpress.org expectscomposer.json+vendor/. The full build still ships the lock, as before; - runs the leak check on the result and fails the build (exit 11, every file named) on any finding.
Leak check
mmwp-release check-free <zip|dir> [--path=<plugin repo>] runs the same check on a zip or folder. Built-in rules: vendor/freemius/,
freemius-config.php, a freemius/wordpress-sdk / wordpress-sdk/start.php reference in vendor/ or composer.json, a premium path or
*__premium_only.php file still present, and, in plugin PHP files outside vendor/, __premium_only, fs_dynamic_init,
wordpress-sdk or "freemius" (any case; readme.txt may still say Pro is sold through Freemius).
Plugin settings: mmwp-free.json
Plugin-specific rules live in mmwp-free.json in the plugin root (never shipped), or under extra.mmwp-release.free in
composer.json (the file wins). All keys are optional:
{
"forbid": ["myplugin_pro_enabled", "/revenue/i"],
"allow": { "/revenue/i": ["src/Database/Database.php", "src/Repository/"] },
"freemius": {
"constants": { "id": "MYPLUGIN_FS_ID", "public_key": "MYPLUGIN_FS_PUBLIC_KEY" },
"config_file": "freemius-config.php"
},
"sdk": {
"replacement": "MyPlugin\\Uninstaller::register(__FILE__, null);",
"delete": ["src/Licensing/Freemius.php", "src/Licensing/functions.php"]
}
}
-
forbid: extra patterns that must not appear in plugin PHP files of the free zip. A literal string, or a PCRE when written/.../flags. -
allow: forbid pattern (same text as inforbid) => paths where it is tolerated (exact file,dir/prefix orfnmatchglob, relative to the plugin root), e.g. a storage file that keeps a Pro column. The built-in rules cannot be allowed. -
sdk.replacement: PHP code that takes the place of the SDK region (e.g. the plain WordPress uninstall registration).sdk.delete: SDK wrapper files removed from the free zip. -
freemius.constants: names of the constants the plugin'sFreemius::init()reads for the product id and public key (deploy-freemiusdefines them in the generated config file; required there).freemius.config_file: name of that file (defaultfreemius-config.php; removed from the free build).
mmwp-free.json is never shipped, in any build.
Freemius upload (deploy-freemius)
Set MMWP_FREEMIUS_PRODUCT_ID, MMWP_FREEMIUS_PUBLIC_KEY and MMWP_FREEMIUS_SECRET_KEY (see the environment table) and define freemius.constants in mmwp-free.json.
- Full build into
.scratch/freemius/(the working tree'sdist/and<slug>.zipare untouched), generated config file added, optional version override applied. - Guard: the zip may hold only the release build (tracked files,
vendor/, the config file); hidden files,browser-report/,.cache/,tests/,node_modules/,.scratch/or unlisted files fail the run (exit 11) naming the paths. - Upload (
POST /products/{id}/tags.json): creates a pending version. Arelease_modeother thanpendingfails (exit 50). The dotenv filefreemius.env(MMWP_FREEMIUS_TAG_ID,MMWP_FREEMIUS_TAG_VERSION) is written to$CI_PROJECT_DIR(default: the plugin root) only after a successful upload; a stale one is removed first. Freemius does not list pending versions, so later jobs read the tag from this file. - Unless
--upload-only: downloadtags/{id}.zip?is_premium=falseto.scratch/freemius/free/<slug>.zip,unzip -tit, run the strict Plugin Check (MMWP_BROWSER_DEPS,MMWP_PCP_ALLOW).
--dry-run stops after step 2 and makes no network call.
CI
ours is the default, so a plugin with a Pro side switches to the stripped zip as soon as it uses this version. The Freemius upload job
must call mmwp-release build --full (or set MMWP_FREE_BUILD=freemius). Making ours the pipeline default and the Freemius jobs
explicit needs a matching change in mammothwp/ci-templates (separate MR).
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success, already released, or plugin not yet approved (unless --fail-if-unapproved) |
| 1 | Unexpected error |
| 2 | Usage / configuration error (no slug, bad flag, missing .distignore, missing credentials) |
| 3 | Required tool missing (rsync, zip, svn, ...) |
| 10 | Build failed (composer, rsync, zip) |
| 11 | Validation failed (headers, readme, version mismatch, forbidden files) |
| 20 | Plugin Check errors (or warnings with --strict) |
| 21 | Temp site setup or plugin activation failed |
| 22 | wp mmwp self-test failed |
| 30 | deploy-wporg refused: no passing plugin-check.json for this slug and version |
| 31 | SVN failure or wordpress.org unreachable |
| 32 | Plugin not approved yet and --fail-if-unapproved set |
| 40 | GitHub API failure or missing GITHUB_TOKEN |
| 50 | deploy-freemius: Freemius API failure, no tag id in the answer, or a release mode other than pending |
What build validates
<slug>.phpexists; headersText Domain== slug,Requires PHP,Requires at least,License(GPL),Version.readme.txtexists withStable tagandTested up to; headerVersion== readmeStable tag.- The zip contains no
.git,node_modules,tests,phpunit*,phpcs*,phpstan*,.gitlab-ci.yml,.wordpress-org,.env*,*.log;composer.lockonly ifvendor/ships. Insidevendor/only the secret-like patterns apply (third-party packages legitimately ship tests). - Pre-flight (exit 11, offending paths listed, before Plugin Check): the zip contains no hidden file or directory at any
level (dot-files, dot-dirs, including inside
vendor/) and no*.phar. .distignore(rsync exclude syntax) is required and is itself never shipped.- Runtime dependencies (exit 11): when
composer.jsonrequires anything besidesphp/ext-*/lib-*, the zip must containvendor/autoload.php,vendor/composer/autoload_real.phpandautoload_classmap.php, and avendor/<name>/directory for every package in the plugin'scomposer.lockpackages(non-dev; metapackages skipped). Typical cause: a barevendorline in.distignore. Plugin Check does not notice a zip like that. .distignorewarning (fails the build with--strict/MMWP_PCP_STRICT): an unanchored entry (no leading/) namedtests,docs,bin,README.md,composer.lockorvendoralso strips those files insidevendor/packages; write/testsetc.
What build copies
In a git work tree build ships only tracked files (git ls-files), then applies .distignore, then adds the fresh
no-dev vendor/. Untracked CI leftovers (.cache/composer, a downloaded wp-cli.phar, a tools dir, stray files) can
never reach the zip, and .distignore does not need to know about CI internals. Without git, the whole tree is copied
minus every dot-file/dot-dir, *.phar, .cache/, node_modules, dist, the zip itself and the directory named by
MMWP_TOOLS_DIR (when inside the plugin root).
Local usage (DDEV or any local PHP)
ddev composer require --dev mammothwp/wp-release ddev exec vendor/bin/mmwp-release build ddev exec vendor/bin/mmwp-release check # add --strict, --self-test, --keep ddev exec vendor/bin/mmwp-release status ddev exec vendor/bin/mmwp-release deploy-wporg --dry-run
Use --db=mysql with MMWP_DB_HOST=db MMWP_DB_USER=db MMWP_DB_PASS=db to reuse a local database service such as DDEV's.
CI
Plugins get these jobs through mammothwp/ci-templates (job and file names there may differ; the commands are what
matters). The jobs boil down to:
stages: [build, check, release] mmwp-build: tags: [docker] stage: build script: [vendor/bin/mmwp-release build] artifacts: { paths: ["*.zip", "dist/"] } mmwp-check: tags: [docker] stage: check script: [vendor/bin/mmwp-release check] artifacts: { when: always, paths: [plugin-check.json, plugin-check.txt] } mmwp-release: tags: [docker] stage: release rules: [{ if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH }] script: - apk add --no-cache rsync zip unzip curl bash subversion - vendor/bin/mmwp-release deploy-wporg - vendor/bin/mmwp-release release-github
Required CI variables (masked, protected): WPORG_USERNAME, WPORG_PASSWORD, GITHUB_TOKEN; optional MMWP_*
as above. Because every release command is idempotent, the release job can run on every push to main.
See docs/ci.md and docs/release-model.md.
Development
composer install vendor/bin/phpcs && vendor/bin/phpstan analyse && vendor/bin/phpunit --testsuite unit vendor/bin/phpunit --testsuite integration # real build + check; needs wp-cli, network and pdo_sqlite
License
GPL-2.0-or-later. Support: info@mammothwp.com · https://mammothwp.com