Vibe coding is not the problem. Shipping the vibe is.

I use AI coding tools. They are useful for turning a rough idea into a prototype, explaining an unfamiliar API and clearing away repetitive work. They can build a WordPress settings screen in minutes and produce a plugin that appears surprisingly complete.

That last part is where people get into trouble.

A generated plugin can activate without errors, save its settings and display exactly what the prompt requested. None of that proves it is safe. It proves that the path used during the demonstration works.

Security lives in all the other paths.

What happens when a subscriber sends the same request? What happens when somebody calls the AJAX action without opening the admin screen? Can a post ID be replaced with the ID of another user’s post? Does a value saved as plain text later appear inside an HTML attribute, JavaScript block or URL? Can an import URL point at an internal service? Can a ZIP file write outside the directory where it is being extracted?

Those questions rarely appear in a casual prompt. The model therefore optimizes for the visible feature and fills in the security decisions from familiar code patterns. Sometimes those patterns are current and appropriate. Sometimes they are incomplete. Occasionally they are copied from examples that were never intended to become production code.

The result is a new category of risky plugin: code that looks cleaner than the person reviewing it expects insecure code to look.

There may be a nonce. There may be sanitization. The SQL may even contain $wpdb->prepare(). Each line creates the impression that security has been handled, while the complete request remains exploitable.

WordPress.org has already adjusted to this reality. Since September 2026, plugin releases hosted in the official directory go through an automated security review during the release cooldown. The Plugins Team says the system combines several AI models with Jetpack Scan, cross-checks the results and can block a release assessed as high risk before WordPress sites receive it through the update API. This is not a theoretical response to some future problem. It is part of the current release process. The official announcement names missing authorization, unsafe queries, dangerous file operations and untrusted deserialization among the recurring patterns contributors should catch before uploading a release.

That is the correct framing for vibe-coded WordPress plugins. AI did not invent these vulnerabilities. It made them much easier to produce at a speed that exceeds normal review.

A working plugin proves very little

When a generated plugin lands in front of me, the settings page is not the first place I look. I search for the entry points.

That means REST routes, AJAX actions, admin-post.php handlers, shortcodes, form submissions, webhooks, cron callbacks, WP-CLI commands, uploads and anything that accepts a URL or filename. I want to know where untrusted data enters the plugin and what happens before it reaches a privileged operation.

This approach quickly exposes the difference between a finished interface and a finished feature.

A model asked to “add a button that deletes a log” sees a UI task. It creates the button, adds a nonce, registers an AJAX handler and calls the deletion function. The interaction works. The browser displays a success notice. The answer looks professional.

A security review sees a different task. It asks which users may delete which logs, whether the object is really a log, whether the action can be replayed, whether the handler can be called independently of the button and whether failure reveals information it should not reveal.

The model will answer those questions if the prompt contains them. The problem is that a developer using vibe coding often does not know which questions are missing. If the generated feature behaves correctly, the code is accepted before its trust boundaries have even been identified.

WordPress makes this especially deceptive because it is intentionally forgiving. A callback can be reached in several ways. Data is often stored in one context and rendered later in another. Plugins run inside a mature application with authenticated users occupying very different trust levels. “Logged in” can mean administrator, editor, customer, subscriber or a custom role with one carefully assigned capability.

A generated plugin that treats all of those users as equivalent has not solved authorization. It has merely avoided testing it.

The nonce that pretends to be permission

The most common security mistake I see in AI-generated WordPress code is a nonce being used as if it grants permission.

It does not.

A WordPress nonce helps establish that a request came from an expected interaction and was not simply forged by another website. Its main job is protection against cross-site request forgery. The WordPress nonce documentation is explicit that nonces are not authentication, authorization or access control. They are not strictly one-time tokens either.

Consider this plausible deletion handler:

add_action( 'wp_ajax_acme_delete_log', 'acme_delete_log' );

function acme_delete_log() {
	check_ajax_referer( 'acme_delete_log', 'nonce' );

	$log_id = absint( $_POST['log_id'] ?? 0 );

	wp_delete_post( $log_id, true );

	wp_send_json_success();
}

It looks responsible at first glance. The request has a nonce. The ID is converted to an integer. A WordPress API performs the deletion.

The missing question is whether the current user may delete that object.

