Skip to content

[pull] main from TryGhost:main - #1500

Merged
pull[bot] merged 27 commits into
code:mainfrom
TryGhost:main
Sep 17, 2026
Merged

pull[bot] merged 27 commits into
code:mainfrom
TryGhost:main

Conversation

@pull

@pull pull Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

rob-ghost and others added 27 commits September 17, 2026 14:04
…fter leaving it

ref https://linear.app/ghost/issue/BER-3958

The members list keeps its filters in the URL, and when the URL changed from outside, a link or a saved view, it wrote its previous filters back over the new URL before settling on the new ones. A navigation that landed during that write-back was lost, so leaving a saved view and clicking straight back opened the list with no filter. The browser test for reopening a country filter view hit this every time on this branch. The list now skips that one stale write, keeping the guard that protects its own pending edits.
ref https://linear.app/ghost/issue/BER-3958

A member's avatar is worked out from their email rather than stored, and only the member model knew how, so anything reading members without the model had no avatar to show. The rule now lives in one helper the model calls, which lets a query that selects only the member columns it needs still show the same avatar.
ref https://linear.app/ghost/issue/BER-3958

A member updating their own fields in Portal leaves no trace on their activity feed, so a publisher looking at a value has no way to tell who changed it or when. This is the storage for that entry, on its own so the schema change can be reviewed apart from the code that writes and reads it. It records which fields changed rather than their values, so an address a member has since replaced does not live on in a history table.
ref https://linear.app/ghost/issue/BER-3958

Once members can edit their own fields, a value has more than one author, and a publisher looking at one needs to see who changed it, where and when. Every write to a member's custom fields now leaves an entry on their activity feed naming the fields it touched, whether it came from staff in Admin, an integration, an import, checkout or the member in Portal. The entry is written by the value write itself, in the same transaction, so the feed cannot disagree with what is stored, and each write has to name who made it and where, with pairs that cannot happen refused by the compiler. The entries belong to the metafields domain, which reads them with knex for the members events endpoint rather than through a Bookshelf model, and each field's name is copied so an entry still reads after the field is renamed or deleted.
ref https://linear.app/ghost/issue/BER-3958

The members events endpoint now returns an entry whenever a member's custom fields change, and without this Admin would render it as an unlabelled row. The row names the changed fields and where the change was made, and the activity page can filter on it where the site has custom fields. The preview on a member's page now leaves out the same events as their full activity page, so following "View all" never shows a different set.
ref https://linear.app/ghost/issue/BER-3958

The members activity page is still Ember unless the React version's labs flag is on, and Ember had no case for the custom field change event, so each entry rendered as a row with no text. Ember now describes the entry the same way React Admin does, offers it as a filter, and hides it where custom fields are not available, so both versions of the page show the same thing.
ref https://linear.app/ghost/issue/HKG-1980

Newsletter sends ran as closures on the legacy job manager's inline
queue. A closure only exists in the process that created it, so a
restart mid-dispatch lost the send and we relied on the boot resume
scan to recover it.

Sends are now serialisable jobs, which is what lets us move them to a
durable backend. With one in place, a send survives a restart without
further changes to the email path. We also give them their own lane
to run in, so that they're not blocked by other jobs like imports and
email analytics fetching
no ref
- the sitemap generators hold one entry per routable resource for the lifetime of the index, and each entry was a nested element tree for the xml package plus a Moment in a parallel map. Measured against a heap snapshot from the Pro performance benchmark seeded with 10,000 posts, that came to 9.99 MB retained — 8.1% of the process heap, 159,568 objects, 9,381 of them Moments that nothing ever read as Moments: the sort coerces them with a unary minus

and the lastmod wants an ISO string.
Each entry is now a flat {loc, ts, imageLoc} record and the timestamp is epoch milliseconds. moment still does the parsing, because it is more forgiving than `new Date` about the shapes the database returns; only the stored value changed.

Rendering builds the document by joining an array instead of handing the tree to the xml package. That drops the largest allocation in the path — the package concatenates its output, and a rope of several megabytes costs multiples of its length until something flattens it — and lets the two transform-ready urls be made absolute as they are added, rather than by a regex across the finished document. The element shape is fixed and the five characters the package escaped are escaped the same way, so the bytes are unchanged: a differential harness rendered ~2,500 resources through both implementations, including xml special characters in slugs and image names, transform-ready and external canonical urls, missing dates and the author generator's stricter image validation, and every document matched.

