From aae242d255824b6ba0d726bf60832cf6a81273a3 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Fri, 24 Apr 2026 11:31:10 -0500 Subject: [PATCH 1/3] Made devcontainer auto-start shareable and trimmed CI for dev-tooling PRs (#27545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/PLA-33/ Second of the devcontainer series (stacks on top of #27544). Turns the "Reopen in Container → run three commands" flow into "Reopen in Container → done" by shipping the auto-start task with the repo, and recommends the Dev Containers extension on host so newcomers get prompted to install it. Also trims CI for dev-tooling-only PRs: the only things running on this PR (and #27544) should now be Setup and anything with no path filter. --- .github/workflows/ci.yml | 2 ++ .gitignore | 2 ++ .vscode/extensions.json | 5 +++++ .vscode/settings.json | 1 + .vscode/tasks.json | 41 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 51 insertions(+) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/tasks.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b1889f04d2..9a0f61cfcd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,8 @@ jobs: - '!ghost/core/core/server/data/tinybird/**/*.md' any-code: - '!**/*.md' + - '!.devcontainer/**' + - '!.vscode/**' - name: Define Node test matrix id: node_matrix diff --git a/.gitignore b/.gitignore index 1131d2a2fdc..11fe9e51b39 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,8 @@ typings/ .vscode/* !.vscode/launch.json !.vscode/settings.json +!.vscode/tasks.json +!.vscode/extensions.json # OSX .DS_Store diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000000..894e9d73921 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "ms-vscode-remote.remote-containers" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 7659b906987..7034a4dae5a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ "**/ghost.map": true, "**/node_modules": true, "ghost/core/core/built/**": true, + ".claude/worktrees/**": true, "**/config.*.json": false, "**/config.*.jsonc": false }, diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000000..28ef382717b --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Ghost: Backend (nodemon)", + "type": "shell", + "command": "pnpm --filter ghost dev", + "options": {"cwd": "${workspaceFolder}"}, + "isBackground": true, + "presentation": {"panel": "dedicated", "group": "dev"}, + "problemMatcher": [] + }, + { + "label": "Ghost: Frontend dev servers", + "type": "shell", + "command": "pnpm nx run-many -t dev --projects=@tryghost/admin,@tryghost/portal,@tryghost/comments-ui,@tryghost/signup-form,@tryghost/sodo-search,@tryghost/announcement-bar", + "options": {"cwd": "${workspaceFolder}"}, + "isBackground": true, + "presentation": {"panel": "dedicated", "group": "dev"}, + "problemMatcher": [] + }, + { + "label": "Ghost: Full dev stack", + "dependsOn": [ + "Ghost: Backend (nodemon)", + "Ghost: Frontend dev servers" + ], + "dependsOrder": "parallel", + "problemMatcher": [], + "group": {"kind": "build", "isDefault": true}, + "runOptions": {"runOn": "folderOpen"} + }, + { + "label": "Ghost: Reset data (empty)", + "type": "shell", + "command": "node index.js generate-data --clear-database --quantities members:0,posts:0 --seed 123", + "options": {"cwd": "${workspaceFolder}/ghost/core"}, + "problemMatcher": [] + } + ] +} From 8306eb74c030db927c75d644fa1ded9389ce5053 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Fri, 24 Apr 2026 15:29:12 -0500 Subject: [PATCH 2/3] Moved dev stack auto-start from VS Code tasks to postAttachCommand (#27547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/PLA-33/ Follow-up to #27544 and #27545. The auto-start flow shipped in #27545 used a `runOn: "folderOpen"` task in `.vscode/tasks.json`, which fires on **host** VS Code too — before the user clicks "Reopen in Container". On a fresh clone the host has no `node_modules`, so the task crashes with `sh: nodemon: command not found` + `Command "nx" not found`, greeting first-time contributors with a wall of red errors. This PR moves auto-start into the **devcontainer lifecycle** so it only fires inside the container, and also fixes a second fresh-clone failure mode discovered while testing: Ghost's backend crashes importing `@tryghost/parse-email-address` because its `build/` output doesn't exist yet. --- .devcontainer/compose.devcontainer.yaml | 15 +++++++++++ .devcontainer/devcontainer.json | 24 +++++++++++++++++ .devcontainer/postCreate.sh | 12 +++++++++ .devcontainer/start-dev-stack.sh | 36 +++++++++++++++++++++++++ .nxignore | 7 +++++ .vscode/tasks.json | 3 +-- 6 files changed, 95 insertions(+), 2 deletions(-) create mode 100755 .devcontainer/start-dev-stack.sh create mode 100644 .nxignore diff --git a/.devcontainer/compose.devcontainer.yaml b/.devcontainer/compose.devcontainer.yaml index cc91b72fcb4..1382a6e457d 100644 --- a/.devcontainer/compose.devcontainer.yaml +++ b/.devcontainer/compose.devcontainer.yaml @@ -1,4 +1,19 @@ 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. + 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 + restart: unless-stopped + + redis: + restart: unless-stopped + + mailpit: + restart: unless-stopped + ghost-dev: # Mount the full repo so VS Code can edit apps/, e2e/, and root config files. # The original ./ghost:/home/ghost/ghost mount from compose.dev.yaml is diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 649dcec35c7..b260a719efb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,6 +9,24 @@ "shutdownAction": "stopCompose", "remoteUser": "root", + // Installs docker CLI and mounts the host docker socket into ghost-dev so + // developers can run `docker ps / logs / inspect / exec` against peer + // compose services (mysql, redis, mailpit, gateway) from inside the dev + // container. Without this, diagnosing any compose-peer failure requires + // SSHing to the Codespaces host, which our image doesn't support out of + // the box. + // + // Security note: mounting the host's docker socket combined with + // `remoteUser: "root"` effectively gives the dev container root-equivalent + // control over the host Docker daemon (spawn privileged containers, mount + // host paths, etc.). This is the standard pattern for local / Codespaces + // 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. + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker: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", @@ -16,6 +34,12 @@ // Runs after the workspace mount is ready on every container create. "postCreateCommand": ".devcontainer/postCreate.sh", + // Runs whenever VS Code attaches to the container. Fires only inside the + // container, so this is safe (unlike a tasks.json runOn: folderOpen which + // also fires on host VS Code where node_modules doesn't exist yet). + // The script guards against double-starting if the stack is already up. + "postAttachCommand": "bash .devcontainer/start-dev-stack.sh", + "forwardPorts": [ 2368, 3306, diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 62da08d0ff7..6d74af239e1 100755 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -9,3 +9,15 @@ corepack prepare --activate git submodule update --init --recursive pnpm install --prefer-offline + +# Build workspace packages that ghost/core imports at runtime with build +# outputs (not source). @tryghost/parse-email-address is the only one today +# — its package.json "main" points at build/index.js, so the backend can't +# import it on a fresh clone until it's compiled. +# On host, `pnpm dev` triggers this via Nx dependsOn cascades; inside the +# devcontainer we invoke `pnpm --filter ghost dev` directly, which bypasses +# those cascades. +# Frontend apps (admin, posts, stats, activitypub, etc.) do NOT need +# pre-building here — their own dev targets handle it when start-dev-stack.sh +# runs `nx run-many -t dev`. +pnpm --filter @tryghost/parse-email-address build diff --git a/.devcontainer/start-dev-stack.sh b/.devcontainer/start-dev-stack.sh new file mode 100755 index 00000000000..40792931c6f --- /dev/null +++ b/.devcontainer/start-dev-stack.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /workspaces/Ghost + +# Skip if backend is already bound to port 2368 — avoids double-starting on +# VS Code reload/re-attach. The subshell isolates bash's noisy +# "connection refused" message on first run when nothing's listening yet. +if (exec 3<>/dev/tcp/127.0.0.1/2368) 2>/dev/null; then + echo "Ghost dev stack already running on :2368, skipping start." + exit 0 +fi + +echo "Starting Ghost dev stack..." + +# 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 +nohup pnpm nx run-many -t dev \ + --projects=@tryghost/admin,@tryghost/portal,@tryghost/comments-ui,@tryghost/signup-form,@tryghost/sodo-search,@tryghost/announcement-bar \ + >> /tmp/ghost-frontends.log 2>&1 & +disown + +cat <<'MSG' +Ghost dev stack starting in the background. + + Backend log: tail -f /tmp/ghost-backend.log + Frontend log: tail -f /tmp/ghost-frontends.log + Gateway: http://localhost:2368/ + +Give it ~30-60s, then open http://localhost:2368/ghost/ for admin. +MSG diff --git a/.nxignore b/.nxignore new file mode 100644 index 00000000000..a94997ad936 --- /dev/null +++ b/.nxignore @@ -0,0 +1,7 @@ +# Paths Nx should exclude from the project graph and `nx --affected` +# computation. Without this, Nx's default "unknown files affect everything" +# behaviour makes Lint, Unit tests, and per-app Build jobs run on PRs that +# only touch dev-tooling files. Pairs with the `any-code` path filter in +# .github/workflows/ci.yml, which also excludes these paths. +.devcontainer/ +.vscode/ diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 28ef382717b..3958cf9ae28 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -27,8 +27,7 @@ ], "dependsOrder": "parallel", "problemMatcher": [], - "group": {"kind": "build", "isDefault": true}, - "runOptions": {"runOn": "folderOpen"} + "group": {"kind": "build", "isDefault": true} }, { "label": "Ghost: Reset data (empty)", From cbb3b7eafb44a58ef62b7ff2c18eaf82bff36356 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Fri, 24 Apr 2026 21:18:17 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20Gmail=20inbox=20link?= =?UTF-8?q?s=20404ing=20for=20Workspace=20and=20signed-out=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/mail/u//` path only resolves when that exact address is already signed in at the `` account slot, so Workspace accounts and signed-out users hit a 404 before the `#search` fragment could run. Switched to `/mail/u/0/?authuser=`, which uses Gmail's own account resolver and falls through to the sign-in flow instead of erroring. --- ghost/core/core/server/lib/get-inbox-links.ts | 13 +++++++------ .../test/unit/server/lib/get-inbox-links.test.ts | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/ghost/core/core/server/lib/get-inbox-links.ts b/ghost/core/core/server/lib/get-inbox-links.ts index 0e7d6528502..1bc6ae1b1c4 100644 --- a/ghost/core/core/server/lib/get-inbox-links.ts +++ b/ghost/core/core/server/lib/get-inbox-links.ts @@ -61,18 +61,19 @@ const buildUrl = (baseHref: string, key: string, value: string): string => { return result.toString(); }; -const encodeRecipientForGmailUrl = (recipient: string) => ( - encodeURIComponent(recipient).replaceAll('%40', '@') -); - const PROVIDERS: ReadonlyArray = [ { name: 'gmail', domains: ['gmail.com', 'googlemail.com', 'google.com'], + // Gmail's `/mail/u//` path expects a numeric account index. Passing a + // raw email only resolves when that account happens to be signed in at + // that slot; Workspace accounts and signed-out users hit a 404 before + // the `#search` fragment runs. `authuser` is Gmail's own account + // resolver and falls through to sign-in instead of erroring. getDesktopLink: ({recipient, sender}) => ( - `https://mail.google.com/mail/u/${encodeRecipientForGmailUrl( + `https://mail.google.com/mail/u/0/?authuser=${encodeURIComponent( recipient - )}/#search/from%3A(${encodeURIComponent( + )}#search/from%3A(${encodeURIComponent( sender )})+in%3Aanywhere+newer_than%3A1h` ), diff --git a/ghost/core/test/unit/server/lib/get-inbox-links.test.ts b/ghost/core/test/unit/server/lib/get-inbox-links.test.ts index a7c7470c305..688e06fafa9 100644 --- a/ghost/core/test/unit/server/lib/get-inbox-links.test.ts +++ b/ghost/core/test/unit/server/lib/get-inbox-links.test.ts @@ -41,8 +41,8 @@ describe('getInboxLinks', function () { dnsResolver: resolverThatShouldNeverBeUsed }); assert.equal(result?.provider, 'gmail'); - assert(result?.desktop.startsWith('https://mail.google.com/')); - assert(result?.desktop.includes(recipient)); + assert(result?.desktop.startsWith('https://mail.google.com/mail/u/0/')); + assert(result?.desktop.includes(`authuser=${encodeURIComponent(recipient)}`)); assert(result?.desktop.includes(encodeURIComponent('sender@example.com'))); assert(result?.android.startsWith('intent:')); assert(result?.android.includes('com.google.android.gm')); @@ -54,7 +54,7 @@ describe('getInboxLinks', function () { sender: 'sendér@example.com', dnsResolver: resolverThatShouldNeverBeUsed }); - assert(nonAsciiResult?.desktop.includes('exampl%C3%A9@gmail.com')); + assert(nonAsciiResult?.desktop.includes(`authuser=${encodeURIComponent('examplé@gmail.com')}`)); assert(nonAsciiResult?.desktop.includes(encodeURIComponent('sendér@example.com'))); });