A subscriber who can obtain a valid nonce may be able to call the handler with an arbitrary post ID. A user permitted to delete one type of record may be able to substitute the ID of another. If the nonce is printed on a screen available to a broad role, every user who can reach that screen may inherit the deletion power of the AJAX callback.

The corrected version needs to establish the object and apply object-level authorization:

add_action( 'wp_ajax_acme_delete_log', 'acme_delete_log' );

function acme_delete_log() {
	check_ajax_referer( 'acme_delete_log', 'nonce' );

	$log_id = absint( $_POST['log_id'] ?? 0 );
	$log    = get_post( $log_id );

	if ( ! $log || 'acme_log' !== $log->post_type ) {
		wp_send_json_error(
			array( 'message' => __( 'Log not found.', 'acme' ) ),
			404
		);
	}

	if ( ! current_user_can( 'delete_post', $log_id ) ) {
		wp_send_json_error(
			array( 'message' => __( 'You cannot delete this log.', 'acme' ) ),
			403
		);
	}

	$deleted = wp_delete_post( $log_id, true );

	if ( ! $deleted ) {
		wp_send_json_error(
			array( 'message' => __( 'The log could not be deleted.', 'acme' ) ),
			500
		);
	}

	wp_send_json_success();
}

The important line is not a check for manage_options. It is current_user_can( 'delete_post', $log_id ).

That object ID gives WordPress enough context to map the meta capability against the current user and the specific post. A broad capability may be appropriate for a genuinely global plugin setting, but it is often the wrong answer for content-level operations. The official capability guidance recommends checking the capability connected to the actual action and object.

AI regularly reaches for manage_options because it appears in thousands of settings-page examples. That can make a feature unnecessarily administrator-only. At the other extreme, it may choose edit_posts, read or merely is_user_logged_in() because those checks make the demo work for more accounts.

Neither choice is a security design. The correct capability comes from the plugin’s permission model, not from whichever function name occurs most often in training data.

is_admin() is another recurring mistake. It tells you that WordPress is handling an administration-area request. It does not tell you that the current user is an administrator. Code that uses is_admin() as a permission check has confused request context with authority.

An admin page is not an access boundary

Hiding a button does not protect the action behind it.

This sounds obvious, yet generated plugins repeatedly make the same assumption. The menu is registered only for administrators, so the callback underneath it receives no separate capability check. A control is omitted for subscribers, so the corresponding AJAX handler trusts anyone who can call it. A JavaScript component refuses to render without a particular setting, but its REST route accepts the request anyway.

Attackers do not need to click the interface. They can send the HTTP request directly.

Every executable entry point needs its own authorization. That rule still applies when another part of the plugin already restricted the screen where the request normally begins.

REST routes make the problem easy to spot. A route should have a meaningful permission_callback, and that callback should check the requested operation rather than merely confirm the user is logged in.

add_action(
	'rest_api_init',
	function () {
		register_rest_route(
			'acme/v1',
			'/report/(?P<id>\d+)',
			array(
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => 'acme_update_report',
				'permission_callback' => function ( WP_REST_Request $request ) {
					return current_user_can(
						'edit_post',
						(int) $request['id']
					);
				},
				'args'                => array(
					'id' => array(
						'type'              => 'integer',
						'minimum'           => 1,
						'sanitize_callback' => 'absint',
					),
				),
			)
		);
	}
);

WordPress requires a permission_callback, but satisfying the parameter requirement is not the same as protecting the endpoint. __return_true is correct for an intentionally public route. It is not a convenient placeholder for a private operation.

The REST API handbook recommends checking what the current user is allowed to do and using the route’s argument schema for validation and sanitization. Authentication tells the route who made the request. Authorization decides whether that person may perform this particular action.

Generated code often merges those two ideas. “The user is logged in” sounds reassuring in a chat response. On a WooCommerce site, it may describe thousands of customer accounts.

There is another subtle failure here: insecure direct object references. A user may legitimately access the endpoint and still be unauthorized for the object named in the request. Checking a generic capability once and then trusting a submitted post, order or user ID leaves the real boundary open.

The test is simple. Use two accounts with the same role. Create one object for each account. Capture a legitimate request from the first account, replace the object ID with the second account’s ID and send it again. If the request succeeds, the plugin has checked the type of user but not the ownership of the data.

Sanitized input can still produce stored XSS

The next AI habit is treating sanitization as a magic security coating.

