Skip to content

Pre-release fixes for 1.2.1: valid block markup, review findings, WordPress 7.1 - #28

Open
jakemgold wants to merge 30 commits into
mainfrom
release/1.2.1-prep
Open

jakemgold wants to merge 30 commits into
mainfrom
release/1.2.1-prep

Conversation

@jakemgold

@jakemgold jakemgold commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to the 1.2.1 merge, from a fresh six-reviewer pass over 1.2.1 plus a WordPress 7.1 compatibility check. One commit per change (30) so any one can be dropped; every commit passes the suite on its own (checked in a throwaway worktree).

WordPress 7.1 and packaging

  • Tested up to: 7.1. Core's AI Client SDK is byte-identical between 7.0.6 and 7.1.2, 7.1's connector changes are additive, and nothing the plugin calls is newly deprecated. Smoke-tested end to end in isolated WordPress Playground sandboxes on 7.1.2 / PHP 8.5 and 7.0.6 / PHP 8.2, with a mocked Anthropic API.
  • Stop shipping bin/ and languages/.gitkeep (Plugin Check hidden_files error). The empty languages/ still ships.

Generated content (pre-existing since at least 1.2.0)

  • Emit the block markup the editor saves. Nested lists, quotes, and code inside list items were cut in half, and code blocks and quotes were always "invalid content". Shapes with no 1:1 block are kept as one HTML block, exactly as written. Validated with real core block-library JavaScript from 7.0.6 and 7.1.2 on 63 fixtures (26 realistic AI shapes, the reviewer's adversarial set, and the simplification's edge cases): none of the realistic shapes has an invalid block or needs a deprecation repair. On 7.0.6, main fails 51 of the 63 fixtures, this branch 4, and those 4 fail identically on main.
  • Neutralize AI-written text WordPress would read as markup.
    • A stray <: "PHP < 8.2 or WordPress >= 6.0" was saved as "PHP = 6.0".
    • Smuggled block comments: a wp:rss block rendered live.
    • Quoted shortcodes, which ran.

Regressions introduced by 1.2.1 (all low severity)

  • Post-release tags (1.5.0-1, -p1, -patch.1) were never posted.
  • Own-repo links were fetched without the token when their casing differed.
  • Plural security keywords stopped classifying a release as security.
  • Each run wiped unseen "drafts created" entries.
  • Anonymous lookups scheduled retry runs and logged unconditionally.
  • Transient GitHub failures were cached as "invalid token" for 15 minutes.
  • The AI-failure notice never cleared.
  • Enter-to-add fired on IME commits and key repeat.

Pre-existing issues, contained fixes (the batch Jake approved)

  • Escape on a confirmation dialog left its confirm handler armed, so the next confirmation could also regenerate a published post the admin had declined. Verified in the browser: one confirm now triggers exactly one generation.
  • Cut-off, refused, or bodiless AI responses are failures, never posts. Anthropic connector 1.0.3 returns token-limit text as a normal result, and 1.0.5 does the same for refusals. Verified end to end with 1.0.5.
  • Code-fenced responses are unwrapped.
  • Sites that never ran activation (network subsites, Composer installs) get their option rows on first boot. The first token save was being encrypted twice, and unchecked boxes could not be saved.
  • Monitoring:
    • Releases left queued by an interrupted run are no longer posted for repositories paused or removed since.
    • A rate-limit stop is recorded in the run summary.
    • A renamed or transferred repository gets a clear error and is rejected when added.
  • Manual "Generate draft" stays a draft even when a ghrp_post_status filter would publish it.
  • Text corrections:
    • The AI disclosure names the plugin correctly.
    • The editor panel is translatable.
    • Help text and the readme's external-services disclosure (WordPress.org guideline 7) now match what the plugin actually sends.

Changelog updated under 1.2.1.

Deferred (need design decisions):

  • rolling nightly/latest tags hiding versioned releases
  • non-GitHub image hotlinks
  • multisite token scoping and per-site deactivation cleanup
  • pre-release-to-pre-release ordering
  • Go-style module/vX tags
  • deep research on Regenerate
  • error-message UX for timeouts

