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

Shipping

Versioning, Upgrades and readme.txt

One version number lives in two places in this plugin, the database schema is never versioned at all, and readme.txt is 313 bytes away from failing the validator.

Open the plugin's main file and count how many times the string 1.2.0 appears in it. The header has it, the SFCT_VERSION constant has it, and thirteen call sites pass that constant to wp_enqueue_script() and wp_enqueue_style(). The last group is why the number matters more than it looks: those arguments are the cache buster, so a release that forgets to move the constant ships new JavaScript to a browser that keeps the old copy.

The database has a version problem of a different shape. This plugin has no stored schema version and no upgrade routine. Line 218 of the bootstrap registers an activation hook and line 219 registers a deactivation hook, and that is the whole list. So the only moment a schema change reaches an existing site is when a human deactivates the plugin and activates it again. A store that updates through the WordPress updater never runs that code path.

And readme.txt is 9,687 bytes. The Plugin Handbook warns that a readme larger than 10,000 bytes can produce errors, so this one is 313 bytes of changelog away from a failure mode that never tells you the file was too big.

One number in two places is one number too many

The header and the constant carry the same value today. That is not a design, it is a coincidence maintained by hand. The header comment is read by WordPress through get_plugin_data() and republished by the directory as the download version. The constant is read by the plugin's own code. Nothing checks that they agree, which is how a plugin page ends up offering 1.4.0 while the active code reports 1.3.0.

If you are changing this plugin, the second source is the one to delete. Have the bootstrap parse its own header once:

if ( ! function_exists( 'get_plugin_data' ) ) {
	require_once ABSPATH . 'wp-admin/includes/plugin.php';
}

$sfct_header = get_plugin_data( __FILE__, false, false );

define( 'SFCT_VERSION', $sfct_header['Version'] );

That costs a file include on every request, which is why many plugins keep the constant and accept the drift. The cheaper alternative is a check that runs before the release rather than during it:

$src = file_get_contents( 'sifero-courier-timeline-for-woocommerce.php' );

preg_match( '/^[ \t\/*#@]*Version:\s*(.*)$/mi', $src, $header );
preg_match( "/define\(\s*'SFCT_VERSION'\s*,\s*'([^']+)'\s*\)/", $src, $constant );

if ( $header[1] !== $constant[1] ) {
	fwrite( STDERR, "version drift: header {$header[1]} vs constant {$constant[1]}\n" );
	exit( 1 );
}

echo "version " . $constant[1] . " agrees\n";

Run against the shipping source, that prints version 1.2.0 agrees. Edit only the header and it exits non-zero with the two values printed. The check is eleven lines, and it covers the mistake a release checklist reliably misses, because the person bumping the version is looking at the header where the number is most visible.

The schema has no version, so nothing gates the upgrade

WordPress core stores its schema version in the db_version option and compares it before running migrations. Plugins that own tables are expected to copy the pattern: store a version, compare it, run the upgrade only when it changed. This plugin does neither. Its activation path in includes/database.php is a straight line:

function sfct_install_current_site() {
	sfct_install_tables();
	sfct_register_order_statuses();
	sfct_add_rewrite_rules();
	sfct_install_default_options();
	flush_rewrite_rules();
}

sfct_install_tables() builds both tables and hands the SQL to dbDelta(). On a fresh site that creates them. On a site that already has them, dbDelta() compares the declared schema against the live one and issues nothing, but on an unchanged schema it still runs four metadata queries. I ran the real function twice against MySQL 9.6 with WordPress 7.0.7's dbDelta() and SAVEQUERIES on:

== second install call, unchanged schema ==
queries issued by the second call: 4
  DESCRIBE wp_sfct_delivery_companies;
  SHOW INDEX FROM wp_sfct_delivery_companies;
  DESCRIBE wp_sfct_delivery_logs;
  SHOW INDEX FROM wp_sfct_delivery_logs;
columns unchanged: yes