A value passes through sanitize_text_field(), so the model declares it safe. The value is stored. Weeks later, another method reads it from the database and places it inside an HTML attribute without escaping.

The plugin sanitized its input and still created a cross-site scripting vulnerability.

Validation, sanitization and escaping solve different problems. Validation asks whether a value belongs to the set the application accepts. Sanitization modifies a value into a safer or normalized form when a broader range of input is allowed. Escaping prepares a value for the exact output context where it is about to be rendered.

A plugin setting that accepts one of three modes should be validated against those three values. A numeric ID should be converted and checked as an integer. An email address should be validated as an email address. Free-form text may be sanitized before storage, but it must still be escaped when it is printed.

$label = isset( $_POST['label'] )
	? sanitize_text_field( wp_unslash( $_POST['label'] ) )
	: '';

update_post_meta( $post_id, '_acme_label', $label );

That handles the incoming text. It does not decide how the stored value can safely appear everywhere else.

$label = (string) get_post_meta( $post_id, '_acme_label', true );

printf(
	'<input type="text" name="acme_label" value="%s">',
	esc_attr( $label )
);

The output is escaped for an HTML attribute at the moment it is rendered. If the same value appears as visible HTML text, use esc_html(). If it becomes part of a URL, use the appropriate URL escape. If limited HTML is deliberately allowed, use a carefully selected KSES policy rather than printing the value raw.

The WordPress escaping handbook recommends escaping as late as possible. That keeps the context visible in the code and prevents a value escaped for one destination from being reused incorrectly in another.

This matters because the database is not a trusted source. A value may have been written by an older vulnerable version, imported through a migration, modified through another plugin or changed directly. “It came from our option” is not a reason to skip output escaping.

AI tends to flatten this entire lifecycle into one function call near the input. Secure plugin code keeps the stages separate.

Prepared SQL that is not actually prepared

Seeing $wpdb->prepare() in a generated function can create false confidence. What matters is whether untrusted values are passed as arguments bound to placeholders.

This is not safe:

$status = sanitize_text_field( wp_unslash( $_GET['status'] ?? '' ) );

$sql = $wpdb->prepare(
	"SELECT * FROM {$wpdb->prefix}acme_jobs WHERE status = '$status'"
);

$jobs = $wpdb->get_results( $sql );

The value has already been interpolated into the SQL string before prepare() receives it. There is nothing left for the method to bind.

The query should keep data outside the SQL template:

global $wpdb;

$table  = $wpdb->prefix . 'acme_jobs';
$status = sanitize_key( wp_unslash( $_GET['status'] ?? '' ) );

$sql = $wpdb->prepare(
	'SELECT id, status, created_at
	FROM %i
	WHERE owner_id = %d
	AND status = %s
	ORDER BY created_at DESC
	LIMIT %d',
	$table,
	get_current_user_id(),
	$status,
	50
);

$jobs = $wpdb->get_results( $sql );

Values belong in placeholders. Identifiers such as current table and column names can use %i on supported WordPress versions. When a user controls sorting, a strict allowlist should still decide which columns may be selected.

$allowed_columns = array( 'created_at', 'status', 'id' );
$order_by        = sanitize_key( wp_unslash( $_GET['order_by'] ?? '' ) );

if ( ! in_array( $order_by, $allowed_columns, true ) ) {
	$order_by = 'created_at';
}

The official $wpdb->prepare() reference documents the supported placeholders and their proper use. Sanitization does not replace parameterization. A cleaned string can still alter a query if it is placed into the wrong syntactic position.

Generated code also tends to make table names, sort directions and SQL fragments freely configurable because it is trying to build a flexible feature. Those pieces cannot always be handled like ordinary values. The safe design is usually to map a small set of public choices to fixed internal SQL fragments.

Flexibility is not automatically good architecture. Quite often it is just a larger attack surface with a settings page attached.

File operations are where small mistakes become serious

A plugin that only changes a display option has a limited blast radius. A plugin that imports files, extracts archives, writes templates, manages backups or deletes directories is playing with the server’s filesystem.

This is where generated code deserves its most hostile review.

The dangerous pattern usually begins with a request parameter becoming part of a path:

$file = $_POST['file'] ?? '';
unlink( WP_CONTENT_DIR . '/uploads/acme/' . $file );