Review round 1 (Codex), four blockers — all fixed:

  • R1: discarding the whole queue lost a release when a newer one in the same stream shipped first. Queued entries are kept again (a69e199), and since e70dd36 only entries for a removed or paused repository are skipped.
  • R2–R4: the converter reordered list text around nested lists, ran paragraph text together, and dropped <li value> numbering and heading ids. Heading ids are kept as anchors (f351d3a), and since bab7f3d any list or quote with no 1:1 block stays one HTML block, exactly as written, instead of being reshaped.
  • Follow-ups also fixed:
    • unclosed elements no longer rescan the rest of the content (10,000 unclosed paragraphs: 4.8 s to 8 ms)
    • nothing is recursed into, so depth costs no more than length (20,000 nested quotes: 8 ms, 4 MiB)
    • only a normal finish becomes a post (2e01d40)
  • The reviewer's probes were re-run unchanged after the simplification:
    • queue: matches main (both queued releases are generated)
    • content validation: four pre-existing residual cases remain, each failing identically on main. The fifth (a < inside a title attribute) no longer produces an invalid block, but KSES garbles its text exactly as on main.
    • provider, cron, and token probes: pass (untouched code)

Review round 2 (Codex delta recheck at e70dd36): R1–R3 closed, and both queue deviations and the native-or-HTML boundary accepted. R4 was only partly fixed: heading ids were matched with a word-character pattern anywhere in the opening tag, so c++, café, config[api], or an id written with a character reference was dropped, and id= text inside a title was taken as the id. Ids are now read with the DOM parser the converter already uses for image attributes and written back with core's serialize_block_attributes() and esc_attr() (2db1297). Codex's anchor probe passes on 7.0.6 and 7.1.2 after real KSES (main and the fix exit 0, e70dd36 exits 1); only those five fixtures' output changed, and each validates and round-trips byte-for-byte.

Simplification after round 1. Asked whether the round-1 fixes added unhelpful complexity, two did, and both are now reduced to the minimum that fixes the reported defect:

  • Converter (bab7f3d): native blocks only for shapes that map 1:1 onto a core block as written: paragraphs, headings, flat lists of inline items (start/reversed kept), quotes made of paragraphs or bare text, code, tables, separators, images. Everything else (a nested list, block markup inside an item, <li value>, type, a <p> wrapped around block markup, a quote holding a list or a citation) is one HTML block, exactly as written. The list-item reshaping, all recursion, and the nesting cap are gone.
  • Queue (e70dd36): entries are skipped only when their repository has been removed or paused. The pre-release and package re-checks, the de-duplication (a repeat entry already hits the cached AI response and the existing post), and the extra queue field are gone; Release_Queue is back to main's version.
  • Net change: source −221/+74 lines, tests −87/+45.
  • 7.1.2 validation note: in 7.1.2, core/html's save() returns null and its content is no longer sourced from the markup, so any validator that compares against save() flags every HTML block. Those blocks parse as valid, round-trip byte-for-byte, and need no deprecation; on 7.0.6 they validate cleanly.

Verification:

  • composer test: 518 tests / 862 assertions / 1 pre-existing skip.
  • Each new commit passes the full battery on its own, in a worktree with a copied vendor/.
  • phpcs, phpstan, git diff --check, and lint:js all exit 0.
  • Committed dist/ matches a fresh build.

🤖 Generated with Claude Code

jakemgold and others added 23 commits October 4, 2026 17:47
Smoke-tested 1.2.1 on WordPress 7.1.2 / PHP 8.5 (activation, settings
and connector status, add repository with initial generation, editor
panel and Regenerate, version picker with a backdated draft): no PHP
notices, no console errors, every generated block valid. Core's AI
Client SDK is unchanged between 7.0.6 and 7.1.2, and nothing the plugin
calls is newly deprecated.

bin/ holds two one-time SVN maintenance scripts that have been shipping
inside the WordPress.org package since the first release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The HTML-to-blocks splitter ended an element at the first closing tag
of ANY container type, so a nested list, a <p> inside a <blockquote>,
or code inside a list item was cut in half and the remainder became a
broken paragraph; and code blocks and quotes were emitted without the
classes core requires, so every one showed "This block contains
unexpected or invalid content". Headings and lists only loaded through
deprecations and kept the bare markup on the front end.

Elements now run to their own closing tag (counting nesting), and each
block is built the way core serializes it: headings with their class,
lists as list-item blocks with nested lists inside their item, quotes
holding paragraph blocks, <pre><code> as a code block and bare <pre>
as preformatted, tables with auto layout. Markup a list item cannot
hold is flattened into it; a paragraph wrapped around block markup is
unwrapped; an unclosed element is closed where the next block starts.

