WordPress.org Plugin Submission Skill
Get any plugin through the wp.org automated scan and manual review without
re-uploading five times. Distilled from real review cycles (the kind where a stray
.gitkeep fails the whole scan).
Maintainer: moksa · moksaweb.com · License: MIT
When to use
Trigger this skill when the user asks to:
- Package / build a clean install zip for wordpress.org
- Submit or resubmit a plugin to the wp.org directory
- Fix wp.org review-team feedback (the 4 classic categories)
- Diagnose an automated-scan rejection (hidden files, prefixes, DB calls…)
- Bump a plugin version consistently (header + readme + constants)
- Pre-flight a plugin before upload
Do NOT use for:
- Non-WordPress projects
- Private/internal plugins never going to wp.org (review rules don't apply, though the security practices still help)
The two gates
- Automated scan (Plugin Check, run on upload) — hard blocks. Must pass to even
reach a human. See
references/automated-scan.md. - Manual review (a human, after scan passes) — emails you issues, you fix + reply.
Almost always one of 4 code categories (
references/review-categories.md) plus policy guidelines no linter catches — licensing, remote code, tracking, admin nags, trademarks, external-service disclosure (references/guidelines.md).
Reference map
| Topic | File |
|---|---|
| Plugin Check (PCP) runbook — UI/CLI, all 31 checks → fix map, errors vs warnings | references/plugin-check.md |
| Automated-scan hard blocks (hidden files, DB, disallowed calls) | references/automated-scan.md |
| The 4 manual-review categories + reply phrasing | references/review-categories.md |
| Real-review lessons (sample→sweep, 3-case nonce, phpcs:ignore≠escape, cast≠sanitize, paths, fork prefixes) | references/real-review-lessons.md |
| Prefixing every global (functions/options/hooks/handles/CPT/meta…) | references/prefixing.md |
Sanitize · validate · escape + $wpdb->prepare + webhook signatures | references/sanitization-escaping.md |
| Enqueue JS/CSS (deps/version, inline, gating, blocks, no raw tags) | references/enqueue.md |
| i18n (textdomain==slug, literal strings, translators comments, JS) | references/i18n.md |
| Capabilities & permission callbacks (authz, REST, priv/nopriv) | references/capabilities.md |
| Policy guidelines (license, remote code, tracking, nags, trademarks, disclosure) | references/guidelines.md |
| readme.txt format spec (headers, tags≤5, short desc≤150, screenshots, External services) | references/readme-txt.md |
| Abilities API · AI Client · MCP (per-ability authz, data boundary, destructive confirm, provider disclosure) | references/abilities-ai-mcp.md |
| Security extras (files/WP_Filesystem, uploads, SSRF, disallowed/obfuscation calls) | references/security-extras.md |
| Uninstall & lifecycle (data cleanup, don't delete user content, multisite) | references/uninstall.md |
| Packaging the zip + version/header/readme consistency + .mo | references/packaging.md |
| Scan/PHPCS code → fix lookup | data/plugin-check-errors.csv |
Quick pre-upload checklist (copy-paste verify)
AUTOMATED SCAN (hard blocks)
[ ] No hidden files in the zip: `find . -name '.*' -not -name '.' -not -name '..'` is EMPTY
(.gitkeep / .gitignore / .DS_Store / .editorconfig all fail "hidden_files")
[ ] No node_modules/, .git/, tests/, scripts/, phpcs.xml, webpack.config.js in the zip
[ ] All plugin-owned globals use a unique >=4-char prefix (no wp_ / __ / single _ )
[ ] No direct $wpdb queries without prepare(); no remote code execution; no phone-home
MANUAL REVIEW (the 4 categories)
[ ] Enqueue: no raw echo '<script>'/'<style>'; wp_enqueue_* + wp_add_inline_*
[ ] Nonce + current_user_can on every state-changing AJAX/admin_post; webhooks verify
provider signature with hash_equals before use (no WP nonce possible)
[ ] Every $_POST/$_GET sanitized (wp_unslash + sanitize_*); json_decode -> map_deep;
output escaped late (esc_html/esc_attr/esc_url/wp_kses)
[ ] textdomain == slug; __() args are literal strings; /* translators: */ before %s
POLICY (manual review, no linter catches these — references/guidelines.md)
[ ] GPL-compatible license stated + LICENSE file; every bundled lib/asset GPL-compatible
[ ] No remote code execution / no loading executable JS from a CDN; bundle assets locally
[ ] Any third-party API call disclosed in readme "== External services ==" (what data, when, URLs)
[ ] No data collection/tracking without explicit opt-in (default OFF); no front-end credit
link unless opt-in; admin notices dismissible + screen-scoped (no nag spam)
[ ] Slug/name doesn't lead with a trademark you don't own (woocommerce-… / google-…)
README.TXT (references/readme-txt.md — validate at the wp.org readme validator)
[ ] Header parses: Contributors=wp.org usernames, Tags<=5, Stable tag = real shipped version
[ ] Short description <=150 chars, plain text; Changelog has an entry for Stable tag
[ ] Screenshots/banner/icon go in SVN /assets/, NOT in the zip
AI / ABILITIES / MCP (if the plugin registers abilities, calls an LLM, or exposes MCP)
[ ] Every wp_register_ability has a real permission_callback (object-level cap), not __return_true
[ ] Data boundary: an ability/MCP tool returns only what the current user's caps allow
[ ] Model/agent input treated as untrusted; destructive abilities gated behind confirmation
[ ] LLM/AI SDK bundled (GPL); provider disclosed in == External services ==; keys never logged
[ ] MCP endpoint authenticates (App Password/OAuth) AND runs each ability's permission_callback
EXTRA SECURITY + LIFECYCLE
[ ] No eval/shell/obfuscation/extract; no var_dump/print_r/error_log left in
[ ] Files via WP_Filesystem to uploads only; uploads via wp_handle_upload + MIME whitelist
[ ] Remote calls via wp_safe_remote_* (SSRF: block internal ranges) + timeout + response check
[ ] uninstall.php guarded by WP_UNINSTALL_PLUGIN; removes only your prefixed data; never user content
PACKAGE + METADATA
[ ] Version synced: main-file header Version, version constant, readme Stable tag, Changelog
[ ] Requires headers match in BOTH main file AND readme.txt (WP / PHP / WC) — and any
runtime MIN_* constant that gates activation
[ ] vendor/ is `composer install --no-dev` (no squizlabs/phpunit/phpcompatibility)
[ ] zip root folder name == plugin slug
Workflow
- Read the rules for the task (see the Reference map above) — start with
references/automated-scan.md+references/review-categories.md, then the deep-dive that matches the finding (prefixing.md/sanitization-escaping.md/enqueue.md/i18n.md) andreferences/packaging.mdfor the build. - Build the clean zip — run
scripts/build-zip.sh(handles git archive, strips dev files + ALL dotfiles, installsvendor/--no-dev, zips with slug as root). Never hand-zip the working tree; that's how dev junk and dotfiles leak in. - Preflight — run
scripts/preflight.sh <path-to-zip-or-dir>: scans for hidden files, checks version sync + header/readme consistency, runs PHPCS if configured. Fix until clean. - Run Plugin Check (PCP) — the official tool that runs the directory's own automated
checks locally.
wp plugin check <slug> --require=./wp-content/plugins/plugin-check/cli.php(or Tools → Plugin Check). Fix every ERROR, review every WARNING. Seereferences/plugin-check.mdfor all 31 checks, flags, and which skill chapter fixes each. - Upload. If the automated scan rejects → read the exact code, map it via
data/plugin-check-errors.csv, fix, rebuild, re-upload. - Manual-review reply — when the reviewer emails issues, fix each everywhere (the
examples are a sample — grep the whole tree, run Plugin Check + PHPCS over the entire
plugin; the same category recurring = instant re-rejection). Then reply point-by-point
referencing what changed. See
references/review-categories.mdfor canonical fixes + reply phrasing andreferences/real-review-lessons.mdfor the per-category sweep commands and the traps that survive a first fix.
Hard-won gotchas (the expensive ones)
.gitkeepfails the automated scan. It's a hidden file. Strip every dotfile from the package; remove.gitkeepfrom git sogit archivenever re-adds it.- Requires-version drift. wp.org reads the main-file header AND readme.txt; a runtime
MIN_WC/MIN_PHPconstant can gate activation separately. All three must agree, or a user on a too-low version installs successfully then hits broken features. - Webhooks can't use WP nonces. Reviewers flag "no nonce" on IPN handlers — that's a
false positive; the correct answer is provider-signature verification with
hash_equals(). Document it with a per-linephpcs:ignore+ reason. json_decodeis not sanitization. Alwaysmap_deep(sanitize_text_field)the result.composer install(without--no-dev) leaks phpcs/phpunit into vendor/ → bloated, flagged package. Always--no-devfor the shipped zip.