Adding sanitize_file_name() does not answer the main security questions. Who may delete a file? Which files may be deleted? Is the resolved path definitely inside the plugin’s intended directory? What happens with symbolic links? Can a stored database value introduce traversal even if the current request looks clean?

The same concern applies to dynamic includes:

include ACME_PATH . '/templates/' . $_GET['template'] . '.php';

A safer design does not accept a path at all. It accepts a small identifier and maps that identifier to a known file.

$templates = array(
	'summary' => ACME_PATH . '/templates/summary.php',
	'detail'  => ACME_PATH . '/templates/detail.php',
);

$template = sanitize_key( wp_unslash( $_GET['template'] ?? '' ) );

if ( ! isset( $templates[ $template ] ) ) {
	wp_die( esc_html__( 'Unknown template.', 'acme' ) );
}

include $templates[ $template ];

Archive extraction needs even more care. Filenames inside a ZIP are attacker-controlled data. Before committing extracted files to a permanent directory, the plugin needs to inspect entries, reject traversal and absolute paths, establish size limits, reject symbolic links where applicable, restrict file types and verify that every resolved destination remains inside an isolated temporary directory.

An upload being called an “image” does not make it an image. The extension, MIME information, actual file signature and eventual storage location all matter. A file that cannot execute inside a properly configured uploads directory may become dangerous if the plugin moves it under a web-accessible path with different server rules.

The safest question is not “How do I sanitize this filename?” It is “Why does this request get to choose a filename or path at all?”

That change in framing removes entire vulnerability classes.

The innocent importer that becomes an SSRF tool

RSS importers, screenshot services, license checks, webhook testers and AI connectors all accept URLs. AI-generated plugins frequently pass those URLs straight into wp_remote_get().

If an untrusted user controls the destination, the feature may become a server-side request forgery primitive. The server can reach addresses that the visitor’s browser cannot: loopback services, private network ranges, cloud metadata endpoints or internal administration tools.

For an integration that only needs known providers, I prefer a strict host allowlist and HTTPS requirement before making the request.

function acme_fetch_feed( string $raw_url ) {
	$url   = esc_url_raw( $raw_url );
	$parts = wp_parse_url( $url );

	$allowed_hosts = array(
		'feeds.example.com',
		'cdn.example.com',
	);

	$scheme = is_array( $parts ) ? ( $parts['scheme'] ?? '' ) : '';
	$host   = is_array( $parts )
		? strtolower( $parts['host'] ?? '' )
		: '';

	if (
		'https' !== $scheme ||
		! in_array( $host, $allowed_hosts, true )
	) {
		return new WP_Error(
			'acme_invalid_feed_url',
			__( 'This feed host is not allowed.', 'acme' )
		);
	}

	return wp_safe_remote_get(
		$url,
		array(
			'timeout'             => 10,
			'redirection'         => 0,
			'limit_response_size' => 2 * MB_IN_BYTES,
		)
	);
}

wp_safe_remote_get() validates the URL and redirects to reduce SSRF risk. A feature that only communicates with specific services should still enforce its own business-level allowlist. Disabling redirects in this example also prevents an approved host from redirecting the request somewhere the plugin did not intend to contact.

The response requires validation as well. Check for WP_Error, verify the HTTP status, inspect the content type when relevant and limit how much data can be downloaded. Do not assume that JSON from an expected domain has the expected schema. Do not place a remote error body directly into an admin notice. Do not log authorization headers because a request failed.

This is a good example of why generated code can be misleading. The basic request may be perfectly valid WordPress code. The vulnerability comes from the feature’s trust model, not from a misspelled function.

Dynamic code is not a clever shortcut

When a prompt asks for a “flexible” plugin, models sometimes reach for mechanisms that should trigger an immediate stop: eval(), request-controlled call_user_func(), dynamic PHP includes, executable code downloaded at runtime or serialized objects accepted from untrusted sources.

There is almost always a safer design.

A user-selectable action should map an identifier to a fixed callable. Configuration should be represented as structured data, preferably JSON when it crosses a trust boundary. Templates should be selected from a fixed map. Extensions should be registered through documented hooks rather than pasted into a database and evaluated.

unserialize() deserves particular suspicion. PHP object deserialization can invoke object behavior and form the basis of dangerous gadget chains. maybe_unserialize() does not make hostile serialized input safe; it merely decides whether to deserialize it. Data arriving from a request, webhook or remote service should not become an arbitrary PHP object graph.

