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.
| Check | Result | Consequence |
|---|---|---|
| Required headers | all present | parses |
| Stable tag vs plugin version | match, both 1.2.0 | updates flow |
| Changelog entry for 1.2.0 | absent | update screen shows no notes |
| File size | 9,687 bytes | 313 bytes under the documented limit |
| Screenshots section | missing | six uploaded screenshots stay invisible |
| WC tested up to | 9.0 | two 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.
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.