Shipping
Versioning and readme.txt
Two places to write a version number, one of them wrong. Semantic versioning for a plugin, the Stable tag trap, an upgrade routine with a guard, and readme.txt.
A support ticket arrives with one line: "the new field is not saving". The plugin
was updated an hour ago. You open the site's plugin file and the Version:
header says 2.0.0. The constant five lines below says
1.9.1, because that is the value somebody copied when they created the
branch. The upgrade routine compares the stored version against that constant, decides
the site is already migrated, returns early, and the new database column is never
created. Nothing fatal. Nothing in the log.
Version numbers are read by three separate systems — the update check, your own migration code, and the plugin directory — and each one reads a different copy of the number. This lesson is about keeping those three copies honest.
A version number is a promise about compatibility
Semantic versioning is the convention the plugin directory asks for in its readme documentation and strongly encourages in its SVN guidance. For a plugin the three segments map onto three different promises to the person running the site.
| Segment | Example step | What it promises |
|---|---|---|
| Major | 1.9.1 to 2.0.0 |
Something changed that can break a site or another plugin: a removed function, a changed filter signature, a different database shape. |
| Minor | 2.0.0 to 2.1.0 |
New behaviour only. Existing hooks, options and stored rows keep working. |
| Patch | 2.0.0 to 2.0.1 |
A fix. Same interfaces, same data, usually the release people should install without thinking about it. |
The promise only means something if you keep it. Bumping the major number for a
release that only added a settings field teaches the site owner that major versions are
noise, and then they ignore the one that mattered. The other direction is worse: a
major change shipped as 2.0.1 is a silent break, because every automatic
updater in the install base takes it.
Version arithmetic is where this goes wrong in practice. PHP's
version_compare() knows the difference between 1.9.0 and
1.10.0; the less-than operator does not.
stored current < string version_compare(<)
1.10.0 1.9.0 true false
2.0.0 2.0.0-beta1true false
1.4.2 2.0.0 true true
A string comparison calls 1.10.0 older than 1.9.0, because
1 sorts before 9 at the fourth character. An upgrade routine
that uses < re-runs its migration on every request on a site running
1.10, and never runs it on a site running 1.9. PHP reads a
trailing -beta1 or -rc1 as older than the plain release,
which is what you want a prerelease to mean.
The header is the version the world reads
WordPress reads the Version: line out of the main plugin file, in the
first 8 KB, with get_plugin_data(). That value is the one the update check
compares against the directory's newest release, the one shown in the plugins list, and
the one the directory itself publishes on the plugin page. The directory reads the
plugin's main file for the version, not the readme.
That header list is shorter than people expect. get_plugin_data() in
WordPress 7.0.7 parses exactly these keys: Name, PluginURI,
Version, Description, Author,
AuthorURI, TextDomain, DomainPath,
Network, RequiresWP (from Requires at least),
RequiresPHP, UpdateURI and RequiresPlugins.
License and License URI are not in that list. They belong in
the header because the directory and humans read them, not because WordPress does.
A version constant is a convenience, not a source of truth. You want one because
WPSIFERO_NOTES_VERSION is greppable and because reading the header needs a
function call and a file path. The mistake is having a constant instead of one
place to change the number. Export the constant in the same commit as the header, or
better, assert the two agree in a test so a mismatch fails the build rather than a
customer.
Two numbers, one of them wrong
Nothing in WordPress compares the header to a constant. There is no warning, no
admin notice and no log line when they disagree; the only signal is a feature that
behaves as if the wrong version were true. A test that reads
get_plugin_data() and asserts it equals the constant is the cheapest
insurance in this module.
Stable tag decides which readme the directory serves
The plugin directory's parsing order explains several otherwise baffling behaviours.
It reads /trunk/readme.txt, takes the Stable tag: value from
it, and looks in /tags/ for that version. If /tags/2.0.0/
exists, everything shown on the plugin page comes from the readme inside that
tag. Changes you make to trunk/readme.txt after that point do nothing
visible.
Two consequences follow. The description on your plugin page is the one in the tag the
stable tag names, not the one in trunk. And the readme in that tag is frozen: the
directory's Release Confirmation documentation states that once released, alterations
cannot be made to the tagged version, including changing Tested up to. To
update the compatibility line you push a new tag; you do not edit the old one.
Set the stable tag to a version, never to trunk. The directory prohibits
trunk as a stable tag for new plugins and discourages it for existing ones,
because it makes rollbacks impossible and has confused the automatic updater. Keep the
tag folder name and the header version in agreement, too: the download button shows the
version from the PHP file, so a stable tag of 1.4 pointing at code whose
header says 1.3 publishes a plugin that claims to be 1.3.
Migrate once, keyed to the version that did the work
The stored version is the only reliable record of whether a migration has run. A boolean flag per migration accumulates forever; a stored version gives you one option and a comparison that answers every past and future question at once.
function wpsifero_notes_maybe_upgrade(): void {
$stored = get_option( 'wpsifero_notes_version' );
if ( WPSIFERO_NOTES_VERSION === $stored ) {
return;
}
if ( ! $stored ) {
// Fresh install: there is no old shape to migrate.
update_option( 'wpsifero_notes_version', WPSIFERO_NOTES_VERSION, false );
return;
}
if ( version_compare( $stored, '2.0.0', '<' ) ) {
$notes = get_option( 'wpsifero_notes', array() );
foreach ( $notes as $slug => $note ) {
if ( ! isset( $note['created'] ) ) {
$notes[ $slug ]['created'] = time();
}
}
update_option( 'wpsifero_notes', $notes, false );
}
update_option( 'wpsifero_notes_version', WPSIFERO_NOTES_VERSION, false );
}
add_action( 'plugins_loaded', 'wpsifero_notes_maybe_upgrade' );
Three details carry the weight. The guard is the first comparison: a site already on
the current version returns after one option read, which is why this is safe on every
request. The migration compares against a literal '2.0.0' rather than the
current constant, so the step keeps meaning the same thing when 2.1.0 adds
a second step below it. And the last line runs whatever happened, so a site that skips
a version replays every step it has not recorded.
Hooking this on plugins_loaded means it runs on the next page load after
an update. There is also a hook that fires while the update is still happening, which
is the difference between the first visitor after the update paying for the migration
and the update paying for it.
add_action(
'upgrader_process_complete',
function ( $upgrader, $hook_extra ) {
if ( 'plugin' !== $hook_extra['type'] || 'update' !== $hook_extra['action'] ) {
return;
}
wpsifero_notes_maybe_upgrade();
},
10,
2
);
The hook fires after the files are replaced and the plugin reloaded, and passes the
upgrader instance plus an array of bulk item update data. The single-plugin path puts
plugin in that array; the bulk path puts plugins instead and
sets bulk => true, so code that reaches for
$hook_extra['plugins'] undefined-keys on a single update. Check
type and action and let the plugin's own guard decide whether
there is work to do.
The readme headers are the parts with rules
The readme is a plain text file with one header block, a short description, and
== Section == headings.
=== WPSifero Notes ===
Contributors: wpsifero
Donate link: https://example.com/donate
Tags: notes, shortcode, content
Requires at least: 7.0
Tested up to: 7.0
Stable tag: 2.0.0
Requires PHP: 8.3
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Store short notes and show them anywhere with a shortcode.
Contributors is a case-sensitive, comma-separated list of WordPress.org
usernames; anything that is not a real username shows up without a profile link or
avatar. Tags is capped at five, and a tag no other plugin uses is not
shown at all. Requires PHP and Requires at least should be
numbers only — 7.0, not WP 7.0. The last line before the
first blank line is the short description, and it is markup-free.
One of those two requirement lines is read from the main plugin file and the other
from the readme. get_plugin_data() returns Requires at least
and Requires PHP from the plugin header, so a plugin whose readme claims
Requires PHP: 8.3 while the header says nothing gives WordPress no basis
for the compatibility check it performs before activation. Put both lines in the main
file. Tested up to has no header equivalent: the directory takes it from
the readme and serves it back from its API, which is why a stale value produces the
"not been tested with the most recent WordPress versions" note on the plugin page. It
ignores minor versions, so 7.0 covers 7.0 through
7.0.999.
Screenshots live in assets and are numbered, not named
The sections the directory renders are Description,
Installation, Frequently Asked Questions,
Screenshots, Changelog, Upgrade Notice and any
custom section you add. Screenshots are not stored in the plugin at all: the numbered
lines under == Screenshots == become captions, and the images live in the
top-level /assets/ directory of the SVN repository as
screenshot-1.png, screenshot-2.png and so on. Line one of the
section matches screenshot-1, and filenames must be lowercase.
The markdown subset is narrower than the word suggests. Emphasis, links, inline
code, lists, blockquotes and a bare video URL on its own line are handled; raw HTML is
not a rendering path to rely on, and the short description ignores markup entirely.
Keep the file under 10 KB, and keep only the current release plus one major version
back in the changelog. Older entries belong in a changelog.txt that the
directory does not display.
Write the release mechanics for a plugin whose stored data changed shape.
Start with a readme.txt whose Stable tag is
3.0.0, whose Tags line has seven tags, and whose plugin
header says Version: 2.9.1. Then write an upgrade routine that adds a
updated timestamp to every stored row and runs on
plugins_loaded.
Answer three questions before you write the routine. Which of the three version
disagreements you just created will the directory notice? On a site that skips
2.5.0 entirely, what guarantees the 2.5.0 migration still
runs? And what does your routine do on the ninety-ninth request after the
upgrade?
Show the solution and the reasoning
The readme first. Two of its three problems are things the directory fixes or ignores, and one is fatal.
=== WPSifero Notes ===
Contributors: wpsifero
Tags: notes, shortcode, content, admin
Requires at least: 7.0
Tested up to: 7.0
Stable tag: 3.0.0
Requires PHP: 8.3
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Store short notes and show them anywhere with a shortcode.
Seven tags is over the limit, and the fix is not a second Tags line:
only the first five survive, so keeping four is a choice about discovery rather than
an inventory. The version mismatch is the one that matters. With
Stable tag: 3.0.0 the directory looks for /tags/3.0.0/; if
that folder holds code whose header still reads 2.9.1, the plugin page
advertises a download of 2.9.1, because the version comes from the PHP
header. The update check then compares an installed 2.9.1 against a
directory release it also believes is 2.9.1 and offers nothing. The tag
folder name does not set the version; the header does.
Now the routine. The shape is the same as the one in the lesson, with the literal version doing the work.
function wpsifero_notes_migrate_to_3_0_0(): void {
$notes = get_option( 'wpsifero_notes', array() );
foreach ( $notes as $slug => $note ) {
if ( ! isset( $note['updated'] ) ) {
$notes[ $slug ]['updated'] = time();
}
}
update_option( 'wpsifero_notes', $notes, false );
}
function wpsifero_notes_maybe_upgrade(): void {
$stored = get_option( 'wpsifero_notes_version' );
if ( WPSIFERO_NOTES_VERSION === $stored ) {
return;
}
if ( ! $stored ) {
update_option( 'wpsifero_notes_version', WPSIFERO_NOTES_VERSION, false );
return;
}
if ( version_compare( $stored, '3.0.0', '<' ) ) {
wpsifero_notes_migrate_to_3_0_0();
}
update_option( 'wpsifero_notes_version', WPSIFERO_NOTES_VERSION, false );
}
add_action( 'plugins_loaded', 'wpsifero_notes_maybe_upgrade' );
The only disagreement the directory notices is the stable tag versus the header,
and it notices by publishing the wrong version rather than by complaining. The readme's Requires at least and the plugin header's copy
are never compared; they go to different consumers. A plugin whose header omits
Requires at least while the readme carries it loses the activation-time
compatibility check and keeps the directory page line.
The skipped version still migrates because the guard compares the stored version
against the current one, not against the previous release. A site on
2.4.0 jumping to 3.0.0 has
version_compare( '2.4.0', '3.0.0', '<' ) true, so the step runs. That
is why each step is written against its own literal version with the steps ordered
oldest first: the routine replays every step the site has not recorded. No per-release
boolean can express "not yet" for a release that was never installed.
The ninety-ninth request reads one option and returns. That is the whole cost, and it is why a stored version beats a
wpsifero_notes_upgraded flag: the flag answers one question and then you
need a second flag for the next migration. Register the option as non-autoloading if
that read is still too expensive, which is what the third argument to
update_option() above does.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.