The official WordPress.org automated review documentation identifies untrusted deserialization and fetched or evaluated code as high-risk patterns. That does not mean every call is automatically vulnerable. It means the burden of proof has shifted. If generated code introduces one of these escape hatches, the developer needs a specific architectural reason for keeping it.

“AI wrote it this way” is not that reason.

Secrets are another area where convenience wins too easily. A generated plugin may pass an API key into wp_localize_script() because the JavaScript needs to call a service. Once the key is printed into a page response, it is no longer secret. Hiding it in an obfuscated bundle changes nothing.

Sensitive credentials should remain server-side. The browser calls an authorized WordPress endpoint; WordPress performs the remote request; the endpoint returns only the data the current user is permitted to receive. Logs and error messages should redact tokens, authorization headers and provider responses that may contain private input.

A polished settings field does not make a credential secure. Its full lifecycle matters: who may save it, where it is stored, where it is exposed, how it is redacted and how it is removed.

Secure lines can still create an insecure feature

Static analysis is good at finding suspicious functions. It is less reliable at understanding whether the business operation is correct.

Imagine a WooCommerce extension that issues store credit when a webhook arrives. The signature check is valid. The input is sanitized. The query is prepared. The output is escaped.

Then the provider retries the webhook and the plugin issues the credit twice.

Nothing about the individual lines looks obviously insecure. The feature is still exploitable because it has no idempotency control.

The same problem appears in coupon generation, order synchronization, subscription actions, email triggers, usage counters and background jobs. External services retry requests. Customers double-click. Cron events overlap. HTTP clients time out after the server has already completed the action.

A privileged operation should behave predictably when the same legitimate request arrives more than once. That may require a provider event ID, a unique database constraint, a durable processing state or an atomic conditional update. A transient is not automatically a reliable lock.

AI code often implements the event, not the lifecycle. It handles “create a coupon” and forgets “prove that this event has not already created one.” It handles “decrease credits” and forgets two requests can read the same starting balance before either writes the result.

These failures rarely appear in a five-minute test. They appear under concurrency, retries and partial outages—the conditions a prompt usually leaves out.

How I use AI without trusting its first answer

I would not stop using AI for plugin development because it makes security mistakes. Human developers make the same mistakes. The practical response is to stop treating generated output as an implementation decision.

AI should produce a draft. The developer still owns the threat model.

Before asking for code, define who can call the feature, which objects each role may access, which data is untrusted and which side effects are irreversible. State whether the feature writes files, calls arbitrary URLs, handles secrets, changes orders or accepts content that will later be rendered.

Then keep the change small. A model asked to build an entire plugin in one pass has to invent architecture, storage, authorization, UI behavior and error handling simultaneously. A model asked to implement one defined endpoint against an existing permission model has far fewer opportunities to improvise.

After the feature works, start a separate security pass. Do not ask, “Is this secure?” That question invites a reassuring summary. Give the reviewer a concrete job:

Review this WordPress plugin diff as hostile code, not as a feature demo.

Map every REST, AJAX, admin-post, shortcode, cron, CLI and webhook
entry point. For each one, trace authentication, nonce handling,
object-level authorization, input validation, database queries,
file operations, remote requests and output.

Do not rewrite the code yet. Report the exact attack precondition,
affected data and the smallest test that would prove or disprove
each finding. Treat values read from the database and remote APIs
as untrusted. Flag uncertain findings as uncertain.

That prompt is useful because it asks for evidence instead of confidence. I would still verify every finding manually, especially when the suggested fix changes capabilities or data ownership.

The tests should be written from the attacker’s position. Send the request while logged out. Send it as a subscriber with a valid nonce. Replace the object ID. Repeat the request. Store markup and render it in every context where the value appears. Supply malformed arrays where the handler expects strings. Redirect a remote request. Return an oversized response. Trigger two copies of a financial operation at the same time.

A feature has not been meaningfully reviewed if it was only tested with an administrator account through the interface that generated the request.

The tooling should reflect the same approach. In its September 2026 announcement, the WordPress Plugins Team recommends WordPress Coding Standards checks covering escaped output, validated and sanitized input, and nonce verification. It also points developers toward Plugin Check, PHPStan with WordPress support, Semgrep rules and QIT for WooCommerce projects. These tools catch different classes of failure; none is a substitute for the others or for a human review.