Four statements is not a performance problem on an activation a human triggered. The problem is the release process. Because the schema is only reconciled when somebody deactivates and reactivates, a column added in 1.2.0 does not arrive on a site that updated in place. I confirmed both halves with the same harness: a table created without the notes column gains it on the next install call, and with a gate in front the second call issues no SQL at all.

What the gate would look like here

The general pattern is two constants, not one. SFCT_VERSION is the release number and changes for a CSS fix; SFCT_SCHEMA_VERSION changes only when the SQL changes. Gating on the release number makes every cosmetic release run dbDelta() for no reason.

The hook to put it on is not the activation hook. Reactivation does call sfct_activate(), so a schema change does land when an administrator deactivates and reactivates, but you cannot ask a store owner to do that after an update they did not know was a schema change. Put the check on plugins_loaded, after the option API is available and before anything reads the tables.

One property of dbDelta() makes this sharper. It never drops a column or an index, so a release that renames a column leaves the old one behind indefinitely. A gate tells you which schema a site claims to have. It does not reconcile what it does not know about.

readme.txt is parsed as a file, not read as a document

The directory reads trunk/readme.txt, looks at one line in it, and then stops. That line is Stable tag. If the tag is 1.2.0, everything shown on the plugin page comes from /tags/1.2.0/readme.txt. Change trunk's readme after tagging and the live page does not move. The handbook is explicit about this, including the warning that a mismatched stable tag can stop your plugin from updating at all.

Since WordPress 5.8 the readme is no longer parsed for requirements. Requires at least and Requires PHP come from the plugin's main PHP file even when both lines are present in the readme. So this header block, which the plugin ships, is partly decorative:

Requires at least:     6.2
Tested up to:          7.0
Requires PHP:          8.0
Requires Plugins:      woocommerce

Requires at least: 6.2 and Requires PHP: 8.0 also appear in the plugin header, so they are honoured from there. Tested up to exists only in the readme, and the parser ignores minor versions: a value of 7.0 means 7.0.x, and the directory appends the current minor itself. Requires Plugins is read from the plugin header, where the value must be a wordpress.org slug, not a path like woocommerce/woocommerce.php. This plugin gets that right.

The rest of the parse is a subset of markdown. Section headings are == Section == and subheadings are = Subheading =. Lists, bold, links and inline code all work. Tables do not. A YouTube or Vimeo URL alone on its own line becomes an embed. Everything after the header block and before the first section heading is the short description, and it is cut at 150 characters.

What this readme would look like to a reviewer

I wrote a parser that reads the real file the way the directory does and reports what it finds. The required headers are all present, spelled correctly and in a parseable order: Contributors, Tags, Requires at least, Tested up to, Requires PHP, Stable tag, License and License URI. The stable tag is 1.2.0 and the plugin header says 1.2.0, so the check that rejects the most readmes passes. Five tags, which is the ceiling. The short description is 95 characters. Sections are Description, Installation, Frequently Asked Questions and Changelog. There is no Screenshots section.

CheckResultConsequence
Required headersall presentparses
Stable tag vs plugin versionmatch, both 1.2.0updates flow
Changelog entry for 1.2.0absentupdate screen shows no notes
File size9,687 bytes313 bytes under the documented limit
Screenshots sectionmissingsix uploaded screenshots stay invisible
WC tested up to9.0two majors behind WooCommerce 11.2

The changelog is the failure worth dwelling on. Its newest entry is 1.1.178, its oldest 1.1.140, and nothing in it describes 1.2.0. The numbering is not monotonic either: 1.1.172 down to 1.1.157 appear first, then 1.1.178 down to 1.1.173, then 1.1.156. A changelog assembled in release order would not do that, so what you are reading is a history stitched together from more than one sequence.

For the user this is concrete. They upgrade to 1.2.0, click "View version 1.2.0 details", and get a page that starts at 1.1.178. If the release contained a schema change or a behaviour change, the only place they could have learned about it is the release notes, and the release notes do not exist.

