Search by

mammothwp / wp-release

gnnpls

Prepare, verify and ship a Mammoth WP plugin release to wordpress.org and GitHub.

Package info

github.com/mammothwp/wp-release

Homepage

pkg:composer/mammothwp/wp-release

Statistics

Installs: 235

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-11-14 08:10 UTC

This package is auto-updated.

Last update: 2026-11-14 08:17:14 UTC


README

Mammoth WP Release — Build, Plugin-Check and ship WordPress plugins from one command.

Packagist License: GPL-2.0-or-later PHP 8.1 or newer

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

One mmwp-release command builds a plugin zip, runs Plugin Check on it and ships it to wordpress.org and GitHub Releases; an already published version is skipped.

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 u flag; use inline flags such as (?i). An invalid regex exits with code 2.
  • Allowed findings are listed as ALLOWED: <code>: <message> (rule: <regex>) in plugin-check.txt and on the console, the counts read N error(s), M warning(s), K allowed, and plugin-check.json gets allowed and allowed_findings. warnings excludes allowed findings, so the strict exit-20 decision and the deploy-wporg gate 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):

  1. removes the paths of the @fs_premium_only header, *__premium_only.php files, __premium_only functions/methods (with their docblock) and if ( ...__premium_only()... ) { ... } blocks (no else, as in Freemius); <fs_premium_only> blocks of readme.txt are dropped and <fs_free_only> tags unwrapped. vendor/ is never touched;
  2. replaces the region between // @mmwp-sdk-begin and // @mmwp-sdk-end lines in root PHP files by the configured replacement (default: nothing) and deletes the configured SDK wrapper files;
  3. runs composer remove freemius/wordpress-sdk --update-no-dev (SDK out of composer.json, the lock and Composer's autoload files; other packages keep their locked versions), or a normal production install when the SDK is not required;
  4. deletes freemius-config.php and mmwp-free.json, then applies .distignore, drops composer.lock and zips as usual. composer.json and vendor/ (which carries runtime packages such as mammothwp/wp-kit) stay; the lock is dropped only in this free build because it lists dev packages that are not shipped and wordpress.org expects composer.json + vendor/. The full build still ships the lock, as before;
  5. 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 in forbid) => paths where it is tolerated (exact file, dir/ prefix or fnmatch glob, 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's Freemius::init() reads for the product id and public key (deploy-freemius defines them in the generated config file; required there). freemius.config_file: name of that file (default freemius-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.

  1. Full build into .scratch/freemius/ (the working tree's dist/ and <slug>.zip are untouched), generated config file added, optional version override applied.
  2. 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.
  3. Upload (POST /products/{id}/tags.json): creates a pending version. A release_mode other than pending fails (exit 50). The dotenv file freemius.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.
  4. Unless --upload-only: download tags/{id}.zip?is_premium=false to .scratch/freemius/free/<slug>.zip, unzip -t it, 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>.php exists; headers Text Domain == slug, Requires PHP, Requires at least, License (GPL), Version.
  • readme.txt exists with Stable tag and Tested up to; header Version == readme Stable tag.
  • The zip contains no .git, node_modules, tests, phpunit*, phpcs*, phpstan*, .gitlab-ci.yml, .wordpress-org, .env*, *.log; composer.lock only if vendor/ ships. Inside vendor/ 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.json requires anything besides php/ext-*/lib-*, the zip must contain vendor/autoload.php, vendor/composer/autoload_real.php and autoload_classmap.php, and a vendor/<name>/ directory for every package in the plugin's composer.lock packages (non-dev; metapackages skipped). Typical cause: a bare vendor line in .distignore. Plugin Check does not notice a zip like that.
  • .distignore warning (fails the build with --strict / MMWP_PCP_STRICT): an unanchored entry (no leading /) named tests, docs, bin, README.md, composer.lock or vendor also strips those files inside vendor/ packages; write /tests etc.

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