Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 38 additions & 10 deletions .devcontainer/compose.devcontainer.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@ services:
mysql:
# Smaller InnoDB buffer pool than the baseline compose.dev.yaml (1G).
# Lets the dev stack stay within a 2-core / 8 GB Codespace's budget
# after Ghost + 6 Vite dev servers are running. 256 MB is plenty for
# dev data volumes.
# after Ghost + the default Admin/Portal dev watchers are running.
# 256 MB is plenty for dev data volumes.
command: --innodb-buffer-pool-size=256M --innodb-log-buffer-size=64M --innodb-flush-log-at-trx_commit=0 --innodb-flush-method=O_DIRECT
mem_limit: 512m
# mem_limit is a hard cgroup ceiling, separate from the buffer pool size
# above. Measured via `docker stats` under real usage: actual RSS sits
# around 440-460MiB, so the previous 512m cap left MySQL pinned at
# 85-90% of its ceiling continuously (real, not just at boot) -- more
# memory-management overhead on every query than the buffer pool size
# alone would suggest. 1g (matching host-hybrid's unconstrained default)
# gives real headroom above the actual working set, still a small slice
# even on the 2-core/8GB minimum tier.
mem_limit: 1g
restart: unless-stopped

redis:
Expand All @@ -19,8 +27,28 @@ services:
# The original ./ghost:/home/ghost/ghost mount from compose.dev.yaml is
# preserved via merge semantics so nodemon hot-reload still works for anyone
# running the backend the original way.
#
# pnpm-store is a separate named volume, NOT under /workspaces/Ghost, so
# it survives the bind mount above (which fully shadows anything baked
# into the image at that path with the host's — node_modules-less — repo
# checkout). Because the source is live-mounted, onCreateCommand still has
# to run a real `pnpm install` every time, but with the store already warm
# it's linking already-fetched content instead of
# re-downloading and recompiling native deps (better-sqlite3, sharp, etc.)
# from scratch.
volumes:
- ./:/workspaces/Ghost:cached
- pnpm-store:/pnpm-store
environment:
pnpm_config_store_dir: /pnpm-store
# Prefer the prebuilt image (docker/ghost-dev/Dockerfile, published by
# .github/workflows/devcontainer-build.yml) over a local build. cache_from
# makes a local `docker compose build` (e.g. after a Dockerfile edit)
# reuse those layers too, rather than starting from zero.
image: ghcr.io/tryghost/ghost-devcontainer:latest
build:
cache_from:
- ghcr.io/tryghost/ghost-devcontainer:latest
# Override the default `pnpm dev` command. VS Code controls startup and the
# user runs backend/frontend dev servers as tasks (see .vscode/tasks.json).
command: ["sleep", "infinity"]
Expand All @@ -38,15 +66,15 @@ services:
ghost-dev-gateway:
# Point Caddy at the dev servers running inside the ghost-dev container
# instead of host.docker.internal (which is the default for the hybrid
# host/container dev setup).
# host/container dev setup). Public apps (Portal, Comments UI, Signup
# Form, Sodo Search, Announcement Bar, Admin Toolbar) have no entry here
# — Caddy serves their umd/ build output directly via file_server, so
# there's no dev-server host:port to point at.
environment:
GHOST_BACKEND: ghost-dev:2368
ADMIN_DEV_SERVER: ghost-dev:5174
ADMIN_LIVE_RELOAD_SERVER: ghost-dev:4200
PORTAL_DEV_SERVER: ghost-dev:4175
COMMENTS_DEV_SERVER: ghost-dev:7173
SIGNUP_DEV_SERVER: ghost-dev:6174
SEARCH_DEV_SERVER: ghost-dev:4178
ANNOUNCEMENT_DEV_SERVER: ghost-dev:4177
ADMIN_TOOLBAR_DEV_SERVER: ghost-dev:4176
LEXICAL_DEV_SERVER: ghost-dev:4173

volumes:
pnpm-store:
32 changes: 17 additions & 15 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,21 @@
// dev containers where the host is the developer's own machine, but do
// NOT replicate this pattern in shared build agents, CI runners, or
// environments where untrusted code runs in the container.
// sshd enables `gh codespace ssh`/`logs` for direct diagnosis (top,
// free, docker stats) instead of only being able to probe over HTTP.
// Same trust boundary as docker-outside-of-docker below — standard for
// a Codespace/local dev container where the host is your own machine.
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {},
"ghcr.io/devcontainers/features/sshd:1": {}
},

// Codespaces prebuild step — runs once when the image is built.
// Primes the pnpm store so first-open is fast.
"onCreateCommand": "corepack enable && corepack prepare --activate && cd /workspaces/Ghost && pnpm install --prefer-offline || true",
// Runs once the workspace mount is ready, after the container is created.
// Note: no Codespaces prebuild is configured for this repo, so this runs
// live on every creation rather than being baked into a snapshot ahead of
// time. A failed install must stop setup because postCreateCommand assumes
// workspace dependencies are available.
"onCreateCommand": "corepack enable && corepack prepare --activate && cd /workspaces/Ghost && pnpm install --prefer-offline",

// Runs after the workspace mount is ready on every container create.
"postCreateCommand": ".devcontainer/postCreate.sh",
Expand All @@ -40,6 +48,10 @@
// The script guards against double-starting if the stack is already up.
"postAttachCommand": "bash .devcontainer/start-dev-stack.sh",

// Portal, Comments UI, Signup Form, Sodo Search, Announcement Bar, and
// Admin Toolbar are NOT listed here — since the Caddy file_server switch
// (#28970) they build to disk (`vite build --watch`) instead of running
// a dev server, so there's no port to forward for them.
"forwardPorts": [
2368,
3306,
Expand All @@ -48,11 +60,6 @@
8025,
5174,
4200,
4175,
7173,
6174,
4178,
4177,
4173
],
"portsAttributes": {
Expand All @@ -63,12 +70,7 @@
"8025": {"label": "Mailpit Web"},
"5174": {"label": "Admin (Vite)"},
"4200": {"label": "Ember live-reload"},
"4175": {"label": "Portal"},
"7173": {"label": "Comments UI"},
"6174": {"label": "Signup Form"},
"4178": {"label": "Sodo Search"},
"4177": {"label": "Announcement Bar"},
"4173": {"label": "Koenig Lexical"}
"4173": {"label": "Koenig Lexical (optional)"}
},

"customizations": {
Expand Down
5 changes: 4 additions & 1 deletion .devcontainer/postCreate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ corepack prepare --activate

git submodule update --init --recursive

pnpm install --prefer-offline
# pnpm install already ran in onCreateCommand against this same fully-mounted
# workspace (submodules are theme content, not workspace packages, so their
# absence above didn't affect that install) — a second pass here would just
# re-walk the whole dependency graph for nothing.

# Build workspace packages that ghost/core imports at runtime with build
# outputs (not source). @tryghost/parse-email-address is the only one today
Expand Down
42 changes: 41 additions & 1 deletion .devcontainer/start-dev-stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,51 @@ fi

echo "Starting Ghost dev stack..."

# Ghost's own `url` config (default http://localhost:2368) is what session
# CSRF checks compare the browser's Origin header against (see
# cookieCsrfProtection in ghost/core/core/server/services/auth/session/
# session-service.js). Codespaces serves everything through a forwarded
# https://<name>-2368.<domain> origin instead, so without this every
# authenticated admin request fails that origin check and bounces back to
# login. `url` is a top-level nconf key, so a bare `url` env var overrides
# it (see ghost/core/core/shared/config/loader.js's nconf.env() call).
# Local (non-Codespaces) VS Code Dev Containers forward to genuine
# localhost, so this only applies inside Codespaces itself.
if [ -n "${CODESPACES:-}" ] && [ -n "${CODESPACE_NAME:-}" ]; then
export url="https://${CODESPACE_NAME}-2368.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-app.github.dev}"
echo "Codespaces detected: setting Ghost url=$url so admin session origin checks match the forwarded tunnel" >> /tmp/ghost-backend.log

# Once url above is HTTPS, Ghost's url-redirects middleware
# (getAdminRedirectUrl in ghost/core/core/server/web/shared/middleware/
# url-redirects.js) 301s any request it sees as insecure (protocol
# mismatch) to that HTTPS url. Real browser traffic is fine — Caddy sets
# X-Forwarded-Proto: https, so Express's req.secure is true. But
# apps/admin's own vite dev server bootstraps itself by fetching
# GHOST_URL + ghost/api/admin/site/ directly (vite-backend-proxy.ts),
# bypassing Caddy entirely by default (http://localhost:2368) — Express
# sees that as insecure, 301s it to the public tunnel URL, and since
# that's an external, GitHub-auth-gated address, Vite's plain fetch()
# can't complete the login flow and crashes on the HTML it gets back
# instead of JSON. Routing GHOST_URL through the gateway container
# instead keeps the request on the internal Docker network (still gets
# X-Forwarded-Proto: https from Caddy, so no redirect) while never
# touching the external, auth-gated tunnel at all.
export GHOST_URL="http://ghost-dev-gateway:80/"
fi

# Append to log files (don't truncate) so previous crash tails survive a
# restart and the user can still tail them for context.
{ echo "=== $(date -Is) starting backend ==="; } >> /tmp/ghost-backend.log
nohup pnpm --filter ghost dev >> /tmp/ghost-backend.log 2>&1 &
disown

{ echo "=== $(date -Is) starting frontends ==="; } >> /tmp/ghost-frontends.log
# Matches root `pnpm dev`'s default fan-out (Admin + Portal only) — most
# devcontainer sessions never touch the public UMD apps, and those watchers
# are the heaviest processes in the stack. To also start them, run e.g.:
# pnpm nx run-many -t dev --projects=@tryghost/comments-ui,@tryghost/signup-form,@tryghost/sodo-search,@tryghost/announcement-bar,@tryghost/admin-toolbar
nohup pnpm nx run-many -t dev \
--projects=@tryghost/admin,@tryghost/portal,@tryghost/comments-ui,@tryghost/signup-form,@tryghost/sodo-search,@tryghost/announcement-bar \
--projects=@tryghost/admin,@tryghost/portal \
>> /tmp/ghost-frontends.log 2>&1 &
disown

Expand All @@ -33,4 +69,8 @@ Ghost dev stack starting in the background.
Gateway: http://localhost:2368/

Give it ~30-60s, then open http://localhost:2368/ghost/ for admin.

Only Admin + Portal dev watchers start by default. To add the public UMD
apps (Comments, Signup Form, Sodo Search, Announcement Bar, Admin Toolbar):
pnpm nx run-many -t dev --projects=@tryghost/comments-ui,@tryghost/signup-form,@tryghost/sodo-search,@tryghost/announcement-bar,@tryghost/admin-toolbar
MSG
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,31 @@ function customFieldsBoot() {
};
}