The screenshots are the quieter loss. SVN/assets/ holds six screenshot-N.png files and both banner sizes. The directory shows one screenshot per line in the readme's Screenshots section, and each line becomes that screenshot's caption. With no section, the files are uploaded and never displayed.

Two numbers will also get you in a review. WC tested up to: 9.0 was already stale when 1.2.0 shipped, and the readme's own dependency line says the plugin needs WooCommerce 7.0. Declaring compatibility with WooCommerce 11.2 means bumping that value and testing against it. And SVN/assets/icon-1024.png is 1,207,510 bytes against a documented 1 MB ceiling for icons. The names the directory asks for are icon-128x128.png with icon-256x256.png for high-DPI displays; the 1024 file is a source asset that does not belong in the assets directory.

Cutting the release is a sequence, not a number

Everything above is a document problem until you ship, and then order decides whether users get the release. The directory rebuilds ZIPs from SVN, so the release is the commit. Code goes to trunk with a readme whose stable tag is the version you are publishing, the tag folder is created from trunk so tag and trunk are the same tree, and only then does the directory serve it. Tag first and fix the readme afterwards and the live page keeps serving the old tag. The commands are the next lesson.

Lab 40 minutes

Work against the plugin source and a database. Write a script that prints one line per finding.

First, read sifero-courier-timeline-for-woocommerce.php, extract the Version header and the SFCT_VERSION constant, and fail with a non-zero exit code if they differ. Do the same for the readme's Stable tag against the header version.

Second, determine from the source whether the plugin can perform a schema upgrade after an in-place update, without being deactivated. Answer with evidence: name the hooks it registers and the function each one calls.

Third, write a version-gated upgrade routine for this plugin: one option that stores a schema version, one function that compares it against a constant and calls sfct_install_tables() only when it differs, and one hook registration. Then prove it on a live database. Run it twice and show that the second run does not reach dbDelta().

Fourth, list every finding in readme.txt that a review team would send back, with the line number and the rule it breaks.

Show the solution and the reasoning

The version comparison is the easy part. Read the header with the pattern get_plugin_data() uses, Version:\s*(.*) in multiline mode, and read the constant with a literal match on its name. Failing is the point: a warning in a log is a warning nobody reads, an exit code is a build that stops.

The schema question has a short answer with a long consequence. The bootstrap registers two hooks, register_activation_hook( __FILE__, 'sfct_plugin_activate' ) and register_deactivation_hook( __FILE__, 'sfct_plugin_deactivate' ), and neither is a migration path. Nothing reads or writes a schema version, so there is no state a running site could compare against. The answer is no, and the evidence is the absence of a hook rather than the presence of one.

const SFCT_SCHEMA_VERSION = '2026.06.1';

function sfct_maybe_upgrade_schema() {
	$installed = get_option( 'sfct_schema_version' );

	if ( SFCT_SCHEMA_VERSION === $installed ) {
		return false;
	}

	sfct_install_tables();
	update_option( 'sfct_schema_version', SFCT_SCHEMA_VERSION, false );

	return true;
}
add_action( 'plugins_loaded', 'sfct_maybe_upgrade_schema', 5 );

Proving it needs the measurement. I extracted sfct_install_tables() and evaluated it against MySQL 9.6 with WordPress 7.0.7's own dbDelta(). With a gate in front, the first call returns true, the second false, and the second issues no SQL. Without the gate both calls issue four DESCRIBE and SHOW INDEX statements. Three details in the snippet are deliberate: the option is written with autoload off, because the value is read once per site on the request that upgrades; the priority of 5 runs before code that queries the tables; and the boolean return lets a test or a WP-CLI command tell whether work happened.

What the gate does not fix is multisite. One request reaches one site, so the loop sfct_activate() performs over get_sites() stays as the path for a new blog, and wp_initialize_site keeps handling new sites.

For the readme, send back the missing 1.2.0 changelog entry, the out-of-order numbering, the missing Screenshots section, the stale WC tested up to: 9.0, the oversized icon-1024.png, and the 9,687-byte file size that leaves no room for the next six releases.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.