Skip to content

Sync with upstream Ghost v6.22.1#58

Open
madewithlove-machine-user wants to merge 212 commits intomainfrom
chore/sync-v6.22.1
Open

Sync with upstream Ghost v6.22.1#58
madewithlove-machine-user wants to merge 212 commits intomainfrom
chore/sync-v6.22.1

Conversation

@madewithlove-machine-user
Copy link
Copy Markdown

Syncing fork to upstream release v6.22.1.

kevinansfield and others added 30 commits March 2, 2026 15:55
fixes https://linear.app/ghost/issue/ONC-1510

Sites using a subdirectory/proxy setup (e.g. `example.com/blog/ghost/`) could not sign out via the UI — the sign-out handler redirected to `/ghost` instead of `/blog/ghost/`, resulting in a "Cannot GET /ghost" error.

- Replaced hardcoded `/ghost/api/admin/session` fetch URL with `getGhostPaths().apiRoot` which derives the correct subdirectory from the current URL
- Replaced hardcoded `/ghost` redirect with `getGhostPaths().adminRoot` so the post-signout redirect respects the subdirectory
ref TryGhost#26640

The subdirectory signout bug was caused by hardcoded /ghost/ paths that
don't account for subdirectory installations. This adds a custom lint
rule to catch string literals and template literals starting with
/ghost/ in src files, guiding developers to use getGhostPaths() instead.
This PR adds i18n ("t") wrapping for the private.hbs, pagination.hbs,
and content-cta.hbs templates.