function fakeCustomFields(fields = [companyField]) {
// Settings opts into archived fields (`?filter=status:[active,archived]`),
// so the fake matches the path with any query.
return fakeAdminEndpoint("GET", new RegExp("^/members/custom_fields/\\?"), {members_custom_fields: fields});
type CustomField = typeof companyField;

// Settings opts into archived fields (`?filter=status:[active,archived]`),
// so the fakes match the path with any query.
const customFieldsBrowsePath = new RegExp("^/members/custom_fields/\\?");

function fakeCustomFields(fields: CustomField[] = [companyField]) {
return fakeAdminEndpoint("GET", customFieldsBrowsePath, {members_custom_fields: fields});
}

/**
* Mutations invalidate and refetch the list, so a spec observing the list
* after a create serves it from state that grows when the POST lands; the
* created entity is declared by the spec, the fake invents nothing.
* Post-mutation outcomes of edits and deletes are server behavior, owned by
* the API suite (ghost/core e2e-api member-custom-fields) — those specs
* assert the outgoing request and the refetch instead.
*/
function fakeCustomFieldsWithCreate(initial: CustomField[], created: CustomField) {
let fields = initial;
fakeAdminEndpoint("GET", customFieldsBrowsePath, () => ({members_custom_fields: fields}));
return fakeAdminEndpoint("POST", "/members/custom_fields/", () => {
fields = [...fields, created];
return {members_custom_fields: [created]};
});
}

describe("Custom fields", () => {
Expand Down Expand Up @@ -202,17 +223,12 @@ describe("Custom fields", () => {

it("reveals a just-created field even when the list is collapsed", async () => {
fakeSettingsScreens();
let currentFields = Array.from({length: 6}, (_, index) => ({
const initialFields = Array.from({length: 6}, (_, index) => ({
...companyField,
key: `field-${index}`,
name: `Field ${index}`,
}));
fakeAdminEndpoint("GET", new RegExp("^/members/custom_fields/\\?"), () => ({members_custom_fields: currentFields}));
fakeAdminEndpoint("POST", "/members/custom_fields/", () => {
const created = {...companyField, key: "newest", name: "Newest"};
currentFields = [...currentFields, created];
return {members_custom_fields: [created]};
});
fakeCustomFieldsWithCreate(initialFields, {...companyField, key: "newest", name: "Newest"});
await renderAdminApp("/settings", {boot: customFieldsBoot()});

const rows = settingsScreen.customFields().getByTestId("custom-field-list-item");
Expand All @@ -237,7 +253,7 @@ describe("Custom fields", () => {

it("permanently deletes an archived field from the header menu, after a heavy warning", async () => {
fakeSettingsScreens();
fakeCustomFields([companyField, archivedField]);
const customFieldsApi = fakeCustomFields([companyField, archivedField]);
const deleteApi = fakeAdminEndpoint("DELETE", "/members/custom_fields/old-hobby/", {});
await renderAdminApp("/settings", {boot: customFieldsBoot()});

Expand All @@ -251,10 +267,15 @@ describe("Custom fields", () => {

const confirmation = settingsScreen.confirmationModal();
await expect.element(confirmation).toHaveTextContent("Old hobby and every value collected from your members will be permanently deleted from the database. This can’t be undone.");
const fetchesBeforeConfirm = customFieldsApi.requests.length;
await confirmation.getByRole("button", {name: "Delete"}).click();

await expect.element(settingsScreen.successToast()).toHaveTextContent("Custom field deleted");
expect(deleteApi.requests).toHaveLength(1);

// The list refetches after the delete; the refreshed outcome (the
// field leaving Archived) is server behavior, owned by the API suite.
await expect.poll(() => customFieldsApi.requests.length).toBeGreaterThan(fetchesBeforeConfirm);
});

it("does not expose permanent deletion for an active field", async () => {
Expand All @@ -280,7 +301,7 @@ describe("Custom fields", () => {

it("reactivates an archived field after confirmation, as a status edit", async () => {
fakeSettingsScreens();
fakeCustomFields([companyField, archivedField]);
const customFieldsApi = fakeCustomFields([companyField, archivedField]);
const editApi = fakeAdminEndpoint("PUT", "/members/custom_fields/old-hobby/", {
members_custom_fields: [{...archivedField, status: "active"}],
});
Expand All @@ -291,9 +312,14 @@ describe("Custom fields", () => {
await settingsScreen.customFieldModal().getByRole("button", {name: "Reactivate"}).click();
const confirmation = settingsScreen.confirmationModal();
await expect.element(confirmation).toHaveTextContent("Values already collected for this field will remain unchanged");
const fetchesBeforeConfirm = customFieldsApi.requests.length;
await confirmation.getByRole("button", {name: "Reactivate"}).click();

await expect.element(settingsScreen.successToast()).toHaveTextContent("Custom field reactivated");
expect(editApi.lastRequest?.body).toEqual({members_custom_fields: [{status: "active"}]});

// The list refetches after the edit; the refreshed outcome (the field
// moving back under Active) is server behavior, owned by the API suite.
await expect.poll(() => customFieldsApi.requests.length).toBeGreaterThan(fetchesBeforeConfirm);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -488,12 +488,13 @@ describe("Member welcome emails", () => {
expect(editApi.lastRequest?.body).toMatchObject({automated_emails: [{id: freeWelcomeEmail.id, status: "inactive"}]});
});

it("shows the paid welcome email row when Stripe is connected", async () => {
it("shows and enables the paid welcome email row when Stripe is connected", async () => {
fakeSettingsScreens();
fakeDefaultNewsletter();
fakeAutomatedEmails();
fakeRecentPosts();
fakeTiers([tier({name: "Supporter"})]);
const addApi = fakeAdminEndpoint("POST", "/automated_emails/", {automated_emails: [{...paidWelcomeEmail, status: "active"}]});
const stripe = settingsResponse({settings: {
stripe_connect_publishable_key: "pk_test_123",
stripe_connect_secret_key: "sk_test_123",
Expand All @@ -506,6 +507,11 @@ describe("Member welcome emails", () => {
await expect.element(paidRow).toBeVisible();
await expect.element(paidRow).toHaveTextContent("Paid members welcome email");
await expect.element(paidRow.getByRole("switch")).toHaveAttribute("aria-checked", "false");

await paidRow.getByRole("switch").click();

await expect.element(page.getByText("Paid members welcome email enabled")).toBeVisible();
expect(addApi.lastRequest?.body).toMatchObject({automated_emails: [{slug: "member-welcome-email-paid", status: "active"}]});
});

it("keeps the newest draft's preview when a stale preview response arrives late", async () => {
Expand Down
12 changes: 5 additions & 7 deletions docker/dev-gateway/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@ FROM caddy:2-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95a

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

# Default proxy targets (can be overridden via environment variables)
# Default proxy targets (can be overridden via environment variables).
# Public apps (portal, comments-ui, signup-form, sodo-search,
# announcement-bar, admin-toolbar) are served directly from their umd/
# build output via the Caddyfile's file_server block, not proxied to a
# dev server — they have no *_DEV_SERVER entry here.
ENV GHOST_BACKEND=ghost-dev:2368 \
ADMIN_DEV_SERVER=host.docker.internal:5174 \
ADMIN_LIVE_RELOAD_SERVER=host.docker.internal:4200 \
PORTAL_DEV_SERVER=host.docker.internal:4175 \
COMMENTS_DEV_SERVER=host.docker.internal:7173 \
SIGNUP_DEV_SERVER=host.docker.internal:6174 \
SEARCH_DEV_SERVER=host.docker.internal:4178 \
ANNOUNCEMENT_DEV_SERVER=host.docker.internal:4177 \
ADMIN_TOOLBAR_DEV_SERVER=host.docker.internal:4176 \
LEXICAL_DEV_SERVER=host.docker.internal:4173 \
ANALYTICS_PROXY_TARGET=analytics:3000 \
ACTIVITYPUB_PROXY_TARGET=host.docker.internal:8080
Expand Down
Loading
Loading