Rules every plugin made from the wpallstars starter plugin follows. This file
is the same in each of them: the starter holds the master copy, so a lesson
learned in one plugin goes into the starter's copy, then to every plugin
(scripts/sync-core.sh shows the differences). Rules for one plugin go in
its AGENTS.md or its docs/ (Agent docs below), never here.
Names below are placeholders. Each plugin's AGENTS.md gives its values:
| Placeholder | Meaning | Example |
|---|---|---|
{slug} |
Folder, main file and text domain | my-plugin |
{prefix} |
Option, hook, function and file prefix: {PREFIX} in lower case |
myplugin |
{Prefix} |
Class prefix (@package) |
MyPlugin |
{PREFIX} |
Constant prefix | MYPLUGIN |
{Name} |
Plugin name | My Plugin |
{css} |
CSS class and data attribute prefix ({css}-card, data-{css}-setting) |
mp |
Minimums: WordPress 6.2, PHP 7.4 (readme.txt, plugin header).
How changes are made and checked: DEVELOPMENT.md. Releases: RELEASING.md.
-
Core files are listed in
scripts/core-files.txt: the feature registry (includes/class-{prefix}.php), the base feature (includes/class-{prefix}-feature.php), the settings store and its network settings (includes/class-{prefix}-settings.php,includes/class-{prefix}-network-settings.php,includes/class-{prefix}-network-sites.php,includes/class-{prefix}-network-differences.php), the admin screen, its network screen and Read Me tab (admin/), the shared GitHub updater (includes/github-updater/), the scripts, the CI workflow, tool configuration and the shared docs (this file,STYLING.md,DEVELOPMENT.md,RELEASING.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,SECURITY.md). They hold no code for one plugin (a plugin's own lines in a core file go only between its{css}-ownmarkers): they read{Prefix}_Setup(includes/class-{prefix}-setup.php: features, settings tabs, header links, settings version and history, the plugin's own helpers and admin parts) or use hooks ({prefix}_admin_tabsfor tabs,{prefix}_admin_enqueuefor its own admin CSS and JS,scripts/preflight-plugin.shfor its own release checks). Anything only one plugin needs goes there, in a feature, or in its own file loaded from there. Change a core file in the starter first, then runscripts/sync-core.shin each plugin;scripts/sync-core.sh --checklists core files that differ. A new plugin is a copy of the starter renamed withscripts/rename-plugin.sh. -
Every plugin keeps up with the starter: the weekly Starter sync workflow keeps one
starter-syncissue open while core files differ (DEVELOPMENT.md→ Starter sync). Work it like any issue: sync, check the starter's changelog for changes the plugin's own files need, lint, smoke test, pull request. -
One class per feature in
includes/features/, extending{Prefix}_Feature, registered in{Prefix}_Setup::FEATURES. Features some builds leave out go in{Prefix}_Setup::OPTIONAL_FEATURESand load only when present. -
settings()declares the schema; the admin screen renders, searches and saves it with no extra code. Field types and keys:README.md→ Developers. -
boot()returns early unlessself::enabled(). Features are off by default; the plugin'sAGENTS.mdlists any the owner asked to be on. Turning another feature on by default needs the owner's say. -
Feature output at the top of a setting's options panel (site or network) may use
{css}-panel-noteand its{css}-panel-note--warningmodifier, and settings tabs the settings components: public core classes (STYLING.md→ Classes a plugin may use). -
A changed default is only sure to reach new installs: each migration and any save on the settings screen store every setting, defaults included (
set()writes them all back), and a stored setting keeps its value. A setting with no stored value yet (one added since) follows the current default, so a new setting's default reaches every site. Change a stored value on existing sites only inmigrate()with aDB_VERSIONbump, and only where the owner agrees. -
A feature that replaces another plugin lists it in a class constant on the feature class,
const REPLACES = array(slug => name);, and sets'replaces' => self::REPLACES. Other code (migrations, audits, checks for active plugins) readsself::REPLACES, never a copy of the list, andscripts/replaced-plugins.phpcounts it.migrate()imports that plugin's settings withself::import_setting()(fills only unset keys), never writing or deleting its options. While that plugin is active the feature waits, and the Plugins screen suggests deactivating and deleting it (admin/includes/class-replaced-plugins.php) with no extra code. -
Migrations run once per
{Prefix}_Setup::DB_VERSION. After a release, a new or changed import needs a version bump and a line in its docblock.uninstall.phpremoves every new option, post meta, user meta, transient, cron hook and file. -
README.mdis also the Read Me tab (admin/includes/class-readme-manager.php): it renders headings, lists, tables, bold, italic, inline code, links (http(s) and#headinglinks, with GitHub-style heading IDs) and images from the plugin folder on a line of their own (). Use only that Markdown, or extend the renderer in the same change. The tab leaves out HTML comments, anything between<!-- github-only:start -->and<!-- github-only:end -->(GitHub-only parts such as the screenshots section) and the badges block under the title (<!-- aidevops:badges:start -->to<!-- aidevops:badges:end -->). TheVersion: X.Y.Zline under the intro holds the version itself (GitHub shows it as written) and changes with every release (RELEASING.md). -
The badges block is the starter's, the same in every plugin, and other tools leave it alone:
scripts/readme-badges.shwrites it (scripts/rename-plugin.shfor a new repository). Three rows, one blank line between them: status (CI, SonarCloud, Codacy, CodeFactor, OpenSSF Scorecard, licence, release fromreadme.txt'sStable tag:, which also shows for a private repository); requirements fromreadme.txt(Requires WordPress, Tested up to, Requires PHP) and size (the facts indocs/metrics/that.github/workflows/repo-metrics.ymlkeeps up to date); then the languages chart alone. Add a service's badge (--add,--codacy) once it has the repository (DEVELOPMENT.md→ Services setup), never before: it would be a broken image.scripts/preflight-release.shwarns when the block differs. -
Listing images are in
.wordpress-org/. Screenshots are.wordpress-org/screenshot-N.png(WordPress.org'sassets/names, captions inreadme.txt→ Screenshots), which the release zip leaves out; they show only what the WordPress.org build shows. GitHub-only pictures, such as Updates from GitHub, go indocs/images/with no caption inreadme.txt.scripts/build-banner.shturns the banner (banner.svg) and the icon (icon.svg, the banner's picture alone, with no words) intobanner-772x250.png,banner-1544x500.png,icon-128x128.png,icon-256x256.pngand the shippedadmin/images/banner.svg,admin/images/banner-details.svgandadmin/images/icon.svg, and the screenshots intoadmin/images/screenshot-N.webp; the GitHub updater shows them on the Updates screen and in View details, andbanner-details.svgand the screenshot copies are in the GitHub build only.scripts/preflight-release.shchecks their sizes, that the icon, View details banner and screenshot copies ship, and that eachscreenshot-Nhas a caption. -
A wpallstars-branded banner keeps the starter's two words layouts, so every plugin's banners match.
banner.svgholds the same four lines on the left twice, at sizes 27 (WPALLSTARS), 104 (the name), 72 (the gold line) and 33 (the tagline):<g id="words">, centred top to bottom against the picture (baselines y 128, 240, 326 and 396;gold-textgradient y 280 to 340), becomesadmin/images/banner.svg: the README on GitHub and the Read Me tab.<g id="words-details" display="none">, all above y 340 (baselines y 86, 190, 270 and 324;gold-text-detailsgradient y 224 to 284), becomesadmin/images/banner-details.svg(GitHub build only) and the WordPress.org PNGs. View details (Plugins and Updates screens) writes the plugin name in a dark box over the banner's lower left, y 348 to 448 of 500; the GitHub updater showsbanner-details.svgthere, and WordPress.org's View details shows its PNGs.
Change the words in both groups, and only the words; when a name or line is too long, shorten it rather than move or resize the lines. Each group is one
<g id=...>line, its<text>lines and</g>, which is howscripts/build-banner.shfinds them. -
Every wpallstars plugin's banner and icon show the same picture: the stack of three isometric tiles under the arc of five stars, on the navy background with red and cream stripes. Never replace the stack with another picture.
- Vary only two things: the tiles' colours (with the matching stars and glow) and the picture on the top tile. The starter has all red tiles with the WordPress Plugins icon; other plugins have, say, rising bars, or red, cream and blue tiles with a gold lightning bolt.
- Draw the top tile's picture on the tile's isometric axes, in a 20-unit
space (0 to 20 on both axes, centre 10,10) through the starter's face
matrix,
matrix(4.95 2.871 -4.95 2.871 1214 -57.42)in the top tile'stranslate(0 238)group of.wordpress-org/banner.svg. Picture x then runs along the tile's top-right edge and y along its top-left edge, so straight edges stay parallel to the tile's. Squashing upright art (scale(1 0.58)) is not enough: its edges stay level and look wrong. Strokes scale with the matrix: about 0.35 units draws a 2 px line. - Raised art (optional, such as bars or a bolt standing on the tile):
a dark shadow copy offset
translate(5 3), then darker side copies stepped up 1 px at a time, then the top copy with a light outline, all in tile coordinates (a<use>translate applies outside the matrix). - Keep
banner.svgandicon.svgin step (the icon is the banner's picture with no words), runscripts/build-banner.sh, and look aticon-128x128.png: the top tile's picture must still read at that size.
-
Settings → {Name} is the settings screen. A plugin with its own top-level menu names it in
{Prefix}_Setup::MENU_PARENT, and the screen is Settings, last in that menu (not also under Settings). Link to it with{Prefix}_Admin_Manager::page_url()ortab_url(), never a fixedoptions-general.phpaddress. Its header links come from{Prefix}_Setup::header_links():source(the GitHub repository, Source code),support(its issues, Support) anddonate(Buy me a coffee); leave one out for no button, and keep labels short so they fit one row. The plugin's own screens show the same header ({Prefix}_Admin_Manager::enqueue_header(),render_header()) and their sections as the same tabs ({Prefix}_Admin_Page::sections_nav()). Settings tabs in one group (between dividers) switch without a reload; a{prefix}_admin_tabstab joins with'preload' => trueonce it is cheap to draw and its script works with its panel hidden. The browser title names the screen, then the tab or section shown ("{Name}: General ‹ Site — WordPress"), also after a switch without a reload; the plugin's own screens with sections call{Prefix}_Admin_Page::title_section()on theirload-{hook}action. The screen's name is its page title (get_admin_page_title()), so it reads as the menu does: in the plugin's own menu, the first item (the one with the menu's slug) keeps{Name}as its page title and takes the first section's name only as its menu title, giving "{Name}: Overview", never "Overview: Overview". -
Update
README.md(feature section, hooks, changelog),changelog.txt(the user-facing changelog entry) andreadme.txtin the same change.readme.txtmust stay under 10 KB for WordPress.org: one short line per feature, every service the plugin contacts under External services, and only the newest version's changelog, in short. Details go inREADME.md. -
Credits: every plugin keeps, in
README.mdandreadme.txt(the Built with AI section), the credit to aidevops (https://aidevops.sh), a line sending questions to aidevops, which reads the plugin's docs and code to answer them, and the line starting "Made from " that credits the starter with a link to it.scripts/rename-plugin.shwrites the starter's line for a new plugin; keep all three when replacing the starter's README and readme.txt.scripts/preflight-release.shwarns when a credit is missing. -
Licence: the starter, and every plugin made from it, is GPL-3.0-or-later with additional terms under GPL-3.0 section 7(b) in
ATTRIBUTION.txt(WordPress.org accepts GPLv3). The terms make the credit part of the licence. Keep:LICENSEandATTRIBUTION.txt(core files; both ship in both zips, andATTRIBUTION.txtkeeps the starter's names word for word);- the
License:andLicense URI:headers in the main file andreadme.txt(the same licence in both), and the GPL notice in the main file's comment; - the SPDX lines at the top of each source file (licence, copyright, the
pointer to
ATTRIBUTION.txtand the Provenance line; a plugin's own new files carry its own copyright and its own Provenance ID); - the line starting "Made from " in
README.mdandreadme.txt, and the starter's copyright line, "Copyright (C) 2026 Marcus Quinn", in the main file andREADME.md→ License. Add your own copyright line above it; never replace or remove the starter's.scripts/rename-plugin.shwrites both for a new plugin: its own (this year and--author), then the starter's as "Parts copyright (C) 2026 Marcus Quinn, from" the starter's name and link.
scripts/preflight-release.sherrors whenLICENSEorATTRIBUTION.txtis missing from a zip or the two licences differ, and warns when the licence is not GPL-3.0-or-later,ATTRIBUTION.txtis missing, or a copyright line, a source file's SPDX copyright line or its Provenance line is missing. -
Provenance IDs (
ATTRIBUTION.txt→ Provenance): each plugin has its own (wpas-and 16 hex digits) on theProvenance:line of its main file; the starter's iswpas-e4502af0f07e8a92. Core files carry the starter's, and the plugin's own files carry its own.scripts/rename-plugin.shgives a new plugin its ID;scripts/provenance.sh stampgives one to a plugin without, and adds the line to new files (run it after adding a source file);scripts/provenance.sh searchlists public repositories outside the owner's that carry an ID, and whether each keepsATTRIBUTION.txt. The IDs are plain text: no plugin sends anything anywhere. The main file's GPL notice also tells anyone copying the plugin, or an AI assistant doing it for them, to readATTRIBUTION.txtfirst; keep that line. -
Every plugin except SEO Pro Stack ends
README.md→ Built with AI with the line starting "Works well with " that recommends SEO Pro Stack (https://github.com/wpallstars/seoprostack), the base plugin for every site. Keep it out ofreadme.txt, admin notices and the plugin's screens other than the Read Me tab, where WordPress.org's reviewers look for plugins promoting others.scripts/preflight-release.shwarns when the line is missing fromREADME.mdor appears inreadme.txt. -
.distignorelists files kept out of the release zip. Add new development-only files there (the preflight fails when a known one gets in), then check the build with Plugin Check.
AI agents read AGENTS.md in every session, so every line there costs every
session: keep it a short map, with the detail where only the task that needs
it reads it. A small plugin has only AGENTS.md; a large one adds docs.
AGENTS.mdholds the plugin's names (the placeholder table), its rules that apply to any change (a line or two each, such as features the owner asked to be on), and one line per doc saying when to read it. Near the top it makes the shared guidance compulsory reading before any change: this file,STYLING.md,DEVELOPMENT.md,RELEASING.md,LAUNCH.md,ATTRIBUTION.txtand the task's doc indocs/. It also keeps the licence and credit rule (Structure above) in a few lines, since agents copying the plugin readAGENTS.mdfirst.CLAUDE.md(a core file) only points toAGENTS.md(@AGENTS.md): Claude Code readsCLAUDE.md, notAGENTS.md. Other tools' pointer files are made per checkout and stay out of Git (.gitignore).- Agents keep the plugin at the starter's standard, which is set in the
starter, not in the plugin's copy:
- Before work, look for an open
starter-syncissue. If one is open, the plugin's core files, this file included, are behind: read the starter's copy of any core file or rule the task touches, and do that issue first when the task changes the same files. - Never change a core file only in the plugin. A fix or rule every plugin
needs goes to the starter first (a pull request, or an issue there with
the plugin's case), then comes back with
scripts/sync-core.sh. Only the plugin's own files ({Prefix}_Setup, features,phpstan-plugin.neon,scripts/preflight-plugin.sh,AGENTS.md,LAUNCH.md,docs/) take changes for this plugin alone. - Steps that need the owner's accounts or make secrets (SonarCloud,
Codacy,
SYNC_PAT:DEVELOPMENT.md→ Services setup) are listed for the owner, not done by an agent.
- Before work, look for an open
- Guidance for one kind of task (a procedure, a checklist, a data format, a
list of choices) goes in
docs/{topic}.md, named for the task (docs/presets.md), andAGENTS.mdsays when to read it: "Adding or changing a preset: readdocs/presets.mdfirst." Move a section there when only some tasks need it or it grows past about 15 lines. - Rules every plugin shares go in this file and
STYLING.md, in the starter; workflows every plugin shares go inDEVELOPMENT.mdandRELEASING.md. Never copy them into a plugin'sAGENTS.mdordocs/. docs/is for people working on the plugin and never ships (.distignore); users' docs stay inREADME.mdandreadme.txt.- No agent definitions (subagents) in the plugin by default: each AI tool has its own format, and a doc serves every tool. Add one only when agents keep getting a task wrong even with its doc, and have it read that doc.
scripts/preflight-release.shwarns whenAGENTS.mdis over 150 lines, does not name one of the files above, still describes the starter (in a plugin), names a doc that does not exist, or leaves out one indocs/, and whenCLAUDE.mddoes not point to it.
- PHP 7.4 syntax and WordPress 6.2 APIs. Guard newer core APIs with
function_exists()ormethod_exists(). - Capability and nonce checks on every admin action and AJAX handler; escape on output; sanitise through the schema.
- SQL goes through
$wpdb->prepare(): values as%s,%dor%f, and the plugin's own table and column names as%i(WordPress 6.2), never put into the query string; core tables as$wpdb->posts,$wpdb->optionsand the like. Otherwise Plugin Check warnsPluginCheck.Security.DirectDB.UnescapedDBParameter. - A notice shown once after an action, read from a query argument
(
?{prefix}_done=…), adds that argument toremovable_query_args, so WordPress takes it out of the address and a reload does not repeat it. - Prefix everything global with
{prefix}_,{Prefix}_or{PREFIX}_, except the shared GitHub updater: itswpallstars_names are the same in every plugin, so that one copy can stand in for the others. - Admin copy: short, plain words, sentence case, no jargon.
- Decide for the user. Within a feature, the plugin makes the choices
(which plugins, screens or items it applies to) from what it can detect.
Settings are there to bypass something that causes a problem, not choices
people need to understand first. A new setting must earn its place;
prefer detecting the right behaviour plus a short bypass list. Put
bypasses under Troubleshooting (
'group' => 'troubleshooting'). - Site owner in control, performance first: the owner decides what their site sends, contacts and shows. Calls to outside services are opt-in where they are not the point of the feature, made only as often and for as long as needed (cache answers, never on every page load), and never block a visitor's page when they can run later. Features that rein in other plugins hand the choice to the owner instead of deciding for them. WordPress update checks and downloads are the exception (next rule).
- Do not change WordPress update behaviour (update transients,
auto_update_*filters, update checks, including when and where they run) outside the shared GitHub updater: Plugin Check reportsplugin_updater_detectedas an error, and WordPress.org asks plugins not to interfere with the updater. Such a change goes in the shared updater, so it reaches GitHub builds only. - Leave no PHP errors, warnings, notices or deprecations behind. Fix any the plugin causes, including ones in other plugins that happen only because of this one, in the same change when small or as a tracked issue. Messages other plugins cause on their own are theirs: mention them, do not hide them.
- WordPress first: use core's APIs (options, transients, the object cache,
WP_Query, cron, the HTTP API, the Settings and REST APIs) before writing your own, and follow the WordPress Coding Standards (phpcs.xml.dist). Fix a PHPCS finding in the code; an inlinephpcs:ignoreneeds the sniff and the reason, and an inlineNOSONAR(SonarCloud) the reason, on that line only, such as// NOSONAR: a cache key, not security.formd5()used as a fingerprint or cache key.
On a network, Network Admin → Settings → {Name} saves the network's own
settings ({Prefix}_Network_Settings, the {prefix}_network network
option). New sites start with them; a card applied to every site overrides
each site's own values, which come back when it stops (README.md →
Multisite). How they reach each site is in {Prefix}_Network_Sites; the
sites that differ, and Make every site the same, are in
{Prefix}_Network_Differences. Features work on a network with no code of
their own when they follow these rules:
- Read settings with
{Prefix}_Settings::get()orall(), never the{prefix}_optionsoption: only those include what the network applies. Code that runs before the plugin loads (a must-use file) reads the network option'senforcedvalues over the site's own, asall()does. - Do saving work (schedules, rules, files) on
{prefix}_settings_changedor{prefix}_setting_saved, neverupdate_option_{prefix}_options: the network's changes reach a site without a save there, and the site runs those hooks on its next admin, cron or WP-CLI request, as nobody. Work that depends on who saves checks{Prefix}_Network_Sites::syncing(). - Mark a setting
'network' => falsewhen it only makes sense on each site (a list of the site's own posts, pages or people). Media fields andhiddenwiring stay with each site already. - A
{prefix}_admin_tabstab shows on the network screen only with'network' => true, and must then work withis_network_admin()true. The site-only hooks{prefix}_setting_paneland{prefix}_settings_tab_afterhave network ones ({prefix}_network_setting_panel,{prefix}_network_settings_tab_after). - Choices that must stay chosen on every site (such as super admins in a
list of people) are a multi field's
lockedvalues, never stored. uninstall.phpdeletes the network option and each site's{prefix}_network_seenand{prefix}_network_lock(its save lock).- Single sites never read the network option, so check both: a change for networks must leave single sites' behaviour, markup and queries as they were.
- Every own admin screen has the header's Light / Dark / System button.
Settings and network settings are recognised automatically. Other screens
call
{Prefix}_Admin_Manager::enqueue_header()onadmin_enqueue_scriptsbefore priority 20, thenrender_header()in the page. For later enqueues, add their screen hooks to{prefix}_admin_theme_screensbefore the head. A flag set while rendering is too late to prevent a first-paint flash. - Read
wpallstars_admin_themeuser meta first, then{prefix}_admin_theme, then Light. Save both with a nonce and the screen's capability (network:manage_network_options; site:manage_options). After a successful save, send thewpallstars-admin-themedocument event withmodeandsource; listen without saving again. Joinwpallstars_admin_theme_participantsand return false fromseoprostack_admin_dark_screenon own screens. - Only own screens load the theme assets. The head script sets
{css}-theme-{mode}and{css}-darkbefore paint; System follows the computer live. Every dark override is scoped tohtml.{css}-dark. - Every plugin's
uninstall.phprequiresadmin/includes/class-admin-theme.phpand calls{Prefix}_Admin_Theme::uninstall(): remove its own user meta, keeping the shared choice while another wpallstars plugin is installed, including inactive plugins. When syncing, remove any private theme copy and its init call in the same change, so only one button is printed.
Every plugin runs on every request of every site it is on, next to many others, so it must cost nearly nothing when idle and stay fast on large sites. The lessons come from WordPress and WooCommerce sites with hundreds of thousands of posts, products and meta rows, where problems invisible on a test site take the site down.
- Load only what is used. Hook each feature on the narrowest hook and screen it needs. Admin code runs only in the admin; CSS and JS load only on the screens or pages that use them, and nothing loads on the front end unless the feature shows something there.
- No full table scans. Every query reads only the rows it needs,
through an index:
- No unlimited queries (
posts_per_page => -1,nopaging): page or batch them. - No
'no_found_rows' => falseunless the page shows a total or page numbers; settrueotherwise (counting every match is often the slowest part). - Ask only for what is used:
'fields' => 'ids', andupdate_post_meta_cache/update_post_term_cacheoff when meta or terms are not read. - No lookups or sorting by
meta_value,LIKE '%term%',REGEXP,ORDER BY RAND()or largepost__not_inlists on large tables. Searching or filtering by the plugin's own data uses its own table with indexes on the columns it searches, or taxonomies; full-text search uses a full-text index. - Index the plugin's own tables (created with
dbDelta()) for every lookup, join and sort. Never duplicate an index a table already has (other plugins or the host may have added it, and a copy slows every write for no gain): check its keys (SHOW INDEX) first and skip any whose leading columns an existing key covers. An index on a table the plugin does not own (WordPress's or another plugin's) is opt-in, and uninstall removes only the ones it added. - Admin lists of large tables page, sort only on indexed columns and cache counts.
- No unlimited queries (
- Options: one autoloaded settings array (
{prefix}_options). Store large or rarely used data with autoload off (update_option( $name, $value, false )) or in the plugin's own table. Never write an option or transient on every page load. - Small choice lists: a
selectormultisetting'soptionscallable runs on every settings save, not only on its screen, because a save checks every setting. Never list what grows with the site (pages, posts, users) there: one such list ran out of memory on every save with 60,000 pages. Mark the fieldopento keep any ID, and offer the list only where it is shown, paged or searched. - Cache repeated work:
wp_cache_*for lookups repeated within a request (a persistent object cache keeps them between requests), and expiring transients for results that are slow to build. Cache times are at least five minutes. - Work off the page. Heavy or bulk work runs in cron, in batches with a
limit, never on a visitor's page or with no limit. Bulk changes to posts
or terms use
wp_defer_term_counting()andwp_suspend_cache_invalidation(), then turn them back on. - No request per page view. No admin-ajax, REST or remote request on every visitor page unless the feature needs it; remote requests a page waits on time out within 3 seconds. Admin screens do not wait on remote requests either when the answer can be fetched in cron and cached: licence and update servers are the usual cause of slow admin screens.
- Clear only your own cache. Delete the plugin's own object-cache keys
or groups, never the whole object cache (
wp_cache_flush()): on many hosts every site on the account shares one memcached server, so a flush empties every site's cache and slows each of their next uncached page loads (measured on a host with 14 sites: about 0.2 seconds a page, up to 0.5). - Measure on large data.
scripts/smoke-test.shloads every page on a site seeded with thousands of posts and meta rows, reports query counts and times, and fails on a full table or index scan, or a large sort, in the plugin's own queries (DEVELOPMENT.md→ Smoke test). PHPCS flags the patterns above as you write them (WordPress.DB.SlowDBQueryand VIP's performance sniffs,phpcs.xml.dist); an exception, such as an unlimited query over a list that cannot grow, needs an inlinephpcs:ignorewith why.
The one exception to the update rule, at the owner's request (for speed,
reliability and site owners' control, and because the plugins are released
on GitHub first): the shared GitHub updater in includes/github-updater/,
which replaces Git Updater.
- Every plugin made from the starter carries a copy. Each copy registers its
version from
load.phpwhen its plugin loads; onplugins_loadedonly the newest copy loads, once, and serves every installed plugin with aGitHub Plugin URIheader, in one update check. - It adds GitHub releases of those plugins to core's own update check and
plugins_api, and leaves the download, install, auto-updates and rollback to core. It only adds entries for those plugins, never removing or blocking other updates. Its icon, banner and View details (readme.txtand the screenshots) are the installed plugin's own files. - Update checks that fall due while someone opens an admin screen run in
WP-Cron instead: core runs them on
admin_initwhen its stored check is 12 hours old, so that screen waits while WordPress and every plugin's own updater ask their servers (seconds on hosts with many premium plugins). The updater moves only those three checks (_maybe_update_core,_maybe_update_plugins,_maybe_update_themes) to core's own cron events, and leaves them in place while WP-Cron is not running. The checks on the Plugins, Themes and Updates screens, the twice-daily checks, the checks after updating and automatic updates stay as in core. - It is the same in every plugin apart from its text domain and
@package. Change it in the starter, raise the version in itsload.php, and copy it to each plugin. Plugins change what it does only through its filters (wpallstars_github_updater_enabled,wpallstars_github_updater_early,wpallstars_github_plugins,wpallstars_github_token), never by calling its class: another plugin's copy may be the one that runs. - Keep anything that installs or updates code from outside WordPress.org in
that folder (and in a feature listed in
.distignore-wporg, when a plugin has a setting for it): the WordPress.org build leaves them out. - Never put an
Update URIheader in the main file in Git: WordPress.org rejects it.scripts/build-release.shaddsUpdate URI: https://github.com/{owner}/{repo}to the GitHub zip only, so WordPress.org never offers a plugin of the same slug in its place, and the updater never takes WordPress.org's answer for that build. A site moves to WordPress.org updates only by installing the WordPress.org build. - Tokens for private repositories come only from
wp-config.php(WPALLSTARS_GITHUB_TOKEN) or the filter, go only to api.github.com and are never stored. - Release answers are cached for 12 hours (an hour after a failure, which
keeps the release found before and shows its error on the Plugins and
Updates screens). "Check again" on the Updates screen, even when core
skips its own check, or a core check without the
update_pluginssite transient asks GitHub again, at most once a minute.
Two release channels, at the owner's decision:
- GitHub releases are the stable beta channel. Every version is released there first, as soon as it is ready; sites with the GitHub build (and sites that turn on early updates from GitHub) get it at once.
- WordPress.org gets a version 30 days after its GitHub release, once it has been used on real sites, except a security release (one that fixes a vulnerability), which goes to WordPress.org as soon as it is on GitHub. It is built from the GitHub tag of the version it ships, never from changes that are not on GitHub.
- Say which channel a site is on in
README.md→ Updates and releases and inreadme.txt(FAQ), and mark security releases in the changelog ("Security:") so the exception is clear.
Sites install the latest GitHub release whose tag is a plain version and the
asset named exactly {folder}-X.Y.Z.zip (the shared updater; Git Updater,
where still active, reads Version: on main instead), so:
- Publish the GitHub release (tag
vX.Y.Z, asset{slug}-X.Y.Z.zipwith a{slug}/folder, built with.distignore) straight after the version change reachesmain: push the tag, and the Release workflow (.github/workflows/release.yml) builds, checks and publishes it, with signed build provenance in a public repository (provenance-{slug}-X.Y.Z.sigstore.json). No other asset name may start with{slug}: Git Updater installs the first one that does. - Never put a pre-release version (
-beta1,-rc1) inVersion:onmain; mark test releases as pre-releases on GitHub. - The WordPress.org build,
wordpress-org-{slug}-X.Y.Z.zip(named so no updater picks it; never attach it to a GitHub release), is the release build without the files in.distignore-wporg(the GitHub updater), theGitHub Plugin URI,Primary BranchandRelease Assetheader lines, andUpdate URI. - It has no affiliate links: guideline 11 does not permit tracking referrals
in the dashboard, and guideline 12 wants affiliate links to point straight
at the service. List each one in
.wporg-linksforscripts/build-release.shto replace in that build only (format and preflight checks:RELEASING.md→ WordPress.org). GitHub builds keep them, disclosed inREADME.mdandreadme.txt. - Any plugin released on GitHub (made from the starter or not) follows the
same pattern: a
GitHub Plugin URI: owner/repoheader (andRelease Asset: true), plain version tags, and a{folder}-X.Y.Z.zipasset with a{folder}/inside. - Build both zips with
scripts/build-release.shand check them withscripts/preflight-release.shandscripts/plugin-check.sh. None of them tags, publishes or uploads anything; only the Release workflow publishes, and only from a tag someone pushed. - Releasing and submitting to WordPress.org need the owner's say. Sites
cannot read a private repository without a token
(
WPALLSTARS_GITHUB_TOKEN).
Details: RELEASING.md; the plugin's own submission state: LAUNCH.md.
Styling rules are in STYLING.md; read it before changing styles:
- Admin screens: spacing and forms (React forms and modals in wp-admin).
- Front-end styling and dark mode (the Kadence Pro dark mode switcher).
- Classes a plugin may use: the public core classes in
scripts/core-public-classes.txt(page shell, settings components, panel notes,{css}-dark) in markup, never restyled; the plugin's own components under{css}-<screen>-*or{css}-app-*; other core classes are internal. Release preflight warns on internal classes and restyling.
CI (.github/workflows/ci.yml) runs the code checks, the release preflight,
Plugin Check and a smoke test on every pull request; details and local
commands: DEVELOPMENT.md. While a repository is private, CI and review
apps only advise, for speed (DEVELOPMENT.md → While private): fix failures
your change causes before merging; open an issue for any other failure and
merge anyway. Do not turn on branch protection, required checks or paid
reviewers. At public launch (owner's say), run the sweep in
DEVELOPMENT.md → At public launch, which makes them required.
The checks catch errors, not wrong behaviour, so also verify on real WordPress:
scripts/lint.sh(syntax, ShellCheck, PHPCS, PHPStan; runcomposer installonce). Fix findings in the code; never growphpstan-baseline.neonor add aphpcs:ignorewithout a reason.- The user reviews on one local test site per plugin, shared by every
session and worktree, showing a combined preview:
origin/mainplus every open pull request, merged together. Update it only withscripts/preview-site.sh, from any worktree (--dry-runreports what it would include); how it works, conflicts and throwaway sites for your own checks:DEVELOPMENT.md→ Preview site.- Never
rsyncyour worktree into the shared site or deploy it with Git hooks (shared by every worktree): either hides every other session's work or deploys the wrong checkout. - Only pushed work with an open PR is included. Before asking the user to
look at unmerged work, push the branch, open a draft PR and run the
script. After merging a PR, run it again and check that the stamp lists
your merge in
mainbefore telling the user to look. Tell the user which PRs it leaves out. - Size every test site, shared or throwaway, as
DEVELOPMENT.md→ Test site resources says: PHP's defaults fill up and the slowdowns look like bugs.
- Never
- Exercise the changed feature through the admin UI or HTTP, and check the
debug log for new messages mentioning
{slug}or a plugin it affects (wp-content/debug.log, or the file Debug Log Manager writes inwp-content/uploads/debug-log-manager/when it is active). For settings imports, seed the replaced plugin's options and delete{prefix}_optionsand{prefix}_db_versionwhile the plugin is inactive, then activate it. - For changes that touch core APIs, also smoke-test on WordPress 6.2 with
PHP 7.4 (
scripts/smoke-test.sh --wp 6.2 --php 7.4, or thewordpress:php7.4-apacheDocker image withwp core download --version=6.2 --force). - For front-end styling, view the page with the Kadence theme in light and
dark mode; without Kadence Pro, simulate the switcher as
STYLING.md→ Front-end styling and dark mode says.
Since WordPress 5.6, posts restored from the Bin become drafts; republish test posts after.