Building a Plugin Zip You Can Trust on Release Day
A release script turns a working tree into a zip you can verify. The build order, the checks worth automating, and the property that makes them mean anything.
Devon Parker · @dparker
APIs and integrations
- Topic
- TOOL
- Section
- Tooling & Workflow
- Published
- 09 JUL 2026
- Reading time
- 8 MIN
- Words
- 2130W
Two weeks after an update, a customer reports a fatal error on the settings screen. You ask for the file list from their install and the file your change added is not in it. The tag has the file. The repository has the file. The archive that went to the directory, and from there to every customer's site, does not.
That is the shape of most release bugs. Not a mistake in the code, a difference between the tree you
tested and the artefact you shipped. The usual versions of it look like this: a forgotten file, a
var_dump() left in a code path that prints before headers are sent, a version string that
says 1.4.0 in the plugin header while readme.txt still says 1.3.9, and a
node_modules directory inside the zip because someone ran zip -r from the
project root.
A release script is not really about saving typing. It is about removing the moment where a tired person at six in the evening decides which files to include. The script should be the only thing that produces a zip, and it should refuse to run when anything it depends on is missing.
The version number lives in three places, and only one of them can be generated away
WordPress reads the plugin header by parsing the file, not by executing it. That is why the version
has to be a literal in the header. readme.txt carries a Stable tag line, which
is what the plugin directory uses to decide which tag to serve. Most plugins define a version constant
as well, because asset URLs need a query string that changes when the files change.
Pick the plugin header as the source of truth. It is the string WordPress reads first, it is the one
shown on the plugins screen, and it is the only one guaranteed to be present in the archive without
loading a line of PHP. Then decide what happens to the other two. The constant can leave the set of
sources entirely: get_file_data() reads a header field out of a file, so you can define
the constant from the header at runtime and keep it in a static so the file is read once per request.
That leaves two places to keep in step instead of three, which is the difference between a check you
trust and a check you skip.
readme.txt is the awkward one, because the directory reads it from the tag rather than
from the running plugin. Generating it inside the packaging step is the trap. The zip then contains a
readme.txt that no commit contains, and the artefact is no longer reproducible from the
tag. Generate it in a commit before you tag, or write it by hand and let the script fail when it
disagrees with the version being released. Both work. A build step that rewrites the file on the way
into the zip does not.
The Tested up to line and the changelog section belong to the release rather than to the
code. The first should name a core version somebody actually installed; the second is where most users
find out what changed. Nothing checks either of them automatically, so both drift. A line in the release
pull request template that asks when the plugin was last activated against a current core is enough to
stop the drift turning into a wrong claim on the directory page.
The build steps only work in one order
The sequence matters because each step narrows what the next one can include. Installing dependencies after compiling assets, or compiling after the test run, produces a tree that was never tested in the shape it ships.
Install production dependencies only, and let the autoloader be generated from that production set.
Compile the assets next, because the test suite may exercise the compiled bundle. Run the tests against
that exact tree. Excluding development files comes after the tests, and it is not something to improvise
on a command line: keep the patterns in a .distignore file in the repository, where the
exclusions are reviewable and shared.
# .distignore
.distignore
.git
.github
node_modules
tests
phpunit.xml.dist
phpstan.neon
phpstan-baseline.neon
composer.json
composer.lock
package.json
package-lock.json
The test run is the step people reorder, because it is the slowest. Running the suite before the asset build means the tests pass against source files that never reach a customer, and a build failure later ships nothing while the log stays green. The order that holds is dependencies, assets, tests, exclusions, archive. If a step genuinely can be skipped for a patch release, make it a flag in the script rather than a line somebody comments out and forgets to restore.
Then generate the zip from a copy of the tree that contains only what ships, and verify the archive's contents instead of assuming the exclusion worked. A build that ends by printing the file count is much easier to trust than one that ends by printing a success message.
The whole sequence as one script
This is the version I use for a plugin whose repository holds only source, with no build output
committed. It builds inside a detached checkout of the tag, so nothing left over in the working tree can
leak into the archive. Run it from the repository root, and point wp at a throwaway site
before the last two lines.
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:?usage: release.sh 1.4.2}"
SLUG="my-plugin"
TAG="v${VERSION}"
# 1. Build from the tag, never from whatever is in the working tree.
git diff --quiet && git diff --cached --quiet || { echo "tree is dirty"; exit 1; }
git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null || { echo "no ${TAG}"; exit 1; }
# 2. The header is authoritative. readme.txt has to agree with it.
grep -qE "^ \* Version:[[:space:]]*${VERSION}[[:space:]]*$" "${SLUG}.php" \
|| { echo "plugin header is not at ${VERSION}"; exit 1; }
grep -qE "^Stable tag:[[:space:]]*${VERSION}[[:space:]]*$" readme.txt \
|| { echo "stable tag is not at ${VERSION}"; exit 1; }
WORKTREE="$(mktemp -d)"
git worktree add --detach "${WORKTREE}" "${TAG}"
trap 'git worktree remove --force "${WORKTREE}"' EXIT
# 3. Production dependencies, compiled assets, then the test suite.
composer install --working-dir="${WORKTREE}" --no-dev \
--optimize-autoloader --classmap-authoritative
( cd "${WORKTREE}" && npm ci && npm run build )
"${WORKTREE}/vendor/bin/phpunit" -c "${WORKTREE}/phpunit.xml.dist"
# 4. Stage what ships, then zip the staged copy.
BUILD_DIR="$(mktemp -d)"
STAGE="${BUILD_DIR}/${SLUG}"
mkdir -p "${STAGE}"
rsync -a --exclude-from="${PWD}/.distignore" "${WORKTREE}/" "${STAGE}/"
ZIP="${PWD}/${SLUG}.${VERSION}.zip"
rm -f "${ZIP}"
( cd "${BUILD_DIR}" && zip -qr "${ZIP}" "${SLUG}" )
# 5. Verify the archive instead of trusting the build.
unzip -l "${ZIP}" | grep -q "${SLUG}/${SLUG}.php" \
|| { echo "main plugin file missing from the zip"; exit 1; }
if unzip -l "${ZIP}" | grep -q "node_modules/"; then
echo "node_modules is in the zip"
exit 1
fi
CHECK="${BUILD_DIR}/check"
unzip -q "${ZIP}" -d "${CHECK}"
unzip -p "${ZIP}" "${SLUG}/${SLUG}.php" \
| grep -qE "^ \* Version:[[:space:]]*${VERSION}[[:space:]]*$" \
|| { echo "header version differs inside the zip"; exit 1; }
if grep -rnE 'var_dump|print_r|error_log' --include='*.php' "${CHECK}"; then
echo "debug output survived the build"
exit 1
fi
# 6. Install it on a site that has never seen the plugin.
wp plugin install "${ZIP}" --force --activate
wp plugin get "${SLUG}" --field=version
echo "built ${ZIP}"
Version constraint
git worktree add needs Git 2.5 or newer, which every current CI runner and developer
machine has. On something older, clone the tag into a temporary directory instead; the rest of the
script takes the same variable and does not change.
Some checks are cheap enough to run on every release
The checks that pay for themselves each catch a failure that would otherwise reach customers. The
archive must contain the main plugin file, named the way WordPress expects, at the top level of the plugin
directory. The version in the header inside the archive must equal the tag being released, which is a
different claim from the version in the working tree. The shipped PHP must be free of
var_dump(), print_r() and error_log(), and that check has to run
against the extracted archive rather than against the repository, because the repository is not what
customers install.
The one people skip is the install check: the zip must install and activate on a site that has never seen the plugin. That is the only check that proves the archive is installable rather than merely complete. Standing up a disposable site with WP-CLI on a throwaway database takes seconds. It catches the class of failure no file listing can, such as a plugin that installs but fatals on activation because a class file matched an exclusion pattern that was slightly too broad.
Those checks cost less than they look. The archive listing is text, so comparing it against the previous release's listing turns any unexpected addition into a visible diff at review time. And a size check catches what pattern matching misses: a plugin whose archive sat around 900 KB for a year does not reach 40 MB by adding a feature, so a jump like that means a dependency tree came along for the ride.
| Failure mode | What catches it | When |
|---|---|---|
| A new file never reached the zip | Archive listing checked for the main file and the expected top-level entries | Build |
A var_dump() left in a code path | grep over the extracted archive | Build |
| Header version disagrees with the tag | Version comparison before the zip is written, repeated inside it | Build |
readme.txt stable tag is stale | The same comparison, run against the file in the archive | Build |
node_modules or tests/ shipped | Archive listing plus a size sanity check | Build |
| Activation fatal from a missing class file | Install and activate on a clean site | Release |
A zip that cannot be rebuilt from the tag is not a release
Everything above assumes the artefact can be reproduced. If the zip depends on files that exist only on the machine that built it, the checks prove what was shipped and nothing at all about what is in the repository. The test is short: clone the repository into an empty directory, check out the tag, run the script, and compare the two archives file by file. If the contents differ, the build is reading from outside the tag.
Comparing two builds is easier than it sounds. Unzip both into separate directories, run a recursive diff over them, and ignore modification times; what you are looking for is a path that exists on one side only. If a second build from the same tag produces a different file list, stop and find the step that reads from outside the repository, because every check in the script is only as good as the tree it ran against.
Three things break that property more often than anything else. Tool versions that nobody pinned, so the compiler or the dependency resolver behaves differently on a laptop than on the runner. A lockfile that is not committed, or that the install command quietly ignores. And generated files that a human edits by hand after the build, which puts the shipped bytes outside version control. Pin the build toolchain the way you pin dependencies, and treat a build-tool upgrade as a change that needs its own release-candidate run before it touches a real version number.
The limit is worth stating plainly, because it is the one that matters. None of this helps unless the script is the only path to a zip. One archive built by hand during an incident at eleven at night makes every check meaningless for that release, since the thing customers installed was never verified. If you genuinely have to build by hand to stop an outage, treat that archive as a temporary deploy and replace it through the script within the hour, then diff the two.
There is a cost, and it is not nothing. A script that runs a full test suite inside a clean checkout takes minutes rather than seconds, and it will fail on a Friday afternoon when somebody wants to push a one-line fix. That is the trade: the failure lands on your machine, in front of you, instead of on a customer's site after the update prompt.
What to do on Monday
- Write down where the version lives today. Three places is normal, so pick the plugin header and stop treating the constant as a source.
- Add
.distignoreto the repository root and move the exclusions out of whatever command line is currently doing them. - Write the script, even if the first version only checks the tag and zips a detached
git worktree. - Add the archive checks in the same commit. They are cheap, and they are the reason to have a script in the first place.
- Delete the old release instructions from the wiki. If a second procedure exists, somebody will use it at the worst possible moment.
Devon Parker · @dparker
APIs and integrations
Works through REST contract design, webhook reliability, idempotency keys, retry policy and the unglamorous edge cases that appear when two systems disagree about what happened.
Continue