Skip to content

Sync with upstream Ghost v6.59.0 - #22

Open
andreascreten wants to merge 246 commits into
mainfrom
chore/sync-v6.59.0
Open

Sync with upstream Ghost v6.59.0#22
andreascreten wants to merge 246 commits into
mainfrom
chore/sync-v6.59.0

Conversation

@andreascreten

Copy link
Copy Markdown
Member

Syncing fork to upstream release v6.59.0.

github-actions Bot and others added 30 commits August 10, 2026 20:25
…t#29873)

no ref
- makes the core build consistent with admin/e2e builds
no ref

First slice of the settings NiceModal burn-down (follows the router swap
TryGhost#29780 and un-portal TryGhost#29789).

`ConfirmationModal` and `LimitModal` were NiceModal-shown from **54 call
sites across 27 files** — the bulk of settings' remaining NiceModal
usage. They now go through a settings-local `ConfirmationProvider`:
ref https://linear.app/ghost/issue/BER-3816

A member import too large to run inside the request is accepted straight away and
reported by email once the background job finishes, and when that job failed no
email was ever sent. The publisher was told to expect one and nothing ever came,
leaving a failed import indistinguishable from one still running. Nobody else
found out either, since a queued import runs in-process where a thrown error
never reaches the error tracker.

The import now runs in three phases and each says what it may do. Gathering what
the rows are written against is the only phase allowed to throw, so a throw
means nothing was written; writing reports rather than throws, since once a row
has committed an import that failed halfway is not one that never ran; and
settling up afterwards guards each step on its own, so a failed lookup cannot
skip archiving a Stripe price that nothing else will ever clean up. The job then
always tells the publisher something, sending a distinct email for an import
that could not be completed at all.

Whether a row failed for a reason the publisher can act on is not asked. A row
that failed is a row that failed, and it goes into the report they receive with
the reason beside it. Nothing inspects those errors, which is also why a row's
values -- which a database driver inlines into the message of the statement that
failed -- cannot reach a log or an error tracker from here. What does reach them
is the run itself failing, a cleanup that did not finish, or a notification that
could not be sent, none of which carry a row.
no ref
- Added an additive Shade `CodeEditor` with lazy CodeMirror loading.
- Adopted it only for the header and footer fields on the flagged React
tag-details screen.
- Added Storybook states for default, focused, error, and disabled
behavior.
- Portalled Shade autocomplete into a transparent body-level host so
suggestions escape clipped editor and accordion containers.
- Connected hint and error copy to the editable element and reset shared
focus state when a focused editor unmounts.
- Added real-browser coverage for editing, saving, autocomplete
visibility, positioning stability, and portal paint safety.
ref BER-3863

An investigation into whether a member could reach custom fields through the
members API found that they cannot, and never could. Every member-facing
surface serialises from a field whitelist that predates the feature, and the
member update path drops unknown keys twice before it reaches any custom-field
code. Nothing asserted that, so these two tests pin it: a member's own response
carries no custom fields, and custom fields a member sends are dropped while the
rest of the update still applies, the same way email already behaves on that
endpoint.
ref https://linear.app/ghost/issue/BER-3862

A key is typed by hand into member filters, CSV columns, email replacement
strings and, before long, themes and editor cards. The hyphen a slug separates
with is the one character those readers disagree about: NQL will not parse a
hyphen in a property path, and a replacement string matches word characters
only, so a hyphenated field reaches the reader as literal text with nothing to
say it was never substituted. The convention contradicted itself as well, since
the parts of a composite field were already underscored and a name typed with
underscores kept them, leaving a single export column carrying both.

Underneath that the format had no definition of its own. It was whatever slugify
produced, so a rule a growing number of surfaces depend on lived in a helper that
answers to URLs and could widen on a version bump. The characters a key may
contain are now stated where its readers are known, as an allowlist rather than a
list of things to strip out, and the libraries keep only the transliteration and
invisible-character stripping they own. Trimming the ends falls out of that rule
and makes __proto__ unmintable under every spelling, so it no longer needs
reserving alongside constructor.

A key is minted once and never changes, so a definition created earlier keeps its
old key for good, and rewriting one in place would leave its stored values and
every reference to it pointing somewhere else. Those definitions are discarded
with their values instead: custom fields sit behind a private flag and have never
been released, so only a site that deliberately opted in can hold one, and
re-creating the field re-mints the key. The migration measures a key against the
shape this release mints rather than looking for a hyphen, because the previous
minting passed underscores through untouched and the definitions endpoint shipped
four days before the reserved-key guard did.
no ref
- Clear populated CodeMirror fields through their own `ControlOrMeta+A`
and Backspace key handling.
- Wait for the empty document before filling each replacement, then
verify the rendered value before saving.
- Reuse the expected replacement values in the exact request-body
assertions.
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@tanstack/react-query](https://tanstack.com/query)
([source](https://redirect.github.com/TanStack/query/tree/HEAD/packages/react-query))
| [`5.101.2` →
`5.101.4`](https://renovatebot.com/diffs/npm/@tanstack%2freact-query/5.101.2/5.101.4)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@tanstack%2freact-query/5.101.4?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@tanstack%2freact-query/5.101.2/5.101.4?slim=true)
|

---

### Release Notes

<details>
<summary>TanStack/query (@&TryGhost#8203;tanstack/react-query)</summary>

###
[`v5.101.4`](https://redirect.github.com/TanStack/query/blob/HEAD/packages/react-query/CHANGELOG.md#51014)

[Compare
Source](https://redirect.github.com/TanStack/query/compare/@tanstack/react-query@5.101.3...@tanstack/react-query@5.101.4)

##### Patch Changes

- Updated dependencies \[]:
-
[@&TryGhost#8203;tanstack/query-core](https://redirect.github.com/tanstack/query-core)@&#8203;5.101.4

###
[`v5.101.3`](https://redirect.github.com/TanStack/query/blob/HEAD/packages/react-query/CHANGELOG.md#51013)

[Compare
Source](https://redirect.github.com/TanStack/query/compare/@tanstack/react-query@5.101.2...@tanstack/react-query@5.101.3)

##### Patch Changes

- Updated dependencies
\[[`7e3c822`](https://redirect.github.com/TanStack/query/commit/7e3c822a10896f41a8f1031c16b85096277af677)]:
-
[@&TryGhost#8203;tanstack/query-core](https://redirect.github.com/tanstack/query-core)@&#8203;5.101.3

</details>

---

### Configuration

📅 **Schedule**: (in timezone Etc/UTC)

- Branch creation
  - Only on Sunday and Saturday (`* * * * 0,6`)
- Between 11:00 PM and 11:59 PM, Monday through Friday (`* 23 * * 1-5`)
- Between 12:00 AM and 04:59 AM, Monday through Saturday (`* 0-4 * *
1-6`)
- Automerge
  - Only on Sunday and Saturday (`* * * * 0,6`)
- Between 11:00 PM and 11:59 PM, Monday through Friday (`* 23 * * 1-5`)
- Between 12:00 AM and 04:59 AM, Monday through Saturday (`* 0-4 * *
1-6`)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR has been generated by [Mend
Renovate](https://redirect.github.com/renovatebot/renovate).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yODAuMyIsInVwZGF0ZWRJblZlciI6IjQzLjI4MC4zIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: tryghost-renovate[bot] <269725441+tryghost-renovate[bot]@users.noreply.github.com>
ref https://linear.app/ghost/issue/BER-3861

A value is addressed by key everywhere it matters: the write names the
field by key, a segment filter names it by key, and the key is immutable.
Carrying the key directly on the values row lets the read and filter paths
drop the id-to-key join, so the foreign key now references the field's key
column rather than its id.
Two migrations read custom_field_id in their up: the 6.57 leaf-rows migration and the 6.58 underscore-keys reset. The value re-key drops that column, and the idempotency check re-runs every current-major up against the final schema, so each guards its custom_field_id work on the column still being present. A forward install always sees the column, so this changes nothing an install actually applies.
…e holes (TryGhost#29817)

ref https://linear.app/ghost/project/moving-routesyaml-off-of-disk-9887873946a1/

- removed comments describing code we already deleted: they point at an eager
  URL service and a macrotask queue that no longer exist, and one names the
  wrong place for where `{field}` placeholders get rewritten
- removed `unmountRoute` - it had no callers and didn't work anyway, matching
  `item.path` when a mounted route's path lives at `layer.route.path`
- removed the `resourceAlias` fallback and the `filter` key on static pages,
  neither of which is set anywhere, so both were already fixed values
- dropped the query defaults in `fetch-data`, since both callers now pass a
  complete query and nothing was being defaulted
- built the default post query from the API adapter instead of repeating
  `postsPublic` by hand, so renaming it in `config.ts` can't quietly point the
  frontend at the Admin endpoint
- fixed `Omit<Route, 'path'>` dropping `filter`, `order`, `limit`, `rss` and
  `contentType` - `Omit` over a union keeps only the shared keys, so the parser
  could leave one out and still compile
- added a `satisfies` check so a resource added without a `QUERY` entry fails
  the build instead of erroring on every request through that route
- fixed three fixtures stubbing `api.posts` where production passes
  `postsPublic`, and ran the FileStore against the store contract it was the
  only adapter missing
- no behaviour change: every path removed was already unreachable
Hardens the internal-package migration workflow based on its first
cold-start use.

Requires isolated, freshly based worktrees and fail-fast checks around
subtree splitting and importing. Verifies the source checkout is clean,
the destination is a linked worktree, and the resulting merge topology
preserves and reaches the recorded source history.

Adds guidance for assessing public consumers, mapping workspace
dependencies under Ghost's strict catalog policy, and minimally
integrating legacy CommonJS packages without mixing migration with
modernization.

Expands verification to cover package resolution, Nx tests, consumer
tests, the full build, and release-archive contents.
Removes the permanently enabled `llmsTxt` labs flag now that llms.txt
support is generally available. LLMs.txt is still off by default, and can be
enabled in settings.
Removes the permanently enabled `explore` Labs flag from Explore Ping
now that the feature is generally available. Explore is still configurable in 
settings.
Updates the settings API response header snapshot after the merged
Labs flag removals. Minimal change required to get main green.
…ryGhost#29884)

no issue

- the config API snapshot embedded the exact labs flag list, and the
  settings API Stripe test pinned an exact content-length that varies
  with the size of the labs setting — both churned every time a feature
  flag was added or removed, causing avoidable snapshot conflicts
  between PRs
- labs is now matched as any object, with an explicit assertion keeping
  the meaningful coverage: the flags must be a non-empty plain object of
  booleans; the settings header match now uses anyContentLength like the
  other settings tests
no issue

- the flag has been permanently enabled via GA_FEATURES for a while, so
  the conditional it gated was dead in practice
- the Featurebase widget remains gated by the `featurebase` config, which
  was already the real on/off switch everywhere else (token endpoint,
  config serializer)
Removes the permanently enabled `dangerZoneResetAuth` labs flag now that
resetting all authentication is generally available. The Danger Zone action
is always shown, and the tests no longer need to opt in to the flag.
ref https://linear.app/ghost/issue/BER-3850

Definitions came back in whatever order they happened to be created in, and
every surface reads that one list: the settings list, a member's details, the
import mapping's targets, the export's columns. A sort_order column now carries
the order, added without a backfill because the read tie-breaks on created_at,
so a site that never reorders keeps the order it has. The order is set by
PUTting the whole collection in the order it should have rather than by editing
one field, because where a field sits is a fact about the list and not about the
field, so no rank is exposed on a definition anywhere in the API or the domain.
That list has to name every definition exactly once, and the rows are locked in
key order so two publishers dragging at the same moment cannot deadlock.
ref https://linear.app/ghost/issue/BER-3850

The read that assembles a member's values sorted its rows by field, on the
hottest query in the domain: every member of a page, joined against their
values. It could never deliver that order to anyone, because the rows become an
object keyed by field and an object carries no order, since JSON gives member
order no meaning and JavaScript enumerates any key that looks like an array
index first. Path ordering stays, so the parts of one composite field still
assemble the same way every time.
ref https://linear.app/ghost/issue/BER-3850

The API could take an order but nothing could give it one. The active tab in
Settings is now a sortable list, following the newsletters list it borrows its
component from. A drag is applied to the whole list rather than the tab it was
made in, because a reorder names every definition and the archived ones keep
their places around the move. The order just drawn is held on the screen that
drew it and rendered from immediately, so letting go is the end of the
interaction rather than the start of a round-trip; the response then settles the
cached lists directly, since a reorder only succeeds when it named exactly the
fields the site has and a refetch would learn nothing. Each cached list is put
into the new order rather than replaced, because several live under one key and
they do not hold the same fields.
…#29845)

no ref

*I recommend [reviewing this with whitespace changes
disabled](https://github.com/TryGhost/Ghost/pull/29845/changes?w=1).*

What
----

Before this change, there was a class called
`EmailAnalyticsProviderMailgun`, which basically just exposed a single
function that fetched Mailgun events.

After this change, there's a function called `fetchMailgunEvents` that
does this.

Why
---

There was no reason for this to be a stateful class. The function is
simpler.

Test plan
---------

- Automated tests
- Made sure newsletter opened events still worked with Mailgun. Sent a
newsletter, opened it in Gmail, and verified that it appeared in the UI
no issue

- removed the `commentsThreads` and `commentsPinning` labs flags now that both features are GA, along with all flag conditionals
- deleted the unreachable non-threaded comments UI: the flat reply layout, "Replied to:" snippets, and top-level reply collapsing
- made pinned-first ordering and pin moderation unconditional, removing the comments service's labs dependency entirely
- bumped comments-ui to 1.6 so already-deployed bundles that still read the flags stay paired with backends that serve them
…ost#29846)

ref TryGhost#29845

This change should have no user impact.



---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
towards https://linear.app/ghost/issue/NY-1473

This change should have no user impact.

I think this is a useful change on its own, but it'll make [an upcoming
change][0] easier.

[0]: https://linear.app/ghost/issue/NY-1473

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
values-service hardcoded the members_custom_fields.key length as 191 while its sibling definitions-service already reads it from the canonical schema. This reads it from the same source so the two services cannot disagree, and neither can drift from the column if the width ever changes.
The static schema restates the field-type and status lists because it cannot import them, guarded only by a keep-in-sync comment. This asserts the schema's isIn lists equal FIELD_TYPE_IDS and FIELD_STATUS, so a divergence fails the build rather than shipping a schema that accepts or rejects the wrong values.
…schema

The presentation catalog listed an address's sub-field labels as a loose Record of string to string, so a mistyped or omitted part compiled and silently lost its label. Typing it against the shared Address value schema's keys makes a part added, removed, or renamed upstream a compile error here instead of a missing label at runtime.
9larsons and others added 29 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
…yGhost#30112)

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 TryGhost#30082's portal modal)
…ost#30107)

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.
…0113)

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 (TryGhost#30018, TryGhost#30019, TryGhost#29916, TryGhost#30054, TryGhost#30099, TryGhost#30110, TryGhost#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 TryGhost#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 TryGhost#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.
TryGhost#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.
…Ghost#29967)

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 (TryGhost#30122)

no ref

Follow-up from the review of TryGhost#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.
…st#30124)

no issue

The custom fields import dialog should ask before closing when the mapping
has been changed. It could miss that check if Escape was pressed immediately
after an edit, and close without showing "Leave without importing?".

`hasEdits` was React state read from the `handleOpenChange` render closure.
Radix keeps `onOpenChange` in a callback ref that it updates in a passive
effect, while its Escape listener runs on `document` outside React event
dispatch. If Escape arrived before React committed and flushed the edit,
Radix could call the old closure and see `hasEdits` as false.

Nothing renders from this flag, so it uses a ref now. The edit updates the
ref immediately, and `handleOpenChange` reads the current value.
no refs

## History and intent

Portal introduced `contrast-color.js` in July 2020 to calculate
complementary text and icon colours for accent-coloured controls:

TryGhost@fb6a2c9

Portal deliberately removed that automatic colour inversion in October
2020:

TryGhost@b037d98

The second commit provides direct evidence that making this utility
unreachable was intentional rather than accidental: its message is
`Removed auto-invert color from primary buttons`, and its atomic diff
removes every import of the utility while replacing each computed
foreground colour with `#fff` across trigger icons, trigger text, and
primary action buttons.

The historical record does **not** explain the deeper UX rationale for
preferring white. The commit body contains only `no refs`, GitHub has no
associated pull request or commit discussion, and adjacent styling work
provides no written rationale. Therefore, the supported conclusion is
limited to this: automatic inversion was intentionally retired in favour
of a fixed-white treatment. Design consistency is suggested by the diff,
but is an inference rather than documented intent.

## Why deletion is safe

The utility remained in the tree after its final consumers were
deliberately removed and has been unreachable for nearly six years. A
repository-wide usage search finds no remaining consumer, and Knip
independently reports the file as unused. Portal has a single
`src/index.jsx` build entry, and the published package includes only
built UMD output rather than internal source paths, so the utility
cannot be reached through a secondary entry point or public export.

Deleting the orphan preserves the intentional fixed-white behavior
established by the October 2020 change.

## Validation

- `pnpm --dir apps/portal lint`
- `pnpm --dir apps/portal test` (592 passed, 1 skipped)
- `pnpm --dir apps/portal build`
- `pnpm check` (lint and all other project tests passed; the aggregate
run reported `ghost:test` as flaky)
- `pnpm nx test ghost --skip-nx-cache` (604 files and 7,852 tests passed
on retry; Nx classified the prior failure as flaky)
…t#30127)

- Cache the extracted `.ttf` files with `actions/cache` (key
`msttcorefonts-ttf-v1`; the fonts never change).
- Cache hit → copy the fonts into place, no apt, no network.
- Cache miss → install from apt with the existing bounded retry/timeout,
then stash the `.ttf`s for the cache to save.
- `fc-cache` after placing the fonts; then install the firefox media
codecs the video-card tests need.

Only a cache miss (first run, or after ~7-day cache eviction) touches
SourceForge, and that path still fails fast instead of hanging. Ghost's
CI runs frequently enough to keep the cache warm.
…ryGhost#30129)

no ref

TryGhost#28368 added icons and visibility controls to navigation behind the
`navigationIcons` lab flag, but one part of it shipped ungated: the
built-in navigation partial started wrapping every label in `<span
class="nav-label">`.

That affects every theme that uses `{{navigation}}` without shipping its
own `partials/navigation.hbs`, whether or not the flag is on. CSS and JS
that target the anchor's text directly — `.nav a` flex/gap layouts,
`::before` tricks, truncation on the anchor, `a.textContent` reads — see
different markup than before.

The helper now passes the flag to the template as `@navigationIcons`,
and the template renders the icon plus the label wrapper only when it is
set. With the flag off, output is byte-identical to the pre-TryGhost#28368
markup.

The flag-off tests now assert the bare label, plus a new test that
asserts the full rendered string matches the old markup exactly.
fixes TryGhost#30100

The 6.58 rename of `email_batches.provider_id` asked for `ALGORITHM=INSTANT`,
which MySQL only accepts for RENAME COLUMN from 8.0.28 onwards. Anyone on an
older 8.0 server — still supported per our docs — hit
ER_ALTER_OPERATION_NOT_SUPPORTED and could not upgrade past 6.58.

That migration now asks for `auto`, which is what every other algorithm-bearing
migration uses and costs nothing here: a plain RENAME COLUMN is metadata-only on
INPLACE too, and 8.0.28+ servers still pick INSTANT themselves. Editing a shipped
migration is safe because knex-migrator tracks migrations by name with no
checksum, so sites that already ran it skip it and stuck sites run the fixed
version.

To stop this recurring, the schema commands now retry an ALTER without the
algorithm clause when the server rejects it. INSTANT support is per-operation
(ADD COLUMN 8.0.12, RENAME COLUMN 8.0.28, DROP COLUMN 8.0.29), so a version
matrix in the migration utils would be a maintenance trap; reacting to the
server's own error is version- and vendor-agnostic and costs nothing on the
happy path. Verified against real MySQL 8.0.21 and 8.4 containers.
closes https://linear.app/ghost/issue/NY-1526

Added Last entry, Total entries, and In progress columns using stats
from the automation browse API. Kept the mobile list focused on name and
status, and gated the new stats behind automationRunAnalytics while run
analytics remains experimental.

<img width="1560" height="1043" alt="image"
src="https://github.com/user-attachments/assets/c60cfc64-480e-4e6f-a0c9-490391d1099e"
/>
…0131)

no ref

Follow-up to TryGhost#30129.

The navigation settings validator accepts an item with an icon and no
label regardless of the `navigationIcons` flag. So enabling the flag,
adding an icon-only item, then disabling the flag leaves the front end
rendering `<li class="nav-"><a href="/foo"></a></li>` — an empty,
unlabelled link, since the icon is dropped when the flag is off.

Before TryGhost#28368 that data threw an `IncorrectUsageError`, which is the
worse option: it 500s the whole front end for data Admin allowed to be
saved. So items with nothing to render are now dropped instead, and a
nav where every item drops out renders nothing, matching the existing
empty-nav behaviour.

Note this also drops an item with a null label and no icon, which
previously rendered the same empty link. That shape is only reachable by
writing to the database directly — Admin has always required a label
(now label-or-icon).

Tests: an icon-only item is dropped while a sibling label item still
renders, and an all-icon-only nav renders an empty string. Flag-on
icon-only rendering is unchanged and still covered.
@andreascreten andreascreten self-assigned this Aug 20, 2026
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.