Run them against the release artifact, not only the development directory. Build scripts can omit files, bundle obsolete assets or include material that was never meant to ship. The ZIP users install is the product.

WordPress.org’s scanner is the final barrier, not your QA process

The new WordPress.org review system is useful, but passing it does not certify a plugin as secure.

The Automated Security Review handbook describes a risk-based system. A high-risk release can be blocked while the previously available version and directory page remain in place. False positives are possible, and a developer can normally fix the problem and submit a new release.

That is a release gate. It is not an architectural review.

A scanner may see a missing capability check. It may not know that the capability selected by the developer exposes one customer’s data to another. It may flag a file operation without understanding whether a fixed internal path makes it safe. It may miss a race condition whose impact only appears when two valid requests overlap.

Premium plugins distributed through a marketplace or directly from a developer’s site do not inherit the WordPress.org release gate anyway. Their vendors need equivalent checks in their own build pipeline.

The wrong lesson would be to keep generating code until the scanner stops complaining. The useful lesson is that WordPress.org now considers pre-distribution security review necessary for every release. Plugin developers should reach that conclusion earlier.

AI can write the code. It cannot take ownership of it.

Vibe-coded WordPress plugins are not automatically insecure. They are insecure when generated code receives less scrutiny because it arrived quickly, looked polished or contained familiar security function names.

That is the trap. AI is capable of producing code that looks more deliberate than the reasoning behind it.

A nonce can exist without authorization. Input can be sanitized and output can still be unsafe. A query can call prepare() without binding anything. An authenticated endpoint can expose another user’s data. A safe HTTP function can still contact the wrong service. Every line can look respectable while the feature remains vulnerable.

The standard should be the same whether the first draft came from a senior developer, a contractor, a code generator or a chat window. Identify the trust boundaries. Trace the data. Test the request outside the intended interface. Review object-level permissions. Treat external input and stored values as hostile. Make irreversible operations idempotent. Scan the exact release package.

Use AI for speed. Do not let speed decide what reaches production.

The vibe is allowed to write the first draft. It should never approve the release.

Frequently asked questions

Are vibe-coded WordPress plugins inherently insecure?

No. The origin of the first draft does not decide whether a plugin is secure. The risk comes from accepting generated code without understanding its trust boundaries, permission model and failure modes. AI can produce secure patterns when given enough context, but the developer remains responsible for validating the complete feature.

Is a WordPress nonce enough to protect an AJAX action?

No. A nonce primarily protects against cross-site request forgery. The AJAX callback must also verify that the current user has the capability required for the requested action. When the action affects a post, order, user or other object, the authorization check should normally include that object’s ID.

Does sanitize_text_field() prevent XSS?

Not by itself. It can help normalize incoming plain text, but the value still needs to be escaped for its output context. Use functions such as esc_html(), esc_attr() and esc_url() when rendering. Validation should be preferred when the application accepts a limited set of values.

Is __return_true always unsafe in a REST route?

No. It is appropriate when the route is intentionally public. The mistake is using it for a route that reads private information, changes data or triggers a privileged operation. Public access should be an explicit product decision, not a shortcut used to remove a REST API warning.

Does Plugin Check prove that a WordPress plugin is secure?

No. Plugin Check can identify many compatibility, standards and security-related problems, but no automated tool understands every permission rule, workflow or business-logic condition. Treat it as one layer in the release process.

Can WordPress.org block a plugin update for security reasons?

Yes. WordPress.org now performs automated review during the plugin release cooldown and can block a specific release assessed as high risk before it is distributed through the update API. The previous version remains available while the developer investigates and submits a corrected release.

Can I ask the same AI that wrote the plugin to review it?

Yes, and that review may find real issues. It should not be the only review. Give it the code in smaller sections, ask for attack preconditions and reproducible tests, and verify every claim. A model can repeat the same mistaken assumption during generation and review.

What is the safest way to publish an AI-generated WordPress plugin?

Treat it exactly like human-written production code. Define the permissions and trust boundaries, review every executable entry point, run static analysis and WordPress-specific checks, test with multiple roles, exercise failure and retry paths, inspect the finished ZIP and have a qualified developer approve the release.

The downloadable file could not be created because the local workspace service is currently unavailable, so I provided the complete publication copy directly here.