Validated against the real 7.0.6 and 7.1.2 editors: 26 realistic
fixtures, zero invalid blocks and zero blocks needing a deprecation
(the old output fails the same check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applied to the post body and the excerpt before KSES:

- A "<" that does not open an HTML element is text. KSES deletes from
  such a "<" to the next ">", so "Requires PHP < 8.2 or WordPress
  >= 6.0" was saved as "Requires PHP = 6.0", and array<string> lost its
  type. Core's own pre-KSES pass only catches a "<" with no ">" after it.
- HTML comments are dropped: the model never needs one, and a block
  delimiter smuggled in through release notes (<!-- wp:rss {…} /-->)
  rendered as a live dynamic block with the attacker's attributes.
  Block markup the model writes itself is no longer wrapped twice.
- "[" is encoded, so a shortcode quoted in release notes ([gallery
  include=…]) is shown as text instead of running on the site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1.2.1's final-beats-pre-release guard also caught post-releases — a
re-release of the same version published as final (1.5.0-1, -p1,
-patch.1, -post1) — so it was ranked below the bare version and never
posted, where 1.2.0 posted it. PHP already ranks those above the
release, correctly; the guard now leaves them to version_compare() and
still fixes -pre/-preview and the rc-with-build-metadata case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub identifiers are case-insensitive and generated release notes
link with the canonical casing, so a repo stored as acme/plugin had its
own Acme/Plugin pull requests fetched anonymously — a 404 for a private
repo, losing the context — after 1.2.1 started fetching off-repo links
without the token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1.2.1's whole-word matching stopped "rce" matching inside "source",
but also stopped "CVEs", "injections" and "authentication bypasses"
from classifying a release as security, which substring matching
caught. Plurals (and -ies) are accepted again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1.2.1 deleted the whole summary at the start of every run so failing
repositories would not stack errors forever, which also discarded
"drafts created" entries the admin had not seen yet — any retry run
or hourly schedule wiped them. Only the error list is reset now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Off-repo links have been fetched anonymously since 1.2.1, drawing on
the server's shared per-IP quota. Exhausting it scheduled a full retry
run, wrote an unconditional error-log line, and overwrote the site's
remaining-count. An anonymous lookup now just fails. The exhaustion log
line is also gated on WP_DEBUG like every other log line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Raising the token-status cache to 15 minutes in 1.2.1 also cached
timeouts and GitHub outages as "invalid" for 15 minutes. Only a
definitive answer (valid, or a 401) is cached that long now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1.2.1 started showing the notice, but nothing cleared it: after the
release generated successfully, or its repository was removed, the
screen still said generation "keeps failing" for a day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 1.2.1 handler submitted on the Enter that commits an IME
composition, and once per repeat while Enter was held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WordPress.org's Plugin Check reports hidden files as an error, and a
reviewer flagged this one before 1.0.1; the CI deploy brought it back.
The empty languages/ directory still ships, so Domain Path stays valid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also narrows the 1.1.x upgrade bullet: only the package the old
version was tracking carries its cursor over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Neither the version picker nor the "post already exists" dialog
listened for its cancel event, so pressing Escape closed it with the
row's confirm handler still attached to the shared button. The next
confirmation on any row then also ran the declined action — for the
conflict dialog, regenerating (and replacing the content of) a
published post the admin had just declined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generate_text() returns whatever text the provider sent, even when it
stopped at the token limit or a content filter: a post cut off
mid-sentence and mid-list was saved and, on repositories set to
publish, published and emailed. The connector now asks for the result,
checks the finish reason, and also refuses a response with a title but
no body (a refusal, or a reply that stopped after the metadata lines).
Each is a generation failure: recorded, retried, never cached.
Also points the unavailable-client message at Settings → Connectors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some models wrap HTML output in ```html … ```. Around the whole
response the fence became the post title; around the body it became
visible paragraphs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Subsites of a network activation (and Composer or mu-plugin installs)
never ran the activation hook, so their option rows did not exist. On
a missing row update_option() sanitizes twice, so the first saved
GitHub token was encrypted twice and every request then failed, and
an unchecked box could not be saved at all. The first-boot self-heal
that schedules the check now writes the defaults too, and the token
sanitizer keeps a value that is already its own ciphertext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run that died mid-way left queued entries that the next run posted
even if the repository had since been paused or removed; nothing is
lost by discarding them, since cursors advance only after a post
exists. A run stopped by GitHub's rate limit now says so in the run
summary instead of leaving it empty, run after run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Requests never follow redirects (that would replay the token), so a
renamed repository failed every check with a bare "HTTP 301", and
adding it under its old name succeeded and then waited forever. A
redirect now gets an error that says what happened, and the add is
rejected with the same explanation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The filter ran after force_draft was resolved, so a documented use
("publish major releases") also published a manual Generate draft
immediately and reset its backdated date to now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The disclosure appended to posts still named the pre-rename product.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wp_set_script_translations() was never called for the editor script,
so the GitHub Release panel stayed English on translated sites. Help
text no longer refers to a Save Repositories button, names pinned
model versions, or says every regenerated post keeps its slug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The external-services section listed repository metadata the plugin
never sends and omitted the linked issue and pull request text it
does send (WordPress.org guideline 7). Also corrects the research-depth
and email-notification descriptions, the pinned model names, and how
the check-frequency filter takes effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jakemgold jakemgold changed the title Pre-release fixes for 1.2.1: valid block markup, review regressions, WordPress 7.1 Pre-release fixes for 1.2.1: valid block markup, review findings, WordPress 7.1 Oct 5, 2026
jakemgold and others added 6 commits October 4, 2026 18:35
With a check frequency that is not a registered WP-Cron schedule,
scheduling fails on every request, so the defaults written on first
boot would have been rewritten (and their rows re-read) on every
request too. They are written once the event is actually scheduled —
exactly once per site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review R1: discarding the whole queue at the start of a run lost a
release for good. A scan only generates each stream's newest release,
so a queued v1.1.0 was never detected again once v1.2.0 shipped before
the next run. Queued entries are kept again; process_queue() skips
only entries that are no longer eligible — the repository removed or
paused, a pre-release with pre-releases off, or a package outside the
current selection — and runs duplicates once. Queue entries now record
the pre-release flag so that check is possible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review R2-R4: the converter changed what posts said while producing
valid blocks. A list item saves its text before its nested lists, so
text after a nested list moved ahead of it; paragraph boundaries
inside an item ran text together; per-item <li value> numbering and a
heading's id (an in-post link target) were dropped.

A list core/list-item cannot represent faithfully — text after or
between nested lists, <li value>, a list type, block markup inside an
item — is now kept as an HTML block, exactly as written. Paragraph and
code boundaries inside items become line breaks; line breaks the model
wrote are untouched. Heading ids are kept as core anchors.

Also bounds the work on hostile input (review follow-up): lists and
quotes nested deeper than 16 levels stay HTML instead of recursing,
and an element with no closing tag anywhere after it no longer rescans
the rest of the content (10,000 unclosed paragraphs: 4.8 s to 8 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up: an error or tool-call finish reason was still
accepted as a post. Anything but a normal stop is now a generation
failure, with the cut-off and content-filter cases keeping their
specific messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Native blocks are now built only for shapes that map onto a core block
as written: paragraphs, headings, flat lists of inline items, quotes
made of paragraphs (or bare text), code, tables, separators, images.
Anything else (a nested list, block markup inside a list item, per-item
numbering, a paragraph wrapped around block markup, a quote holding a
list or a citation) is kept as one HTML block exactly as written.

This replaces the list-item reshaping, the recursion through quotes and
paragraphs, and the nesting cap with a single fallback that cannot
reorder or drop content, and makes depth cost no more than length.
Elements are still matched to their own closing tags, so nothing is cut
in half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A release left in the queue by an interrupted run is generated like any
other, unless its repository has since been removed or paused. The
re-checks of the pre-release setting and package selection, the
de-duplication (a repeat entry already finds the cached response and the
existing post), and the extra pre-release field on queue entries are
dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The anchor was matched with a pattern that accepted only word characters
and searched the whole opening tag, so a valid id such as "c++", "café",
"config[api]", or one written with a character reference was dropped,
and "id=" text inside another attribute (title="An id=example") was taken
as the id. In-post links to those headings lost their target.

The id is now read by the DOM parser the converter already uses for
image attributes, decoded as a browser reads it, and written back with
core's block-attribute serializer and esc_attr().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant