Skip to content

[pull] main from TryGhost:main - #1428

Merged
pull[bot] merged 19 commits into
code:mainfrom
TryGhost:main
Aug 19, 2026
Merged

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

Conversation

@pull

@pull pull Bot commented Aug 19, 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 : )

9larsons and others added 19 commits August 19, 2026 06:11
no ref

Fourth chunk out of `settings/app/`: `email/*` and the shared
`email-design/*` now live at `settings/email` and
`settings/email-design`, beside the email acceptance tests. Moving them
enables the type-aware lint rules the rest of admin uses, so that debt
is cleared for these files:

- async handlers passed to void callbacks are wrapped; fire-and-forget
promises get `void`
- `searchKeywords` → `email/search-keywords.ts` (email-settings) and
`email/emails-search-keywords.ts` (emails)
- `EmailDesignContext` split the way admin's other contexts are: context
+ `useEmailDesign` in `email-design-context.ts`, `EmailDesignProvider`
in `email-design-provider.tsx`
- two `act(async …)` callbacks with no awaits made sync in the
customize-modal unit test
…0112)

ref TryGhost/renovate-config#31

Removes the local `Require human review for CSS preprocessor updates`
rule from `.github/renovate.json5`. It existed only to mirror the shared
preset's CSS/style toolchain exclusion so the pnpm-catalog automerge
rule wouldn't override it; the shared exclusion is being removed.

## Why

Minor/patch updates to PostCSS, Sass, Stylelint, Tailwind, etc. don't
cause visual regressions in practice, and the exclusion was backing up
PRs. Majors for these packages still require dashboard approval via the
existing `Require dashboard approval for major updates` rule.
no ref

Fifth area out of `settings/app/`: site components now live beside their
acceptance tests in `settings/site`. Moving them enables the type-aware
lint rules the rest of admin uses, so that debt is cleared for these
files:

- async handlers passed to void callbacks are wrapped; fire-and-forget
promises get `void`; async functions that never await become sync
- `searchKeywords` → `site/search-keywords.ts`; `FatalErrors` +
`getIssuesFromFatalErrors`/`getIssuesFromInstalledTheme` →
`theme/theme-validation-issues.ts`, so component files only export
components
- the three identical icon/logo/cover upload handlers in
`global-settings.tsx` collapse into one `uploadSettingImage`
- `getThemeLabel` builds the default/legacy label as a string instead of
`+=` onto a `ReactNode`
- `design-modal.tsx`'s `useForm` gets an explicit type parameter instead
of the `as Dirtyable<…>` cast (the lint autofix strips that cast and
breaks `dirty` typing — same trap as #30082's portal modal)
ref https://linear.app/ghost/issue/BER-3851

Gift delivery needs the same UTC timestamp conversion as automations and
seeders. Keeping separate implementations risks differences in timezone,
precision and validation behaviour when handling raw Knex values.

A single Luxon-backed conversion path beside the DbDate codec keeps MySQL
and SQLite behaviour consistent and rejects invalid inputs explicitly.
ref https://linear.app/ghost/issue/BER-3851

Transactional messages carrying private redemption links must be able to override publication tracking settings. Mailgun transports drop boolean false values, so the adapters use Mailgun's explicit string form and keep the transport-specific detail behind their existing interfaces.
no ref

The settings shell and the shared `components/`, `hooks/`, `utils/`,
`data/`, `assets/` and `providers/` now live directly under `settings/`,
leaving only `advanced/` in `settings/app/` until the PRs touching it
land (#30018, #30019, #29916, #30054, #30099, #30110, #28368). Big diff,
but ~300 of the 325 files are import-line rewrites; the substantive
changes are the lint-debt fixes the move surfaces:

- shell files grouped in `settings/layout/`: `app.tsx` (provider tree),
`main-content.tsx`, `sidebar.tsx`, `settings-sections.tsx` (was
`components/settings.tsx`); root `settings.tsx` route entry unchanged
- contexts split the way admin's other contexts are — hooks + context in
`*-context.ts`, provider component in its own file — for global data,
settings app, confirmations, scroll sections; `withErrorBoundary` →
`with-error-boundary.tsx`
- `loadKoenig` → `components/koenig-loader.ts`; the untyped
`@tryghost/koenig-lexical` bundle gets a minimal `KoenigLexicalModule`
type instead of `any`
- `@tryghost/limit-service` and `@tryghost/nql` get typed ambient
declarations in `vite-env.d.ts` (clears ~25 `no-unsafe-*` in
`use-limiter`)
- `iframe-buffering` reuses `utils/debounce`; `TopLevelGroup.saveState`
uses the framework's `SaveState`; the usual `void`-wrapping of async
handlers
Fixes #30102

- Maps the exact source path `css/header_v2.css` to the documented
public `header` card when building the card asset manifest.
- Keeps the versioned source files separate while combining both
stylesheets under one logical manifest key.
- Preserves the existing source order and separator so the default
all-card bundle remains byte-for-byte identical.
- Makes the manifest builder importable without executing it and adds
regression coverage for mapping, inclusion, exclusion, and asset-type
isolation.
no ref

Last area out of `settings/app/`: advanced components now live beside
their acceptance tests in `settings/advanced`. With it gone
`settings/app/` is empty and **the ESLint quarantine override is
deleted** — every settings file now lints under the same type-aware
rules as the rest of admin.
ref https://linear.app/ghost/issue/BER-3851

Delayed payment methods can complete a Checkout Session before Stripe marks its payment as paid. Routing completed and asynchronous payment events through one guarded path prevents legacy gift purchases from being finalized early while leaving donation behavior unchanged.
no ref

Tail of the settings integration:

- `admin-x-settings-{content,sidebar,sidebar-scroller,scroller}` element
ids → `settings-*`; the `admin-x-base admin-x-settings` wrapper class →
`settings-app` (and the heading line-height rule in `index.css`
follows). Nothing outside `apps/admin/src/settings` referenced any of
them (checked Ember, e2e, shade, test-utils); no id collisions with
Ember's DOM.
- `SettingsAppProvider` typed every context field as an optional prop
but overwrote
`sortingState`/`setSortingState`/`offersShowArchived`/`setOffersShowArchived`
after spreading them (the CodeRabbit note on #30113 — pre-existing). It
now takes only `upgradeStatus`, the one prop its caller passes.
The list read a column's value by switching on the column key in a function far from where that column was declared, so a field naming a column and the code filling it had to be kept in step by hand. A column now carries its reader, attached where the column is built, and a cell asks the column for a member's value instead of working back from its key. What the list must ask the API to include is declared on the field alongside the column rather than derived from the columns that resolve, because whether a value is needed follows from the filter alone while naming a column can wait on data still in flight, and a column can be dropped by the display budget while its values are still wanted. Custom fields are addressed under one custom_fields namespace named in a single place, so a field reads the same as a filter key and as a column key. No behaviour change.
A filter told a publisher who matched but not what they said, so reading
the data they had just filtered on meant opening members one at a time.
Labels, tiers and the subscription filters already append a column for
that reason; custom fields are the case where it matters most, since a
field exists precisely because its value differs member to member. Every
custom field filtered on now brings its own column, whatever the operator,
the way Label does.

The column layer already lets a field declare a column and how to read it.
What is new is that a field whose key is a pattern stands for many columns
rather than one, so `custom_fields.:key` resolves its column per instance
from the matched key, hydrated from the fields the API loaded -- the same
hydration the filter list already does for newsletters, tiers and offers.
A name it cannot resolve is a column it does not show, which is also what
the flag being off looks like from there. Asking the API for the values
does not wait on those names, so the list does not fetch them twice, and
the fetch that names them waits for a filter rather than riding every
visit to the list.

Rendering a value as one line was the member detail screen's, and is now
the framework's user-type catalog, next to the part labels and CSV column
names it already owns. A scalar reads as it stands and a composite joins
its parts the way that type reads; the detail screen renders through the
same function, so the two cannot drift, and the catalog is total over the
field types so a new composite cannot arrive without a line to read as.

Values a publisher collected have no length a column can be sized to, so
a dynamic cell carries its full text as a native title. That is a fallback
for the edge, not a tooltip component per row across a virtualised list.
closes
https://linear.app/ghost/issue/NY-1525/check-how-the-caching-works-for-the-automations-endpoint-and-make-sure

Automation run counts change independently of Admin mutations.
Refetching the browse query whenever the list mounts prevents the
five-minute client freshness window from leaving summary stats behind
the detail view while retaining cached data for rendering.
#30119)

no ref

Closing a dirty settings dialog through Escape, its buttons, the exit
button or a sidebar link already confirms before discarding changes; the
browser **back/forward buttons did not** — the router swap lost the
route-transition guard Ember settings had, and tags/members already
guard this with `useBlocker`.

- `layout/dirty-navigation-guard.tsx`: `useBlocker` on the global dirty
state, wired to the shared `DirtyConfirmDialog` (Leave → `proceed`, Stay
→ `reset`). Only `POP` navigations are blocked — every in-app exit
already runs its own dirty confirmation, so blocking PUSH/REPLACE would
double-prompt. Also registers `useConfirmUnload` while dirty, like the
other guarded screens. Mounted beside `<Outlet />` in `layout/app.tsx`
so contributors' profile dialog is covered too.
- Blocks only when the entry being left was **router-created**
(`history.state.key`). React Router can only undo a POP from an entry it
created; from a native hash-navigation entry it miscounts the delta and
can call `history.go(0)` (reload) — found during review, so the guard
deliberately stays out of that case.
- Compares matched dialog routes, not pathnames: sibling routes rendered
by one dialog instance declare `handle: {dialogGroup}` in
`settings/routes.tsx` (currently the staff-profile tabs) so back between
tabs doesn't prompt; every other route change out of a dialog does.
Offers and design/theme containers swap child components per route, so
they are intentionally *not* grouped.
ref https://linear.app/ghost/project/4b2edbd66469/ 
Each commit references the issue individually 

This PR covers various issues, all linked in the individual commits. It
adds basic processing of post content CSV files, limits imports to 100
posts (while in development, this will be removed later), and lays the
foundation for future milestones.

- **CSV import engine** — Uploading a title/html/published_at CSV to
`POST /posts/upload/` now creates real posts instead of a no-op 202.
Rows are parsed in-request, then written by an in-process background job
via `models.Post.add` under `{importing: true}`; html is converted to
lexical, slugs are set as a slugified title, and a file that can't be
parsed as CSV at all is rejected with a 422.
- **Temporary 100-post cap** — Files with more than 100 rows are
rejected with a clear 422 before anything is written. A hard-coded
constant with a single check, deliberately with no config surface — the
whole limit goes away when the durable job system lands.
- **Dates come from the CSV** — The one date column sets `published_at`,
`created_at` and `updated_at`, so imported archives look written when
they were written, not created at import time.
- **In-memory outcome tracking** — Each import registers a run in an
in-memory store recording one outcome per row (status, source line
number, title, post id/URL); the 202 body returns the run's `import_id`
and row total. This is what the completion-report milestone will render
from; nothing is persisted until the durable job system.
- **Bad rows don't kill the run** — A malformed row (missing/overlong
title, invalid date, unconvertible html) is skipped on its own with an
actionable reason; the rest of the file still imports. Skipped (fix the
file) is distinguished from failed (write attempted and lost).
- **Defaults for absent fields** — Imported posts land published,
public, type `post`, authored by the site owner, with two internal batch
tags: a date stamp (matching the JSON importer's format) and a unique
`#Import Run <id>` tag the report milestones key on.
- **Zero side-effects, pinned** — A regression suite proves a bulk
import sends no newsletter emails and fires no per-post webhooks, with a
positive control so the assertion can't pass vacuously.
)

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

- Added labs-gated recipient details, buyer names and personal messages to gift
  checkout.
- Pre-created payment-pending gifts and optional delivery records before Stripe
  Checkout, keeping recipient data inside Ghost.
- Sent only the internal gift ID to Stripe and preserved the locally calculated
  amount and currency, because Stripe totals may include tax.
- Completed immediate and delayed payments idempotently while retaining support
  for legacy link-gift checkout sessions.
- Delivered recipient emails through atomically claimed jobs with Mailgun
  tracking disabled, recording success only after provider acceptance.
- Recovered pending and interrupted deliveries during boot and scheduled
  cleanup, and cancelled unsent delivery when a gift became unusable.
- Deleted abandoned payment-pending gifts and recipient data after 30 days.
- Added the persistence schema, service boundaries, email templates and
  translations required for the new delivery flow.
no ref

Ghost shuts down slowly on some sites and the cleanup tasks were
anonymous closures, so there was no way to tell which task (or the HTTP
server stop itself) was hanging. This labels each cleanup task and logs
how long it and _stopServer take, so a single SIGTERM on the affected
environment names the culprit instead of requiring a bisect.
…outer (#30122)

no ref

Follow-up from the review of #30119. The admin sidebar rendered every
destination as a plain `<a href="#/…">`. Those navigations bypass the
React router, so the history entries they create carry no router state
and React Router refuses to block a browser back onto them (`delta ==
null`) — the `useBlocker` guards on tags, members and settings never
fire when the screen was entered from the sidebar, and from some entry
shapes the router miscounts the delta and calls `history.go(0)` (reload)
instead of stepping back.

- `NavMenuLink` renders a router `Link` for React-owned routes and keeps
a native anchor for Ember-owned ones. Ember's `HashLocation` only
follows `hashchange`, which the router's `pushState` doesn't fire, so
Ember targets must stay anchors.
- Ownership comes from the route table at runtime
(`isEmberOwnedRoute(path)` in `routes.tsx`: leaf route served by
`EmberFallback` / `EmberListWithGiftLinks` / `TagDetailGate`), so
flag-gated routes follow the same rule without a hand-maintained list.
Query strings (`posts?type=…`, member views) are preserved either way.
- External (`target="_blank"`) links unchanged.
@pull pull Bot locked and limited conversation to collaborators Aug 19, 2026
@pull pull Bot added the ⤵️ pull label Aug 19, 2026
@pull
pull Bot merged commit 03c62e4 into code:main Aug 19, 2026
4 checks 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