Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
211f112
TypeScriptified limits service (#31032)
EvanHahn Sep 28, 2026
446817a
Moved magic-link helpers into a dedicated server library (#31028)
ErisDS Sep 28, 2026
e3972e6
Added Sentry reporting for failed saves in the React editor (#30999)
9larsons Sep 28, 2026
414bf1b
Fixed the React editor refusing a new post's saves while no tier is p…
9larsons Sep 28, 2026
4669ca5
Added in-development table support for iterating on new schemas (#30987)
acburdine Sep 28, 2026
bf68b83
Fixed publish focus restoration during settings refresh (#31036)
9larsons Sep 28, 2026
086507f
Moved the View site and migration screens from Ember to React (#31003)
9larsons Sep 28, 2026
f2bff7f
Improved Admin acceptance test output (#31038)
9larsons Sep 28, 2026
7cde4c3
🐛 Fixed crashes when a subscription's member or paid tier is missing …
acburdine Sep 28, 2026
58f1762
TypeScriptified member attribution unit tests (#31045)
EvanHahn Sep 28, 2026
7394564
TypeScriptified session controller tests (#31044)
EvanHahn Sep 28, 2026
2057fe8
Fixed flaky "queue request" middleware test (#31049)
EvanHahn Sep 28, 2026
29345c9
Added tests for frontend add/edit automated email APIs (#31048)
EvanHahn Sep 28, 2026
f65963a
Formatted tinybird api_kpis.pipe file (#30831)
cmraible Sep 28, 2026
f6e5f78
Fixed welcome email rows ignoring Edit clicks while emails load (#31046)
9larsons Sep 28, 2026
55bf991
🐛 Fixed Posts filter behavior in editor and sidebar navigation (#31056)
9larsons Sep 28, 2026
8e14e7b
🐛 Fixed posts losing selection during bulk actions (#31057)
9larsons Sep 28, 2026
60bb8dd
Changed Admin acceptance tests to run in two CI shards (#31037)
9larsons Sep 28, 2026
88ae6d6
Improved handling of import regexes (#31058)
acburdine Sep 28, 2026
1be06f4
Improved image type checks for uploads, imports and remote images (#3…
acburdine Sep 28, 2026
ae3febb
Added automatic Tinybird rebuilds to analytics development (#31061)
cmraible Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agents/skills/create-database-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ accompanies it.

## Instructions

If you are adding a new table whose shape is still changing, consider marking it
as in development instead of writing a migration yet; see "New tables still in
development" in the guide.

1. Create a new, empty migration file: `cd ghost/core && pnpm migrate:create <kebab-case-slug>`. IMPORTANT: do not create the migration file manually; always use this script to create the initial empty migration file. The slug must be kebab-case (e.g. `add-column-to-posts`).
2. The above command will create a new directory in `ghost/core/core/server/data/migrations/versions` if needed, create the empty migration file with the appropriate name, and bump the core and admin package versions to RC if this is the first migration after a release.
3. Update the migration file with the changes you want to make in the database, following the existing patterns in the codebase. Where appropriate, prefer to use the utility functions in `ghost/core/core/server/data/migrations/utils/*`.
Expand Down
27 changes: 17 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ jobs:
echo 'typecheck_projects_str=' >> "$GITHUB_OUTPUT"
echo 'unit_test_projects_str=' >> "$GITHUB_OUTPUT"
echo 'affected_i18n_projects=' >> "$GITHUB_OUTPUT"
echo 'affected_playwright_projects=[]' >> "$GITHUB_OUTPUT"
echo 'affected_playwright_matrix=[]' >> "$GITHUB_OUTPUT"
echo 'publish_public_apps_matrix=[]' >> "$GITHUB_OUTPUT"
exit 0
fi
Expand Down Expand Up @@ -290,7 +290,12 @@ jobs:
--withTarget test:acceptance \
--projects 'tag:playwright' \
--json)
echo "affected_playwright_projects=$PLAYWRIGHT_PROJECTS" >> "$GITHUB_OUTPUT"
# Split Admin across two runners; every other app keeps a single shard.
PLAYWRIGHT_MATRIX=$(jq -c '[.[] | . as $app |
(if $app == "@tryghost/admin" then 2 else 1 end) as $total |
range(1; $total + 1) | {app: $app, shardIndex: ., shardTotal: $total}
]' <<< "$PLAYWRIGHT_PROJECTS")
echo "affected_playwright_matrix=$PLAYWRIGHT_MATRIX" >> "$GITHUB_OUTPUT"

# Build the publish matrix for affected public apps. Matrix context
# isn't available in a job-level `if:`, so the publish job can't filter
Expand All @@ -305,7 +310,7 @@ jobs:
affected_projects_str: ${{ steps.affected.outputs.affected_projects_str }}
typecheck_projects_str: ${{ steps.affected.outputs.typecheck_projects_str }}
unit_test_projects_str: ${{ steps.affected.outputs.unit_test_projects_str }}
affected_playwright_projects: ${{ steps.affected.outputs.affected_playwright_projects }}
affected_playwright_matrix: ${{ steps.affected.outputs.affected_playwright_matrix }}
publish_public_apps_matrix: ${{ steps.affected.outputs.publish_public_apps_matrix }}
changed_i18n_apps: ${{ steps.affected.outputs.affected_i18n_projects != '' }}
changed_core: ${{ steps.changed.outputs.core }}
Expand Down Expand Up @@ -988,12 +993,12 @@ jobs:
# are not starved on the default runner. See job_acceptance-tests above.
runs-on: ${{ (github.repository_owner == 'TryGhost' && github.repository != 'TryGhost/Ghost' && matrix.app == '@tryghost/admin') && 'ubuntu-latest-4-cores' || 'ubuntu-latest' }}
needs: [job_setup]
if: needs.job_setup.outputs.affected_playwright_projects != '[]'
name: App Playwright Acceptance Tests
if: needs.job_setup.outputs.affected_playwright_matrix != '[]'
name: App Playwright Acceptance Tests (${{ matrix.app }} ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
strategy:
fail-fast: false
matrix:
app: ${{ fromJSON(needs.job_setup.outputs.affected_playwright_projects) }}
include: ${{ fromJSON(needs.job_setup.outputs.affected_playwright_matrix) }}
env:
CI: true
steps:
Expand Down Expand Up @@ -1066,7 +1071,7 @@ jobs:
run: pnpm exec playwright install-deps firefox

- name: Run Playwright tests
run: pnpm nx run ${{ matrix.app }}:test:acceptance
run: pnpm nx run ${{ matrix.app }}:test:acceptance --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}

- name: Get App Name
if: always()
Expand All @@ -1090,7 +1095,7 @@ jobs:
if: always() && steps.app_name.outputs.has_report == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.app_name.outputs.name }}-playwright-report
name: ${{ steps.app_name.outputs.name }}-playwright-report-${{ matrix.shardIndex }}
path: ${{ steps.app_name.outputs.root }}/playwright-report
retention-days: 30

Expand All @@ -1101,7 +1106,7 @@ jobs:
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.app_name.outputs.name }}-failure-screenshots
name: ${{ steps.app_name.outputs.name }}-failure-screenshots-${{ matrix.shardIndex }}
path: ${{ steps.app_name.outputs.root }}/**/__screenshots__/**
if-no-files-found: ignore
retention-days: 30
Expand Down Expand Up @@ -2388,7 +2393,9 @@ jobs:
runs-on: ubuntu-slim
steps:
- name: Output needs
run: echo "${{ toJson(needs) }}"
env:
NEEDS_JSON: ${{ toJson(needs) }}
run: printf '%s\n' "$NEEDS_JSON"

- name: Check if any required jobs failed or been cancelled
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
Expand Down
58 changes: 58 additions & 0 deletions apps/admin-x-framework/test/unit/api/automated-emails.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { act } from '@testing-library/react';
import { renderHookWithProviders } from '../../../src/test/test-utils';
import {
useAddAutomatedEmail,
useEditAutomatedEmail,
type AutomatedEmail,
} from '../../../src/api/automated-emails';
import { withMockFetch } from '../../utils/mock-fetch';

const email: AutomatedEmail = {
id: 'email-id',
name: 'Free member welcome flow',
slug: 'member-welcome-email-free',
status: 'active',
subject: 'Welcome!',
lexical: null,
sender_name: null,
sender_email: null,
sender_reply_to: null,
created_at: '2026-01-01T00:00:00.000Z',
updated_at: null,
};

describe('automated email APIs', () => {
describe('useAddAutomatedEmail', () => {
it('sends the creation payload', async () => {
await withMockFetch({ json: { automated_emails: [email] } }, async (mock) => {
const { result } = renderHookWithProviders(() => useAddAutomatedEmail());
await act(async () => {
await result.current.mutateAsync(email);
});
const request = mock.calls.find(
([, options]: [unknown, RequestInit]) => options.method === 'POST',
);
expect(request[0]).toBe('http://localhost:3000/ghost/api/admin/automated_emails/');
expect(JSON.parse(request[1].body)).toEqual({ automated_emails: [email] });
});
});
});

describe('useEditAutomatedEmail', () => {
it('sends the update payload', async () => {
await withMockFetch({ json: { automated_emails: [email] } }, async (mock) => {
const { result } = renderHookWithProviders(() => useEditAutomatedEmail());
await act(async () => {
await result.current.mutateAsync(email);
});
const request = mock.calls.find(
([, options]: [unknown, RequestInit]) => options.method === 'PUT',
);
expect(request[0]).toBe('http://localhost:3000/ghost/api/admin/automated_emails/email-id/');
expect(JSON.parse(request[1].body)).toEqual({
automated_emails: [email],
});
});
});
});
});
60 changes: 53 additions & 7 deletions apps/admin/src/editor/editor-header.acceptance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ describe('Editor header actions', () => {

await expect(previewScreen.modal()).toHaveCount(0);
await expect.element(publishScreen.options()).toBeVisible();
await expect.element(publishScreen.previewButton()).toHaveFocus();
});

it('publishes from a preview opened by the header Preview button', async () => {
Expand Down Expand Up @@ -614,25 +615,67 @@ describe('Editor header actions', () => {
await editorScreen.previewButton().click();
await expect.element(previewScreen.modal()).toBeVisible();

await expect
.poll(() => previewScreen.modal().element().contains(document.activeElement))
.toBe(true);

await userEvent.keyboard('{Escape}');

await expect(previewScreen.modal()).toHaveCount(0);
await expect.element(editorScreen.previewButton()).toHaveFocus();
});

it('returns focus to the Publish button when the publish flow closes', async () => {
it.each(['Escape', 'Close button'] as const)(
'returns focus to Publish after closing with %s and reopening',
async (closeWith) => {
publishChrome();
fakeSavablePost();
await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON);

await expect.element(editorScreen.publishButton()).toBeEnabled();
for (let opening = 0; opening < 2; opening += 1) {
await editorScreen.publishButton().click();
await expect.element(publishScreen.options()).toBeVisible();
await expect
.poll(() => publishScreen.root().element().contains(document.activeElement))
.toBe(true);

if (closeWith === 'Escape') {
await userEvent.keyboard('{Escape}');
} else {
await publishScreen.closeButton().click();
}

await expect(publishScreen.root()).toHaveCount(0);
await expect.element(editorScreen.publishButton()).toHaveFocus();
}
},
);

it('returns focus to Publish when closed during the publish settings refresh', async () => {
publishChrome();
fakeSavablePost();
await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON);

await expect.element(editorScreen.publishButton()).toBeEnabled();
await editorScreen.publishButton().click();
await expect.element(publishScreen.options()).toBeVisible();

await userEvent.keyboard('{Escape}');
const refreshedSettings = deferred<void>();
const settingsApi = fakeAdminEndpoint('GET', /^\/settings\//, async () => {
await refreshedSettings.promise;
return settingsResponse({ labs: FLAG_ON.labs });
});

try {
await editorScreen.publishButton().click();
await expect.element(publishScreen.options()).toBeVisible();
await expect.poll(() => settingsApi.requests.length).toBeGreaterThan(0);
await expect.element(editorScreen.publishButton()).toBeEnabled();

await expect(publishScreen.root()).toHaveCount(0);
await expect.element(editorScreen.publishButton()).toHaveFocus();
await userEvent.keyboard('{Escape}');
await expect(publishScreen.root()).toHaveCount(0);
await expect.element(editorScreen.publishButton()).toHaveFocus();
} finally {
refreshedSettings.resolve();
}
});

it('returns focus to the Unpublish button when the update flow closes', async () => {
Expand All @@ -642,6 +685,9 @@ describe('Editor header actions', () => {

await editorScreen.unpublishButton().click();
await expect.element(publishScreen.updateFlow()).toBeVisible();
await expect
.poll(() => publishScreen.updateFlow().element().contains(document.activeElement))
.toBe(true);

await userEvent.keyboard('{Escape}');

Expand Down
6 changes: 5 additions & 1 deletion apps/admin/src/editor/editor-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useState,
} from 'react';
import { AdminLink } from '@/shared/admin-link';
import { getPostListReturnUrl } from '@/posts/api';
import { NotFound } from '@/shared/not-found';
import { Navigate, useNavigate, useParams } from '@tryghost/admin-x-framework';
import { Button, LoadingIndicator } from '@tryghost/shade/components';
Expand Down Expand Up @@ -75,6 +76,9 @@ function EditorLoadError({ message, onRetry }: { message: string; onRetry: () =>

function EditorHeader({ postType, children }: { postType: PostType; children?: ReactNode }) {
const listLabel = postType === 'page' ? 'Pages' : 'Posts';
const reactOwnsList = useFeatureFlag('postsListReact');
const resource = postType === 'page' ? 'pages' : 'posts';
const listUrl = reactOwnsList ? getPostListReturnUrl(resource) : `/${resource}`;

return (
<Grid
Expand All @@ -89,7 +93,7 @@ function EditorHeader({ postType, children }: { postType: PostType; children?: R
label={listLabel}
asChild
>
<AdminLink to={postType === 'page' ? '/pages' : '/posts'}>
<AdminLink to={listUrl}>
<LucideIcon.ArrowLeft />
{listLabel}
</AdminLink>
Expand Down
77 changes: 70 additions & 7 deletions apps/admin/src/editor/editor-settings-access.acceptance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { userEvent } from 'vitest/browser';

import {
browseResponse,
currentRoute,
currentUserResponse,
fakeAdminEndpoint,
fakeEditorChrome,
Expand All @@ -15,6 +16,7 @@ import {
submittedPost,
tier,
unsavedChangesGuarded,
withFastAutosave,
withoutAutosave,
} from '@test-utils/acceptance';
import { editorScreen } from '@/editor/editor.screen';
Expand Down Expand Up @@ -142,23 +144,84 @@ describe('Post settings access', () => {
await expect(editorScreen.settingsTiersError()).toHaveCount(0);
});

it('refuses the first save until an explicitly selected tier access has a tier', async () => {
it('creates a new post with specific-tier access before a tier is picked, then sends the pair', async () => {
editorChrome();
const createApi = fakeAdminEndpoint('POST', /^\/posts\/\?/, {
posts: [post({ id: NEW_POST_ID, visibility: 'public' })],
// A Public read carries every site tier, the free one included.
let created = post({
id: NEW_POST_ID,
title: '(Untitled)',
status: 'draft',
visibility: 'public',
tiers: SITE_TIERS,
tags: [],
});
const createApi = fakeAdminEndpoint('POST', /^\/posts\/\?/, ({ body }) => {
const submitted = (body as { posts: Partial<SavedPost>[] }).posts[0];
created = { ...created, ...submitted, id: NEW_POST_ID, updated_at: LOADED_AT };
return { posts: [created] };
});
await renderAdminApp('/editor/post', FLAG_ON);
fakeAdminEndpoint('GET', new RegExp(`^/posts/${NEW_POST_ID}/\\?`), () => ({
posts: [created],
}));
const updateApi = fakeAdminEndpoint(
'PUT',
new RegExp(`^/posts/${NEW_POST_ID}/\\?`),
({ body }) => {
const submitted = (body as { posts: Partial<SavedPost>[] }).posts[0];
created = {
...created,
...submitted,
updated_at: new Date(Date.parse(created.updated_at) + 1000).toISOString(),
};
return { posts: [created] };
},
);

await renderAdminApp('/editor/post', withFastAutosave({ labs: { editorReact: true } }));
await openAccess();

await chooseVisibility('Specific tier(s)');
await userEvent.keyboard('{Meta>}s{/Meta}');

// The pair is staged and asks for a tier, but it does not save on its own:
// the write would carry nothing of it.
await expect
.element(editorScreen.saveErrorBanner())
.element(editorScreen.settingsTiersError())
.toHaveTextContent('Please select at least one tier.');
await expect.element(editorScreen.settingsTier('Gold')).toBeVisible();
expect(createApi.requests).toHaveLength(0);
await expect.element(editorScreen.settingsVisibility()).toHaveTextContent('Specific tier(s)');
await expect.poll(unsavedChangesGuarded).toBe(true);

await typeIntoBody('First words');

// The content creates the post with the pair left out of the write.
await expect.poll(() => createApi.requests.length, POLL).toBe(1);
expect(submittedPost(createApi)).not.toHaveProperty('visibility');
expect(submittedPost(createApi)).not.toHaveProperty('tiers');
await expect.poll(currentRoute, POLL).toBe(`/editor/post/${NEW_POST_ID}`);

// The pair was never acknowledged, so the server's default visibility and
// the tiers its read carries do not replace the writer's choice.
await expect.element(editorScreen.settingsVisibility()).toHaveTextContent('Specific tier(s)');
await expect
.element(editorScreen.settingsTiersError())
.toHaveTextContent('Please select at least one tier.');
await expect.element(editorScreen.settingsTiers()).toHaveAttribute('aria-invalid', 'true');
await expect
.element(editorScreen.settingsTier('Gold'))
.toHaveAttribute('data-state', 'unchecked');
// A save that follows the create leaves the pair out as well.
for (let index = 0; index < updateApi.requests.length; index += 1) {
expect(submittedPost(updateApi, index)).not.toHaveProperty('visibility');
expect(submittedPost(updateApi, index)).not.toHaveProperty('tiers');
}

await editorScreen.settingsTier('Gold').click();

await expect(updateApi).toHaveSavedFields({ visibility: 'tiers', tiers: [{ id: GOLD.id }] });
await expect(editorScreen.settingsTiersError()).toHaveCount(0);
expect(created).toMatchObject({ visibility: 'tiers', tiers: [{ id: GOLD.id }] });
// Body typed after the create waits for the tier and lands with it.
await expect.poll(() => String(created.lexical ?? ''), POLL).toContain('First words');
});

it('sends the visibility and the tiers together once a tier is picked', async () => {
Expand Down
Loading
Loading