Lessons from releasing a WordPress plugin: version numbers, the release gate, SVN and CloudFront caching

Releasing AI Chat for Amazon Bedrock: 3-way version check, a gate that reports skip, SVN, and CloudFront's stale CSS by ?ver= after a same-version re-upload.

The chat plugin running on this site, AI Chat for Amazon Bedrock, is an open-source WordPress plugin. Its source is on GitHub and it is distributed through wordpress.org’s SVN. Its release process is a scripted “gate” plus a few manual SVN steps. This article collects the reusable parts of that process, along with a lesson involving CloudFront: if you re-upload a package under the same version number, the site keeps serving the old CSS and JS.

Change the version number in three places at once

A WordPress plugin’s version appears in at least three places, and missing any one of them causes problems:

  • Version: in the main file’s header, which WordPress uses to determine the installed version;
  • the version constant in the code (AI_CHAT_BEDROCK_VERSION in this plugin), used as ?ver= when enqueuing scripts and styles;
  • Stable tag in readme.txt; wordpress.org uses this line in trunk to decide which tag to distribute to users.

You also need to add a changelog entry and an Upgrade Notice to readme.txt, and regenerate the .pot translation template. The project’s bin/prerelease.sh reads the three version numbers, requires them to match exactly, and checks that the changelog has a = x.y.z = section; if anything is missing, it fails.

The pre-release gate script

bin/prerelease.sh runs the following checks in fail-fast order. Local runs and CI use the same script:

  • every PHP file passes php -l;
  • the version consistency and changelog checks described above;
  • readme length limits: short description no more than 150 characters, each upgrade notice no more than 300 characters, description plus privacy policy under 2500 words;
  • all test suites under tests/;
  • the WordPress coding standards, run with a pinned version of phpcs;
  • the official Plugin Check, when WP-CLI is available;
  • the list of files to be released, derived from .distignore, scanned for anything that looks like a credential.

The README calls out two design decisions:

  • A check that cannot run reports skip, not ok. During development the gate produced two “false passes”: phpcs failed to start and was treated as finding no problems, and the credential scan never actually ran in the mode the gate invoked it in. After that, tests/tooling.php was written specifically for this: it copies the project to a temporary directory, deliberately breaks one thing at a time, and asserts that the corresponding check reports an error on a FAIL line.
  • .distignore is the single exclusion list. The packaging script bin/build-package.py reads it, and the gate asks the packaging script “what will be shipped”. The reason is that there used to be three hand-maintained lists that disagreed with each other, and a newly added tooling directory ended up counted in the audit scope.

The packaging script refuses to package files that contain patterns of AWS access keys, SVN tokens or private keys, and when it finishes it prints the version, file count, byte count and SHA-256. CI (GitHub Actions) runs the gate once each on PHP 7.4 and 8.3, then separately runs the tooling tests, builds and verifies the package, and runs Plugin Check against a real WordPress installation. 7.4 is the minimum version declared in the plugin header.

Releasing through wordpress.org SVN

Once the gate passes and the package is built, releasing is a separate manual step; the scripts never touch wordpress.org. The steps in the README are:

  1. copy the package contents into trunk/ of the SVN working copy and commit;
  2. copy a new tag from trunk and commit, i.e. svn cp trunk tags/x.y.z as recommended by the WordPress handbook;
  3. confirm that the plugin directory page shows the new version and that the package on downloads.wordpress.org matches what you built.

The WordPress handbook warns that every push to SVN rebuilds the zip files for all versions, so an update can sometimes take up to 6 hours to appear. Screenshots have to keep up with the UI: the screenshots in assets/ must be real screenshots of the current UI, and have to be retaken whenever the UI changes.

Re-uploading under the same version: CloudFront keeps serving old files

When WordPress enqueues scripts and styles, it appends the version number to the URL as a query parameter, for example …/admin/css/ai-chat-bedrock-admin.css?ver=1.47.6. This is the $ver parameter of wp_enqueue_style(), and its whole purpose is cache busting. This site’s CloudFront also caches the plugin’s scripts and styles, with the query string in the cache key. In our tests, requesting the same static asset with two different ver values gave a Miss the first time for each; requesting the same ver value again gave a Hit. The origin response has no Cache-Control, so how long it stays cached is determined by the default TTL of that cache behavior on the distribution.

Normal upgrades are not affected, because a new version number means a new URL. The problem came up with 1.47.6. This site upgrades the plugin by uploading a zip, and 1.47.6 had already been uploaded once. After that, a CSS-only fix was merged into the repository: the screen-reader text inside table-row buttons was absolutely positioned and not clipped by the outer scroll container, so on a 390px-wide phone it stretched the MCP page to 548px. This fix did not bump the version number. After the re-upload the PHP was new, but the URL was still ?ver=1.47.6, so CloudFront kept returning the old CSS it had cached the first time, according to the cache key, until the cached copy expired. tags/1.47.6 on wordpress.org already contains the fix; the problem only arose from this site’s “re-upload under the same version” step.

Ways to avoid it, in order of preference:

  1. If you change any released file, bump the patch version. This matches the AWS documentation’s advice: for frequently updated files, prefer versioned file names over invalidations.
  2. If you really must reuse a version number, run a CloudFront invalidation on the plugin path:
    aws cloudfront create-invalidation --distribution-id <distribution-id> \
      --paths "/wp-content/plugins/<plugin-dir>/*"
  3. Check that your cache policy really includes the query string in the cache key. The managed policy CachingOptimized does not include query strings and has a default TTL of 24 hours. If you use it to cache wp-content, even normal version upgrades will serve old files.

Limitations

  • The browser’s own cache has nothing to do with CloudFront. After you clear the CDN cache, some visitors may still see old files; changing the version number fixes this layer too.
  • The accessibility checks (bin/audit-accessibility.py, bin/audit-frontend.py) need to drive a real browser, so they are a manual step and not part of the gate. According to the README, they have found problems that static analysis could not, for example twenty controls without an accessible name in a single run.

Links