Note that content-cta required a little bit of a logic restructuring to
make them more translatable. (Substituting in "page"/"post" was more
likely to be a-grammatical or confusing vs splitting out two version of
the string.

At the present time, translations will be pulled from the
theme/locales/xx.json file, with fallback first to en, then to the key.
Thus, no effects on page render are expected until we add translations.

A separate PR will handle a shared set of translations so that each
theme doesn't need to reproduce the work of translating these shared
strings.

Note: I needed to update tests to make the 't' helper available.
Additionally, since we have two different paths for t (based on
feature-flagging the i18n/i18next swap), I did a restructure to test
both routes.



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Low behavioral risk but moderate template-rendering risk: multiple
shared frontend templates now rely on `t` helper bindings and changed
string composition, which could affect output if helpers/locales aren’t
registered or interpolation is wrong.
> 
> **Overview**
> Adds theme i18n support to core frontend templates by wrapping
user-facing strings in `{{t}}` across `private.hbs`, `pagination.hbs`,
and the paywall `content-cta.hbs` (including parameterized `Page {page}
of {totalPages}` and tier-list interpolation).
> 
> Refactors `content-cta.hbs` copy to split page vs post variants for
better translation grammar, and updates tests to register `t` plus
verify translations/fallbacks for both legacy `themeI18n` and new
`themeI18next` paths via a new `i18n-test-utils` harness and expanded
locale fixtures (en/de).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
c5bfec9. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Steve Larson <9larsons@gmail.com>
no-issue

Moved URL decoding and path normalization to a single place at the
top of the request handler, rather than having each helper function
decode independently. isDeniedFile and isAllowedFile are now pure
functions that operate on an already-decoded path. Extracted denylist
and allowlist constants to module level for clarity.
closes https://linear.app/ghost/issue/NY-1097

This change should have no user impact. It's just a cleanup.
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[eslint-plugin-playwright](https://redirect.github.com/mskelton/eslint-plugin-playwright)
| [`2.7.1` →
`2.8.0`](https://renovatebot.com/diffs/npm/eslint-plugin-playwright/2.7.1/2.8.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-playwright/2.8.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-playwright/2.7.1/2.8.0?slim=true)
|

---

### Release Notes

<details>
<summary>mskelton/eslint-plugin-playwright
(eslint-plugin-playwright)</summary>

###
[`v2.8.0`](https://redirect.github.com/mskelton/eslint-plugin-playwright/releases/tag/v2.8.0)

[Compare
Source](https://redirect.github.com/mskelton/eslint-plugin-playwright/compare/v2.7.1...v2.8.0)

##### Bug Fixes

- Add missing test coverage and fix several minor bugs
([#&TryGhost#8203;434](https://redirect.github.com/mskelton/eslint-plugin-playwright/issues/434))
([e3398ec](https://redirect.github.com/mskelton/eslint-plugin-playwright/commit/e3398ec61da52de205e7c9af2896633357769f74))
- **missing-playwright-await:** Handle spread elements
([df30163](https://redirect.github.com/mskelton/eslint-plugin-playwright/commit/df3016323819f7bc335fd1841971dccc2ae64f51)),
closes
[#&TryGhost#8203;430](https://redirect.github.com/mskelton/eslint-plugin-playwright/issues/430)
- **missing-playwright-await:** Support more promise edge cases
([b4cdcbd](https://redirect.github.com/mskelton/eslint-plugin-playwright/commit/b4cdcbd010a2b4dfc7ee14ab5bdc655897389f19))

##### Features

- Auto-detect `test.extend()` fixtures and import aliases
([#&TryGhost#8203;432](https://redirect.github.com/mskelton/eslint-plugin-playwright/issues/432))
([8b22ee7](https://redirect.github.com/mskelton/eslint-plugin-playwright/commit/8b22ee7b1f7823d81bafda82e240dd51106726dd))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - Only on Sunday and Saturday ( * * * * 0,6 ), Between 12:00
AM and 12:59 PM, only on Monday ( * 0-12 * * 1 ) in timezone Etc/UTC.

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Never, 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 was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/TryGhost/Ghost).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My40My4yIiwidXBkYXRlZEluVmVyIjoiNDMuNDMuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
no ref

This types-only change should have no user impact.
no ref
- added yarn dev:mailgun script to bypass mailpit when needing to use a
real service for testing trx emails
towards https://linear.app/ghost/issue/NY-1101
ref TryGhost/Koenig#1750

You can now paste URLs and use the bookmark card in the welcome email
editor.

https://github.com/user-attachments/assets/ca7d2be9-488c-4433-b679-8beccdcb1714

Co-authored-by: Steve Larson <9larsons@gmail.com>
ref https://linear.app/ghost/issue/NY-1114/
- added card styles to member email editor to support image, button,
callout, etc cards
ref https://linear.app/ghost/issue/NY-1109/

- added use link suggestions hook in framework
- added adapter hook in settings for ease
- added link suggestions functionality to welcome email editor

This PR adds link suggestions similar to the editor implementation. It's
largely a rewrite of the hooks used there that'll later be used for all
of admin.

![](https://github.com/user-attachments/assets/c53bd82d-eb0a-4bf4-bb30-c2114ad6cf13)

Co-authored-by: Evan Hahn <evan@ghost.org>
…26641)

no ref

When someone signs up with a [Feedbin] email address, we now have a link
to take them straight to the app. (Feedbin is an RSS reader that has
custom email inboxes you can use to read everything all in one place.)

Note that Feedbin doesn't have an Android app, so we use the regular
desktop link for both.

[Feedbin]: https://feedbin.com/

Co-authored-by: Cathy Sarisky <42299862+cathysarisky@users.noreply.github.com>
## Summary

- Removed @cmraible and @ibalosh from `/e2e/` code ownership
- Added @EvanHahn to `**/tinybird/` code ownership

## Why?

The `/e2e/` package is changed in a lot of pull requests. Having 3 people tagged for review on any PR that touched the e2e package isn't ideal, because it makes the ownership unclear - which of the 3 of us will actually review each PR? Adding a single owner eliminates this confusion.

The tinybird files are a different case - these change rarely, so it makes sense to have the whole team as owners — not necessarily so we will all review each PR, but so we all at least _see_ any PR that changes these files, even if retroactively.
…st#26655)

refs https://linear.app/tryghost/issue/ONC-1518

## Summary
- Added `labels-manager` service with shared paginated label cache,
debounced server-side search, and `sortLabels`/`findBySlug` helpers —
all label dropdowns share one cache instead of each fetching
independently
- Rewrote `gh-member-label-input` to lazy-load labels on dropdown open
with infinite scroll and automatic server-side/client-side search toggle
based on whether all labels have been loaded
- Converted `gh-member-single-label-input` from `OneWaySelect` to
`PowerSelect` with search and infinite scroll support
- Added `power-select-options-with-scroll` component for infinite-scroll
pagination in `PowerSelect` dropdowns (segment select, recipient select)
- Added cache invalidation to the shared label cache:
`addLabel()`/`removeLabel()` for targeted mutations (member save, label
delete), `reset()` for full reload on `refreshData`

---------

Co-authored-by: Steve Larson <9larsons@gmail.com>
ref https://linear.app/ghost/issue/ONC-1521/

We should follow best practice to not trust any server-provided content,
even when we're sanitizing input on the server.

- Added `escapeHtml()` for template interpolations in reader iframe
srcdoc
- Added DOMPurify sanitization for all `dangerouslySetInnerHTML` usages
no issue

This should prevent the flakiness we've seen where the domain tests take
too long to run. Instead of using the ORM layer, we're bulk inserting /
deleting using the knex layer. I wouldn't want to do this in the source,
but in tests this feels like a suitable optimisation to check bulk cases
quickly.
Ref
https://linear.app/ghost/issue/DES-1293/add-back-button-escape-hatch-on-email-failure-screen

There was previously no way to escape the email failed to send modal
without hitting escape. This just keeps the close button that's visible
on the pre-publish step.

| Header | After |
|--------|--------|
| <img width="1440" height="1024" alt="localhost_2368_ghost_ (16)"
src="https://github.com/user-attachments/assets/15145a24-eb47-4504-8d4c-7d12968abc24"
/> | <img width="1440" height="1024" alt="localhost_2368_ghost_ (15)"
src="https://github.com/user-attachments/assets/9b452941-ddbe-4f03-9561-ccbbdf4899fb"
/> |

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Template-only UI logic changes to button visibility/wiring in the
publish modal, with no data or auth impact.
> 
> **Overview**
> Ensures the publish flow header always shows the **Close** button
whenever the modal isn’t complete, including when an `emailErrorMessage`
is present, so users can exit the email-failure screen without using
Escape.
> 
> The **Preview** button is now suppressed while an email error is
shown, and the email-error step receives `@close` so it can trigger the
same exit behavior.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
8d62d09. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
no ref

This contains some changes to support additions within the Koenig repo:
- embed cards
- youtube cards
- restricting width for image cards

Youtube embeds are still having some issues with the renderer.
closes
https://linear.app/ghost/issue/NY-1106/create-feature-flag-for-email-settings-customization

## Summary
- Adds a new `welcomeEmailDesignCustomization` private feature flag to
enable toggling welcome email design customization on/off
- Adds the flag to the labs settings UI under private features
- Updates the config API snapshot to include the new flag

## Test plan
- [x] Unit tests pass (`ghost/core/test/unit/shared/labs.test.js` — 10
passing)
- [x] Config API e2e snapshot updated and passing
- [X] Toggle visible in Labs → Private features when developer
experiments is enabled
…logic (TryGhost#26543)

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

Both functions independently computed discount windows using the same
three data paths (trial periods, Stripe coupon data, and legacy offer
duration fallback). This extracts the shared computation into a single
`getDiscountWindow` utility so the two callers stay in sync as the
discount model evolves.
…ryGhost#26672)

closes https://linear.app/ghost/issue/BER-3406

- previously, we expected the /tiers endpoint to always have
tier-related data to render retention offer popups
- however, archived tiers are not returned as part of the `/tiers`
endpoint
- with this fix, we directly get necessary data from the member
subscription object
ref TryGhost#26416

Fixed `SharedStorage` deprecation warning
Added label picker with inline editing to members filter

ref https://linear.app/tryghost/issue/BER-3342/

The members list label filter now uses a custom label picker built with Popover + Command (cmdk) from Shade. Labels can be created, renamed, and deleted directly from both the filter dropdown and the bulk-action modals (add/remove label), without leaving the members page.
Fixed DST drift in domain warming integration test clock

no ref
The test used setDate(), which diverged from elapsed-24h day math around DST and caused flaky mysql8 CI failures.
9larsons and others added 28 commits March 18, 2026 14:56
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [gscan](https://ghost.org/)
([source](https://redirect.github.com/TryGhost/gscan)) | [`5.3.4` →
`5.4.0`](https://renovatebot.com/diffs/npm/gscan/5.3.4/5.4.0) |
![age](https://developer.mend.io/api/mc/badges/age/npm/gscan/5.4.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/gscan/5.3.4/5.4.0?slim=true)
|

---

### Release Notes

<details>
<summary>TryGhost/gscan (gscan)</summary>

###
[`v5.4.0`](https://redirect.github.com/TryGhost/gscan/compare/v5.3.5...v5.4.0)

[Compare
Source](https://redirect.github.com/TryGhost/gscan/compare/v5.3.5...v5.4.0)

###
[`v5.3.5`](https://redirect.github.com/TryGhost/gscan/compare/v5.3.4...v5.3.5)

[Compare
Source](https://redirect.github.com/TryGhost/gscan/compare/v5.3.4...v5.3.5)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - Only on Sunday and Saturday ( * * * * 0,6 ), Between 12:00
AM and 12:59 PM, only on Monday ( * 0-12 * * 1 ) in timezone Etc/UTC.

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Never, 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 was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/TryGhost/Ghost).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My42Ni40IiwidXBkYXRlZEluVmVyIjoiNDMuNjYuNCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Changelog for v2.66.4 -> 2.66.5:
  - Updated i18n translations - new strings for zh and zh-Hant
The private page now needs a richer branded experience and signup flow,
but Portal is not available on this template. Keeping the behavior local
to Ghost core gives us the required UX without broadening the page
runtime.

The private page has been updated to use publication branding and 
description, instead of just an inline form to gain access.

The access form has been moved to a dialog with a footer link, alongside
an admin login link with Portal-style footer treatment, making these secondary
concerns for the page.

The main content is now a self-signup form, with brand-aware styling, 
ading/success states, success redirect handling, and normalized member
signup errors

Expanding the brand experience on the private page necessitated adding 
4 new handlebars utilities: @site.admin_url, json, contrast_text_color and
color_to_rgba - these have all been added to GScan, and docs will be 
updated soon. 


---------

Co-authored-by: Hannah Wolfe <github.erisds@gmail.com>
## Summary

Integrates all foundation and visual components into the welcome email
customization modal:

- **`welcome-email-customize-modal.tsx`** — Full modal with `GeneralTab`
(sender name, reply-to, header image, footer), `DesignTab` (all design
fields via context), `Sidebar` (tab navigation), and live `EmailPreview`
- Reads current design settings from Ghost settings API on mount
- Hydrates general settings when global data loads asynchronously

> **Stack:** PR 3 of 3 — targets
`cmraible/welcome-email-visual-components` (TryGhost#26842)
> 
> **Full stack:** TryGhost#26841 (foundation) → TryGhost#26842 (visual components) →
this PR (wiring)

## Known TODOs

- **Header image upload** — The UI shows a preview/remove control when a
header image is set, but there is no upload control to add one yet. This
will be wired up in a follow-up.
- **Save handler** — The Save button currently closes the modal without
persisting changes. Backend persistence will be added once the design
columns exist in the database.
ref TryGhost#26762

- privacy pages were updated, but browser tests reference the old flow
- this fixes them to use the new flow, whereby the user clicks "Enter
access code" and the input and button are visible
ref https://linear.app/ghost/issue/BER-3427/fix-price-formatting-issue

Non-whole discounted prices (e.g. $5.40) were displayed without trailing
zeros (e.g. $5.4) in two places:

- Portal: Retention offer discounted price in the cancellation flow used
`formatNumber()` which calls `.toLocaleString()` without enforcing
decimal places. Fixed by applying the same `.toFixed(2)` rounding that
the signup offer page already uses in `renderRoundedPrice()`.
- Admin: Member detail subscription discounted price used
`{{format-number}}` which doesn't enforce decimal places. Fixed by
switching to `{{gh-price-amount}} (with cents=false)`, which already
handles this correctly via `{minimumFractionDigits: 2}`.

---------

Co-authored-by: Sag <guptazy@gmail.com>
## Summary
- default E2E isolation to per-file and add a root-level
`test.describe.configure({mode: 'parallel'})` opt-in for per-test
isolation
- add fixture mode resolution, mode guard rails, `resolvedIsolation`,
and `resetEnvironment()` to support mixed isolation safely
- support both local workflows by auto-selecting build vs dev mode and
opt stateful member/billing suites back into per-test isolation

## Performance
- Over the last 3 days, Velo reports Ghost CI averaging 29.7 minutes per
successful run. The latest successful run on this branch completed in
22.6 minutes, a 7.1 minute improvement (23.8%).

- Looking specifically at the E2E matrix, the last 10 successful CI runs
averaged a 17.8 minute critical path across the 4 E2E shards; this
branch completed that path in 13.1 minutes, a 4.7 minute improvement
(26.2%).

- Total E2E shard compute time also dropped from 66.9 minutes on average
to 45.6 minutes, reducing runner time by 21.3 minutes (31.9%).

ref https://linear.app/ghost/issue/BER-3428/rework-e2e-isolation-model

---------

Co-authored-by: Steve Larson <9larsons@gmail.com>
no ref

Added Stripe checkout initialization tests to `/e2e/`. This includes a
nasty-looking smoke test that is helping validate the fake Stripe
functionality while we build out the rest of this suite.
no ref
- increases CI sharding to 8 to reduce run time
no ref

The cadence was not correctly wrapped, so we're getting 'month' and 'year' in all languages.

---------

Co-authored-by: Steve Larson <9larsons@gmail.com>
TryGhost#26869)

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

The files upload endpoint accepted any file type without validation,
unlike images and media which check extension and MIME type. With the
move to a shared CDN domain, we don't want to upload files with
executable content types.

This commit adds:
- An allowlist of supported file extensions (based on Ghost Pro data)
- Extension-only validation middleware on the upload route
- Content-type resolution that serves browser-renderable types (images,
PDF, JSON, audio, video, fonts) with their natural type, overrides
formats (HTML, JS, CSS, XML) to text/plain, and defaults everything else
to application/octet-stream (forced download)
- Users can zip unsupported file types as an escape hatch
ref https://linear.app/ghost/issue/BER-3414/

## Summary

- added a shared filter core for field definitions, field resolution,
AST helpers, codecs, and NQL query compilation
- moved the members list filter state, query translation, hydration, and
bulk/export/search flows onto that shared core while keeping the
existing `membersForward` rollout model unchanged
- removed the legacy members filter config path and intentionally left
the comments moderation runtime on its current implementation for
follow-up work
no ref

Iterative cleanup and improvements to the welcome email design
customization modal (PRs TryGhost#26841, TryGhost#26842, TryGhost#26843). Fixes bugs found
during review, removes dead code, adds missing functionality (image
upload, Ghost badge toggle), and refactors the preview to be composable
and visually consistent with the existing newsletter preview.
no ref

* Abstracted the translateCadence function to utils since we need it in
multiple places.
* Fixed two more places cadence wasn't wrapped for translations.
* Removed "{amountOff} off" string, since we are already using "{amount}
off" elsewhere.
…a API (TryGhost#26837)

closes https://linear.app/tryghost/issue/ONC-1553

- The `add()` method in `memberBREADService` passed the full `options`
object (including `withRelated: ['labels', 'newsletters']`) to
`setComplimentarySubscription()`.
- When the Stripe customer had existing subscriptions, fetching related
stripeSubscriptions with those options caused a "labels is not defined
on the model" error on StripeCustomerSubscription models.
- This fix uses `sharedOptions` (context + transacting only), matching
the pattern already used for `linkStripeCustomer()`.
---
### Summary

When creating a comped member via the Admin API with a
`stripe_customer_id` pointing to a Stripe customer that has existing
subscriptions, `setComplimentarySubscription()` received the full
options object including `withRelated: ['labels', 'newsletters']`.
Inside that method,
`member.related('stripeSubscriptions').fetch(options)` attempted to
eager-load `labels` on `StripeCustomerSubscription` models, which don't
have that relation — throwing "labels is not defined on the model".

### Impact

The bug's impact depends on the Stripe customer's existing
subscriptions:

- **Customer with a paid subscription + `comped: true`**: The comp
conversion never runs. The member ends up with `paid` status instead of
`comped`, and the API returns a 500. This is a real data issue — the
member doesn't get their complimentary access.
- **Customer with an existing comp subscription**: All database records
are written correctly by `linkStripeCustomer()` before the error occurs,
so the member is properly set up. However, the API still returns a 500
to the caller.
- **Customer with no subscriptions**: The bug doesn't trigger because
there are no `StripeCustomerSubscription` models to eager-load on.

### Fix

Uses `sharedOptions` (only `context` + `transacting`) instead of the
full `options` when calling `setComplimentarySubscription()` on line 406
of `member-bread-service.js`. This matches the pattern already
established for the `linkStripeCustomer()` call on line 381.

---------

Co-authored-by: Chris Raible <chris@ghost.org>
Closes
https://linear.app/ghost/issue/BER-3451/make-sure-that-tier-names-in-the-table-truncate

Tier names will now truncate and not break the layout of the table.

| Before | After |
|--------|--------|
| <img width="504" height="331" alt="Screenshot 2026-03-19 at 14 14 45"
src="https://github.com/user-attachments/assets/bced60d9-708a-4a21-a1c9-34341c309217"
/> | <img width="452" height="335" alt="Screenshot 2026-03-19 at 14 14
26"
src="https://github.com/user-attachments/assets/4495b68e-d25c-44ed-b8f0-2dd97172ab66"
/> |
Closes
https://linear.app/ghost/issue/BER-3452/use-sensible-operators-and-defaults-for-each-filter-type

Makes sure we map to Ember for operators and improves Name and Email
filters to use contains instead of is as a default for fuzzy searching.
The deploy-to-staging label was only checked inside the CI trigger_cd job
which runs on push events. If a developer added the label after CI had
already started, the deploy flag was missed and the CD pipeline ran as a
dry run. This adds a dedicated workflow that fires on the label event
itself (pull_request_target: labeled), waits for CI build artifacts to be
ready, then dispatches to Ghost-Moya — the same pattern as the preview
label. The existing trigger_cd label check is preserved so subsequent
pushes to a labeled PR still auto-redeploy.

Also adds CI-wait polling to the preview label workflow for consistency —
previously it dispatched immediately on label addition, which could fail
if CI hadn't finished pushing the GHCR image yet.
no refs

This build artifact shouldn't be committed since it's generated as part
of the build pipeline, but it wasn't included in `.gitignore`. This adds
it to prevent anyone from accidentally committing it.
… format (TryGhost#26863)

closes https://linear.app/ghost/issue/ONC-1560/re-unable-to-access-my-admin-site

## Summary

- Bumps `@tryghost/image-transform` from 1.4.6 to 1.4.13, which upgrades
Sharp from 0.34.2 to 0.34.5
- Adds `fontconfig` to the production Docker image (~7MB increase)

## Root Cause

A customer uploaded an SVG favicon containing a `<text>` element. When
Ghost tried to rasterize it to PNG (via the
`/content/images/size/w256h256/format/png/` endpoint), Sharp's bundled
librsvg needed fontconfig to resolve fonts for the text. Two problems:

1. **Sharp 0.34.2** (librsvg 2.60.0) **segfaults** when fontconfig is
missing — killing the Ghost process instantly with no error log. Sharp
0.34.5 (librsvg 2.61.2) handles this gracefully.
2. **The production Docker image** (`node:22-bookworm-slim`) has no
fontconfig installed, so even without the segfault, SVG `<text>`
elements render as empty placeholders.

This caused a **boot loop**: every page load triggered a favicon resize
request → process crash → restart → crash again.

## Reproduction

Confirmed segfault in a clean `node:22-bookworm-slim` container (same
base as production):

```
$ docker run --rm node:22-bookworm-slim bash -c '
  npm install sharp@0.34.2 && node -e "
  require(\"sharp\")(Buffer.from(
    \"<svg xmlns='http://www.w3.org/2000/svg'><text>A</text></svg>\"
  )).resize(256).png().toBuffer()"'

Fontconfig error: Cannot load default config file: No such file: (null)
Segmentation fault (exit 139)
```

Same test with `sharp@0.34.5` → succeeds (no segfault, but text renders
as placeholder).
Same test with `sharp@0.34.5` + `fontconfig` installed → succeeds with
correct text rendering.

## Changes

| Change | Why |
|---|---|
| Bump `@tryghost/image-transform` to 1.4.13 (Sharp 0.34.2 → 0.34.5) |
Prevents segfault — librsvg 2.61.2 handles missing fontconfig gracefully
instead of crashing |
| Add `fontconfig` to `Dockerfile.production` runtime layer | Provides
font resolution so SVG `<text>` elements render correctly (~7MB image
size increase) |

## Test plan

- [x] Verified Sharp 0.34.2 segfaults in `node:22-bookworm-slim` (no
fontconfig)
- [x] Verified Sharp 0.34.5 does not segfault in same environment
- [x] Verified fontconfig survives the `build-essential` purge step in
Dockerfile
- [x] Verified correct SVG text rendering with fontconfig on staging
preview
- [x] Verified fontconfig adds ~7MB to production image size
no ref

This scaffolding is needed before we can migrate over the browser tests
to e2e.
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.