From 7352f845802a91a33aea6383234bd66fff96e34f Mon Sep 17 00:00:00 2001
From: Peter Zimon
Date: Tue, 29 Sep 2026 18:18:08 +0200
Subject: [PATCH 01/17] Improved editor preview layout and controls (#31090)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Refines the React editor preview so the controls stay centered and the
content uses the available space.
Fixes [PLA-459 — Preview
refinements](https://linear.app/ghost/issue/PLA-459/preview-refinements).
- Centers pill-shaped format/device controls and ghost audience
selectors with dropdown chevrons when space allows. Controls shift
toward the title as space narrows; device controls hide below 800px, and
the title and audience selectors hide below 640px, keeping format tabs
and actions on one row.
- Makes desktop Web previews fill the area below the header without
gutters.
- Groups Copy preview link and Open in new tab in an icon-only Share
menu; Close uses ghost styling.
- Limits desktop Email previews to 720px on a light grey canvas, using
the sidebar corner radius and mobile preview shadow. Both Web and Email
mobile previews use the same light grey canvas.
- Keeps long newsletter and tier labels clear of the actions, and the
top of mobile previews accessible on small screens.
- Preserves centered loading and error states while mobile frames align
to the top.
- Refines Shade Select spacing globally: removes the extra chevron left
margin and reduces right padding from 12px to 8px when a chevron is
visible.
- Makes the desktop email subject editable, saving through the editor
session on blur or Enter with title fallback and a 300-character limit.
Sender and subject controls align in shared columns at a local 28px
height, with the subject outline always visible. Test sending waits for
saved edits; failed saves preserve the entered subject. If an invalid
subject blocks reopening preview, the failure screen keeps it editable
so it can be corrected without losing other edits.
Validation: 43 preview browser tests, 64 editor header/history browser
tests, 15 preview URL unit tests, 531 editor session/tracker/field unit
tests, Admin typecheck, focused lint, and commit hooks passed. Visually
checked Web/Email previews and Share controls at desktop widths, 640px,
and 390px mobile. Shade typecheck, lint, and all 284 unit tests also
pass; Select spacing was checked in Storybook and the editor preview.
Existing role permissions, audience links, and save-before-preview
behavior remain covered.
- [x] I've read and followed the [Contributor
Guide](https://github.com/TryGhost/Ghost/blob/main/.github/CONTRIBUTING.md)
- [x] I've explained my change
- [x] I've written an automated test to prove my change works
---
.../src/editor/editor-header-actions.tsx | 8 +
.../editor/editor-header.acceptance.test.tsx | 148 +++++++++
.../src/editor/engine/change-tracker.test.ts | 1 +
.../admin/src/editor/engine/change-tracker.ts | 2 +
apps/admin/src/editor/fullscreen-dialog.tsx | 19 +-
apps/admin/src/editor/preview/README.md | 30 +-
.../src/editor/preview/browser-preview.tsx | 13 +-
.../src/editor/preview/email-preview.tsx | 103 +++---
.../src/editor/preview/email-subject.tsx | 79 +++++
.../post-preview-modal.component.test.tsx | 203 +++++++++++-
.../src/editor/preview/post-preview-modal.tsx | 294 +++++++++++-------
.../src/editor/preview/preview.screen.ts | 8 +-
.../src/editor/preview/send-test-email.tsx | 2 +-
apps/admin/src/editor/session/README.md | 2 +-
.../src/editor/session/projection.test.ts | 37 +++
apps/admin/src/editor/session/projection.ts | 11 +
.../editor/session/settings-fields.test.ts | 8 +
.../src/editor/session/settings-fields.ts | 7 +-
.../settings/use-settings-field.test.ts | 1 +
.../src/components/ui/select.stories.tsx | 3 +-
apps/shade/src/components/ui/select.tsx | 3 +-
.../testing/test-data/src/selectors/editor.ts | 2 +
22 files changed, 806 insertions(+), 178 deletions(-)
create mode 100644 apps/admin/src/editor/preview/email-subject.tsx
create mode 100644 apps/admin/src/editor/session/projection.test.ts
diff --git a/apps/admin/src/editor/editor-header-actions.tsx b/apps/admin/src/editor/editor-header-actions.tsx
index 824f28d341a..e3441871434 100644
--- a/apps/admin/src/editor/editor-header-actions.tsx
+++ b/apps/admin/src/editor/editor-header-actions.tsx
@@ -113,6 +113,14 @@ export function EditorHeaderActions({
}
const preview: HeaderPreviewProps = {
+ subjectEditor: {
+ value: session.settings.email_subject,
+ fallback: session.title,
+ hasUnsavedChanges: session.isDirty(),
+ isSaving,
+ onChange: (value) => session.stageSettings({ email_subject: value }),
+ onSave: saveBeforePreview,
+ },
isPost: postType === 'post',
newsletterSlug: post.newsletter ?? undefined,
open: previewOpen,
diff --git a/apps/admin/src/editor/editor-header.acceptance.test.tsx b/apps/admin/src/editor/editor-header.acceptance.test.tsx
index 4612a923f9a..50133312f56 100644
--- a/apps/admin/src/editor/editor-header.acceptance.test.tsx
+++ b/apps/admin/src/editor/editor-header.acceptance.test.tsx
@@ -378,6 +378,154 @@ describe('Editor header actions', () => {
await expect(previewScreen.modal()).toHaveCount(0);
});
+ it.each(['Enter', 'Tab'])(
+ 'saves the email subject from preview on %s and keeps it when reopened',
+ async (key) => {
+ publishChrome({ newsletters: 1 });
+ const saveApi = fakeSavablePost({ email_subject: null });
+ fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, {
+ email_previews: [
+ { subject: 'Hello from React', html: 'Email body
', plaintext: 'Email body' },
+ ],
+ });
+ await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON);
+ await editorScreen.previewButton().click();
+ await previewScreen.emailTab().click();
+ await expect.element(previewScreen.emailSubject()).toHaveValue('Hello from React');
+
+ await previewScreen.emailSubject().fill('A custom email subject');
+ await expect.element(previewScreen.testEmailButton()).toBeDisabled();
+ await userEvent.keyboard(`{${key}}`);
+ await expect.poll(() => submittedPost(saveApi)?.email_subject).toBe('A custom email subject');
+ await expect.element(previewScreen.testEmailButton()).toBeEnabled();
+ await previewScreen.closeButton().click();
+ await editorScreen.previewButton().click();
+ await previewScreen.emailTab().click();
+ await expect.element(previewScreen.emailSubject()).toHaveValue('A custom email subject');
+
+ await previewScreen.emailSubject().fill('');
+ await userEvent.keyboard('{Tab}');
+ await expect.poll(() => saveApi.requests.length).toBe(2);
+ expect(submittedPost(saveApi, 1)).toMatchObject({ email_subject: '' });
+ await expect
+ .element(previewScreen.emailSubject())
+ .toHaveAttribute('placeholder', 'Hello from React');
+ },
+ );
+
+ it('keeps an invalid email subject editable without saving or enabling test sends', async () => {
+ publishChrome({ newsletters: 1 });
+ const saveApi = fakeSavablePost();
+ fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, {
+ email_previews: [
+ { subject: 'Hello from React', html: 'Email body
', plaintext: 'Email body' },
+ ],
+ });
+ await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON);
+ await editorScreen.previewButton().click();
+ await previewScreen.emailTab().click();
+ await previewScreen.emailSubject().fill('a'.repeat(301));
+ await userEvent.keyboard('{Enter}');
+ await expect.element(previewScreen.emailSubject()).toHaveAttribute('aria-invalid', 'true');
+ await expect
+ .element(page.getByRole('alert'))
+ .toHaveTextContent('Email subject cannot be longer than 300 characters.');
+ await expect.element(previewScreen.testEmailButton()).toBeDisabled();
+ expect(saveApi.requests).toHaveLength(0);
+
+ await previewScreen.emailSubject().fill('a'.repeat(300));
+ await userEvent.keyboard('{Enter}');
+ await expect.poll(() => submittedPost(saveApi)?.email_subject).toBe('a'.repeat(300));
+ await expect.element(previewScreen.testEmailButton()).toBeEnabled();
+ });
+
+ it.each(['Close', 'Escape'])(
+ 'recovers an invalid subject after leaving preview with %s',
+ async (dismiss) => {
+ publishChrome({ newsletters: 1 });
+ const saveApi = fakeSavablePost();
+ fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, {
+ email_previews: [
+ { subject: 'Hello from React', html: 'Email body
', plaintext: 'Email body' },
+ ],
+ });
+ await renderAdminApp(`/editor/post/${POST_ID}`, withoutAutosave(MAILGUN_ON));
+ await editorScreen.previewButton().click();
+ await previewScreen.emailTab().click();
+ await previewScreen.emailSubject().fill('a'.repeat(301));
+ if (dismiss === 'Close') {
+ await previewScreen.closeButton().click();
+ } else {
+ await userEvent.keyboard('{Escape}');
+ }
+ await expect(previewScreen.modal()).toHaveCount(0);
+ await editorScreen.titleInput().fill('Keep this title edit');
+ await editorScreen.previewButton().click();
+ await expect.element(previewScreen.saveFailed()).toBeVisible();
+ await expect(previewScreen.browserFrame()).toHaveCount(0);
+ await expect(previewScreen.emailFrame()).toHaveCount(0);
+ await expect.element(previewScreen.shareButton()).toBeDisabled();
+ await expect.element(previewScreen.emailSubject()).toHaveValue('a'.repeat(301));
+ await expect.element(previewScreen.emailSubject()).toHaveAttribute('aria-invalid', 'true');
+ expect(saveApi.requests).toHaveLength(0);
+
+ await previewScreen.emailSubject().fill('A corrected subject');
+ await userEvent.keyboard('{Enter}');
+ await expect(saveApi).toHaveSavedFields({
+ email_subject: 'A corrected subject',
+ title: 'Keep this title edit',
+ });
+ await expect(previewScreen.saveFailed()).toHaveCount(0);
+ await expect.element(previewScreen.emailFrame()).toBeVisible();
+ await expect.element(previewScreen.testEmailButton()).toBeEnabled();
+ await expect.element(previewScreen.shareButton()).toBeEnabled();
+ },
+ );
+
+ it('retains the subject and blocks test sends when saving fails', async () => {
+ publishChrome({ newsletters: 1 });
+ const saveApi = fakeSavablePost({}, { failWith: 422 });
+ fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, {
+ email_previews: [
+ { subject: 'Hello from React', html: 'Email body
', plaintext: 'Email body' },
+ ],
+ });
+ await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON);
+ await editorScreen.previewButton().click();
+ await previewScreen.emailTab().click();
+ await previewScreen.emailSubject().fill('Keep this subject');
+ await userEvent.keyboard('{Enter}');
+ await expect.poll(() => saveApi.requests.length).toBe(1);
+ await expect.element(previewScreen.emailSubject()).toHaveAttribute('aria-invalid', 'true');
+ await expect.element(previewScreen.emailSubject()).toHaveValue('Keep this subject');
+ await expect.element(previewScreen.testEmailButton()).toBeDisabled();
+ });
+
+ it('keeps a newer subject while an earlier subject save is pending', async () => {
+ publishChrome({ newsletters: 1 });
+ const held = deferred();
+ const saveApi = fakeSavablePost({}, { holdFirstSave: held.promise });
+ fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, {
+ email_previews: [
+ { subject: 'Hello from React', html: 'Email body
', plaintext: 'Email body' },
+ ],
+ });
+ await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON);
+ await editorScreen.previewButton().click();
+ await previewScreen.emailTab().click();
+ await previewScreen.emailSubject().fill('First subject');
+ await userEvent.keyboard('{Enter}');
+ await expect.poll(() => saveApi.requests.length).toBe(1);
+ await previewScreen.emailSubject().fill('Newer subject');
+ await userEvent.keyboard('{Enter}');
+ await expect.element(previewScreen.testEmailButton()).toBeDisabled();
+ held.resolve();
+ await expect.poll(() => saveApi.requests.length).toBe(2);
+ expect(submittedPost(saveApi, 1)).toMatchObject({ email_subject: 'Newer subject' });
+ await expect.element(previewScreen.emailSubject()).toHaveValue('Newer subject');
+ await expect.element(previewScreen.testEmailButton()).toBeEnabled();
+ });
+
it('keeps the failure in the publish flow and sends nothing more', async () => {
publishChrome();
const saveApi = fakeSavablePost({}, { failWith: 422 });
diff --git a/apps/admin/src/editor/engine/change-tracker.test.ts b/apps/admin/src/editor/engine/change-tracker.test.ts
index 819d17c48b6..f8bd5a0c77c 100644
--- a/apps/admin/src/editor/engine/change-tracker.test.ts
+++ b/apps/admin/src/editor/engine/change-tracker.test.ts
@@ -74,6 +74,7 @@ function post(overrides: Partial = {}): EditablePostProj
visibility: 'public',
tiers: [],
authors: [{ id: 'author-1' }],
+ email_subject: null,
meta_title: null,
meta_description: null,
canonical_url: null,
diff --git a/apps/admin/src/editor/engine/change-tracker.ts b/apps/admin/src/editor/engine/change-tracker.ts
index 5cf49cc53d9..556c0af53dc 100644
--- a/apps/admin/src/editor/engine/change-tracker.ts
+++ b/apps/admin/src/editor/engine/change-tracker.ts
@@ -39,6 +39,7 @@ export interface EditablePostProjection {
lexical: string | null;
tags: ReadonlyArray;
custom_excerpt: string | null;
+ email_subject: string | null;
feature_image: string | null;
feature_image_alt: string | null;
feature_image_caption: string | null;
@@ -117,6 +118,7 @@ const PROJECTION_KEYS: ReadonlyArray = [
'lexical',
'tags',
'custom_excerpt',
+ 'email_subject',
'feature_image',
'feature_image_alt',
'feature_image_caption',
diff --git a/apps/admin/src/editor/fullscreen-dialog.tsx b/apps/admin/src/editor/fullscreen-dialog.tsx
index d2da9d8890c..0bc0fd50116 100644
--- a/apps/admin/src/editor/fullscreen-dialog.tsx
+++ b/apps/admin/src/editor/fullscreen-dialog.tsx
@@ -1,4 +1,5 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@tryghost/shade/components';
+import { Box } from '@tryghost/shade/primitives';
import { cn } from '@tryghost/shade/utils';
import { useLayoutEffect, useRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
@@ -24,6 +25,8 @@ export interface FullscreenDialogProps extends Omit
layout?: keyof typeof LAYOUTS;
/** Controls rendered in the header row beside the title. */
headerActions?: ReactNode;
+ /** View controls centered between the title and actions on wide screens. */
+ headerControls?: ReactNode;
}
/**
@@ -37,6 +40,7 @@ export function FullscreenDialog({
title,
layout = 'plain',
headerActions,
+ headerControls,
className,
children,
onCloseAutoFocus,
@@ -79,9 +83,18 @@ export function FullscreenDialog({
{...props}
>
{layout === 'header' ? (
-
- {title}
- {headerActions}
+
+
+ {title}
+
+ {headerControls && {headerControls} }
+ {headerActions}
) : (
{title}
diff --git a/apps/admin/src/editor/preview/README.md b/apps/admin/src/editor/preview/README.md
index bdfe27042be..525c5602642 100644
--- a/apps/admin/src/editor/preview/README.md
+++ b/apps/admin/src/editor/preview/README.md
@@ -9,11 +9,26 @@
| `previewUrl` | The post's public preview URL; empty until the post has a uuid |
| `isPost` | Pages have no email preview |
| `newsletterSlug` | The post's own newsletter, preselected in the email preview |
+| `subjectEditor` | Live subject, title fallback, save state, and session callbacks for editing the subject |
| `onBeforeOpen` | Awaited before the preview renders, so the caller can save the draft it previews |
| `onPublish` | Renders a Publish button; supplied for every user who can publish |
| `publishDisabled` | Keeps the Publish button rendered but disabled while the caller cannot open its publish flow |
-The modal never writes to the post. `onBeforeOpen` exists because a draft must be persisted before the site or the email renderer can see the latest content; what that means — dirty checks, a save in flight — belongs to the caller.
+The modal delegates subject edits and saves through the optional `subjectEditor` port to the editor session. `onBeforeOpen` exists because a draft must be persisted before the site or the email renderer can see the latest content; what that means — dirty checks, a save in flight — belongs to the caller.
+
+## Layout and controls
+
+View controls are centered in the header when space allows and shift toward the title
+as the screen narrows. Device controls hide below 800px. Below 640px, the title and audience selectors
+are hidden so format tabs and actions stay on one row. Format and device controls use pill groups; audience
+and tier selectors use the ghost header treatment. The icon-only Share menu keeps
+copying the audience-specific preview link and opening it in a new tab together,
+and stays disabled until the post has been saved successfully.
+
+Desktop Web previews fill the space below the header without gutters or device
+chrome. Desktop Email previews are centered at a maximum width of 720px on a
+muted canvas, with the sidebar's corner radius and the mobile frame's shadow.
+Mobile previews retain their phone frame on the same muted canvas in either format.
## Audience
@@ -38,6 +53,17 @@ The newsletters offered are the site's active ones, read from the same full brow
Switching newsletters re-renders the preview against that newsletter, and the test send goes to exactly one address — the current user's, unless it is edited — for the audience currently selected.
+The sender and subject controls share a label column and a local 28px height;
+the subject input keeps its visible outline. The desktop subject field stages edits
+in the session and saves on blur or Enter.
+An empty subject falls back to the post title. The session enforces the 300-character
+limit on every save; validation and save errors appear beside the field. Test sending
+stays disabled while edits are unsaved or a save is pending. The mobile frame displays
+the live subject as text, matching Ember. Closing preview preserves unsaved subject
+edits. If those edits prevent saving when preview reopens, the save-failure screen
+keeps the subject field available for correction. Saving the corrected subject retries
+preparation before displaying the preview or enabling sharing and test sends.
+
## Not here yet
-Known gaps, listed so they are not mistaken for decisions: the email subject is read-only (editing it would write to the post), there is no over-100kB "may get clipped" warning, an Escape pressed inside the site preview frame does not close the modal, an already-sent post is re-rendered by the preview endpoint rather than showing its stored email, and the sender address does not apply the managed-email override.
+Known gaps, listed so they are not mistaken for decisions: there is no over-100kB "may get clipped" warning, an Escape pressed inside the site preview frame does not close the modal, an already-sent post is re-rendered by the preview endpoint rather than showing its stored email, and the sender address does not apply the managed-email override.
diff --git a/apps/admin/src/editor/preview/browser-preview.tsx b/apps/admin/src/editor/preview/browser-preview.tsx
index 67dbaec0e55..5d1e686b024 100644
--- a/apps/admin/src/editor/preview/browser-preview.tsx
+++ b/apps/admin/src/editor/preview/browser-preview.tsx
@@ -1,4 +1,5 @@
import { EmptyIndicator, PreviewChrome } from '@tryghost/shade/components';
+import { Box } from '@tryghost/shade/primitives';
import { LucideIcon } from '@tryghost/shade/utils';
import {
postPreviewBrowser,
@@ -19,7 +20,7 @@ export function BrowserPreview({ previewUrl, audience, device }: BrowserPreviewP
if (!previewUrl) {
return (
+
-
+
);
}
diff --git a/apps/admin/src/editor/preview/email-preview.tsx b/apps/admin/src/editor/preview/email-preview.tsx
index de29a97d01d..d52a11e3114 100644
--- a/apps/admin/src/editor/preview/email-preview.tsx
+++ b/apps/admin/src/editor/preview/email-preview.tsx
@@ -9,7 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from '@tryghost/shade/components';
-import { Inline, Stack } from '@tryghost/shade/primitives';
+import { Box, Grid, Inline, Stack } from '@tryghost/shade/primitives';
import { LucideIcon } from '@tryghost/shade/utils';
import { getSettingValues } from '@tryghost/admin-x-framework/api/settings';
import { useEmailPreview } from '@tryghost/admin-x-framework/api/email-previews';
@@ -26,6 +26,7 @@ import {
import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options';
import { useEditorSettings } from '@/editor/use-editor-settings';
import { SendTestEmail } from './send-test-email';
+import { EmailSubject, type EmailSubjectEditor } from './email-subject';
import {
audienceDescription,
emailPreviewAudience,
@@ -62,6 +63,7 @@ function withPreviewDocumentStyles(html: string): string {
}
interface EmailPreviewProps {
+ subjectEditor?: EmailSubjectEditor;
postId: string;
audience: PreviewAudience;
/** The selected tier's name, for the test-email audience description. */
@@ -81,6 +83,7 @@ interface EmailPreviewProps {
}
export function EmailPreview({
+ subjectEditor,
postId,
audience,
tierName,
@@ -112,12 +115,27 @@ export function EmailPreview({
const selectedNewsletter = newsletters.find((newsletter) => newsletter.slug === newsletterSlug);
const senderAddress = (sender: string | null) => sender ?? defaultEmailAddress ?? '';
+ const Frame = device === 'mobile' ? PreviewChrome : Box;
+
return (
-
+
-
-
-
+
+ From
+
+
{newsletterLookupPending ? (
) : newsletterLookupError ? (
@@ -131,50 +149,61 @@ export function EmailPreview({
>
This newsletter no longer exists
+ ) : newsletters.length > 1 ? (
+
+ `}
+ >
+
+
+
+ {newsletters.map((newsletter) => (
+
+ {newsletter.name} <{senderAddress(newsletter.sender_email)}>
+
+ ))}
+
+
) : (
- <>
- From
- {newsletters.length > 1 ? (
-
-
-
-
-
- {newsletters.map((newsletter) => (
-
- {newsletter.name} <{senderAddress(newsletter.sender_email)}>
-
- ))}
-
-
- ) : (
-
- {selectedNewsletter?.name}{' '}
-
- <{senderAddress(selectedNewsletter?.sender_email ?? null)}>
-
-
- )}
- >
+
+ {selectedNewsletter?.name}{' '}
+
+ <{senderAddress(selectedNewsletter?.sender_email ?? null)}>
+
+
)}
-
+
{canSendTestEmail && (
)}
-
- Subject
+
+ Subject
+
+ {subjectEditor && device === 'desktop' ? (
+
+ ) : (
- {!isFetching && preview?.subject}
+ {subjectEditor
+ ? subjectEditor.value || subjectEditor.fallback
+ : !isFetching && preview?.subject}
-
-
+ )}
+
{newsletterLookupPending || isFetching ? (
@@ -224,6 +253,6 @@ export function EmailPreview({
/>
)}
-
+
);
}
diff --git a/apps/admin/src/editor/preview/email-subject.tsx b/apps/admin/src/editor/preview/email-subject.tsx
new file mode 100644
index 00000000000..5e1218a3071
--- /dev/null
+++ b/apps/admin/src/editor/preview/email-subject.tsx
@@ -0,0 +1,79 @@
+import { useId, useRef, useState } from 'react';
+import { Input } from '@tryghost/shade/components';
+import { Stack } from '@tryghost/shade/primitives';
+import { postPreviewEmailSubject } from '@tryghost/test-data/selectors/editor';
+import {
+ EMAIL_SUBJECT_MAX,
+ EMAIL_SUBJECT_TOO_LONG,
+ overLength,
+} from '@/editor/session/settings-fields';
+
+/** Subject edits belong to the editor session, including while a save is pending. */
+export interface EmailSubjectEditor {
+ value: string | null;
+ fallback: string;
+ hasUnsavedChanges: boolean;
+ isSaving: boolean;
+ onChange: (value: string) => void;
+ onSave: () => Promise;
+}
+
+export function EmailSubject({ editor }: { editor: EmailSubjectEditor }) {
+ const errorId = useId();
+ const [saveError, setSaveError] = useState(null);
+ const editVersion = useRef(0);
+ const validationError = overLength(editor.value, EMAIL_SUBJECT_MAX)
+ ? EMAIL_SUBJECT_TOO_LONG
+ : null;
+ const error = validationError ?? saveError;
+
+ const save = async () => {
+ if (validationError || !editor.hasUnsavedChanges) {
+ return;
+ }
+ setSaveError(null);
+ const version = editVersion.current;
+ try {
+ await editor.onSave();
+ } catch (saveFailure) {
+ if (version === editVersion.current) {
+ setSaveError(
+ saveFailure instanceof Error
+ ? saveFailure.message
+ : 'Couldn’t save the email subject. Try again.',
+ );
+ }
+ }
+ };
+
+ return (
+
+ void save()}
+ onChange={(event) => {
+ editVersion.current += 1;
+ setSaveError(null);
+ editor.onChange(event.target.value);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
+ event.preventDefault();
+ event.currentTarget.blur();
+ }
+ }}
+ />
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx b/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx
index b20d8441db7..2f0129289bd 100644
--- a/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx
+++ b/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { describe, expect, it, vi } from 'vitest';
+import { describe, expect, it, onTestFinished, vi } from 'vitest';
import { page } from 'vitest/browser';
import {
@@ -26,12 +26,25 @@ const POST_ID = 'abc123';
const PREVIEW_URL = 'http://localhost:2368/p/post-uuid/';
const CURRENT_USER_EMAIL = String(currentUserResponse().users[0].email);
+async function previewViewport(width: number, height: number) {
+ const initialViewport = { width: window.innerWidth, height: window.innerHeight };
+ const initialFontSize = document.documentElement.style.fontSize;
+ // Embedded Admin uses a 10px rem base; match it when checking pixel breakpoints.
+ document.documentElement.style.fontSize = '10px';
+ onTestFinished(async () => {
+ document.documentElement.style.fontSize = initialFontSize;
+ await page.viewport(initialViewport.width, initialViewport.height);
+ });
+ await page.viewport(width, height);
+}
+
interface RenderOptions {
isPost?: boolean;
newsletterSlug?: string;
previewUrl?: string;
onBeforeOpen?: () => Promise;
onOpenChange?: (open: boolean) => void;
+ onPublish?: () => void;
}
async function renderPreviewModal({
@@ -40,6 +53,7 @@ async function renderPreviewModal({
previewUrl = PREVIEW_URL,
onBeforeOpen,
onOpenChange = () => {},
+ onPublish,
}: RenderOptions = {}) {
return await renderInApp(
,
);
}
@@ -173,14 +188,20 @@ describe('Post preview modal', () => {
await expect.element(previewScreen.modal()).toBeVisible();
await expect.poll(() => onBeforeOpen.mock.calls.length).toBe(1);
- await expect.element(previewScreen.copyLinkButton()).toBeDisabled();
+ await expect.element(previewScreen.shareButton()).toBeDisabled();
await expect(previewScreen.openInNewTabLink()).toHaveCount(0);
expect(frames.stop()).toEqual([]);
+ const status = previewScreen.preparingStatus().element();
+ const bounds = status.getBoundingClientRect();
+ const canvas = status.parentElement!.getBoundingClientRect();
+ expect((bounds.top + bounds.bottom) / 2).toBeCloseTo((canvas.top + canvas.bottom) / 2, 0);
+ expect((bounds.left + bounds.right) / 2).toBeCloseTo((canvas.left + canvas.right) / 2, 0);
releaseSave();
await expect.element(previewScreen.browserFrame()).toBeVisible();
- await expect.element(previewScreen.copyLinkButton()).toBeEnabled();
+ await expect.element(previewScreen.shareButton()).toBeEnabled();
+ await previewScreen.shareButton().click();
await expect.element(previewScreen.openInNewTabLink()).toBeVisible();
});
@@ -217,7 +238,7 @@ describe('Post preview modal', () => {
await expect.element(previewScreen.saveFailed()).toBeVisible();
await expect(previewScreen.browserFrame()).toHaveCount(0);
- await expect.element(previewScreen.copyLinkButton()).toBeDisabled();
+ await expect.element(previewScreen.shareButton()).toBeDisabled();
await expect(previewScreen.openInNewTabLink()).toHaveCount(0);
expect(onBeforeOpen).toHaveBeenCalledTimes(1);
@@ -284,15 +305,182 @@ describe('Post preview modal', () => {
await expect(previewScreen.option('Specific tier')).toHaveCount(0);
});
- it('switches the frame to a mobile viewport', async () => {
+ it.each([1183, 1440])(
+ 'centers the view controls and fills the desktop viewport at %ipx',
+ async (width) => {
+ await previewViewport(width, 900);
+ fakePreviewWorld();
+ await renderPreviewModal({ onPublish: () => {} });
+ await expect.element(previewScreen.browserFrame()).toBeVisible();
+
+ const modal = previewScreen.modal().element().getBoundingClientRect();
+ const frame = previewScreen.browserFrame().element().getBoundingClientRect();
+ const controls = previewScreen
+ .segmentSelect()
+ .element()
+ .parentElement!.getBoundingClientRect();
+ const title = page
+ .getByRole('heading', { name: 'Preview', exact: true })
+ .element()
+ .getBoundingClientRect();
+ const close = previewScreen.closeButton().element().getBoundingClientRect();
+ expect((controls.top + controls.bottom) / 2).toBeCloseTo((title.top + title.bottom) / 2, 0);
+ expect((controls.top + controls.bottom) / 2).toBeCloseTo((close.top + close.bottom) / 2, 0);
+ expect(frame.left).toBeCloseTo(modal.left, 0);
+ expect(frame.right).toBeCloseTo(modal.right, 0);
+ expect(frame.bottom).toBeCloseTo(modal.bottom, 0);
+ expect((controls.left + controls.right) / 2).toBeCloseTo((modal.left + modal.right) / 2, 0);
+ },
+ );
+
+ it('keeps format and actions on one row and hides secondary controls on phones', async () => {
+ await previewViewport(390, 844);
+ fakePreviewWorld();
+ fakeEmailPreview();
+ await renderPreviewModal({ onPublish: () => {} });
+
+ await expect.element(previewScreen.webTab()).toBeVisible();
+ await expect(previewScreen.segmentSelect()).toHaveCount(0);
+ await expect(previewScreen.mobileToggle()).toHaveCount(0);
+ const modal = previewScreen.modal().element();
+ expect(modal.scrollWidth).toBeLessThanOrEqual(modal.clientWidth);
+ const tabs = previewScreen.webTab().element().parentElement!.getBoundingClientRect();
+ const actions = previewScreen.shareButton().element().getBoundingClientRect();
+ expect(tabs.right).toBeLessThanOrEqual(actions.left);
+ expect((tabs.top + tabs.bottom) / 2).toBeCloseTo((actions.top + actions.bottom) / 2, 0);
+ await previewScreen.emailTab().click();
+ await expect.element(previewScreen.emailTab()).toHaveAttribute('aria-selected', 'true');
+ });
+
+ it.each([1440, 1183, 1024, 800, 640])(
+ 'keeps a long tier name clear of header actions at %ipx',
+ async (width) => {
+ await previewViewport(width, 844);
+ const name = 'Premium annual membership for independent publishers and supporters';
+ fakePreviewWorld({ tiers: [tier({ name, slug: 'premium' })] });
+ await renderInApp(
+ {}}
+ onPublish={() => {}}
+ />,
+ );
+ await previewScreen.previewAs('Specific tier');
+ await expect.element(previewScreen.tierSelect()).toHaveTextContent(name);
+
+ const modal = previewScreen.modal().element();
+ expect(modal.scrollWidth).toBeLessThanOrEqual(modal.clientWidth);
+ const controls = [
+ previewScreen.webTab(),
+ previewScreen.emailTab(),
+ ...(width >= 800 ? [previewScreen.desktopToggle(), previewScreen.mobileToggle()] : []),
+ previewScreen.segmentSelect(),
+ previewScreen.tierSelect(),
+ ];
+ const actions = [
+ previewScreen.shareButton(),
+ previewScreen.closeButton(),
+ previewScreen.publishButton(),
+ ];
+ for (const control of controls) {
+ const bounds = control.element().getBoundingClientRect();
+ expect(bounds.left).toBeGreaterThanOrEqual(0);
+ expect(bounds.right).toBeLessThanOrEqual(width);
+ for (const action of actions) {
+ const other = action.element().getBoundingClientRect();
+ const overlaps =
+ bounds.left < other.right &&
+ bounds.right > other.left &&
+ bounds.top < other.bottom &&
+ bounds.bottom > other.top;
+ expect(overlaps).toBe(false);
+ }
+ }
+ if (width === 1440) {
+ const group = previewScreen.tierSelect().element().parentElement!.getBoundingClientRect();
+ expect((group.left + group.right) / 2).toBeCloseTo(width / 2, 0);
+ }
+ await previewScreen.tierSelect().click();
+ await expect.element(previewScreen.option(name)).toBeVisible();
+ },
+ );
+
+ it('limits desktop emails to 720px on a muted canvas', async () => {
+ await previewViewport(1440, 900);
+ fakePreviewWorld();
+ fakeEmailPreview();
+ await renderPreviewModal();
+ await previewScreen.emailTab().click();
+ await expect.element(previewScreen.emailFrame()).toBeVisible();
+
+ const email = previewScreen.emailChrome().element();
+ expect(email.getBoundingClientRect().width).toBe(720);
+ expect(getComputedStyle(email.parentElement!).backgroundColor).not.toBe(
+ getComputedStyle(previewScreen.modal().element()).backgroundColor,
+ );
+ });
+
+ it('keeps long newsletter names clear of the test button on narrow screens', async () => {
+ await previewViewport(390, 844);
+ fakePreviewWorld({
+ newsletters: [
+ newsletter({
+ name: 'Weekly roundup',
+ slug: 'weekly-roundup',
+ sender_email: 'zimo@ghost.org',
+ }),
+ newsletter({ name: 'Monthly roundup', slug: 'monthly-roundup' }),
+ ],
+ });
+ fakeEmailPreview();
+ await renderPreviewModal();
+ await previewScreen.emailTab().click();
+ await expect.element(previewScreen.emailFrame()).toBeVisible();
+ await expect
+ .element(previewScreen.newsletterSelect())
+ .toHaveTextContent('Weekly roundup ');
+
+ const trigger = previewScreen.newsletterSelect().element();
+ const label = trigger.querySelector('span')!;
+ const testButton = previewScreen.testEmailButton().element();
+ expect(trigger.getBoundingClientRect().right).toBeLessThan(
+ testButton.getBoundingClientRect().left,
+ );
+ expect(label.getBoundingClientRect().right).toBeLessThanOrEqual(
+ trigger.getBoundingClientRect().right,
+ );
+ expect(label.scrollWidth).toBeGreaterThan(label.clientWidth);
+ expect(getComputedStyle(label).overflow).toBe('hidden');
+ expect(testButton.scrollWidth).toBeLessThanOrEqual(testButton.clientWidth);
+ expect(testButton.getBoundingClientRect().right).toBeLessThanOrEqual(390);
+
+ await previewScreen.newsletterSelect().click();
+ await expect.element(previewScreen.option('Weekly roundup ')).toBeVisible();
+ });
+
+ it('switches the frame to a mobile viewport without clipping its top', async () => {
+ await previewViewport(1183, 844);
fakePreviewWorld();
await renderPreviewModal();
await expect.element(previewScreen.browserChrome()).not.toHaveClass('w-[380px]');
await previewScreen.mobileToggle().click();
+ await page.viewport(390, 844);
await expect.element(previewScreen.browserChrome()).toHaveClass('w-[380px]');
+ const chrome = previewScreen.browserChrome().element();
+ const canvas = chrome.parentElement!;
+ expect(chrome.getBoundingClientRect().top).toBeCloseTo(
+ canvas.getBoundingClientRect().top + parseFloat(getComputedStyle(canvas).paddingTop),
+ 0,
+ );
+ expect(canvas.scrollTop).toBe(0);
+ expect(chrome.getBoundingClientRect().bottom).toBeGreaterThan(
+ canvas.getBoundingClientRect().bottom,
+ );
});
it('has nothing to preview, copy or open before the post is first saved', async () => {
@@ -301,7 +489,7 @@ describe('Post preview modal', () => {
await expect.element(previewScreen.unavailable()).toBeVisible();
await expect(previewScreen.browserFrame()).toHaveCount(0);
- await expect.element(previewScreen.copyLinkButton()).toBeDisabled();
+ await expect.element(previewScreen.shareButton()).toBeDisabled();
await expect(previewScreen.openInNewTabLink()).toHaveCount(0);
});
@@ -311,11 +499,14 @@ describe('Post preview modal', () => {
await renderPreviewModal();
await previewScreen.previewAs('Paid member');
+ await previewScreen.shareButton().click();
+ await expect.element(previewScreen.openInNewTabLink()).toHaveAttribute('target', '_blank');
await previewScreen.copyLinkButton().click();
await expect
.poll(() => writeText.mock.calls.at(-1)?.[0])
.toBe(`${PREVIEW_URL}?member_status=paid`);
+ await previewScreen.shareButton().click();
await expect
.element(previewScreen.openInNewTabLink())
.toHaveAttribute('href', `${PREVIEW_URL}?member_status=paid`);
diff --git a/apps/admin/src/editor/preview/post-preview-modal.tsx b/apps/admin/src/editor/preview/post-preview-modal.tsx
index f2f50a0c0b1..dccabc5c3b8 100644
--- a/apps/admin/src/editor/preview/post-preview-modal.tsx
+++ b/apps/admin/src/editor/preview/post-preview-modal.tsx
@@ -1,6 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
EmptyIndicator,
LoadingIndicator,
Select,
@@ -8,7 +12,6 @@ import {
SelectGroup,
SelectItem,
SelectLabel,
- SelectTrigger,
SelectValue,
Tabs,
TabsList,
@@ -21,9 +24,9 @@ import {
useNewslettersEnabled,
usePaidMembersEnabled,
} from '@tryghost/admin-x-framework/api/settings';
-import { Inline } from '@tryghost/shade/primitives';
+import { Inline, Stack } from '@tryghost/shade/primitives';
import { PageHeader } from '@tryghost/shade/patterns';
-import { LucideIcon } from '@tryghost/shade/utils';
+import { cn, LucideIcon } from '@tryghost/shade/utils';
import { toast } from 'sonner';
import { useBrowseNewsletters } from '@tryghost/admin-x-framework/api/newsletters';
import { useHandleError } from '@tryghost/admin-x-framework/hooks';
@@ -43,6 +46,7 @@ import { useEditorSettings } from '@/editor/use-editor-settings';
import { FullscreenDialog } from '@/editor/fullscreen-dialog';
import { BrowserPreview } from './browser-preview';
import { EmailPreview } from './email-preview';
+import { EmailSubject, type EmailSubjectEditor } from './email-subject';
import {
browserPreviewUrl,
type PreviewAudience,
@@ -60,6 +64,7 @@ interface SegmentOption {
}
export interface PostPreviewModalProps {
+ subjectEditor?: EmailSubjectEditor;
open: boolean;
postId: string;
/** The post's public preview URL (`/p/:uuid/`), empty until the post has a uuid. */
@@ -78,6 +83,7 @@ export interface PostPreviewModalProps {
}
export function PostPreviewModal({
+ subjectEditor,
open,
postId,
previewUrl,
@@ -311,141 +317,188 @@ export function PostPreviewModal({
aria-describedby={undefined}
data-testid={postPreviewModal}
headerActions={
- <>
-
- {emailAvailable && (
- changeFormat(value as PreviewFormat)}
- >
-
- Web
- Email
-
-
- )}
- {
- if (value === 'desktop' || value === 'mobile') {
- setDevice(value);
- }
- }}
- >
-
-
-
-
-
-
-
- {showSegmentSelect && (
- setSegment(value as PreviewSegment)}
- >
-
-
-
-
- {segmentOptions.map((option) => (
-
- {option.label}
-
- ))}
-
-
- )}
- {showTierSelect && (
-
-
-
-
-
- {activeTiers.length > 0 && (
-
- Active tiers
- {activeTiers.map((tier) => (
-
- {tier.name}
-
- ))}
-
- )}
- {archivedTiers.length > 0 && (
-
- Archived tiers
- {archivedTiers.map((tier) => (
-
- {tier.name}
-
- ))}
-
- )}
-
-
- )}
-
-
- void copyPreviewLink()}
- >
-
-
- {previewActionsAvailable ? (
-
+
+
+
+
+
+
+
+
+ void copyPreviewLink()}>
+
+ Copy preview link
+
+
Open in new tab
-
- ) : (
-
-
- Open in new tab
-
- )}
- onOpenChange(false)}>
- Close
+
+
+
+ onOpenChange(false)}>
+ Close
+
+ {onPublish ? (
+
+ Publish
- {onPublish ? (
-
- Publish
-
- ) : null}
-
- >
+ ) : null}
+
+ }
+ headerControls={
+
+ {emailAvailable && (
+ changeFormat(value as PreviewFormat)}
+ >
+
+
+ Web
+
+
+ Email
+
+
+
+ )}
+ {
+ if (value === 'desktop' || value === 'mobile') {
+ setDevice(value);
+ }
+ }}
+ >
+
+
+
+
+
+
+
+ {showSegmentSelect && (
+ setSegment(value as PreviewSegment)}>
+
+
+
+
+ {segmentOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+ {showTierSelect && (
+
+
+
+
+
+ {activeTiers.length > 0 && (
+
+ Active tiers
+ {activeTiers.map((tier) => (
+
+ {tier.name}
+
+ ))}
+
+ )}
+ {archivedTiers.length > 0 && (
+
+ Archived tiers
+ {archivedTiers.map((tier) => (
+
+ {tier.name}
+
+ ))}
+
+ )}
+
+
+ )}
+
}
layout="header"
open={open}
title="Preview"
onOpenChange={onOpenChange}
>
-
+
{prepareState === 'preparing' ? (
-
+
) : prepareState === 'failed' ? (
{
- preparePromise.current = null;
- setPrepareState('preparing');
- }}
- >
- Retry
-
+
+ {emailAvailable && subjectEditor && (
+
+ Email subject
+ {
+ await subjectEditor.onSave();
+ preparePromise.current = null;
+ setPrepareState('preparing');
+ },
+ }}
+ />
+
+ )}
+ {
+ preparePromise.current = null;
+ setPrepareState('preparing');
+ }}
+ >
+ Retry
+
+
}
- className="grow justify-center"
+ className="grow justify-center self-center"
data-testid={postPreviewSaveFailed}
description="Saving the post failed, so there is nothing new to preview."
title="Couldn’t preview this post"
@@ -463,6 +516,7 @@ export function PostPreviewModal({
newsletters={newsletters}
newsletterSlug={selectedNewsletterSlug}
postId={postId}
+ subjectEditor={subjectEditor}
tierName={selectedTier?.name}
onNewsletterChange={setPickedNewsletterSlug}
onRetryNewsletterLookup={retryNewsletterLookup}
diff --git a/apps/admin/src/editor/preview/preview.screen.ts b/apps/admin/src/editor/preview/preview.screen.ts
index 440756b1bd9..576e2c98cba 100644
--- a/apps/admin/src/editor/preview/preview.screen.ts
+++ b/apps/admin/src/editor/preview/preview.screen.ts
@@ -1,6 +1,8 @@
import { page } from 'vitest/browser';
import {
closePreviewButton,
+ preparingPreviewLabel,
+ sharePreviewButton,
copyPreviewLinkButton,
editorPublishButton,
desktopPreviewToggle,
@@ -38,8 +40,10 @@ export const previewScreen = {
tierSelect: () => page.getByRole('combobox', { name: previewTierSelectLabel }),
newsletterSelect: () => page.getByRole('combobox', { name: previewNewsletterSelectLabel }),
option: (name: string) => page.getByRole('option', { name }),
- copyLinkButton: () => page.getByRole('button', { name: copyPreviewLinkButton }),
- openInNewTabLink: () => page.getByRole('link', { name: openPreviewInNewTabLink }),
+ preparingStatus: () => page.getByRole('status', { name: preparingPreviewLabel }),
+ shareButton: () => page.getByRole('button', { name: sharePreviewButton, exact: true }),
+ copyLinkButton: () => page.getByRole('menuitem', { name: copyPreviewLinkButton }),
+ openInNewTabLink: () => page.getByRole('menuitem', { name: openPreviewInNewTabLink }),
closeButton: () =>
page.getByTestId(postPreviewModal).getByRole('button', { name: closePreviewButton }),
publishButton: () =>
diff --git a/apps/admin/src/editor/preview/send-test-email.tsx b/apps/admin/src/editor/preview/send-test-email.tsx
index c986d7165ff..a7b9c3b631d 100644
--- a/apps/admin/src/editor/preview/send-test-email.tsx
+++ b/apps/admin/src/editor/preview/send-test-email.tsx
@@ -88,7 +88,7 @@ export function SendTestEmail({
return (
-
+
Test
diff --git a/apps/admin/src/editor/session/README.md b/apps/admin/src/editor/session/README.md
index d8b6165ed2e..b81dbc77c88 100644
--- a/apps/admin/src/editor/session/README.md
+++ b/apps/admin/src/editor/session/README.md
@@ -3,7 +3,7 @@
`apps/admin/src/editor/session/` composes the three modules described in
[the engine README](../engine/README.md) — the save engine, the change tracker
and the slug machine — into one editing session, and is the editor's only
-writer. Every title, excerpt, body, feature-image and settings change goes
+writer. Every title, excerpt, body, feature-image, email-subject and settings change goes
through it, and it owns everything those three modules deliberately do not:
what a save sends and what an acknowledgement may change. The save engine owns
pending work and scheduling.
diff --git a/apps/admin/src/editor/session/projection.test.ts b/apps/admin/src/editor/session/projection.test.ts
new file mode 100644
index 00000000000..91f62e2a027
--- /dev/null
+++ b/apps/admin/src/editor/session/projection.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import { record } from '@/editor/session/__test-utils__/session-harness';
+import { projectionOf } from '@/editor/session/projection';
+
+describe('projectionOf email subject boundary', () => {
+ it.each(['A custom subject', '', null, undefined])(
+ 'preserves supported subject values (%s)',
+ (value) => {
+ expect(projectionOf(record({ email_subject: value })).email_subject).toBe(value ?? null);
+ },
+ );
+
+ it('supports responses without the optional subject field', () => {
+ const response = record();
+ Reflect.deleteProperty(response, 'email_subject');
+
+ expect(projectionOf(response).email_subject).toBeNull();
+ });
+
+ it.each([
+ { value: 42 },
+ { value: false },
+ { value: {} },
+ { value: [] },
+ { value: ['Unexpected array subject'] },
+ ])('uses the title fallback for malformed API subject metadata: $value', ({ value }) => {
+ const response = record({ title: 'Original title' });
+ // Deliberately violate the compile-time API type, as a malformed JSON response can.
+ Reflect.set(response, 'email_subject', value);
+
+ expect(projectionOf(response)).toMatchObject({
+ email_subject: null,
+ title: 'Original title',
+ lexical: response.lexical,
+ });
+ });
+});
diff --git a/apps/admin/src/editor/session/projection.ts b/apps/admin/src/editor/session/projection.ts
index c242fed9163..b4012388266 100644
--- a/apps/admin/src/editor/session/projection.ts
+++ b/apps/admin/src/editor/session/projection.ts
@@ -1,3 +1,4 @@
+import { z } from 'zod';
import type { PageEditorRecord } from '@tryghost/admin-x-framework/api/pages';
import type { PostEditorRecord } from '@tryghost/admin-x-framework/api/posts';
import { parsePostRevisions, revisionTime } from '@/editor/post-revisions';
@@ -5,6 +6,8 @@ import type { EditablePostProjection, RevisionProjection } from '@/editor/engine
export type EditorRecord = PostEditorRecord | PageEditorRecord;
+const emailSubjectSchema = z.string().nullish();
+
/** A post the writer has not saved yet, credited to whoever is creating it. */
export function newPostProjection(currentUserId?: string): EditablePostProjection {
return {
@@ -13,6 +16,7 @@ export function newPostProjection(currentUserId?: string): EditablePostProjectio
lexical: null,
tags: [],
custom_excerpt: null,
+ email_subject: null,
feature_image: null,
feature_image_alt: null,
feature_image_caption: null,
@@ -38,12 +42,19 @@ export function newPostProjection(currentUserId?: string): EditablePostProjectio
}
export function projectionOf(record: EditorRecord): EditablePostProjection {
+ // API hook types do not validate runtime responses. Malformed optional subject
+ // metadata uses the same title fallback as an absent subject.
+ const emailSubject = emailSubjectSchema.safeParse(
+ 'email_subject' in record ? record.email_subject : undefined,
+ );
+
return {
title: record.title,
slug: record.slug,
lexical: record.lexical ?? null,
tags: record.tags ?? [],
custom_excerpt: record.custom_excerpt ?? null,
+ email_subject: emailSubject.success ? (emailSubject.data ?? null) : null,
feature_image: record.feature_image ?? null,
feature_image_alt: record.feature_image_alt ?? null,
feature_image_caption: record.feature_image_caption ?? null,
diff --git a/apps/admin/src/editor/session/settings-fields.test.ts b/apps/admin/src/editor/session/settings-fields.test.ts
index 7ecf9674e5f..364542b1623 100644
--- a/apps/admin/src/editor/session/settings-fields.test.ts
+++ b/apps/admin/src/editor/session/settings-fields.test.ts
@@ -26,6 +26,7 @@ import {
const VALID = {
visibility: 'public',
tiers: [],
+ email_subject: null,
meta_title: null,
meta_description: null,
canonical_url: null,
@@ -67,6 +68,13 @@ describe('validatedFieldsOf', () => {
});
describe('settingsFieldError', () => {
+ it('validates the email subject on every save, counting Unicode characters', () => {
+ expect(settingsFieldError({ ...VALID, email_subject: '𝔘'.repeat(300) }, false)).toBeNull();
+ expect(settingsFieldError({ ...VALID, email_subject: '𝔘'.repeat(301) }, false)).toBe(
+ 'Email subject cannot be longer than 300 characters.',
+ );
+ expect(settingsFieldError({ ...VALID, email_subject: '' }, false)).toBeNull();
+ });
it('passes fields that break no rule', () => {
expect(settingsFieldError(VALID, false)).toBeNull();
});
diff --git a/apps/admin/src/editor/session/settings-fields.ts b/apps/admin/src/editor/session/settings-fields.ts
index 724a31a5156..ccd005051ab 100644
--- a/apps/admin/src/editor/session/settings-fields.ts
+++ b/apps/admin/src/editor/session/settings-fields.ts
@@ -5,7 +5,7 @@ import { tagIdentities } from '@/shared/tags/tag-selection';
import type { EditorCreatePayload } from './write-payload';
/**
- * The projection keys the settings sidebar may write. Slug, status and publish
+ * The projection keys settings and the email subject editor may write. Slug, status and publish
* time are absent on purpose: the slug machine and the save engine's command
* target own them, and a field patch would be dropped before the request.
*/
@@ -13,6 +13,7 @@ export const SETTINGS_FIELD_KEYS = [
'tags',
'authors',
'custom_excerpt',
+ 'email_subject',
'featured',
'visibility',
'tiers',
@@ -78,6 +79,8 @@ export function identityFor(
}
/** The column widths the schema gives these fields. */
+export const EMAIL_SUBJECT_MAX = 300;
+export const EMAIL_SUBJECT_TOO_LONG = `Email subject cannot be longer than ${EMAIL_SUBJECT_MAX} characters.`;
export const META_TITLE_MAX = 300;
export const META_DESCRIPTION_MAX = 500;
export const OG_TITLE_MAX = 300;
@@ -123,6 +126,7 @@ export function overLength(value: string | null, max: number): boolean {
/** The settings keys the validator reads, and all a prepared save carries for it. */
export const VALIDATED_SETTINGS_FIELD_KEYS = [
+ 'email_subject',
'visibility',
'tiers',
'meta_title',
@@ -149,6 +153,7 @@ const LENGTH_RULES: Record<
Exclude,
{ max: number; message: string }
> = {
+ email_subject: { max: EMAIL_SUBJECT_MAX, message: EMAIL_SUBJECT_TOO_LONG },
meta_title: { max: META_TITLE_MAX, message: META_TITLE_TOO_LONG },
meta_description: { max: META_DESCRIPTION_MAX, message: META_DESCRIPTION_TOO_LONG },
og_title: { max: OG_TITLE_MAX, message: OG_TITLE_TOO_LONG },
diff --git a/apps/admin/src/editor/settings/use-settings-field.test.ts b/apps/admin/src/editor/settings/use-settings-field.test.ts
index 70d5af769e2..c986d0f1ee4 100644
--- a/apps/admin/src/editor/settings/use-settings-field.test.ts
+++ b/apps/admin/src/editor/settings/use-settings-field.test.ts
@@ -13,6 +13,7 @@ import { useSettingsField } from './use-settings-field';
const SETTINGS: ValidatedSettingsFields = {
visibility: 'public',
tiers: [],
+ email_subject: null,
meta_title: null,
meta_description: null,
canonical_url: null,
diff --git a/apps/shade/src/components/ui/select.stories.tsx b/apps/shade/src/components/ui/select.stories.tsx
index 56ab1ceb957..e66658f5c6c 100644
--- a/apps/shade/src/components/ui/select.stories.tsx
+++ b/apps/shade/src/components/ui/select.stories.tsx
@@ -52,7 +52,8 @@ export const Default: Story = {
parameters: {
docs: {
description: {
- story: 'Basic select dropdown with simple options.',
+ story:
+ 'Basic select dropdown with simple options. The chevron has no left margin; triggers with a chevron reduce right padding by one spacing step (4px).',
},
},
},
diff --git a/apps/shade/src/components/ui/select.tsx b/apps/shade/src/components/ui/select.tsx
index 52fe1be3823..ed3a846b82f 100644
--- a/apps/shade/src/components/ui/select.tsx
+++ b/apps/shade/src/components/ui/select.tsx
@@ -81,6 +81,7 @@ const SelectTrigger = React.forwardRef<
inputSurface('self'),
inputSurfaceClasses.disabledFieldSelf,
selectTriggerVariants({ shape: resolvedShape, variant, isAdmin7 }),
+ showChevron && 'pr-2',
className,
)}
data-control-shape={resolvedShape}
@@ -90,7 +91,7 @@ const SelectTrigger = React.forwardRef<
{children}
{showChevron && (
-
+
)}
diff --git a/packages/testing/test-data/src/selectors/editor.ts b/packages/testing/test-data/src/selectors/editor.ts
index aa2ec2af8d9..cb34c44c384 100644
--- a/packages/testing/test-data/src/selectors/editor.ts
+++ b/packages/testing/test-data/src/selectors/editor.ts
@@ -146,6 +146,8 @@ export const mobilePreviewToggle = 'Mobile';
export const previewAsSelectLabel = 'Preview as';
export const previewTierSelectLabel = 'Tier';
export const previewNewsletterSelectLabel = 'Newsletter';
+export const preparingPreviewLabel = 'Preparing preview';
+export const sharePreviewButton = 'Share';
export const copyPreviewLinkButton = 'Copy preview link';
export const openPreviewInNewTabLink = 'Open in new tab';
export const closePreviewButton = 'Close';
From 557d8740aca50af5b19d69a6b194a63f9725a6a0 Mon Sep 17 00:00:00 2001
From: Evan Hahn
Date: Tue, 29 Sep 2026 11:37:08 -0500
Subject: [PATCH 02/17] Added description column to automations table (#30861)
closes https://linear.app/ghost/issue/NY-1622
towards https://linear.app/ghost/issue/NY-1621
This adds a new `description` column to automations. It is required (but
can be blank).
In addition to automated tests, I manually checked that this worked by
running the migrations in a few situations and verifying that the
description field was correct.
---
...16-22-08-add-description-to-automations.js | 30 +++++++++++++++++++
ghost/core/core/server/data/schema/schema.js | 1 +
.../seeders/importers/automations-importer.ts | 4 +++
ghost/core/core/server/models/automation.js | 1 +
.../database-automations-repository.ts | 6 +++-
ghost/core/package.json | 2 +-
.../services/member-welcome-emails.test.js | 2 ++
.../unit/server/data/schema/integrity.test.js | 2 +-
.../data/seeders/data-generator.test.js | 10 +++++--
.../unit/server/models/automation.test.js | 3 +-
.../database-automations-repository.test.ts | 10 +++++++
ghost/core/test/utils/automations-fixtures.ts | 2 ++
12 files changed, 67 insertions(+), 6 deletions(-)
create mode 100644 ghost/core/core/server/data/migrations/versions/6.67/2026-09-29-16-22-08-add-description-to-automations.js
diff --git a/ghost/core/core/server/data/migrations/versions/6.67/2026-09-29-16-22-08-add-description-to-automations.js b/ghost/core/core/server/data/migrations/versions/6.67/2026-09-29-16-22-08-add-description-to-automations.js
new file mode 100644
index 00000000000..583de9276ad
--- /dev/null
+++ b/ghost/core/core/server/data/migrations/versions/6.67/2026-09-29-16-22-08-add-description-to-automations.js
@@ -0,0 +1,30 @@
+const logging = require('@tryghost/logging');
+const {
+ combineNonTransactionalMigrations,
+ createAddColumnMigration,
+ createNonTransactionalMigration,
+} = require('../../utils');
+
+module.exports = combineNonTransactionalMigrations(
+ createAddColumnMigration('automations', 'description', {
+ type: 'string',
+ maxlength: 2000,
+ nullable: false,
+ defaultTo: '',
+ }),
+
+ createNonTransactionalMigration(
+ async function up(knex) {
+ const freeMembersUpdated = await knex('automations')
+ .where('slug', 'member-welcome-email-free')
+ .update({ description: 'Welcome new free members after they sign up.' });
+ const paidMembersUpdated = await knex('automations')
+ .where('slug', 'member-welcome-email-paid')
+ .update({ description: 'Welcome new paid members after they start their subscription.' });
+ logging.info(`Set descriptions for ${freeMembersUpdated + paidMembersUpdated} automations`);
+ },
+ async function down() {
+ logging.info('Keeping automation descriptions');
+ },
+ ),
+);
diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js
index 237aac1f551..361360a1042 100644
--- a/ghost/core/core/server/data/schema/schema.js
+++ b/ghost/core/core/server/data/schema/schema.js
@@ -2170,6 +2170,7 @@ module.exports = {
validations: { isIn: [['active', 'inactive']] },
},
name: { type: 'string', maxlength: 191, nullable: false, unique: true },
+ description: { type: 'string', maxlength: 2000, nullable: false, defaultTo: '' },
slug: { type: 'string', maxlength: 191, nullable: false, unique: true },
created_at: { type: 'dateTime', nullable: false },
updated_at: { type: 'dateTime', nullable: true },
diff --git a/ghost/core/core/server/data/seeders/importers/automations-importer.ts b/ghost/core/core/server/data/seeders/importers/automations-importer.ts
index b1f1ec8486c..e08b1e9389a 100644
--- a/ghost/core/core/server/data/seeders/importers/automations-importer.ts
+++ b/ghost/core/core/server/data/seeders/importers/automations-importer.ts
@@ -12,6 +12,7 @@ type Automation = {
status: 'active' | 'inactive';
name: string;
slug: string;
+ description: string;
created_at: string;
updated_at: string;
};
@@ -20,10 +21,12 @@ const defaultAutomations = [
{
name: 'Free member welcome flow',
slug: MEMBER_WELCOME_EMAIL_SLUGS.free,
+ description: 'Welcome new free members after they sign up.',
},
{
name: 'Paid member welcome flow',
slug: MEMBER_WELCOME_EMAIL_SLUGS.paid,
+ description: 'Welcome new paid members after they start their subscription.',
},
];
@@ -53,6 +56,7 @@ export class AutomationsImporter extends TableImporter {
id,
status: faker.helpers.arrayElement(['active', 'inactive']),
name,
+ description: defaultAutomation?.description ?? '',
slug,
created_at: toDatabaseDate(createdAt),
updated_at: toDatabaseDate(createdAt),
diff --git a/ghost/core/core/server/models/automation.js b/ghost/core/core/server/models/automation.js
index 61e11d577a3..8e04f485756 100644
--- a/ghost/core/core/server/models/automation.js
+++ b/ghost/core/core/server/models/automation.js
@@ -11,6 +11,7 @@ const Automation = ghostBookshelf.Model.extend(
defaults() {
return {
status: 'inactive',
+ description: '',
};
},
diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts
index 601b7ac407d..70207aaec64 100644
--- a/ghost/core/core/server/services/automations/database-automations-repository.ts
+++ b/ghost/core/core/server/services/automations/database-automations-repository.ts
@@ -35,10 +35,12 @@ const HOUR_MS = 60 * 60 * 1000;
const DEFAULT_WELCOME_EMAIL_AUTOMATIONS = [
{
name: 'Free member welcome flow',
+ description: 'Welcome new free members after they sign up.',
slug: MEMBER_WELCOME_EMAIL_SLUGS.free,
},
{
name: 'Paid member welcome flow',
+ description: 'Welcome new paid members after they start their subscription.',
slug: MEMBER_WELCOME_EMAIL_SLUGS.paid,
},
];
@@ -59,6 +61,7 @@ type AutomationRow = {
id: string;
slug: null | string;
name: string;
+ description: string;
status: string;
created_at: DatabaseDate;
updated_at: DatabaseDate;
@@ -501,7 +504,7 @@ async function ensureDefaultAutomations(trx: Knex.Transaction): Promise {
async function ensureAutomation(
trx: Knex.Transaction,
- defaults: Readonly<{ name: string; slug: string }>,
+ defaults: Readonly<{ name: string; description: string; slug: string }>,
): Promise {
const now = toDatabaseDate(new Date());
const id = ObjectId().toHexString();
@@ -511,6 +514,7 @@ async function ensureAutomation(
id,
status: 'inactive',
name: defaults.name,
+ description: defaults.description,
slug: defaults.slug,
created_at: now,
updated_at: now,
diff --git a/ghost/core/package.json b/ghost/core/package.json
index 0b5ba7effda..f5eeab8bfaf 100644
--- a/ghost/core/package.json
+++ b/ghost/core/package.json
@@ -1,6 +1,6 @@
{
"name": "ghost",
- "version": "6.66.1-rc.0",
+ "version": "6.67.0-rc.0",
"description": "The professional publishing platform",
"keywords": [
"blog",
diff --git a/ghost/core/test/integration/services/member-welcome-emails.test.js b/ghost/core/test/integration/services/member-welcome-emails.test.js
index 93978e4e968..be2f3b62602 100644
--- a/ghost/core/test/integration/services/member-welcome-emails.test.js
+++ b/ghost/core/test/integration/services/member-welcome-emails.test.js
@@ -81,6 +81,7 @@ describe('Member Welcome Emails Integration', function () {
id: freeAutomationId,
status: 'active',
name: 'Free Member Welcome Email',
+ description: 'Welcome new free members after they sign up.',
slug: MEMBER_WELCOME_EMAIL_SLUGS.free,
created_at: new Date(),
});
@@ -99,6 +100,7 @@ describe('Member Welcome Emails Integration', function () {
id: paidAutomationId,
status: 'active',
name: 'Paid Member Welcome Email',
+ description: 'Welcome new paid members after they start their subscription.',
slug: MEMBER_WELCOME_EMAIL_SLUGS.paid,
created_at: new Date(),
});
diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js
index bf1f911f23f..7e2980a8359 100644
--- a/ghost/core/test/unit/server/data/schema/integrity.test.js
+++ b/ghost/core/test/unit/server/data/schema/integrity.test.js
@@ -38,7 +38,7 @@ const parseYaml = require('../../../../../core/server/services/route-settings/ya
*/
describe('DB version integrity', function () {
// Only these variables should need updating
- const currentSchemaHash = 'b3467bb26d2c4ef862382718ca6ed6e8';
+ const currentSchemaHash = '8dbebdd89893df7b6dcf6b80aa70544d';
const currentFixturesHash = '5718e0d4eb037f159c312369e949829a';
const currentSettingsHash = 'ad77752f31c6a7f174c04c499214b975';
const currentRoutesHash = 'd8c25fa01bf6d22a2bcb05ba0de70dc1';
diff --git a/ghost/core/test/unit/server/data/seeders/data-generator.test.js b/ghost/core/test/unit/server/data/seeders/data-generator.test.js
index 4f54cc5ac72..d2f7f98aca0 100644
--- a/ghost/core/test/unit/server/data/seeders/data-generator.test.js
+++ b/ghost/core/test/unit/server/data/seeders/data-generator.test.js
@@ -524,6 +524,7 @@ describe('Importer', function () {
table.string('status');
table.string('name').unique();
table.string('slug').unique();
+ table.string('description', 2000);
table.dateTime('created_at');
table.dateTime('updated_at');
});
@@ -565,24 +566,29 @@ describe('Importer', function () {
await automationsImporter.import(3);
await transaction.commit();
- const automations = await db.select('id', 'status', 'name', 'slug').from('automations');
+ const automations = await db
+ .select('id', 'status', 'name', 'slug', 'description')
+ .from('automations');
assert.equal(automations.length, 3);
assert.deepEqual(
- automations.slice(0, 2).map(({ name, slug }) => ({ name, slug })),
+ automations.slice(0, 2).map(({ name, description, slug }) => ({ name, description, slug })),
[
{
name: 'Free member welcome flow',
+ description: 'Welcome new free members after they sign up.',
slug: 'member-welcome-email-free',
},
{
name: 'Paid member welcome flow',
+ description: 'Welcome new paid members after they start their subscription.',
slug: 'member-welcome-email-paid',
},
],
);
assert.equal(new Set(automations.map((automation) => automation.name)).size, 3);
assert.equal(new Set(automations.map((automation) => automation.slug)).size, 3);
+ assert.equal(automations[2].description, '');
assert.ok(
automations.every((automation) => ['active', 'inactive'].includes(automation.status)),
);
diff --git a/ghost/core/test/unit/server/models/automation.test.js b/ghost/core/test/unit/server/models/automation.test.js
index 68c8d22f56b..abcefacaac2 100644
--- a/ghost/core/test/unit/server/models/automation.test.js
+++ b/ghost/core/test/unit/server/models/automation.test.js
@@ -21,8 +21,9 @@ describe('Unit: models/automation', function () {
const defaults = model.defaults();
assert.ok(defaults);
- assert.equal(Object.keys(defaults).length, 1);
+ assert.equal(Object.keys(defaults).length, 2);
assert.equal(defaults.status, 'inactive');
+ assert.equal(defaults.description, '');
});
});
diff --git a/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts
index 700808199c5..841da4da77a 100644
--- a/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts
+++ b/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts
@@ -75,6 +75,7 @@ const createDatabase = async (): Promise => {
table.text('updated_at').notNullable();
table.text('slug').notNullable().unique();
table.text('name').notNullable();
+ table.text('description').notNullable();
table.text('status').notNullable();
});
@@ -222,6 +223,7 @@ const createDatabase = async (): Promise => {
updated_at: now(),
slug: 'member-welcome-email-free',
name: 'Free member welcome flow',
+ description: 'Welcome new free members after they sign up.',
status: 'active',
},
{
@@ -230,6 +232,7 @@ const createDatabase = async (): Promise => {
updated_at: now(),
slug: 'member-welcome-email-paid',
name: 'Paid member welcome flow',
+ description: 'Welcome new paid members after they start their subscription.',
status: 'active',
},
]);
@@ -716,6 +719,7 @@ describe('automations repository', function () {
updated_at: toDatabaseDate(new Date()),
slug: 'alpha-flow',
name: 'Alpha flow',
+ description: '',
status: 'inactive',
});
@@ -728,6 +732,12 @@ describe('automations repository', function () {
'Free member welcome flow',
'Paid member welcome flow',
]);
+ const descriptions = await knex('automations').orderBy('name').pluck('description');
+ assert.deepEqual(descriptions, [
+ '',
+ 'Welcome new free members after they sign up.',
+ 'Welcome new paid members after they start their subscription.',
+ ]);
});
it('can omit stats', async function () {
diff --git a/ghost/core/test/utils/automations-fixtures.ts b/ghost/core/test/utils/automations-fixtures.ts
index 5204fe12d6d..63dfea85058 100644
--- a/ghost/core/test/utils/automations-fixtures.ts
+++ b/ghost/core/test/utils/automations-fixtures.ts
@@ -57,6 +57,7 @@ export async function setupAutomationsFixture(): Promise {
updated_at: timestamp(0),
slug: MEMBER_WELCOME_EMAIL_SLUGS.free,
name: 'Free member welcome flow',
+ description: 'Welcome new free members after they sign up.',
status: 'active',
},
{
@@ -65,6 +66,7 @@ export async function setupAutomationsFixture(): Promise {
updated_at: timestamp(1),
slug: MEMBER_WELCOME_EMAIL_SLUGS.paid,
name: 'Paid member welcome flow',
+ description: 'Welcome new paid members after they start their subscription.',
status: 'active',
},
];
From 7693a360979c2f53e51c6a64814194d8952e7fbf Mon Sep 17 00:00:00 2001
From: Austin Burdine
Date: Tue, 29 Sep 2026 12:47:10 -0400
Subject: [PATCH 03/17] Fixed npm publish failing on symlinks in the Ghost
archive (#31096)
no ref
The v6.66.0 npm publish failed with `E415 Symbolic link is not allowed`.
The bundled Casper and Source themes ship `CLAUDE.md -> AGENTS.md`
symlinks. pnpm 12.4.2's `pnpm pack` silently dropped symlinks, but
12.7.0 (bumped in #31005) keeps any that point inside the package
(pnpm/pnpm#8208), and the npm registry rejects tarballs containing them.
pack.mjs now removes every symlink from the extracted build tree before
tarring, restoring the pre-12.7 output. Doing it generically rather than
excluding the two CLAUDE.md files in `files` keeps a future symlink from
breaking the release again.
---
ghost/core/scripts/pack.mjs | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/ghost/core/scripts/pack.mjs b/ghost/core/scripts/pack.mjs
index 5e298778947..05bc3b4bb5a 100644
--- a/ghost/core/scripts/pack.mjs
+++ b/ghost/core/scripts/pack.mjs
@@ -166,6 +166,20 @@ if (!ghostTgz) {
await execFileAsync('tar', ['xzf', path.join(ghostPackDir, ghostTgz), '-C', CORE_DIR]);
await fs.rm(ghostPackDir, { recursive: true, force: true });
+// pnpm pack keeps symlinks inside the package (pnpm 12.7+), but the npm registry
+// rejects any tarball containing one (E415). Drop them, as older pnpm did — today
+// that's the bundled themes' CLAUDE.md -> AGENTS.md.
+const symlinks = (await fs.readdir(BUILD_DIR, { recursive: true, withFileTypes: true })).filter(
+ (entry) => entry.isSymbolicLink(),
+);
+await Promise.all(
+ symlinks.map(async (entry) => {
+ const file = path.join(entry.parentPath, entry.name);
+ await fs.rm(file);
+ console.log(` Removed symlink ${path.relative(BUILD_DIR, file)}`);
+ }),
+);
+
// Carry the root packageManager over — ghost/core's own manifest doesn't declare one.
const pkgPath = path.join(BUILD_DIR, 'package.json');
const pkg = await readJson(pkgPath);
From 4cb7e7956356a47e2c2c240588ef32ecd9fddeaa Mon Sep 17 00:00:00 2001
From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 29 Sep 2026 17:16:26 +0000
Subject: [PATCH 04/17] v6.67.0
---
.../changelogs/@tryghost!kg-default-nodes@2.2.2.md | 5 -----
.changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md | 9 ---------
.changeset/little-foxes-rule.md | 5 -----
.changeset/proud-tigers-notice.md | 5 -----
.changeset/weak-suns-enjoy.md | 5 -----
apps/ember-admin/package.json | 2 +-
ghost/core/package.json | 2 +-
7 files changed, 2 insertions(+), 31 deletions(-)
delete mode 100644 .changeset/changelogs/@tryghost!kg-default-nodes@2.2.2.md
delete mode 100644 .changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md
delete mode 100644 .changeset/little-foxes-rule.md
delete mode 100644 .changeset/proud-tigers-notice.md
delete mode 100644 .changeset/weak-suns-enjoy.md
diff --git a/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.2.md b/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.2.md
deleted file mode 100644
index 527bea329fe..00000000000
--- a/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.2.md
+++ /dev/null
@@ -1,5 +0,0 @@
-## 2.2.2
-
-### Patch Changes
-
-- Removed html-minifier from runtime dependencies; it is only used by the package's tests
diff --git a/.changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md b/.changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md
deleted file mode 100644
index 618ee77da2b..00000000000
--- a/.changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md
+++ /dev/null
@@ -1,9 +0,0 @@
-## 1.11.0
-
-### Minor Changes
-
-- Added an embedPreviewUrl card config option that previews embed cards in a renderer served from a separate origin
-
-### Patch Changes
-
-- Updated dependencies
diff --git a/.changeset/little-foxes-rule.md b/.changeset/little-foxes-rule.md
deleted file mode 100644
index 2f762631c3a..00000000000
--- a/.changeset/little-foxes-rule.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@tryghost/koenig-lexical": patch
----
-
-Updated dependencies
diff --git a/.changeset/proud-tigers-notice.md b/.changeset/proud-tigers-notice.md
deleted file mode 100644
index 22385aa089c..00000000000
--- a/.changeset/proud-tigers-notice.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@tryghost/kg-default-nodes": patch
----
-
-Removed html-minifier from runtime dependencies; it is only used by the package's tests
diff --git a/.changeset/weak-suns-enjoy.md b/.changeset/weak-suns-enjoy.md
deleted file mode 100644
index 70ed7ecceaa..00000000000
--- a/.changeset/weak-suns-enjoy.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@tryghost/koenig-lexical": minor
----
-
-Added an embedPreviewUrl card config option that previews embed cards in a renderer served from a separate origin
diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json
index 6cd005ec81a..3170aadcaf2 100644
--- a/apps/ember-admin/package.json
+++ b/apps/ember-admin/package.json
@@ -1,6 +1,6 @@
{
"name": "ghost-admin",
- "version": "6.66.1-rc.0",
+ "version": "6.67.0",
"description": "Ember.js admin client for Ghost",
"author": "Ghost Foundation",
"homepage": "http://ghost.org",
diff --git a/ghost/core/package.json b/ghost/core/package.json
index f5eeab8bfaf..4ce0bd87b08 100644
--- a/ghost/core/package.json
+++ b/ghost/core/package.json
@@ -1,6 +1,6 @@
{
"name": "ghost",
- "version": "6.67.0-rc.0",
+ "version": "6.67.0",
"description": "The professional publishing platform",
"keywords": [
"blog",
From 4cb8bbd7f8c68ff728021fe368f043bc8e131136 Mon Sep 17 00:00:00 2001
From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 29 Sep 2026 17:16:32 +0000
Subject: [PATCH 05/17] Bumped version to 6.67.1-rc.0
---
apps/ember-admin/package.json | 2 +-
ghost/core/package.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json
index 3170aadcaf2..07afbc4cffe 100644
--- a/apps/ember-admin/package.json
+++ b/apps/ember-admin/package.json
@@ -1,6 +1,6 @@
{
"name": "ghost-admin",
- "version": "6.67.0",
+ "version": "6.67.1-rc.0",
"description": "Ember.js admin client for Ghost",
"author": "Ghost Foundation",
"homepage": "http://ghost.org",
diff --git a/ghost/core/package.json b/ghost/core/package.json
index 4ce0bd87b08..91ea99601f3 100644
--- a/ghost/core/package.json
+++ b/ghost/core/package.json
@@ -1,6 +1,6 @@
{
"name": "ghost",
- "version": "6.67.0",
+ "version": "6.67.1-rc.0",
"description": "The professional publishing platform",
"keywords": [
"blog",
From 9f87fc77f65cb8e35568a2d12d0aa5eae0a9a778 Mon Sep 17 00:00:00 2001
From: Austin Burdine
Date: Tue, 29 Sep 2026 13:29:00 -0400
Subject: [PATCH 06/17] Fixed embed previews in local development (#31101)
no ref
The public embed renderer only allows https frame ancestors, so Admin on
http://localhost:2368 can't frame it and embed previews time out. Local
development now keeps same-origin previews; set `embedPreviewUrl` in
config.local.json to test against a renderer.
---
ghost/core/core/shared/config/env/config.development.json | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/ghost/core/core/shared/config/env/config.development.json b/ghost/core/core/shared/config/env/config.development.json
index 9c2895cc865..377d7ecc0cf 100644
--- a/ghost/core/core/shared/config/env/config.development.json
+++ b/ghost/core/core/shared/config/env/config.development.json
@@ -33,6 +33,7 @@
}
},
"security": {
- "staffDeviceVerification": false
+ "staffDeviceVerification": false,
+ "embedPreviewUrl": ""
}
}
From 213aac9dd8220dd53b4d8f2588a4fdb841620361 Mon Sep 17 00:00:00 2001
From: Evan Hahn
Date: Tue, 29 Sep 2026 12:54:29 -0500
Subject: [PATCH 07/17] Added per-tier automation trigger migration (#30834)
closes [NY-1617](https://linear.app/ghost/issue/NY-1617)
New migration that:
- creates `automations.trigger_tier_scope` column with backfill. This
column is _nullable_ because it won't always be required once we add new
trigger types.
- creates the `automation_trigger_tiers` table
I considered adding a `trigger_type` column, but all automations are
still something like "member signed up", so I don't think it's worth
adding that column now. Easy to add in the future.
Also requires us to insert this new data for new automations created
with the legacy automated emails endpoint.
---
.../server/api/endpoints/automated-emails.js | 5 ++
.../core/server/data/exporter/table-lists.js | 1 +
...39-23-add-automation-trigger-tier-scope.js | 57 +++++++++++++++++++
ghost/core/core/server/data/schema/schema.js | 22 +++++++
.../seeders/importers/automations-importer.ts | 6 +-
.../database-automations-repository.ts | 12 +++-
ghost/core/package.json | 2 +-
.../e2e-api/admin/automated-emails.test.ts | 17 ++++++
.../integration/exporter/exporter.test.js | 1 +
.../unit/server/data/schema/integrity.test.js | 2 +-
.../data/seeders/data-generator.test.js | 7 ++-
.../database-automations-repository.test.ts | 12 +++-
12 files changed, 136 insertions(+), 8 deletions(-)
create mode 100644 ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-17-39-23-add-automation-trigger-tier-scope.js
diff --git a/ghost/core/core/server/api/endpoints/automated-emails.js b/ghost/core/core/server/api/endpoints/automated-emails.js
index 5bd4a6a529a..24e7e46a984 100644
--- a/ghost/core/core/server/api/endpoints/automated-emails.js
+++ b/ghost/core/core/server/api/endpoints/automated-emails.js
@@ -22,6 +22,10 @@ const messages = {
// acts as a facade that joins/splits data between those two models while preserving the original
// `automated_emails` API shape externally.
const AUTOMATION_FIELDS = ['status', 'name', 'slug'];
+const TRIGGER_TIER_SCOPE_BY_SLUG = {
+ [MEMBER_WELCOME_EMAIL_SLUGS.free]: 'free',
+ [MEMBER_WELCOME_EMAIL_SLUGS.paid]: 'all_paid',
+};
const EMAIL_FIELDS = ['subject', 'lexical', 'email_design_setting_id'];
const SENDER_FIELDS = ['sender_name', 'sender_email', 'sender_reply_to'];
@@ -159,6 +163,7 @@ const controller = {
const emailData = _.pick(data, EMAIL_FIELDS);
const senderData = _.pick(data, SENDER_FIELDS);
const automationData = _.pick(data, AUTOMATION_FIELDS);
+ automationData.trigger_tier_scope = TRIGGER_TIER_SCOPE_BY_SLUG[data.slug];
emailAddressService.init();
validateEmailSenderFields(emailAddressService.service, senderData);
diff --git a/ghost/core/core/server/data/exporter/table-lists.js b/ghost/core/core/server/data/exporter/table-lists.js
index c3477f3cf54..aad8a0161f0 100644
--- a/ghost/core/core/server/data/exporter/table-lists.js
+++ b/ghost/core/core/server/data/exporter/table-lists.js
@@ -75,6 +75,7 @@ const BACKUP_TABLES = [
'automation_action_revisions',
'automation_run_steps',
'automation_runs',
+ 'automation_trigger_tiers',
'welcome_email_automation_runs',
'welcome_email_automated_emails',
'tinybird_syncs',
diff --git a/ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-17-39-23-add-automation-trigger-tier-scope.js b/ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-17-39-23-add-automation-trigger-tier-scope.js
new file mode 100644
index 00000000000..9c55eeca998
--- /dev/null
+++ b/ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-17-39-23-add-automation-trigger-tier-scope.js
@@ -0,0 +1,57 @@
+const logging = require('@tryghost/logging');
+const {
+ addTable,
+ combineNonTransactionalMigrations,
+ createAddColumnMigration,
+ createNonTransactionalMigration,
+} = require('../../utils');
+
+/**
+ * This migration:
+ *
+ * - Creates `automations.trigger_tier_scope` column with backfill
+ * - Creates the `automation_trigger_tiers` table
+ *
+ * Databases should have at most two automations, making this migration
+ * simpler.
+ */
+module.exports = combineNonTransactionalMigrations(
+ createAddColumnMigration('automations', 'trigger_tier_scope', {
+ type: 'string',
+ maxlength: 50,
+ nullable: true,
+ validations: { isIn: [['free', 'all_paid', 'selected_paid']] },
+ }),
+
+ createNonTransactionalMigration(
+ async function up(knex) {
+ const freeUpdatedRows = await knex('automations')
+ .where({ slug: 'member-welcome-email-free' })
+ .update('trigger_tier_scope', 'free');
+ const paidUpdatedRows = await knex('automations')
+ .where({ slug: 'member-welcome-email-paid' })
+ .update('trigger_tier_scope', 'all_paid');
+ logging.info(`Set trigger tier scope for ${freeUpdatedRows + paidUpdatedRows} automations`);
+ },
+ async function down() {
+ logging.info('Keeping automation trigger tier scopes until column rollback');
+ },
+ ),
+
+ addTable('automation_trigger_tiers', {
+ automation_id: {
+ type: 'string',
+ maxlength: 24,
+ nullable: false,
+ references: 'automations.id',
+ cascadeDelete: true,
+ },
+ product_id: {
+ type: 'string',
+ maxlength: 24,
+ nullable: false,
+ references: 'products.id',
+ },
+ '@@UNIQUE_CONSTRAINTS@@': [['automation_id', 'product_id']],
+ }),
+);
diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js
index 361360a1042..40027f5675c 100644
--- a/ghost/core/core/server/data/schema/schema.js
+++ b/ghost/core/core/server/data/schema/schema.js
@@ -2172,9 +2172,31 @@ module.exports = {
name: { type: 'string', maxlength: 191, nullable: false, unique: true },
description: { type: 'string', maxlength: 2000, nullable: false, defaultTo: '' },
slug: { type: 'string', maxlength: 191, nullable: false, unique: true },
+ trigger_tier_scope: {
+ type: 'string',
+ maxlength: 50,
+ nullable: true,
+ validations: { isIn: [['free', 'all_paid', 'selected_paid']] },
+ },
created_at: { type: 'dateTime', nullable: false },
updated_at: { type: 'dateTime', nullable: true },
},
+ automation_trigger_tiers: {
+ automation_id: {
+ type: 'string',
+ maxlength: 24,
+ nullable: false,
+ references: 'automations.id',
+ cascadeDelete: true,
+ },
+ product_id: {
+ type: 'string',
+ maxlength: 24,
+ nullable: false,
+ references: 'products.id',
+ },
+ '@@UNIQUE_CONSTRAINTS@@': [['automation_id', 'product_id']],
+ },
automation_actions: {
id: { type: 'string', maxlength: 24, nullable: false, primary: true },
created_at: { type: 'dateTime', nullable: false },
diff --git a/ghost/core/core/server/data/seeders/importers/automations-importer.ts b/ghost/core/core/server/data/seeders/importers/automations-importer.ts
index e08b1e9389a..f22345a93ca 100644
--- a/ghost/core/core/server/data/seeders/importers/automations-importer.ts
+++ b/ghost/core/core/server/data/seeders/importers/automations-importer.ts
@@ -13,6 +13,7 @@ type Automation = {
name: string;
slug: string;
description: string;
+ trigger_tier_scope: 'free' | 'all_paid';
created_at: string;
updated_at: string;
};
@@ -22,13 +23,15 @@ const defaultAutomations = [
name: 'Free member welcome flow',
slug: MEMBER_WELCOME_EMAIL_SLUGS.free,
description: 'Welcome new free members after they sign up.',
+ trigger_tier_scope: 'free',
},
{
name: 'Paid member welcome flow',
slug: MEMBER_WELCOME_EMAIL_SLUGS.paid,
description: 'Welcome new paid members after they start their subscription.',
+ trigger_tier_scope: 'all_paid',
},
-];
+] as const;
export class AutomationsImporter extends TableImporter {
static table = 'automations';
@@ -58,6 +61,7 @@ export class AutomationsImporter extends TableImporter {
name,
description: defaultAutomation?.description ?? '',
slug,
+ trigger_tier_scope: defaultAutomation?.trigger_tier_scope ?? 'all_paid',
created_at: toDatabaseDate(createdAt),
updated_at: toDatabaseDate(createdAt),
};
diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts
index 70207aaec64..c55c0f14a6b 100644
--- a/ghost/core/core/server/services/automations/database-automations-repository.ts
+++ b/ghost/core/core/server/services/automations/database-automations-repository.ts
@@ -37,13 +37,15 @@ const DEFAULT_WELCOME_EMAIL_AUTOMATIONS = [
name: 'Free member welcome flow',
description: 'Welcome new free members after they sign up.',
slug: MEMBER_WELCOME_EMAIL_SLUGS.free,
+ trigger_tier_scope: 'free',
},
{
name: 'Paid member welcome flow',
description: 'Welcome new paid members after they start their subscription.',
slug: MEMBER_WELCOME_EMAIL_SLUGS.paid,
+ trigger_tier_scope: 'all_paid',
},
-];
+] as const;
const messages = {
invalidAutomationActionRevision:
@@ -504,7 +506,12 @@ async function ensureDefaultAutomations(trx: Knex.Transaction): Promise {
async function ensureAutomation(
trx: Knex.Transaction,
- defaults: Readonly<{ name: string; description: string; slug: string }>,
+ defaults: Readonly<{
+ name: string;
+ description: string;
+ slug: string;
+ trigger_tier_scope: 'free' | 'all_paid';
+ }>,
): Promise {
const now = toDatabaseDate(new Date());
const id = ObjectId().toHexString();
@@ -516,6 +523,7 @@ async function ensureAutomation(
name: defaults.name,
description: defaults.description,
slug: defaults.slug,
+ trigger_tier_scope: defaults.trigger_tier_scope,
created_at: now,
updated_at: now,
})
diff --git a/ghost/core/package.json b/ghost/core/package.json
index 91ea99601f3..8e23d5dd1e9 100644
--- a/ghost/core/package.json
+++ b/ghost/core/package.json
@@ -1,6 +1,6 @@
{
"name": "ghost",
- "version": "6.67.1-rc.0",
+ "version": "6.68.0-rc.0",
"description": "The professional publishing platform",
"keywords": [
"blog",
diff --git a/ghost/core/test/e2e-api/admin/automated-emails.test.ts b/ghost/core/test/e2e-api/admin/automated-emails.test.ts
index faa49d0cbb6..410e38b8f93 100644
--- a/ghost/core/test/e2e-api/admin/automated-emails.test.ts
+++ b/ghost/core/test/e2e-api/admin/automated-emails.test.ts
@@ -358,6 +358,23 @@ describe('Automated Emails API', function () {
etag: anyEtag,
location: anyLocationFor('automated_emails'),
});
+
+ const automation = await models.Base.knex('automations')
+ .where('slug', 'member-welcome-email-free')
+ .first('trigger_tier_scope');
+ assert.equal(automation.trigger_tier_scope, 'free');
+ });
+
+ it('Sets all paid tier scope for paid welcome email', async function () {
+ const automatedEmail = await createAutomatedEmail({
+ name: 'Paid member welcome flow',
+ slug: 'member-welcome-email-paid',
+ });
+
+ const automation = await models.Base.knex('automations')
+ .where('id', automatedEmail.id)
+ .first('trigger_tier_scope');
+ assert.equal(automation.trigger_tier_scope, 'all_paid');
});
it('Writes sender settings to email design settings on add', async function () {
diff --git a/ghost/core/test/integration/exporter/exporter.test.js b/ghost/core/test/integration/exporter/exporter.test.js
index e20d66b1d10..2f0b75bfc91 100644
--- a/ghost/core/test/integration/exporter/exporter.test.js
+++ b/ghost/core/test/integration/exporter/exporter.test.js
@@ -30,6 +30,7 @@ describe('Exporter', function () {
'automation_actions',
'automation_run_steps',
'automation_runs',
+ 'automation_trigger_tiers',
'automated_email_recipients',
'automations',
'benefits',
diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js
index 7e2980a8359..7ac0892381b 100644
--- a/ghost/core/test/unit/server/data/schema/integrity.test.js
+++ b/ghost/core/test/unit/server/data/schema/integrity.test.js
@@ -38,7 +38,7 @@ const parseYaml = require('../../../../../core/server/services/route-settings/ya
*/
describe('DB version integrity', function () {
// Only these variables should need updating
- const currentSchemaHash = '8dbebdd89893df7b6dcf6b80aa70544d';
+ const currentSchemaHash = 'f830d874fe0850d84021c1301e7ce6b7';
const currentFixturesHash = '5718e0d4eb037f159c312369e949829a';
const currentSettingsHash = 'ad77752f31c6a7f174c04c499214b975';
const currentRoutesHash = 'd8c25fa01bf6d22a2bcb05ba0de70dc1';
diff --git a/ghost/core/test/unit/server/data/seeders/data-generator.test.js b/ghost/core/test/unit/server/data/seeders/data-generator.test.js
index d2f7f98aca0..cc5647bb026 100644
--- a/ghost/core/test/unit/server/data/seeders/data-generator.test.js
+++ b/ghost/core/test/unit/server/data/seeders/data-generator.test.js
@@ -525,6 +525,7 @@ describe('Importer', function () {
table.string('name').unique();
table.string('slug').unique();
table.string('description', 2000);
+ table.string('trigger_tier_scope');
table.dateTime('created_at');
table.dateTime('updated_at');
});
@@ -567,7 +568,7 @@ describe('Importer', function () {
await transaction.commit();
const automations = await db
- .select('id', 'status', 'name', 'slug', 'description')
+ .select('id', 'status', 'name', 'description', 'slug', 'trigger_tier_scope')
.from('automations');
assert.equal(automations.length, 3);
@@ -589,6 +590,10 @@ describe('Importer', function () {
assert.equal(new Set(automations.map((automation) => automation.name)).size, 3);
assert.equal(new Set(automations.map((automation) => automation.slug)).size, 3);
assert.equal(automations[2].description, '');
+ assert.deepEqual(
+ automations.map((automation) => automation.trigger_tier_scope),
+ ['free', 'all_paid', 'all_paid'],
+ );
assert.ok(
automations.every((automation) => ['active', 'inactive'].includes(automation.status)),
);
diff --git a/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts
index 841da4da77a..8bc1eff1acb 100644
--- a/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts
+++ b/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts
@@ -77,6 +77,7 @@ const createDatabase = async (): Promise => {
table.text('name').notNullable();
table.text('description').notNullable();
table.text('status').notNullable();
+ table.text('trigger_tier_scope');
});
await database.schema.createTable('automation_actions', (table) => {
@@ -858,22 +859,29 @@ describe('automations repository', function () {
await repo.browse({ includeStats: false });
const automations = await knex('automations')
- .select('id', 'name', 'slug', 'status')
+ .select('id', 'name', 'slug', 'status', 'trigger_tier_scope')
.whereIn('slug', ['member-welcome-email-free', 'member-welcome-email-paid'])
.orderBy('slug');
assert.deepEqual(
- automations.map(({ name, slug, status }) => ({ name, slug, status })),
+ automations.map(({ name, slug, status, trigger_tier_scope }) => ({
+ name,
+ slug,
+ status,
+ trigger_tier_scope,
+ })),
[
{
name: 'Free member welcome flow',
slug: 'member-welcome-email-free',
status: 'inactive',
+ trigger_tier_scope: 'free',
},
{
name: 'Paid member welcome flow',
slug: 'member-welcome-email-paid',
status: 'inactive',
+ trigger_tier_scope: 'all_paid',
},
],
);
From f03ba24e98b50fe3fbb52e47b28fcb1b3a45e4c1 Mon Sep 17 00:00:00 2001
From: Evan Hahn
Date: Tue, 29 Sep 2026 13:13:56 -0500
Subject: [PATCH 08/17] Gave admins "add automation" permissions (#31062)
closes https://linear.app/ghost/issue/NY-1636
towards https://linear.app/ghost/issue/NY-1637
This change should have no user impact.
Admins will [soon][0] be able to add automations. Let's give them the
permission to do this.
As part of this change, I added a skeleton of this endpoint to test the
permissions.
[0]: https://linear.app/ghost/issue/NY-1637
---
.../core/server/api/endpoints/automations.ts | 16 ++++++
...-18-00-00-add-automation-add-permission.js | 10 ++++
.../server/data/schema/fixtures/fixtures.json | 9 +++-
.../web/api/endpoints/admin/middleware.js | 2 +-
.../server/web/api/endpoints/admin/routes.js | 1 +
.../test/e2e-api/admin/automations.test.js | 50 +++++++++++++++++++
.../integration/migrations/migration.test.js | 3 +-
.../unit/server/data/schema/integrity.test.js | 2 +-
ghost/core/test/utils/fixtures/fixtures.json | 9 +++-
9 files changed, 95 insertions(+), 7 deletions(-)
create mode 100644 ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-18-00-00-add-automation-add-permission.js
diff --git a/ghost/core/core/server/api/endpoints/automations.ts b/ghost/core/core/server/api/endpoints/automations.ts
index be0e39e5862..ed3ccb71b45 100644
--- a/ghost/core/core/server/api/endpoints/automations.ts
+++ b/ghost/core/core/server/api/endpoints/automations.ts
@@ -48,6 +48,22 @@ export const controller = {
},
},
+ add: {
+ statusCode: 201,
+ headers: {
+ cacheInvalidate: false,
+ },
+ permissions: true,
+ async query() {
+ // TODO(NY-1637) Implement this endpoint.
+ throw new errors.InternalServerError({
+ statusCode: 501,
+ code: 'NOT_IMPLEMENTED',
+ message: 'Adding automations is not implemented.',
+ });
+ },
+ },
+
edit: {
headers: {
cacheInvalidate: false,
diff --git a/ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-18-00-00-add-automation-add-permission.js b/ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-18-00-00-add-automation-add-permission.js
new file mode 100644
index 00000000000..d939266f35e
--- /dev/null
+++ b/ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-18-00-00-add-automation-add-permission.js
@@ -0,0 +1,10 @@
+const { addPermissionWithRoles } = require('../../utils');
+
+module.exports = addPermissionWithRoles(
+ {
+ name: 'Add automations',
+ action: 'add',
+ object: 'automation',
+ },
+ ['Administrator', 'Admin Integration'],
+);
diff --git a/ghost/core/core/server/data/schema/fixtures/fixtures.json b/ghost/core/core/server/data/schema/fixtures/fixtures.json
index 18888031e9f..9f91d3e0a0d 100644
--- a/ghost/core/core/server/data/schema/fixtures/fixtures.json
+++ b/ghost/core/core/server/data/schema/fixtures/fixtures.json
@@ -795,6 +795,11 @@
"action_type": "read",
"object_type": "automation"
},
+ {
+ "name": "Add automations",
+ "action_type": "add",
+ "object_type": "automation"
+ },
{
"name": "Edit automations",
"action_type": "edit",
@@ -987,7 +992,7 @@
"collection": "all",
"recommendation": "all",
"gift_link": "all",
- "automation": ["browse", "read", "edit"],
+ "automation": ["browse", "read", "edit", "add"],
"identity": "read"
},
"DB Backup Integration": {
@@ -1036,7 +1041,7 @@
"mention": "browse",
"collection": "all",
"recommendation": "all",
- "automation": ["browse", "read", "edit"],
+ "automation": ["browse", "read", "edit", "add"],
"member_signin_url": "read",
"gift_link": "manage"
},
diff --git a/ghost/core/core/server/web/api/endpoints/admin/middleware.js b/ghost/core/core/server/web/api/endpoints/admin/middleware.js
index 3594a7c5cc5..f46cd7e45e3 100644
--- a/ghost/core/core/server/web/api/endpoints/admin/middleware.js
+++ b/ghost/core/core/server/web/api/endpoints/admin/middleware.js
@@ -69,7 +69,7 @@ const tokenPermissionCheck = function tokenPermissionCheck(req, res, next) {
tiers: ['GET', 'PUT', 'POST'],
offers: ['GET', 'PUT', 'POST'],
newsletters: ['GET', 'PUT', 'POST'],
- automations: ['PUT'],
+ automations: ['POST', 'PUT'],
config: ['GET'],
schedules: ['PUT'],
gifts: ['PUT'],
diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js
index d51310883be..df7e9e4905b 100644
--- a/ghost/core/core/server/web/api/endpoints/admin/routes.js
+++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js
@@ -313,6 +313,7 @@ module.exports = function apiRoutes() {
http(api.automationEmailPreviews.sendTestEmail),
);
router.put('/automations/poll', mw.authAdminApiWithUrl, http(api.automations.poll));
+ router.post('/automations', mw.authAdminApi, http(api.automations.add));
router.put('/automations/:id', mw.authAdminApi, http(api.automations.edit));
// ## Automated Emails
diff --git a/ghost/core/test/e2e-api/admin/automations.test.js b/ghost/core/test/e2e-api/admin/automations.test.js
index e97b73f70a3..33fc5438ca3 100644
--- a/ghost/core/test/e2e-api/admin/automations.test.js
+++ b/ghost/core/test/e2e-api/admin/automations.test.js
@@ -139,6 +139,56 @@ describe('Automations API', function () {
await cleanupAutomationsFixture();
});
+ describe('add', function () {
+ afterEach(async function () {
+ await agent.useStaffTokenForOwner();
+ });
+
+ for (const role of ['Owner', 'Admin']) {
+ // TODO(NY-1637): Remove this placeholder test once the endpoint is finished.
+ it(`${role} bypasses permissions checks`, async function () {
+ await agent[`useStaffTokenFor${role}`]();
+ const { body } = await agent
+ .post('automations')
+ .body({ automations: [{ name: 'Test automation' }] })
+ .expectStatus(501)
+ .expect(cacheInvalidateHeaderNotSet());
+
+ assert.equal(body.errors[0].code, 'NOT_IMPLEMENTED');
+ });
+ }
+
+ // TODO(NY-1637): Remove this placeholder test once the endpoint is finished.
+ it('Admin Integration bypasses permissions checks', async function () {
+ await agent.useZapierAdminAPIKey();
+ await agent
+ .post('automations')
+ .body({ automations: [{ name: 'Test automation' }] })
+ .expectStatus(501);
+ });
+
+ it('denies unauthenticated requests', async function () {
+ agent.resetAuthentication();
+ await agent
+ .post('automations')
+ .body({ automations: [{ name: 'Test automation' }] })
+ .expectStatus(403);
+ });
+
+ for (const role of ['Editor', 'Author', 'Contributor']) {
+ it(`denies ${role} permission to add automations`, async function () {
+ await agent[`useStaffTokenFor${role}`]();
+ const { body } = await agent
+ .post('automations')
+ .body({ automations: [{ name: 'Test automation' }] })
+ .expectStatus(403)
+ .expect(cacheInvalidateHeaderNotSet());
+
+ assert.equal(body.errors[0].type, 'NoPermissionError');
+ });
+ }
+ });
+
describe('browse', function () {
async function createAutomationRun(automationId, createdAt) {
const runId = ObjectId().toHexString();
diff --git a/ghost/core/test/integration/migrations/migration.test.js b/ghost/core/test/integration/migrations/migration.test.js
index f814d0afd49..dc95b21dd37 100644
--- a/ghost/core/test/integration/migrations/migration.test.js
+++ b/ghost/core/test/integration/migrations/migration.test.js
@@ -87,7 +87,7 @@ describe('Migrations', function () {
// Custom assertion to wrap all permissions
function assertCompletePermissions(permissions) {
// If you have to change this number, please add the relevant `assertHavePermission` checks below
- assert.equal(permissions.length, 141);
+ assert.equal(permissions.length, 142);
assertHavePermission(permissions, 'Export database', [
'Administrator',
@@ -664,6 +664,7 @@ describe('Migrations', function () {
'Admin Integration',
]);
assertHavePermission(permissions, 'Read automations', ['Administrator', 'Admin Integration']);
+ assertHavePermission(permissions, 'Add automations', ['Administrator', 'Admin Integration']);
assertHavePermission(permissions, 'Edit automations', ['Administrator', 'Admin Integration']);
assertHavePermission(permissions, 'Poll automations', ['Scheduler Integration']);
diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js
index 7ac0892381b..e094c55aac7 100644
--- a/ghost/core/test/unit/server/data/schema/integrity.test.js
+++ b/ghost/core/test/unit/server/data/schema/integrity.test.js
@@ -39,7 +39,7 @@ const parseYaml = require('../../../../../core/server/services/route-settings/ya
describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = 'f830d874fe0850d84021c1301e7ce6b7';
- const currentFixturesHash = '5718e0d4eb037f159c312369e949829a';
+ const currentFixturesHash = '7b7dc2bb39eb98031ef81223c5f5c28e';
const currentSettingsHash = 'ad77752f31c6a7f174c04c499214b975';
const currentRoutesHash = 'd8c25fa01bf6d22a2bcb05ba0de70dc1';
diff --git a/ghost/core/test/utils/fixtures/fixtures.json b/ghost/core/test/utils/fixtures/fixtures.json
index 3e59a521f73..bcbdffe6cfe 100644
--- a/ghost/core/test/utils/fixtures/fixtures.json
+++ b/ghost/core/test/utils/fixtures/fixtures.json
@@ -796,6 +796,11 @@
"action_type": "read",
"object_type": "automation"
},
+ {
+ "name": "Add automations",
+ "action_type": "add",
+ "object_type": "automation"
+ },
{
"name": "Edit automations",
"action_type": "edit",
@@ -1141,7 +1146,7 @@
"collection": "all",
"recommendation": "all",
"gift_link": "all",
- "automation": ["browse", "read", "edit"],
+ "automation": ["browse", "read", "edit", "add"],
"identity": "read"
},
"DB Backup Integration": {
@@ -1191,7 +1196,7 @@
"mention": "browse",
"collection": "all",
"recommendation": "all",
- "automation": ["browse", "read", "edit"],
+ "automation": ["browse", "read", "edit", "add"],
"gift_link": "manage"
},
"Editor": {
From e13c2bc004940d70da3de278986a0ba1cc7ba9f2 Mon Sep 17 00:00:00 2001
From: Evan Hahn
Date: Tue, 29 Sep 2026 13:19:02 -0500
Subject: [PATCH 09/17] Added reusable type for automation trigger tier scope
(#31105)
towards https://linear.app/ghost/issue/NY-1618
This is a types-only change that should have no user impact.
---
.../core/server/services/automations/automations-repository.ts | 2 ++
.../services/automations/database-automations-repository.ts | 3 ++-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/ghost/core/core/server/services/automations/automations-repository.ts b/ghost/core/core/server/services/automations/automations-repository.ts
index 67c6fe5e9e7..5c9c4f2c4f7 100644
--- a/ghost/core/core/server/services/automations/automations-repository.ts
+++ b/ghost/core/core/server/services/automations/automations-repository.ts
@@ -108,6 +108,8 @@ export type RecordEmailSentOptions = Readonly<{
trackOpens: boolean;
}>;
+export type AutomationTriggerTierScope = 'free' | 'all_paid' | 'selected_paid';
+
type AutomationStepBase = {
id: string;
locked_by: string;
diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts
index c55c0f14a6b..68328c4f13c 100644
--- a/ghost/core/core/server/services/automations/database-automations-repository.ts
+++ b/ghost/core/core/server/services/automations/database-automations-repository.ts
@@ -22,6 +22,7 @@ import type {
AutomationSummary,
AutomationStepTerminalStatus,
AutomationStepToRun,
+ AutomationTriggerTierScope,
AutomationsRepository,
BrowseOptions,
EditAutomationData,
@@ -510,7 +511,7 @@ async function ensureAutomation(
name: string;
description: string;
slug: string;
- trigger_tier_scope: 'free' | 'all_paid';
+ trigger_tier_scope: AutomationTriggerTierScope;
}>,
): Promise {
const now = toDatabaseDate(new Date());
From ec404438a7eddd72823d0f70b2dac094cbfb8c09 Mon Sep 17 00:00:00 2001
From: Austin Burdine
Date: Tue, 29 Sep 2026 14:53:17 -0400
Subject: [PATCH 10/17] Fixed PR previews deploying a newer push than the build
they waited on (#31108)
no ref
The preview workflow waits for the Docker build of the PR head SHA, then
resolved the mutable pr-N tag. A push during the (up to 30 minute) wait
moves pr-N, so the preview could run code other than the verified build.
Resolving the per-commit sha- tag closes that gap, but on
pull_request events metadata-action derives it from the synthetic merge
commit, which the CI run API never exposes. DOCKER_METADATA_PR_HEAD_SHA
makes PR builds tag (and label) with the head SHA instead, so the
preview can resolve sha-${HEAD_SHA:0:7} directly. No extra manifest is
pushed, and push-event builds (which the image size report reads as its
baseline) are unaffected.
A missing tag fails the deploy loudly; there is deliberately no pr-N
fallback.
---
.github/workflows/ci.yml | 7 +++++++
.github/workflows/pr-preview.yml | 12 ++++++------
2 files changed, 13 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ecd4188ff2d..ca8a918fa0f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1494,6 +1494,9 @@ jobs:
- name: Docker meta (core)
id: meta-core
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
+ env:
+ # Tag PR builds `sha-` with the PR head, not the synthetic merge commit
+ DOCKER_METADATA_PR_HEAD_SHA: 'true'
with:
images: ${{ steps.strategy.outputs.image-core-name }}
tags: |
@@ -1512,6 +1515,8 @@ jobs:
- name: Docker meta (full)
id: meta-full
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
+ env:
+ DOCKER_METADATA_PR_HEAD_SHA: 'true'
with:
images: ${{ steps.strategy.outputs.image-full-name }}
tags: |
@@ -1762,6 +1767,8 @@ jobs:
continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }}
id: meta-e2e
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
+ env:
+ DOCKER_METADATA_PR_HEAD_SHA: 'true'
with:
images: ${{ steps.strategy.outputs.image-e2e-name }}
tags: |
diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml
index 8a1fc7f258c..1b63dde8a19 100644
--- a/.github/workflows/pr-preview.yml
+++ b/.github/workflows/pr-preview.yml
@@ -105,10 +105,10 @@ jobs:
if: steps.recheck.outputs.skip != 'true'
env:
IMAGE_REPO: tryghost/ghost
- PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
- # Pin the deploy to an immutable digest so Artifact Registry's pull-through
- # cache cannot serve a stale manifest for the mutable pr-N tag.
+ # Resolve the per-commit tag (not mutable pr-N) so a push during the wait
+ # cannot swap the image, then pin the digest past Artifact Registry's cache.
+ IMAGE_TAG="sha-${HEAD_SHA:0:7}"
TOKEN=$(curl -sf "https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull" | jq -r '.token')
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "::error::Failed to acquire anonymous GHCR token for ${IMAGE_REPO}"
@@ -119,13 +119,13 @@ jobs:
-H "Accept: application/vnd.oci.image.index.v1+json" \
-H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
- "https://ghcr.io/v2/${IMAGE_REPO}/manifests/pr-${PR_NUMBER}" \
+ "https://ghcr.io/v2/${IMAGE_REPO}/manifests/${IMAGE_TAG}" \
| awk 'tolower($1) == "docker-content-digest:" {print $2}' | tr -d '\r\n')
if [[ ! "$DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]; then
- echo "::error::Could not resolve digest for ghcr.io/${IMAGE_REPO}:pr-${PR_NUMBER} (got: '$DIGEST')"
+ echo "::error::Could not resolve digest for ghcr.io/${IMAGE_REPO}:${IMAGE_TAG} (got: '$DIGEST')"
exit 1
fi
- echo "Resolved digest: $DIGEST"
+ echo "Resolved ${IMAGE_TAG} digest: $DIGEST"
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
- name: Dispatch deploy to Ghost-Moya
From e0c84e8ed7793f1e86f2aeb519eaa57fd437c80e Mon Sep 17 00:00:00 2001
From: Evan Hahn
Date: Tue, 29 Sep 2026 14:08:38 -0500
Subject: [PATCH 11/17] Added more tests for editing an automation (#31107)
no ref
This is a test-only change that fills in some untested behavior.
---
.../automations/automations-api.test.ts | 43 +++++++++++++++++++
1 file changed, 43 insertions(+)
diff --git a/ghost/core/test/unit/server/services/automations/automations-api.test.ts b/ghost/core/test/unit/server/services/automations/automations-api.test.ts
index 96b225827ee..f7f7e16721b 100644
--- a/ghost/core/test/unit/server/services/automations/automations-api.test.ts
+++ b/ghost/core/test/unit/server/services/automations/automations-api.test.ts
@@ -8,6 +8,12 @@ import {
NON_EMPTY_EMAIL_LEXICAL,
} from '../../../../utils/automations-fixtures';
+const buildWaitAction = () => ({
+ id: ObjectId().toHexString(),
+ type: 'wait',
+ data: { wait_hours: 1 },
+});
+
const buildSendEmailAction = (dataOverrides = {}) => ({
id: ObjectId().toHexString(),
type: 'send_email',
@@ -19,6 +25,11 @@ const buildSendEmailAction = (dataOverrides = {}) => ({
},
});
+const buildEdge = (source: Readonly<{ id: string }>, target: Readonly<{ id: string }>) => ({
+ source_action_id: source.id,
+ target_action_id: target.id,
+});
+
describe('automations API', function () {
afterEach(function () {
sinon.restore();
@@ -128,5 +139,37 @@ describe('automations API', function () {
/well-formed Lexical document/,
);
});
+
+ it('rejects duplicate edges', async function () {
+ const first = buildWaitAction();
+ const second = buildWaitAction();
+
+ await assert.rejects(
+ automationsApi.edit(automationId, {
+ status: 'inactive',
+ actions: [first, second],
+ edges: [buildEdge(first, second), buildEdge(first, second)],
+ }),
+ /edges must be unique/,
+ );
+ });
+
+ it('rejects a path with a separate cycle', async function () {
+ // A -> B is a valid path, but C <-> D forms a disconnected cycle. The
+ // edge count and head/tail counts still look like a linear path.
+ const a = buildWaitAction();
+ const b = buildWaitAction();
+ const c = buildWaitAction();
+ const d = buildWaitAction();
+
+ await assert.rejects(
+ automationsApi.edit(automationId, {
+ status: 'inactive',
+ actions: [a, b, c, d],
+ edges: [buildEdge(a, b), buildEdge(c, d), buildEdge(d, c)],
+ }),
+ /graph must be a single linear path/,
+ );
+ });
});
});
From b213e3bf3be1af1bf66a24626d4adcb2208aa2e5 Mon Sep 17 00:00:00 2001
From: Evan Hahn
Date: Tue, 29 Sep 2026 14:14:37 -0500
Subject: [PATCH 12/17] Tested result of automations "fetch-and-lock" function
(#31109)
towards https://linear.app/ghost/issue/NY-1618
This is a test-only change. I think it's useful on its own but it'll
also make an upcoming change a little easier.
---
.../database-automations-repository.test.ts | 28 +++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts
index 8bc1eff1acb..bef87f2b2fb 100644
--- a/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts
+++ b/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts
@@ -1782,6 +1782,34 @@ describe('automations repository', function () {
assert.equal(step.updated_at, step.locked_at);
};
+ it('returns step data', async function () {
+ const automation = await getAutomationBySlug('member-welcome-email-free');
+ const action = await getActionByIndex(automation.id, 0);
+ const run = await insertRun(automation.id);
+ const readyAt = new Date('2024-01-01T00:00:00.000Z');
+ const stepRow = await insertStep(run.id, action.revision_id, { ready_at: readyAt });
+
+ const { steps } = await repo.fetchAndLockSteps(1);
+ assert.deepEqual(steps, [
+ {
+ id: stepRow.id,
+ locked_by: assertSingleBatchLock(steps),
+ automation_run_id: run.id,
+ automation_id: automation.id,
+ automation_slug: automation.slug,
+ automation_status: 'active',
+ member_id: run.member_id,
+ member_email: run.member_email,
+ action_id: action.action_id,
+ automation_action_revision_id: action.revision_id,
+ ready_at: new Date(toRepositoryDateISOString(readyAt)),
+ step_attempts: 1,
+ type: 'wait',
+ wait_hours: 48,
+ },
+ ]);
+ });
+
it('locks ready and steps with stale locks, but skips future and recently-locked steps', async function () {
const automation = await getAutomationBySlug('member-welcome-email-free');
const action = await getActionByIndex(automation.id, 0);
From 7e32df6cef71729c37b5388ec047e5528f030705 Mon Sep 17 00:00:00 2001
From: Austin Burdine
Date: Tue, 29 Sep 2026 15:19:14 -0400
Subject: [PATCH 13/17] Added seed profile selection to the PR preview label
(#31106)
Previews always seeded the same dataset. A `preview:` label alongside
`preview` now chooses how much data the preview starts with, so a change can be
reviewed against an empty site, an ordinary one, or one at the scale of the
largest Pro archives.
The profile names are not repeated here. They are defined and validated in
TryGhost/Ghost-Moya's preview/profiles.json, and this workflow passes the label
suffix straight through, so adding a profile there is the only edit needed.
Sending nothing keeps the old behaviour: Ghost-Moya applies its own default.
Adding a profile label to an already-labelled PR redeploys, because otherwise
the label would name a profile the running preview is not on. Ghost-Moya
compares the requested profile against the one recorded on the Cloud Run
service and reseeds when they differ.
`preview` stays the switch and a profile label only modifies it, so a profile
label on its own is not a request for a preview. It says that rather than
reporting the label as removed, which is the likeliest first mistake and the
least helpful thing to be told about it.
---
.github/workflows/pr-preview.yml | 81 ++++++++++++++++++++++++++++----
1 file changed, 73 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml
index 1b63dde8a19..ad65e1168e7 100644
--- a/.github/workflows/pr-preview.yml
+++ b/.github/workflows/pr-preview.yml
@@ -7,10 +7,21 @@ on:
jobs:
deploy:
name: Deploy Preview
- # Runs when the "preview" label is added — requires collaborator write access
+ # Runs when "preview" or a "preview:" label is added — either needs
+ # collaborator write access. Adding a profile label to an already-labelled PR
+ # has to redeploy, or the label would claim a profile the preview is not on;
+ # Ghost-Moya notices the change and reseeds.
+ #
+ # Removing a profile label redeploys for the same reason, in reverse: the
+ # labels would otherwise say "default" while the preview stayed seeded on
+ # whatever the removed label named. `startsWith` excludes the bare `preview`
+ # label, which has no colon, so removing that still only destroys.
if: >-
- github.event.action == 'labeled'
- && github.event.label.name == 'preview'
+ (github.event.action == 'labeled'
+ && (github.event.label.name == 'preview'
+ || startsWith(github.event.label.name, 'preview:')))
+ || (github.event.action == 'unlabeled'
+ && startsWith(github.event.label.name, 'preview:'))
runs-on: ubuntu-latest
permissions:
contents: read
@@ -92,14 +103,68 @@ jobs:
if [ "$STATE" != "open" ]; then
echo "::warning::PR is no longer open ($STATE), skipping dispatch"
echo "skip=true" >> "$GITHUB_OUTPUT"
- elif [ "$HAS_LABEL" != "true" ]; then
- echo "::warning::preview label was removed, skipping dispatch"
+ exit 0
+ fi
+ # `preview` is the switch; `preview:` only says which data to
+ # use. A profile label on its own is not a request for a preview, and
+ # saying so beats the older "label was removed" warning, which is
+ # actively misleading when it was never added.
+ if [ "$HAS_LABEL" != "true" ]; then
+ if echo "$PR" | jq -e '.labels | any(startswith("preview:"))' > /dev/null; then
+ echo "::warning::A preview: label needs the 'preview' label too, skipping dispatch"
+ else
+ echo "::warning::preview label was removed, skipping dispatch"
+ fi
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # A `preview:` label picks how much seed data the preview starts
+ # with. The names are defined in TryGhost/Ghost-Moya's
+ # preview/profiles.json and validated there, so nothing here needs to
+ # know them — passing the suffix through keeps one source of truth.
+ # Sending nothing means Moya applies its own default.
+ # Counted with jq rather than by word-splitting: a label may contain a
+ # space, and counting words would read one such label as two profiles
+ # and reject it with a message about a conflict that does not exist.
+ COUNT=$(echo "$PR" | jq '[.labels[] | select(startswith("preview:"))] | length')
+ PROFILE=$(echo "$PR" | jq -r '[.labels[] | select(startswith("preview:")) | ltrimstr("preview:")] | first // ""')
+
+ if [ "$COUNT" -gt 1 ]; then
+ NAMES=$(echo "$PR" | jq -r '[.labels[] | select(startswith("preview:"))] | join(", ")')
+ echo "::error::Multiple preview profile labels ($NAMES) — remove all but one"
echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 1
+ fi
+
+ if [ "$COUNT" -eq 1 ]; then
+ # Shape only, not a list of names — the names stay Ghost-Moya's to
+ # own. This rejects a malformed label here, with a message that says
+ # what is wrong, rather than dispatching it to fail further away.
+ #
+ # Matched with [[ =~ ]] rather than grep. grep works a line at a
+ # time, so ^ and $ bound a line, not the string, and `grep -q`
+ # succeeds if any one line matches: a label containing a newline
+ # would pass on its first line and then write a second line into
+ # GITHUB_OUTPUT, which becomes a step output of its own. This
+ # workflow is pull_request_target and carries a dispatch token, so
+ # that is worth closing rather than noting. bash anchors to the
+ # whole string, so a newline cannot match at all.
+ if ! [[ "$PROFILE" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
+ echo "::error::Malformed profile label 'preview:${PROFILE}' — expected lowercase words separated by hyphens"
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 1
+ fi
+ echo "Preview profile: ${PROFILE}"
+ echo "profile=${PROFILE}" >> "$GITHUB_OUTPUT"
else
- echo "PR still eligible for preview deploy"
- echo "skip=false" >> "$GITHUB_OUTPUT"
+ echo "No preview: label, Ghost-Moya will use its default"
+ echo "profile=" >> "$GITHUB_OUTPUT"
fi
+ echo "PR still eligible for preview deploy"
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+
- name: Resolve image digest from GHCR
id: digest
if: steps.recheck.outputs.skip != 'true'
@@ -140,7 +205,7 @@ jobs:
"pr_number": "${{ github.event.pull_request.number }}",
"image_digest": "${{ steps.digest.outputs.digest }}",
"action": "deploy",
- "seed": "true"
+ "profile": "${{ steps.recheck.outputs.profile }}"
}
destroy:
From 45635eb356304ae0d9d3a8ae5fe9e42dedbfc396 Mon Sep 17 00:00:00 2001
From: Evan Hahn
Date: Tue, 29 Sep 2026 15:19:43 -0500
Subject: [PATCH 14/17] Increased max automation actions accepted by API to 50
(#31098)
towards https://linear.app/ghost/issue/NY-1642
A simple increase to the limit.
A frontend change will follow. (It needs to be deployed after the server
changes, otherwise some requests could fail.)
---
.../server/services/automations/automations-api.ts | 2 +-
ghost/core/test/e2e-api/admin/automations.test.js | 12 ++++++------
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/ghost/core/core/server/services/automations/automations-api.ts b/ghost/core/core/server/services/automations/automations-api.ts
index 19c0fc98db4..1c6b8a432e5 100644
--- a/ghost/core/core/server/services/automations/automations-api.ts
+++ b/ghost/core/core/server/services/automations/automations-api.ts
@@ -19,7 +19,7 @@ const TinybirdServiceWrapper = require('../tinybird');
const { create: createTinybirdClient } = require('../stats/utils/tinybird');
const lexicalLib = require('../../lib/lexical');
-const MAX_AUTOMATION_ACTIONS = 20;
+const MAX_AUTOMATION_ACTIONS = 50;
const messages = {
automationNotFound: 'Automation not found.',
diff --git a/ghost/core/test/e2e-api/admin/automations.test.js b/ghost/core/test/e2e-api/admin/automations.test.js
index 33fc5438ca3..8160afdb8d8 100644
--- a/ghost/core/test/e2e-api/admin/automations.test.js
+++ b/ghost/core/test/e2e-api/admin/automations.test.js
@@ -1051,11 +1051,11 @@ describe('Automations API', function () {
assert.deepEqual(readBody.automations[0], automation);
});
- it('allows an automation with 20 actions', async function () {
+ it('allows an automation with 50 actions', async function () {
const { body: browseBody } = await agent.get('automations').expectStatus(200);
const automationId = browseBody.automations[0].id;
- const actions = Array.from({ length: 20 }, buildWaitAction);
+ const actions = Array.from({ length: 50 }, buildWaitAction);
const edges = buildLinearEdges(actions);
const { body: editBody } = await agent
@@ -1074,20 +1074,20 @@ describe('Automations API', function () {
const automation = editBody.automations[0];
assert.equal(automation.status, 'inactive');
- assert.equal(automation.actions.length, 20);
- assert.equal(automation.edges.length, 19);
+ assert.equal(automation.actions.length, 50);
+ assert.equal(automation.edges.length, 49);
assert.deepEqual(automation.actions, actions);
assert.deepEqual(automation.edges, edges);
});
- it('rejects an automation with more than 20 actions', async function () {
+ it('rejects an automation with more than 50 actions', async function () {
const { body: browseBody } = await agent.get('automations').expectStatus(200);
const automationId = browseBody.automations[0].id;
const { body: beforeBody } = await agent.get(`automations/${automationId}`).expectStatus(200);
- const actions = Array.from({ length: 21 }, buildWaitAction);
+ const actions = Array.from({ length: 51 }, buildWaitAction);
await agent
.put(`automations/${automationId}`)
From 93542b9c316eb1086cc7659e3138adf6f95b48c2 Mon Sep 17 00:00:00 2001
From: Evan Hahn
Date: Tue, 29 Sep 2026 15:35:05 -0500
Subject: [PATCH 15/17] Added automation description to read/browse APIs
(#30862)
towards https://linear.app/ghost/issue/NY-1621
This change should have no user impact today, but automations' "real"
descriptions now render in the UI.
In addition to automated tests, I manually verified this in the UI by
changing the value in the database and making sure the new descriptions
appear:

---
.../automations/automations-repository.ts | 1 +
.../database-automations-repository.ts | 8 +++++---
.../__snapshots__/automations.test.js.snap | 7 +++++--
.../test/e2e-api/members/automations.test.js | 18 ++++++++++++++++++
4 files changed, 29 insertions(+), 5 deletions(-)
diff --git a/ghost/core/core/server/services/automations/automations-repository.ts b/ghost/core/core/server/services/automations/automations-repository.ts
index 5c9c4f2c4f7..2ff222887ab 100644
--- a/ghost/core/core/server/services/automations/automations-repository.ts
+++ b/ghost/core/core/server/services/automations/automations-repository.ts
@@ -60,6 +60,7 @@ export type AutomationSummary = {
id: string;
slug: null | string;
name: string;
+ description: string;
status: string;
created_at: string;
updated_at: string;
diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts
index 68328c4f13c..f9ecb1168e5 100644
--- a/ghost/core/core/server/services/automations/database-automations-repository.ts
+++ b/ghost/core/core/server/services/automations/database-automations-repository.ts
@@ -1100,7 +1100,7 @@ async function loadAutomation(
automationId: string,
): Promise {
const row = await trx('automations')
- .select('id', 'slug', 'name', 'status', 'created_at', 'updated_at')
+ .select('id', 'slug', 'name', 'description', 'status', 'created_at', 'updated_at')
.where('id', automationId)
.first();
return row ?? null;
@@ -1111,7 +1111,7 @@ async function loadAutomationBySlug(
slug: string,
): Promise {
const row = await trx('automations')
- .select('id', 'slug', 'name', 'status', 'created_at', 'updated_at')
+ .select('id', 'slug', 'name', 'description', 'status', 'created_at', 'updated_at')
.where('slug', slug)
.first();
return row ?? null;
@@ -1119,7 +1119,7 @@ async function loadAutomationBySlug(
async function loadAutomations(trx: Knex.Transaction): Promise {
return await trx('automations')
- .select('id', 'slug', 'name', 'status', 'created_at', 'updated_at')
+ .select('id', 'slug', 'name', 'description', 'status', 'created_at', 'updated_at')
.orderBy('name');
}
@@ -1141,6 +1141,7 @@ async function loadAutomationsWithStats(trx: Knex.Transaction): Promise automation.slug === MEMBER_WELCOME_EMAIL_SLUGS.free,
+ );
+ assert(summary);
+ assert.equal(summary.description, description);
+
+ const { body: readBody } = await agent.get(`automations/${summary.id}`).expectStatus(200);
+ assert.equal(readBody.automations[0].description, description);
+ });
+
it('runs every step in the free member signup automation', async function () {
let automation = await getFreeMemberSignupAutomation();
assert.equal(automation.actions.length, 4);
From f693d6d85a31dea8ce53bd54d74437f7029d1ad8 Mon Sep 17 00:00:00 2001
From: Evan Hahn
Date: Tue, 29 Sep 2026 16:14:21 -0500
Subject: [PATCH 16/17] TypeScriptify email rendering "finalize" function
(#31111)
no ref
This change should have no user impact.
---
.../{finalize.js => finalize.ts} | 25 ++++++-------------
1 file changed, 8 insertions(+), 17 deletions(-)
rename ghost/core/core/server/lib/email-rendering/{finalize.js => finalize.ts} (69%)
diff --git a/ghost/core/core/server/lib/email-rendering/finalize.js b/ghost/core/core/server/lib/email-rendering/finalize.ts
similarity index 69%
rename from ghost/core/core/server/lib/email-rendering/finalize.js
rename to ghost/core/core/server/lib/email-rendering/finalize.ts
index 0b45985258e..bf485adf270 100644
--- a/ghost/core/core/server/lib/email-rendering/finalize.js
+++ b/ghost/core/core/server/lib/email-rendering/finalize.ts
@@ -1,12 +1,9 @@
-const cheerio = require('cheerio/slim');
-const juice = require('juice');
-const htmlToPlaintext = require('@tryghost/html-to-plaintext');
+import * as cheerio from 'cheerio/slim';
+import juice from 'juice';
+// @ts-expect-error This module currently lacks type definitions.
+import htmlToPlaintext from '@tryghost/html-to-plaintext';
-/**
- * @param {string} html
- * @returns {string}
- */
-const finalizeHtml = (html) => {
+const finalizeHtml = (html: string): string => {
// Add a class to each figcaption so we can style them in the email.
let $ = cheerio.load(html, null, false);
$('figcaption').addClass('kg-card-figcaption');
@@ -40,13 +37,7 @@ const finalizeHtml = (html) => {
return html;
};
-module.exports = {
- /**
- * @param {string} html
- * @returns {{html: string, plaintext: string}}
- */
- finalize(html) {
- const resultHtml = finalizeHtml(html);
- return { html: resultHtml, plaintext: htmlToPlaintext.email(resultHtml) };
- },
+export const finalize = (html: string): { html: string; plaintext: string } => {
+ const resultHtml = finalizeHtml(html);
+ return { html: resultHtml, plaintext: htmlToPlaintext.email(resultHtml) };
};
From e37237bbb0edad38999ea2d174194e5c2b680c92 Mon Sep 17 00:00:00 2001
From: Evan Hahn
Date: Tue, 29 Sep 2026 16:26:22 -0500
Subject: [PATCH 17/17] Started waiting for automation schedule to complete
(#31112)
no ref
This change should have no user impact.
The scheduler's `schedule()` call may return a promise. If it does, we
should `await` it. (If it doesn't, `await`ing does nothing.)
---
ghost/core/core/server/services/automations/service.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ghost/core/core/server/services/automations/service.ts b/ghost/core/core/server/services/automations/service.ts
index 2fcbced6d1c..565e891fb02 100644
--- a/ghost/core/core/server/services/automations/service.ts
+++ b/ghost/core/core/server/services/automations/service.ts
@@ -80,7 +80,7 @@ export class AutomationsService {
try {
const schedulerPollTime = getSchedulerPollTime(date, siteIdentifier);
const key = await internalKeys.get('ghost-scheduler');
- schedulerAdapter.schedule(
+ await schedulerAdapter.schedule(
buildSignedJob({
apiUrl,
path: ['automations', 'poll'],