Skip to content
System nominalUTC --:--:--Articles 080
MODULE 06 Lesson 16 / 18 35 MIN

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.

SegmentExample stepWhat 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.

Lab 40 minutes

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.