The index generator now caches its xml. It is the only sitemap a crawler fetches repeatedly, and rendering it counts every resource of every type — at 10,000 posts that `Object.keys(...).length` was 240 ms of the benchmark's profile, a third of all sitemap CPU, recomputed per request. The cache is valid exactly while the manager's index is built, so it is dropped with it.

At 50,000 posts the generators hold 14.6 MB rather than 51.1 MB, a render peaks 30 MB above steady state rather than 162 MB, and takes 176 ms rather than 1,796 ms.
no ref
- every publish, update or delete empties the sitemap index, and the
next sitemap read rebuilt it in one synchronous loop over every routable resource. The loop had to be synchronous because it reset and refilled the live generators, so a yield would have let a request render a half-built index. With the real url service and 300,000 posts that loop blocked the event loop for 1.9 s on a fast laptop, stalling every other request on the instance, and longer on production CPUs.

A rebuild now fills fresh generators and swaps them in once complete. Nothing reads the new set before the swap, so the build yields every 10 ms. The build checks for invalidation after every yield, not just after the fetch. That matters because a RouteRegistered can now arrive mid-build. An invalidated build is abandoned without swapping. The index generator is recreated at the swap because it holds references to the generators it counts. Route entries are replayed into each new pages generator instead of being added to the live one.

The longest block during a rebuild went from 72 ms to 10 ms at 10k posts, 337 ms to 12 ms at 50k, and 1,947 ms to 14 ms at 300k. Total rebuild time rose about 10% at 300k. The slice is a time budget rather than a row count, so it holds on slower CPUs; 5 to 50 ms slices gave the same total, and the longest block tracked the slice plus a few ms of GC.

Reads still wait for the rebuild rather than being served the previous index. site.changed is emitted by the same response that purges the CDN, and builds start on read, so the first read after a purge is the one the CDN stores
for the full cache maxAge. Serving the previous index there would pin a sitemap missing the change on every publish. After this change the wait only affects sitemap readers, not the rest of the site.

Longer builds make it more likely a change lands mid-build, so a read now retries up to three builds before failing with the existing SITEMAP_BUILD_SUPERSEDED 503.

Both generator sets are in memory at the swap. To keep that from raising the peak, each fetched row is released once applied. Live heap at 300k peaks at 308 MB either way (old generators plus fetched rows); at the swap it is 273 MB, where holding the rows would have made it 395 MB.
no ref

Updated the sidebar handling to use a portion of the editor state instead of the full object.
…ages (#30759)

no ref

- Centralizes the shared tier and newsletter query parameters
- Makes the author picker fetch every staff page
- Makes email preview fetch every newsletter page
- Reuses the publish flow’s newsletter cache, then filters active newsletters client-side
- Keeps archived newsletters selectable when already attached to a post
- Adds tests covering pagination and shared request URLs
ref [GVA-988](https://linear.app/ghost/issue/GVA-988)

Give publishers advance warning of hosting payment failures and the suspension deadline, with payment and export actions for owners and guidance for staff.

Drive warning severity from host configuration behind a flag. Clear warnings after successful payment, preserve
legacy behavior when configuration is unavailable, and let open dialogs close before showing the takeover.
…ng helpers

ref https://linear.app/ghost/issue/ONC-1983

The automations service was the only consumer of the scheduler's
idempotency key support, so the helper that builds the key lived in
its folder and hard-coded the `ghost-automations-` prefix. Post
scheduling is about to send keys too, and it needs the same recipe
with a different namespace.

The helper now lives in `adapters/scheduling`, beside `build-signed-job`
which is the other half of assembling a job, and takes the namespace
as an argument. The automations call site passes `automations`, so the
keys it produces are byte-for-byte the same as before and jobs already
queued under the old keys still dedupe correctly. This change should
not change behaviour.
ref https://linear.app/ghost/issue/ONC-1983

Ghost(Pro)'s scheduler keeps its job queue in a database, so every boot
rebuild has to replace the job it already holds for each scheduled
post: delete by URL, then create. Those two calls are not ordered on
the wire. When the create lands first, the delete removes both the
old job and the new one, and the post silently never publishes. That
is what happened to Hyperallergic on 1 September.

The scheduler can already recognise a re-registration if the job
carries an idempotency key, which is how the automations poll avoids
duplicates. Post and page jobs now send one too, so the boot rebuild
can stop deleting in a follow-up once every queued job has a key.

The key is a hash of the fire time and the final callback URL,
namespaced `post-scheduling`. The URL is the right identity because it
already carries the resource ID and a token signed for that fire time
under the current signing key: registering the same job again yields
the same key, while a reschedule or a key rotation yields a new one.
A key based on the post ID alone would dedupe a rescheduled job
against the old one that the paired delete is about to remove, which
recreates the same race in a new place.

This change should not change behaviour: the delete-then-create on
boot is untouched, and a persistent queue that already holds a
key-less job for a post simply gains a keyed replacement, exactly as
it gained a key-less replacement before.
ref https://linear.app/ghost/issue/ONC-1983

The schedules endpoint read the post and then edited it with no
transaction and no row lock. A scheduler with a persistent queue can
hold two jobs for one post and fire both in the same tick, so the two
callbacks overlap: both see the post as scheduled, both flip it to
published, and both try to create the newsletter email. Today only
the unique index on `emails.post_id` stops a second send, and it does
so by failing the second request. In production this pattern shows
up a couple of times a day.

The read and the edit now run inside one transaction with the row
locked for update. The second delivery waits for the first to commit,
then finds the post is no longer scheduled and takes the existing
no-op path: an empty 2xx the scheduler treats as done. The post is
published once, the email is created once, and neither request fails.

This is needed before the boot rebuild stops deleting existing jobs,
because sites that sleep through that release will briefly hold a
key-less job and a keyed twin for each pending post.
…30680)

ref https://linear.app/ghost/issue/ONC-1983

The previous commit ran the scheduled publish read and edit inside one
transaction with the post row locked for update, so overlapping
deliveries would serialise on the database. That lock is applied to
every eagerly loaded relation of the post as well, which on MySQL means
gap locks on the `emails`, `posts_authors` and similar indexes.
Publishing a post with a newsletter then creates the email outside that
transaction and hands it to the batch sender straight away, so the
insert waits on the locked post until the InnoDB lock wait timeout and
the publish fails with the transaction rolled back. Any unrelated post
insert in the meantime waits on the same gap locks, which is how the
email preview acceptance test timed out in CI once a file earlier in the
same worker had left a listening server for the scheduler to ping.

Deliveries for one resource are now chained in process instead: the
second runs after the first has finished, reads the post, finds it is no
longer scheduled and takes the existing no-op path. This covers the
production case of two jobs for one post firing in the same tick, and
removes the row lock from the publish path entirely. The legacy
overlapping-deliveries test now publishes a post with a newsletter and
asserts the email is created once, which fails against the row lock.
Fixes
https://linear.app/ghost/issue/DES-1535/paper-cuts-from-new-pill-styles

Corrects the pill-style inconsistencies found during Admin 7 testing,
while preserving the existing appearance when `admin7Pill` is disabled.

- Keep form selects and input groups rounded, with pill-shaped header
controls and embedded buttons. Preview/Copy actions are 24px tall.
- Order Members header actions as Search → More → Filter → New member,
apply the shared primary-action spacing on member details, and fix
header dropdown icon sizing and spacing.
- Use consistent icon-sized controls for the Automation back button and
staff profile menu, including the standard outline treatment. Round the
staff selector in History.
- Add a shared `destructive-ghost` button variant with translucent red
hover/pressed backgrounds and replace repeated local destructive-button
styling.
- Update the PageHeader contract and stories, including a generic “No
primary action” example. Remove one CSS-class-only CopyField test;
retain clipboard behavior tests.

Validation:

- Shade: 278 existing tests passed; lint and build passed.
- Admin: type checking and lint on changed files passed; MembersActions'
7 existing tests passed.
- Visually checked the affected controls in Admin and Storybook,
including the flag-off header/button examples.
- Repository-wide `pnpm check`: formatting and lint passed. The test
phase encountered Core failures (a cron day-of-week assertion and
timeouts) and two Admin country-filter timeouts. The Admin file passes
independently (11 tests). Stopped the remaining unrelated Koenig run
after these failures; the full check is not green.

Reported screenshots and reproduction details are in DES-1535. These are
visual fixes; no new style-assertion tests were added.
ref https://linear.app/ghost/issue/HKG-1973

The wrapper's startFetch loop is about to change transport, so lock down
the behaviour the migration must preserve first: overlapping fetches are
skipped while a sibling wrapper keeps going, fetch order and budgets, a
failed fetch does not block the next tick, and a restarted fetch continues
detached from the invocation that started it.
ref https://linear.app/ghost/issue/HKG-1973

The newsletter jobs migration needs concrete executors at registration.
Retained separate wrapper instances and cursor namespaces, with guarded
accessors and legacy subscriptions owned by analytics initialization.
ref https://linear.app/ghost/issue/HKG-1973

Since construction replaced init(), the wrapper's service, config and
metrics are always assigned, yet they stayed optional behind three 'not
initialized' error branches no call site can reach. The scheduler likewise
guarded a GiftDelivery model that the models index always exports. Making
the fields required removes the dead branches and the errors import that
only served them.
ref https://linear.app/ghost/issue/HKG-1973

The wrapper mapped its log name to the background job name through a
switch, so the name lived in the switch and again wherever the job was
registered. Passing the job type in makes the wrapper's log lines follow
whichever job registers it, which the jobs migration relies on.
ref https://linear.app/ghost/issue/HKG-1973

Each pipeline's completed run was only a free-text line, so nothing could
wait on or chart a run finishing. Attach a <job_type>.completed system
event with the event count and duration, matching the other in-process
jobs, so the migration's integration tests and dashboards have a signal.
The message itself is unchanged so existing log queries keep matching.
…eduler

ref https://linear.app/ghost/issue/HKG-1973

The three pipelines were copies of the same block: flag, config check,
thirty-day probe, random cron, log, register, flag. One private schedule
step keyed on the job name keeps that contract in a single place, so the
jobs migration can swap the registration call once instead of three times.
…ess (#30857)

no ref

Comments have always been readable on posts a visitor can't access, and some sites rely on that to show discussion as a preview of paid content. #30402 put every comment read behind the post's access check, which broke those sites. The gated content that was actually exposed came from the post excerpt, the first 500 characters of the post's plaintext, which the comment serializer added for Admin's thread sidebar and also returned through the members API with `include=post`. The members API now never returns that excerpt, and comment reads go back to not checking post access. Editing, deleting and removing a vote stay open to members who have lost access, so someone who cancels can still take down what they wrote, while liking and disliking keep requiring access like commenting and replying do.
…30850)

no ref

The 499 early returns added to the renderer in #30298 skip res.end(). express-queue only releases an active slot when res.end() is called, so every client that disconnected mid-render leaked a slot permanently. Once concurrencyLimit slots had leaked, the queue stopped serving dynamic requests entirely: each queued request waited until an upstream timeout closed it, and only a restart recovered the site.

Ending the response is a no-op on the already-destroyed socket but emits the event the queue waits for. The new test runs the renderer behind the real queue middleware and fails with the old code.
no ref

express-queue only releases a slot when res.end() is called, so any handler
that never ends its response holds a slot forever; once concurrencyLimit
slots leak, dynamic requests stop being served until a restart. The package
is unmaintained (last release 2022) and has no fix.

The replacement releases a slot on res.end() or connection close, whichever
comes first, and drops queued requests whose client disconnects. It is also
a base for smarter load shedding than a fixed depth limit.

Request logs now record client disconnects as 499 instead of 200. Requests
cancelled while queued were previously logged as 200 before express-queue
set its 204, so disconnects could not be told apart from normal responses.
Queue wait time is logged as req.extra.queueWaitMs, separating it from
service time in res.responseTime.
@pull pull Bot locked and limited conversation to collaborators Sep 17, 2026
@pull pull Bot added the ⤵️ pull label Sep 17, 2026
@pull
pull Bot merged commit 6f86cb6 into code:main Sep 17, 2026
1 check passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants