Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
5 changes: 0 additions & 5 deletions .changeset/changelogs/@tryghost!kg-default-nodes@2.2.2.md

This file was deleted.

9 changes: 0 additions & 9 deletions .changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/little-foxes-rule.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/proud-tigers-notice.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/weak-suns-enjoy.md

This file was deleted.

7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1494,6 +1494,9 @@ jobs:
- name: Docker meta (core)
id: meta-core
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
env:
# Tag PR builds `sha-<short>` with the PR head, not the synthetic merge commit
DOCKER_METADATA_PR_HEAD_SHA: 'true'
with:
images: ${{ steps.strategy.outputs.image-core-name }}
tags: |
Expand All @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down
93 changes: 79 additions & 14 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:<profile>" 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
Expand Down Expand Up @@ -92,23 +103,77 @@ 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:<profile>` 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:<profile> 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:<profile>` 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:<profile> 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'
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}"
Expand All @@ -119,13 +184,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
Expand All @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions apps/admin/src/editor/editor-header-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
148 changes: 148 additions & 0 deletions apps/admin/src/editor/editor-header.acceptance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<p>Email body</p>', 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: '<p>Email body</p>', 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: '<p>Email body</p>', 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: '<p>Email body</p>', 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<void>();
const saveApi = fakeSavablePost({}, { holdFirstSave: held.promise });
fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, {
email_previews: [
{ subject: 'Hello from React', html: '<p>Email body</p>', 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 });
Expand Down
1 change: 1 addition & 0 deletions apps/admin/src/editor/engine/change-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function post(overrides: Partial<EditablePostProjection> = {}): EditablePostProj
visibility: 'public',
tiers: [],
authors: [{ id: 'author-1' }],
email_subject: null,
meta_title: null,
meta_description: null,
canonical_url: null,
Expand Down
2 changes: 2 additions & 0 deletions apps/admin/src/editor/engine/change-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface EditablePostProjection {
lexical: string | null;
tags: ReadonlyArray<TagLike>;
custom_excerpt: string | null;
email_subject: string | null;
feature_image: string | null;
feature_image_alt: string | null;
feature_image_caption: string | null;
Expand Down Expand Up @@ -117,6 +118,7 @@ const PROJECTION_KEYS: ReadonlyArray<ProjectionKey> = [
'lexical',
'tags',
'custom_excerpt',
'email_subject',
'feature_image',
'feature_image_alt',
'feature_image_caption',
Expand Down
Loading
Loading