From 8db3b81ef20770684e127e043ab0eb9089a4a366 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Wed, 11 Feb 2026 17:03:37 +0000 Subject: [PATCH 01/14] Added unit test to detect orphaned migration folders ref https://linear.app/ghost/issue/BER-3318/ knex-migrator silently skips migration folders that exceed the current Ghost version (folder > ghostVersion.safe). If a developer adds a migration folder without bumping package.json to an rc, those migrations will never run. This test catches that by scanning all version folders and failing when any exceed the safe version from package.json. --- .../migrations/version-consistency.test.js | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 ghost/core/test/unit/server/data/migrations/version-consistency.test.js diff --git a/ghost/core/test/unit/server/data/migrations/version-consistency.test.js b/ghost/core/test/unit/server/data/migrations/version-consistency.test.js new file mode 100644 index 00000000000..7453025fbdd --- /dev/null +++ b/ghost/core/test/unit/server/data/migrations/version-consistency.test.js @@ -0,0 +1,33 @@ +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const semver = require('semver'); + +describe('Migration version consistency', function () { + it('all migration folders should be runnable at current package.json version', function () { + const pkg = require('../../../../../package.json'); + const safeVersion = pkg.version.match(/^(\d+\.)?(\d+)/)[0]; + + const migrationsDir = path.join( + __dirname, '../../../../../core/server/data/migrations/versions' + ); + + const folders = fs.readdirSync(migrationsDir) + .filter(f => fs.statSync(path.join(migrationsDir, f)).isDirectory()) + .filter(f => /^\d+\.\d+$/.test(f)); + + const orphaned = folders.filter((folder) => { + // Same comparison knex-migrator uses: folder > currentVersion + return semver.gt(semver.coerce(folder), semver.coerce(safeVersion)); + }); + + assert.equal( + orphaned.length, + 0, + `Migration folders exceed package.json version (${pkg.version}, safe: ${safeVersion}): ` + + `${orphaned.join(', ')}\n` + + `Run \`slimer migration\` which handles the version bump automatically, ` + + `or manually bump package.json to the next minor rc.` + ); + }); +}); From 2b44d6a1045dc319c52e797cdf4ccc65b50f40d0 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Wed, 11 Feb 2026 20:14:16 +0000 Subject: [PATCH 02/14] Added production Docker image builds to CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/BER-3292/add-production-docker-image-build-to-ghost-ci Ghost CI now builds two production Docker images on every commit: - ghcr.io/tryghost/ghost-core (server + production deps, no admin) - ghcr.io/tryghost/ghost (core + built admin assets) Uses npm pack to create a standalone distribution via monobundle.js, which bundles private workspace packages as local tarballs. The Dockerfile mirrors Ghost-Moya's proven production setup (bookworm-slim, jemalloc, ghost user uid 1000, sqlite3 native build). Nothing consumes these images yet — this is purely additive. Ghost-Moya continues building from source until we're ready to switch. --- .github/workflows/ci.yml | 115 +++++++++++++++++++++++++++++++++++++++ Dockerfile.production | 57 +++++++++++++++++++ ghost/core/package.json | 1 + package.json | 1 + 4 files changed, 174 insertions(+) create mode 100644 Dockerfile.production diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ba1247b871..015d3fa164a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1062,6 +1062,121 @@ jobs: image-digest: ${{ steps.build.outputs.digest }} is-fork: ${{ steps.strategy.outputs.is-fork-pr }} + job_docker_build_production: + name: Build & Push Production Docker Images + needs: [job_setup] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-node@v4 + env: + FORCE_COLOR: 0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Restore caches + uses: ./.github/actions/restore-cache + env: + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} + + - name: Build server and admin assets + run: yarn build:production + + - name: Pack standalone distribution + run: yarn workspace ghost pack:standalone + + - name: Prepare Docker build context + run: mv ghost/core/package/ /tmp/ghost-production/ + + - name: Determine push strategy + id: strategy + run: | + IS_FORK_PR="false" + if [ "${{ github.event_name }}" = "pull_request" ] && \ + [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + IS_FORK_PR="true" + fi + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + echo "is-fork-pr=$IS_FORK_PR" >> $GITHUB_OUTPUT + echo "should-push=$( [ "$IS_FORK_PR" = "false" ] && echo "true" || echo "false" )" >> $GITHUB_OUTPUT + echo "owner=$OWNER" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: steps.strategy.outputs.should-push == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta (core) + id: meta-core + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ steps.strategy.outputs.owner }}/ghost-core + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha + type=raw,value=latest,enable={{is_default_branch}} + labels: | + org.opencontainers.image.title=Ghost Core + org.opencontainers.image.description=Ghost production build (server only, no admin) + org.opencontainers.image.vendor=TryGhost + + - name: Docker meta (full) + id: meta-full + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ steps.strategy.outputs.owner }}/ghost + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha + type=raw,value=latest,enable={{is_default_branch}} + labels: | + org.opencontainers.image.title=Ghost + org.opencontainers.image.description=Ghost production build (server + admin) + org.opencontainers.image.vendor=TryGhost + + - name: Build & push core image + uses: docker/build-push-action@v6 + with: + context: /tmp/ghost-production + file: Dockerfile.production + target: core + build-args: NODE_VERSION=${{ env.NODE_VERSION }} + push: ${{ steps.strategy.outputs.should-push }} + load: ${{ steps.strategy.outputs.should-push == 'false' }} + tags: ${{ steps.meta-core.outputs.tags }} + labels: ${{ steps.meta-core.outputs.labels }} + cache-from: type=registry,ref=ghcr.io/${{ steps.strategy.outputs.owner }}/ghost-core:cache-main + cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref=ghcr.io/{0}/ghost-core:cache-{1},mode=max', steps.strategy.outputs.owner, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + + - name: Build & push full image + uses: docker/build-push-action@v6 + with: + context: /tmp/ghost-production + file: Dockerfile.production + target: full + build-args: NODE_VERSION=${{ env.NODE_VERSION }} + push: ${{ steps.strategy.outputs.should-push }} + load: ${{ steps.strategy.outputs.should-push == 'false' }} + tags: ${{ steps.meta-full.outputs.tags }} + labels: ${{ steps.meta-full.outputs.labels }} + cache-from: type=registry,ref=ghcr.io/${{ steps.strategy.outputs.owner }}/ghost:cache-main + cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref=ghcr.io/{0}/ghost:cache-{1},mode=max', steps.strategy.outputs.owner, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + job_inspect_image: name: Inspect Docker Image needs: job_docker_build diff --git a/Dockerfile.production b/Dockerfile.production new file mode 100644 index 00000000000..1d63b311aaf --- /dev/null +++ b/Dockerfile.production @@ -0,0 +1,57 @@ +# syntax=docker/dockerfile:1-labs + +# Production Dockerfile for Ghost +# Two targets: +# core — server + production deps, no admin (Ghost-Pro base) +# full — core + built admin (self-hosting) +# +# Build context: extracted `npm pack` output from ghost/core + +ARG NODE_VERSION=22.18.0 + +# ---- Core: server + production deps ---- +FROM node:$NODE_VERSION-bookworm-slim AS core + +ENV NODE_ENV=production + +RUN apt-get update && \ + apt-get install -y --no-install-recommends libjemalloc2 && \ + rm -rf /var/lib/apt/lists/* && \ + groupmod -g 1001 node && \ + usermod -u 1001 node && \ + adduser --disabled-password --gecos "" -u 1000 ghost + +WORKDIR /home/ghost + +COPY --exclude=core/built/admin . . + +RUN --mount=type=cache,target=/usr/local/share/.cache/yarn/v6 \ + apt-get update && \ + apt-get install -y --no-install-recommends build-essential python3 && \ + yarn install --ignore-scripts --production --prefer-offline && \ + (cd node_modules/sqlite3 && npm run install) && \ + apt-get purge -y build-essential python3 && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* && \ + mkdir -p default log && \ + cp -R content base_content && \ + cp -R content/themes/casper default/casper && \ + ([ -d content/themes/source ] && cp -R content/themes/source default/source || true) && \ + chown ghost:ghost /home/ghost && \ + chown -R nobody:nogroup /home/ghost/* && \ + chown -R ghost:ghost /home/ghost/content /home/ghost/log + +USER ghost +ENV LD_PRELOAD=libjemalloc.so.2 + +EXPOSE 2368 + +CMD ["node", "index.js"] + +# ---- Full: core + admin ---- +FROM core AS full + +USER root +COPY core/built/admin core/built/admin +RUN chown -R nobody:nogroup core/built/admin +USER ghost diff --git a/ghost/core/package.json b/ghost/core/package.json index 42688be14e8..4b51b625271 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -21,6 +21,7 @@ "license": "MIT", "scripts": { "archive": "npm pack", + "pack:standalone": "rm -rf package ghost-*.tgz && npm pack && tar -xzf ghost-*.tgz && cp ../../yarn.lock package/ && rm ghost-*.tgz", "dev": "node --watch --import=tsx index.js", "build:assets": "yarn build:assets:css && yarn build:assets:js", "build:assets:js": "node bin/minify-assets.js", diff --git a/package.json b/package.json index b3c25702da9..9712117e8a6 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ ], "scripts": { "archive": "nx run ghost:archive", + "build:production": "nx run ghost:build:tsc && nx run ghost:build:assets && nx run @tryghost/admin:build", "build": "nx run-many -t build", "build:clean": "nx reset && rimraf -g 'ghost/*/build' && rimraf -g 'ghost/*/tsconfig.tsbuildinfo'", "clean:hard": "node ./.github/scripts/clean.js", From dc4c59b30067f42796dd9a5583f5536c3f2dcf40 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Thu, 12 Feb 2026 13:03:01 +0000 Subject: [PATCH 03/14] Changed E2E tests to run against the production Docker image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/BER-3293/ E2E tests were running against the monorepo dev image — a different artifact from what gets deployed. This switches the CI E2E job to depend on job_docker_build_production and test the real production image. A thin Dockerfile.e2e layers pre-built UMD bundles onto the production image so all 6 public apps are served from /ghost/assets/ paths. This replaces the inconsistent setup where portal had a dedicated Vite preview container while the other 4 apps fell back to CDN. The migration command is unified to `node node_modules/.bin/knex-migrator init` (MigratorConfig.js handles tsx gracefully via try/catch) and build commands are reusable npm scripts (build:apps, build:docker). Dev mode (yarn dev + DevEnvironmentManager) is completely unchanged. --- .github/actions/load-docker-image/action.yml | 8 +++- .github/workflows/ci.yml | 48 +++++++++++++++---- e2e/Dockerfile.e2e | 20 ++++++++ e2e/compose.yml | 20 +------- e2e/helpers/environment/constants.ts | 41 +++++++++++----- .../environment/environment-factory.ts | 18 +++++-- .../environment/environment-manager.ts | 15 +++--- .../service-managers/ghost-manager.ts | 29 ++++++----- .../environment/service-managers/index.ts | 1 - .../service-managers/portal-manager.ts | 26 ---------- e2e/package.json | 3 +- 11 files changed, 137 insertions(+), 92 deletions(-) create mode 100644 e2e/Dockerfile.e2e delete mode 100644 e2e/helpers/environment/service-managers/portal-manager.ts diff --git a/.github/actions/load-docker-image/action.yml b/.github/actions/load-docker-image/action.yml index 4a62fb9316d..2ceeade6983 100644 --- a/.github/actions/load-docker-image/action.yml +++ b/.github/actions/load-docker-image/action.yml @@ -7,6 +7,10 @@ inputs: image-tags: description: 'Docker image tags (multi-line string)' required: true + artifact-name: + description: 'Name of the artifact to download (fork PRs only)' + required: false + default: 'docker-image' runs: using: 'composite' @@ -15,14 +19,14 @@ runs: if: inputs.is-fork == 'true' uses: actions/download-artifact@v4 with: - name: docker-image + name: ${{ inputs.artifact-name }} - name: Load image from artifact (fork PR) if: inputs.is-fork == 'true' shell: bash run: | echo "Loading Docker image from artifact..." - gunzip -c docker-image.tar.gz | docker load + gunzip -c ${{ inputs.artifact-name }}.tar.gz | docker load echo "Available images after load:" docker images diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 015d3fa164a..bafbfa96c69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1177,6 +1177,27 @@ jobs: cache-from: type=registry,ref=ghcr.io/${{ steps.strategy.outputs.owner }}/ghost:cache-main cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref=ghcr.io/{0}/ghost:cache-{1},mode=max', steps.strategy.outputs.owner, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + - name: Save full image as artifact (fork PR) + if: steps.strategy.outputs.is-fork-pr == 'true' + run: | + IMAGE_TAG=$(echo "${{ steps.meta-full.outputs.tags }}" | head -n1) + echo "Saving image: $IMAGE_TAG" + docker save "$IMAGE_TAG" | gzip > docker-image-production.tar.gz + echo "Image saved as docker-image-production.tar.gz" + ls -lh docker-image-production.tar.gz + + - name: Upload image artifact (fork PR) + if: steps.strategy.outputs.is-fork-pr == 'true' + uses: actions/upload-artifact@v4 + with: + name: docker-image-production + path: docker-image-production.tar.gz + retention-days: 1 + + outputs: + image-tags: ${{ steps.meta-full.outputs.tags }} + is-fork: ${{ steps.strategy.outputs.is-fork-pr }} + job_inspect_image: name: Inspect Docker Image needs: job_docker_build @@ -1247,7 +1268,7 @@ jobs: job_e2e_tests: name: E2E Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) runs-on: ubuntu-latest - needs: [job_docker_build, job_setup] + needs: [job_docker_build_production, job_setup] strategy: fail-fast: true matrix: @@ -1275,17 +1296,13 @@ jobs: uses: ./.github/actions/load-docker-image id: load with: - is-fork: ${{ needs.job_docker_build.outputs.is-fork }} - image-tags: ${{ needs.job_docker_build.outputs.image-tags }} + is-fork: ${{ needs.job_docker_build_production.outputs.is-fork }} + image-tags: ${{ needs.job_docker_build_production.outputs.image-tags }} + artifact-name: docker-image-production - name: Setup Docker Registry Mirrors uses: ./.github/actions/setup-docker-registry-mirrors - - name: Pull images - env: - GHOST_IMAGE_TAG: ${{ steps.load.outputs.image-tag }} - run: docker compose -f e2e/compose.yml pull - - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} @@ -1295,12 +1312,25 @@ jobs: env: DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} + - name: Build public app UMD bundles + run: yarn workspace @tryghost/e2e build:apps + + - name: Build E2E image layer + env: + GHOST_E2E_BASE_IMAGE: ${{ steps.load.outputs.image-tag }} + run: yarn workspace @tryghost/e2e build:docker + + - name: Pull images + env: + GHOST_E2E_IMAGE: ghost-e2e:local + run: docker compose -f e2e/compose.yml pull + - name: Setup Playwright uses: ./.github/actions/setup-playwright - name: Run e2e tests env: - GHOST_IMAGE_TAG: ${{ steps.load.outputs.image-tag }} + GHOST_E2E_IMAGE: ghost-e2e:local TEST_WORKERS_COUNT: 1 run: yarn test:e2e:all --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --retries=2 diff --git a/e2e/Dockerfile.e2e b/e2e/Dockerfile.e2e new file mode 100644 index 00000000000..b291e6d07f5 --- /dev/null +++ b/e2e/Dockerfile.e2e @@ -0,0 +1,20 @@ +# E2E test layer: copies locally-built public apps into any Ghost image +# so Ghost serves them at /ghost/assets/{app}/{app}.min.js (same origin, no CORS). +# +# Usage: +# docker build -f e2e/Dockerfile.e2e \ +# --build-arg GHOST_IMAGE=ghost-monorepo:latest \ +# -t ghost-e2e:local . +# +# Works with any base image (monorepo or production). + +ARG GHOST_IMAGE=ghost-monorepo:latest +FROM $GHOST_IMAGE + +# Public app UMD bundles — Ghost serves these from /ghost/assets/ +COPY apps/portal/umd/portal.min.js core/built/admin/assets/portal/portal.min.js +COPY apps/comments-ui/umd/comments-ui.min.js core/built/admin/assets/comments-ui/comments-ui.min.js +COPY apps/sodo-search/umd/sodo-search.min.js core/built/admin/assets/sodo-search/sodo-search.min.js +COPY apps/sodo-search/umd/main.css core/built/admin/assets/sodo-search/main.css +COPY apps/signup-form/umd/signup-form.min.js core/built/admin/assets/signup-form/signup-form.min.js +COPY apps/announcement-bar/umd/announcement-bar.min.js core/built/admin/assets/announcement-bar/announcement-bar.min.js diff --git a/e2e/compose.yml b/e2e/compose.yml index f5357c537f5..2847c2e2178 100644 --- a/e2e/compose.yml +++ b/e2e/compose.yml @@ -18,13 +18,11 @@ services: start_period: 10s ghost-migrations: - # if not specified this will default to tag of Ghost project - image: ${GHOST_IMAGE_TAG:-ghost-monorepo:latest} + image: ${GHOST_E2E_IMAGE:-ghost-e2e:local} pull_policy: never working_dir: /home/ghost - command: ["yarn", "knex-migrator", "init"] + command: ["node", "node_modules/.bin/knex-migrator", "init"] environment: - NODE_OPTIONS: "--import=tsx" database__client: mysql2 database__connection__host: mysql database__connection__user: root @@ -114,20 +112,6 @@ services: interval: 1s retries: 30 - portal: - image: ${GHOST_IMAGE_TAG:-ghost-monorepo:latest} - pull_policy: never - working_dir: /home/ghost/apps/portal - command: ["yarn", "preview", "--port", "4175", "--host", "0.0.0.0"] - ports: - - "4175" # Dynamic host port assignment - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:4175/portal.min.js"] - interval: 1s - timeout: 3s - retries: 30 - start_period: 10s - volumes: shared-config: diff --git a/e2e/helpers/environment/constants.ts b/e2e/helpers/environment/constants.ts index a21e33ad11e..24f7ca5c1d4 100644 --- a/e2e/helpers/environment/constants.ts +++ b/e2e/helpers/environment/constants.ts @@ -12,12 +12,24 @@ export const DOCKER_COMPOSE_CONFIG = { }; export const GHOST_DEFAULTS = { - // if not specified this would be the tag of the Ghost project, built at root of the repository - IMAGE: process.env.GHOST_IMAGE_TAG || 'ghost-monorepo', - PORT: 2368, - WORKDIR: '/home/ghost/ghost/core' + PORT: 2368 }; +export interface GhostImageProfile { + image: string; + workdir: string; + command: string[]; +} + +export function getImageProfile(): GhostImageProfile { + const image = process.env.GHOST_E2E_IMAGE || 'ghost-e2e:local'; + return { + image, + workdir: '/home/ghost', + command: ['node', 'index.js'] + }; +} + export const MYSQL = { HOST: 'mysql', PORT: 3306, @@ -32,8 +44,13 @@ export const TINYBIRD = { CONFIG_DIR: CONFIG_DIR }; -export const PORTAL = { - PORT: 4175 +export const PUBLIC_APPS = { + PORTAL_URL: '/ghost/assets/portal/portal.min.js', + COMMENTS_URL: '/ghost/assets/comments-ui/comments-ui.min.js', + SODO_SEARCH_URL: '/ghost/assets/sodo-search/sodo-search.min.js', + SODO_SEARCH_STYLES: '/ghost/assets/sodo-search/main.css', + SIGNUP_FORM_URL: '/ghost/assets/signup-form/signup-form.min.js', + ANNOUNCEMENT_BAR_URL: '/ghost/assets/announcement-bar/announcement-bar.min.js' }; export const MAILPIT = { @@ -81,12 +98,12 @@ export const TEST_ENVIRONMENT = { 'mail__options__port=1025', // Public assets via gateway (same as compose.dev.yaml) - 'portal__url=/ghost/assets/portal/portal.min.js', - 'comments__url=/ghost/assets/comments-ui/comments-ui.min.js', - 'sodoSearch__url=/ghost/assets/sodo-search/sodo-search.min.js', - 'sodoSearch__styles=/ghost/assets/sodo-search/main.css', - 'signupForm__url=/ghost/assets/signup-form/signup-form.min.js', - 'announcementBar__url=/ghost/assets/announcement-bar/announcement-bar.min.js' + `portal__url=${PUBLIC_APPS.PORTAL_URL}`, + `comments__url=${PUBLIC_APPS.COMMENTS_URL}`, + `sodoSearch__url=${PUBLIC_APPS.SODO_SEARCH_URL}`, + `sodoSearch__styles=${PUBLIC_APPS.SODO_SEARCH_STYLES}`, + `signupForm__url=${PUBLIC_APPS.SIGNUP_FORM_URL}`, + `announcementBar__url=${PUBLIC_APPS.ANNOUNCEMENT_BAR_URL}` ] } } as const; diff --git a/e2e/helpers/environment/environment-factory.ts b/e2e/helpers/environment/environment-factory.ts index 84a5b8320b2..808cc82908a 100644 --- a/e2e/helpers/environment/environment-factory.ts +++ b/e2e/helpers/environment/environment-factory.ts @@ -1,5 +1,6 @@ import {DevEnvironmentManager} from './dev-environment-manager'; import {EnvironmentManager} from './environment-manager'; +import {getImageProfile} from './constants'; import {isDevEnvironmentAvailable} from './service-availability'; // Cached manager instance (one per worker process) @@ -8,12 +9,23 @@ let cachedManager: EnvironmentManager | DevEnvironmentManager | null = null; /** * Get the environment manager for this worker. * Creates and caches a manager on first call, returns cached instance thereafter. + * + * Priority: GHOST_E2E_IMAGE > dev environment detection > default container mode */ export async function getEnvironmentManager(): Promise { if (!cachedManager) { - const useDevEnv = await isDevEnvironmentAvailable(); - cachedManager = useDevEnv ? new DevEnvironmentManager() : new EnvironmentManager(); + // Check for dev environment first (unless an explicit image was provided) + if (!process.env.GHOST_E2E_IMAGE) { + const useDevEnv = await isDevEnvironmentAvailable(); + if (useDevEnv) { + cachedManager = new DevEnvironmentManager(); + return cachedManager; + } + } + + // Container mode: use profile from env vars + const profile = getImageProfile(); + cachedManager = new EnvironmentManager(profile); } return cachedManager; } - diff --git a/e2e/helpers/environment/environment-manager.ts b/e2e/helpers/environment/environment-manager.ts index 7acf468dbb1..ae35364b840 100644 --- a/e2e/helpers/environment/environment-manager.ts +++ b/e2e/helpers/environment/environment-manager.ts @@ -1,10 +1,11 @@ import Docker from 'dockerode'; import baseDebug from '@tryghost/debug'; import logging from '@tryghost/logging'; -import {DOCKER_COMPOSE_CONFIG, PORTAL, TINYBIRD} from './constants'; +import {DOCKER_COMPOSE_CONFIG, TINYBIRD} from './constants'; import {DockerCompose} from './docker-compose'; -import {GhostInstance, GhostManager, MySQLManager, PortalManager, TinybirdManager} from './service-managers'; +import {GhostInstance, GhostManager, MySQLManager, TinybirdManager} from './service-managers'; import {randomUUID} from 'crypto'; +import type {GhostImageProfile} from './constants'; const debug = baseDebug('e2e:EnvironmentManager'); @@ -25,9 +26,9 @@ export class EnvironmentManager { private readonly mysql: MySQLManager; private readonly tinybird: TinybirdManager; private readonly ghost: GhostManager; - private readonly portal: PortalManager; constructor( + profile: GhostImageProfile, composeFilePath: string = DOCKER_COMPOSE_CONFIG.FILE_PATH, composeProjectName: string = DOCKER_COMPOSE_CONFIG.PROJECT ) { @@ -40,12 +41,11 @@ export class EnvironmentManager { this.mysql = new MySQLManager(this.dockerCompose); this.tinybird = new TinybirdManager(this.dockerCompose, TINYBIRD.CONFIG_DIR, TINYBIRD.CLI_ENV_PATH); - this.ghost = new GhostManager(docker, this.dockerCompose, this.tinybird); - this.portal = new PortalManager(this.dockerCompose, PORTAL.PORT); + this.ghost = new GhostManager(docker, this.dockerCompose, this.tinybird, profile); } /** - * Setup shared global environment for tests (i.e. mysql, tinybird, portal) + * Setup shared global environment for tests (i.e. mysql, tinybird) * This should be called once before all tests run. * * 1. Clean up any leftover resources from previous test runs @@ -75,9 +75,8 @@ export class EnvironmentManager { try { const {siteUuid, instanceId} = this.uniqueTestDetails(); await this.mysql.setupTestDatabase(instanceId, siteUuid); - const portalUrl = await this.portal.getUrl(); - return await this.ghost.createAndStartInstance(instanceId, siteUuid, portalUrl, options.config); + return await this.ghost.createAndStartInstance(instanceId, siteUuid, options.config); } catch (error) { logging.error('Failed to setup Ghost instance:', error); throw new Error(`Failed to setup Ghost instance: ${error}`); diff --git a/e2e/helpers/environment/service-managers/ghost-manager.ts b/e2e/helpers/environment/service-managers/ghost-manager.ts index 263cbb0f6ba..3e1435cd31e 100644 --- a/e2e/helpers/environment/service-managers/ghost-manager.ts +++ b/e2e/helpers/environment/service-managers/ghost-manager.ts @@ -1,10 +1,11 @@ import Docker from 'dockerode'; import baseDebug from '@tryghost/debug'; import logging from '@tryghost/logging'; -import {DOCKER_COMPOSE_CONFIG, GHOST_DEFAULTS, MAILPIT, MYSQL, PORTAL, TINYBIRD} from '@/helpers/environment/constants'; +import {DOCKER_COMPOSE_CONFIG, GHOST_DEFAULTS, MAILPIT, MYSQL, PUBLIC_APPS, TINYBIRD} from '@/helpers/environment/constants'; import {DockerCompose} from '@/helpers/environment/docker-compose'; import {TinybirdManager} from './tinybird-manager'; import type {Container, ContainerCreateOptions} from 'dockerode'; +import type {GhostImageProfile} from '@/helpers/environment/constants'; const debug = baseDebug('e2e:GhostManager'); @@ -20,9 +21,6 @@ export interface GhostInstance { export interface GhostStartConfig { instanceId: string; siteUuid: string; - workingDir?: string; - command?: string[]; - portalUrl?: string; config?: unknown; } @@ -30,11 +28,13 @@ export class GhostManager { private docker: Docker; private dockerCompose: DockerCompose; private tinybird: TinybirdManager; + private profile: GhostImageProfile; - constructor(docker: Docker, dockerCompose: DockerCompose, tinybird: TinybirdManager) { + constructor(docker: Docker, dockerCompose: DockerCompose, tinybird: TinybirdManager, profile: GhostImageProfile) { this.docker = docker; this.dockerCompose = dockerCompose; this.tinybird = tinybird; + this.profile = profile; } private async createAndStart(config: GhostStartConfig): Promise { @@ -71,13 +71,18 @@ export class GhostManager { mail__options__host: 'mailpit', mail__options__port: `${MAILPIT.PORT}`, mail__options__secure: 'false', - // other services configuration - portal__url: config.portalUrl || `http://localhost:${PORTAL.PORT}/portal.min.js`, + // Public apps — served from Ghost's admin assets path via E2E image layer + portal__url: PUBLIC_APPS.PORTAL_URL, + comments__url: PUBLIC_APPS.COMMENTS_URL, + sodoSearch__url: PUBLIC_APPS.SODO_SEARCH_URL, + sodoSearch__styles: PUBLIC_APPS.SODO_SEARCH_STYLES, + signupForm__url: PUBLIC_APPS.SIGNUP_FORM_URL, + announcementBar__url: PUBLIC_APPS.ANNOUNCEMENT_BAR_URL, ...(config.config ? config.config : {}) } as Record; const containerConfig: ContainerCreateOptions = { - Image: GHOST_DEFAULTS.IMAGE, + Image: this.profile.image, Env: Object.entries(environment).map(([key, value]) => `${key}=${value}`), NetworkingConfig: { EndpointsConfig: { @@ -99,8 +104,8 @@ export class GhostManager { 'com.docker.compose.service': `ghost-${config.siteUuid}`, 'tryghost/e2e': 'ghost' }, - WorkingDir: config.workingDir || GHOST_DEFAULTS.WORKDIR, - Cmd: config.command || ['yarn', 'dev'], + WorkingDir: this.profile.workdir, + Cmd: this.profile.command, AttachStdout: true, AttachStderr: true }; @@ -120,8 +125,8 @@ export class GhostManager { } } - async createAndStartInstance(instanceId: string, siteUuid: string, portalUrl?: string, config?: unknown): Promise { - const container = await this.createAndStart({instanceId, siteUuid, portalUrl, config}); + async createAndStartInstance(instanceId: string, siteUuid: string, config?: unknown): Promise { + const container = await this.createAndStart({instanceId, siteUuid, config}); const containerInfo = await container.inspect(); const hostPort = parseInt(containerInfo.NetworkSettings.Ports[`${GHOST_DEFAULTS.PORT}/tcp`][0].HostPort, 10); await this.waitReady(hostPort, 30000); diff --git a/e2e/helpers/environment/service-managers/index.ts b/e2e/helpers/environment/service-managers/index.ts index 0f07756d0f6..97b63a9d295 100644 --- a/e2e/helpers/environment/service-managers/index.ts +++ b/e2e/helpers/environment/service-managers/index.ts @@ -1,5 +1,4 @@ export * from './dev-ghost-manager'; export * from './ghost-manager'; export * from './mysql-manager'; -export * from './portal-manager'; export * from './tinybird-manager'; diff --git a/e2e/helpers/environment/service-managers/portal-manager.ts b/e2e/helpers/environment/service-managers/portal-manager.ts deleted file mode 100644 index f5249fd4e3e..00000000000 --- a/e2e/helpers/environment/service-managers/portal-manager.ts +++ /dev/null @@ -1,26 +0,0 @@ -import baseDebug from '@tryghost/debug'; -import logging from '@tryghost/logging'; -import {DockerCompose} from '@/helpers/environment/docker-compose'; - -const debug = baseDebug('e2e:PortalManager'); - -export class PortalManager { - private readonly dockerCompose: DockerCompose; - - constructor(dockerCompose: DockerCompose,private readonly port: number) { - this.dockerCompose = dockerCompose; - } - - async getUrl(): Promise { - try { - const hostPort = await this.dockerCompose.getHostPortForService('portal', this.port); - const portalUrl = `http://localhost:${hostPort}/portal.min.js`; - - debug(`Portal is available at: ${portalUrl}`); - return portalUrl; - } catch (error) { - logging.error('Failed to get Portal URL:', error); - throw new Error(`Failed to get portal URL: ${error}. Ensure portal service is running.`); - } - } -} diff --git a/e2e/package.json b/e2e/package.json index b838966750f..3618f12b945 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -9,7 +9,8 @@ "dev": "tsc --watch --preserveWatchOutput --noEmit", "build": "yarn test:types", "build:ts": "tsc --noEmit", - "docker:build:ghost": "cd .. && docker build -t ghost-monorepo:latest -f Dockerfile .", + "build:apps": "nx run-many --target=build --projects=@tryghost/portal,@tryghost/comments-ui,@tryghost/sodo-search,@tryghost/signup-form,@tryghost/announcement-bar", + "build:docker": "docker build -f Dockerfile.e2e --build-arg GHOST_IMAGE=${GHOST_E2E_BASE_IMAGE:?Set GHOST_E2E_BASE_IMAGE} -t ${GHOST_E2E_IMAGE:-ghost-e2e:local} ..", "docker:update": "docker compose pull && docker compose up -d --force-recreate", "prepare": "tsc --noEmit", "pretest": "(test -n \"$GHOST_E2E_SKIP_BUILD\" || test -n \"$CI\") && echo 'Skipping Docker build (GHOST_E2E_SKIP_BUILD or CI is set)' || yarn docker:build:ghost", From 9b415ed37f8e260b2c04ae87dc4decc13f7fec6b Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Thu, 19 Feb 2026 12:30:42 +0000 Subject: [PATCH 04/14] Fixed broken pretest script referencing removed docker:build:ghost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pretest script still called `yarn docker:build:ghost` which was removed when E2E tests moved to production images. The fallback is no longer needed — local dev uses `yarn dev` (auto-detected) and container mode requires an explicit `GHOST_E2E_IMAGE`. Replaced with a helpful hint instead of a hard failure. --- e2e/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/package.json b/e2e/package.json index 3618f12b945..d6c2715cfd2 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -13,7 +13,7 @@ "build:docker": "docker build -f Dockerfile.e2e --build-arg GHOST_IMAGE=${GHOST_E2E_BASE_IMAGE:?Set GHOST_E2E_BASE_IMAGE} -t ${GHOST_E2E_IMAGE:-ghost-e2e:local} ..", "docker:update": "docker compose pull && docker compose up -d --force-recreate", "prepare": "tsc --noEmit", - "pretest": "(test -n \"$GHOST_E2E_SKIP_BUILD\" || test -n \"$CI\") && echo 'Skipping Docker build (GHOST_E2E_SKIP_BUILD or CI is set)' || yarn docker:build:ghost", + "pretest": "(test -n \"$GHOST_E2E_SKIP_BUILD\" || test -n \"$CI\") && echo 'Skipping Docker build' || echo 'Tip: run yarn dev first, or set GHOST_E2E_IMAGE for container mode'", "test": "playwright test --project=main", "test:analytics": "playwright test --project=analytics", "test:all": "playwright test --project=main --project=analytics", From e76af142ecee8d0169330ebf88bdd778eaafe0e7 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Thu, 19 Feb 2026 12:57:53 +0000 Subject: [PATCH 05/14] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20double-encoded=20H?= =?UTF-8?q?TML=20entities=20in=20email=20newsletter=20cards=20(#26498)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://linear.app/ghost/issue/BER-3229/ - Bookmark card descriptions containing pre-encoded HTML entities (e.g. `&`) were being double-escaped when rendering for email newsletters, showing `&amp;` to readers - Fixed the shared `escapeHtml` utility to decode existing entities before re-escaping, preventing double-encoding - Removed redundant `escapeHtml` call on bookmark descriptions since `truncateHtml` already handles escaping --- .../koenig/node-renderers/bookmark-renderer.js | 3 ++- .../services/koenig/render-utils/escape-html.js | 4 +++- .../node-renderers/bookmark-renderer.test.js | 17 ++++++++++++++++- .../koenig/node-renderers/file-renderer.test.js | 15 +++++++++++++++ .../koenig/render-utils/escape-html.test.js | 16 ++++++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 ghost/core/test/unit/server/services/koenig/render-utils/escape-html.test.js diff --git a/ghost/core/core/server/services/koenig/node-renderers/bookmark-renderer.js b/ghost/core/core/server/services/koenig/node-renderers/bookmark-renderer.js index 3617902fec0..2e5db9fc085 100644 --- a/ghost/core/core/server/services/koenig/node-renderers/bookmark-renderer.js +++ b/ghost/core/core/server/services/koenig/node-renderers/bookmark-renderer.js @@ -23,7 +23,8 @@ function emailTemplate(node, document) { const title = escapeHtml(node.title); const publisher = escapeHtml(node.publisher); const author = escapeHtml(node.author); - const description = escapeHtml(node.description); + // Description is escaped in truncateHtml, keep raw input here to avoid double-escaping entities. + const description = node.description || ''; const icon = node.icon; const url = node.url; diff --git a/ghost/core/core/server/services/koenig/render-utils/escape-html.js b/ghost/core/core/server/services/koenig/render-utils/escape-html.js index 83be9debd76..f371002b581 100644 --- a/ghost/core/core/server/services/koenig/render-utils/escape-html.js +++ b/ghost/core/core/server/services/koenig/render-utils/escape-html.js @@ -1,10 +1,12 @@ +const {decodeHTML} = require('entities'); + /** * Escape HTML special characters * @param {string} unsafe * @returns string */ function escapeHtml(unsafe) { - return unsafe + return decodeHTML(unsafe ?? '') .replace(/&/g, '&') .replace(//g, '>') diff --git a/ghost/core/test/unit/server/services/koenig/node-renderers/bookmark-renderer.test.js b/ghost/core/test/unit/server/services/koenig/node-renderers/bookmark-renderer.test.js index b7a8084df9e..3d586fb5cb4 100644 --- a/ghost/core/test/unit/server/services/koenig/node-renderers/bookmark-renderer.test.js +++ b/ghost/core/test/unit/server/services/koenig/node-renderers/bookmark-renderer.test.js @@ -176,12 +176,27 @@ describe('services/koenig/node-renderers/bookmark-renderer', function () { // Check that text fields are escaped assert.ok(result.html.includes('Ghost: Independent technology <script>alert("XSS")</script> for modern publishing.')); - assert.ok(result.html.includes('doing &quot;kewl&quot; stuff')); + assert.ok(result.html.includes('doing "kewl" stuff')); assert.ok(result.html.includes('fa\'ker')); assert.ok(result.html.includes('Fake <script>alert("XSS")</script>')); // Check that caption is not escaped assert.ok(result.html.includes('

This is a caption

')); }); + + it('decodes pre-encoded entities before escaping', function () { + const result = renderForEmail(getTestData({ + title: 'Q&A with Bernardo Kastrup', + description: '13th Jan Q&A with Bernardo Kastrup', + publisher: 'Research & Development' + })); + + assert.ok(result.html.includes('Q&A with Bernardo Kastrup')); + assert.ok(!result.html.includes('Q&amp;A with Bernardo Kastrup')); + assert.ok(result.html.includes('13th Jan Q&A with Bernardo Kastrup')); + assert.ok(!result.html.includes('13th Jan Q&amp;A with Bernardo Kastrup')); + assert.ok(result.html.includes('Research & Development')); + assert.ok(!result.html.includes('Research &amp; Development')); + }); }); }); diff --git a/ghost/core/test/unit/server/services/koenig/node-renderers/file-renderer.test.js b/ghost/core/test/unit/server/services/koenig/node-renderers/file-renderer.test.js index 5a97e4f0f86..9f42331aa25 100644 --- a/ghost/core/test/unit/server/services/koenig/node-renderers/file-renderer.test.js +++ b/ghost/core/test/unit/server/services/koenig/node-renderers/file-renderer.test.js @@ -132,6 +132,21 @@ describe('services/koenig/node-renderers/file-renderer', function () { `); }); + it('does not double-escape pre-encoded entities', function () { + const result = renderForEmail(getTestData({ + fileTitle: 'Q&A download', + fileCaption: 'Research & Development notes', + fileName: 'Q&A.pdf' + })); + + assert.ok(result.html.includes('Q&A download')); + assert.ok(!result.html.includes('Q&amp;A download')); + assert.ok(result.html.includes('Research & Development notes')); + assert.ok(!result.html.includes('Research &amp; Development notes')); + assert.ok(result.html.includes('Q&A.pdf')); + assert.ok(!result.html.includes('Q&amp;A.pdf')); + }); + it('renders nothing with a missing src', function () { const result = renderForEmail(getTestData({src: ''})); assert.equal(result.html, ''); diff --git a/ghost/core/test/unit/server/services/koenig/render-utils/escape-html.test.js b/ghost/core/test/unit/server/services/koenig/render-utils/escape-html.test.js new file mode 100644 index 00000000000..22785eb77bb --- /dev/null +++ b/ghost/core/test/unit/server/services/koenig/render-utils/escape-html.test.js @@ -0,0 +1,16 @@ +const assert = require('node:assert/strict'); +const {escapeHtml} = require('../../../../../../core/server/services/koenig/render-utils/escape-html'); + +describe('services/koenig/render-utils/escape-html', function () { + it('escapes unsafe html characters', function () { + const escaped = escapeHtml(' & data'); + + assert.equal(escaped, '<script>alert("x")</script> & data'); + }); + + it('does not double-escape pre-encoded entities', function () { + const escaped = escapeHtml('Q&A "session"'); + + assert.equal(escaped, 'Q&A "session"'); + }); +}); From c1e86e6dd150e7ab1a226cfce8f73bc4ee441787 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Thu, 19 Feb 2026 13:08:08 +0000 Subject: [PATCH 06/14] Improved `yarn dev` usage with Stripe (#26127) no issue Previously with the Dockerised `yarn dev` command, in order to enable Stripe webhooks it was necessary to modify your local `.env` file to add the `COMPOSE_PROFILES=stripe` line, and either specify Stripe keys in the `.env` or export ENV variables in your shell file. - added `docker/stripe/with-stripe.sh` command that adds `stripe` to `COMPOSE_PROFILES` and performs requirement checks to enable early exit with a useful log rather than needing to dig into container logs to find the problem - added `yarn dev:stripe` command to make Stripe availability easier to find than stumbling across the `.env.example` file - updated `yarn dev:all` to use `with-stripe.sh` so Stripe webhook support is included alongside other services --- docker/stripe/with-stripe.sh | 41 ++++++++++++++++++++++++++++++++++++ package.json | 5 +++-- 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100755 docker/stripe/with-stripe.sh diff --git a/docker/stripe/with-stripe.sh b/docker/stripe/with-stripe.sh new file mode 100755 index 00000000000..7914511fa8f --- /dev/null +++ b/docker/stripe/with-stripe.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Wrapper script to run commands with the Stripe profile enabled +# Checks for STRIPE_SECRET_KEY before starting, failing early with helpful error +# +# Usage: ./docker/stripe/with-stripe.sh +# Example: ./docker/stripe/with-stripe.sh nx run ghost-monorepo:docker:dev + +set -e + +check_stripe_key() { + # Check environment variable first + if [ -n "$STRIPE_SECRET_KEY" ]; then + return 0 + fi + + # Check .env file for non-empty value + if [ -f .env ] && grep -qE '^STRIPE_SECRET_KEY=.+' .env; then + return 0 + fi + + return 1 +} + +if ! check_stripe_key; then + echo "" + echo "================================================================================" + echo "ERROR: STRIPE_SECRET_KEY is not set" + echo "" + echo "To use the Stripe service, set STRIPE_SECRET_KEY in your .env file or ENV vars:" + echo " STRIPE_SECRET_KEY=sk_test_..." + echo "" + echo "You can find your secret key at: https://dashboard.stripe.com/test/apikeys" + echo "================================================================================" + echo "" + exit 1 +fi + +# Run the command with the stripe profile enabled +export COMPOSE_PROFILES="${COMPOSE_PROFILES:+$COMPOSE_PROFILES,}stripe" +exec "$@" diff --git a/package.json b/package.json index 9712117e8a6..a75ada6409b 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "dev:lexical": "EDITOR_URL=http://localhost:2368/ghost/assets/koenig-lexical/ yarn dev", "dev:analytics": "DEV_COMPOSE_FILES='-f compose.dev.analytics.yaml' nx run ghost-monorepo:docker:dev", "dev:storage": "DEV_COMPOSE_FILES='-f compose.dev.storage.yaml' nx run ghost-monorepo:docker:dev", - "dev:all": "DEV_COMPOSE_FILES='-f compose.dev.analytics.yaml -f compose.dev.storage.yaml' nx run ghost-monorepo:docker:dev", + "dev:stripe": "./docker/stripe/with-stripe.sh nx run ghost-monorepo:docker:dev", + "dev:all": "DEV_COMPOSE_FILES='-f compose.dev.analytics.yaml -f compose.dev.storage.yaml' ./docker/stripe/with-stripe.sh nx run ghost-monorepo:docker:dev", "fix": "yarn cache clean && rimraf -g '**/node_modules' && yarn && yarn nx reset", "knex-migrator": "yarn workspace ghost run knex-migrator", "setup": "yarn && git submodule update --init && NODE_ENV=development node .github/scripts/setup.js", @@ -46,7 +47,7 @@ "test:browser": "node ghost/core/test/scripts/browser-test-runner.js --", "test:e2e": "yarn workspace @tryghost/e2e test", "test:e2e:analytics": "yarn workspace @tryghost/e2e test:analytics", - "test:e2e:all": "yarn workspace @tryghost/e2e test:all", + "test:e2e:all": "yarn workspace @tryghost/e2e test:all", "test:e2e:debug": "DEBUG=@tryghost/e2e:* yarn test:e2e", "main": "yarn main:monorepo && yarn main:submodules", "main:monorepo": "git checkout main && git pull ${GHOST_UPSTREAM:-origin} main && yarn", From 18d19b4124ab54f507b977c82d8424dbeba20fee Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Thu, 19 Feb 2026 07:41:10 -0600 Subject: [PATCH 07/14] Removed setup.js script (#26507) ref https://github.com/TryGhost/Ghost/commit/f1321fb6715245cbbac2c487400e155d16ca06da We no longer need the setup script to do the mysql container and config setup, as the 'new' yarn dev handles this. --- .github/scripts/setup.js | 143 --------------------------------------- docs/README.md | 11 ++- package.json | 2 +- 3 files changed, 5 insertions(+), 151 deletions(-) delete mode 100644 .github/scripts/setup.js diff --git a/.github/scripts/setup.js b/.github/scripts/setup.js deleted file mode 100644 index 801002121ae..00000000000 --- a/.github/scripts/setup.js +++ /dev/null @@ -1,143 +0,0 @@ -const {spawn} = require('child_process'); -const fs = require('fs').promises; -const path = require('path'); - -const chalk = require('chalk'); -const inquirer = require('inquirer'); - -/** - * Run a command and stream output to the console - * - * @param {string} command - * @param {string[]} args - * @param {object} options - */ -async function runAndStream(command, args, options) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - stdio: 'inherit', - ...options - }); - - child.on('close', (code) => { - if (code === 0) { - resolve(code); - } else { - reject(new Error(`'${command} ${args.join(' ')}' exited with code ${code}`)); - } - }); - - }); -} - -(async () => { - if (process.env.NODE_ENV !== 'development') { - console.log(chalk.yellow(`NODE_ENV is not development, skipping setup`)); - return; - } - - if (process.env.DEVCONTAINER === 'true') { - console.log(chalk.yellow(`Devcontainer detected, skipping setup`)); - return; - } - - const coreFolder = path.join(__dirname, '../../ghost/core'); - const rootFolder = path.join(__dirname, '../..'); - const config = require('../../ghost/core/core/shared/config/loader').loadNconf({ - customConfigPath: coreFolder - }); - - const dbClient = config.get('database:client'); - const isUsingDocker = config.get('database:docker'); - - // Only reset data if we are using Docker - let resetData = false; - - if (!dbClient.includes('mysql')) { - let mysqlSetup = false; - console.log(chalk.blue(`Attempting to setup MySQL via Docker`)); - try { - await runAndStream('yarn', ['docker:reset'], {cwd: path.join(__dirname, '../../')}); - mysqlSetup = true; - } catch (err) { - console.error(chalk.red('Failed to run MySQL Docker container'), err); - console.error(chalk.red('Hint: is Docker installed and running?')); - } - - if (mysqlSetup) { - resetData = true; - console.log(chalk.blue(`Adding MySQL credentials to config.local.json`)); - const currentConfigPath = path.join(coreFolder, 'config.local.json'); - - let currentConfig; - try { - currentConfig = require(currentConfigPath); - } catch (err) { - currentConfig = {}; - } - - currentConfig.database = { - client: 'mysql', - docker: true, - connection: { - host: '127.0.0.1', - user: 'root', - password: 'root', - database: 'ghost' - } - }; - - try { - await fs.writeFile(currentConfigPath, JSON.stringify(currentConfig, null, 4)); - } catch (err) { - console.error(chalk.red('Failed to write config.local.json'), err); - console.log(chalk.yellow(`Please add the following to config.local.json:\n`), JSON.stringify(currentConfig, null, 4)); - process.exit(1); - } - } - } else { - if (isUsingDocker) { - const yesAll = process.argv.includes('-y'); - const noAll = process.argv.includes('-n'); - const {confirmed} = - yesAll ? {confirmed: true} - : ( - noAll ? {confirmed: false} - : await inquirer.prompt({name: 'confirmed', type:'confirm', message: 'MySQL is running via Docker, do you want to reset the Docker container? This will delete all existing data.', default: false}) - ); - - if (confirmed) { - console.log(chalk.yellow(`Resetting Docker container`)); - - try { - await runAndStream('yarn', ['docker:reset'], {cwd: path.join(__dirname, '../../')}); - resetData = true; - } catch (err) { - console.error(chalk.red('Failed to run MySQL Docker container'), err); - console.error(chalk.red('Hint: is Docker installed and running?')); - } - } - } else { - console.log(chalk.green(`MySQL already configured locally. Stop your local database and delete your "database" configuration in config.local.json to switch to Docker.`)); - } - } - - console.log(chalk.blue(`Running knex-migrator init`)); - await runAndStream('yarn', ['knex-migrator', 'init'], {cwd: coreFolder}); - if (process.argv.includes('--no-seed')) { - console.log(chalk.yellow(`Skipping seed data`)); - console.log(chalk.yellow(`Done`)); - return; - } - if (resetData) { - const xxl = process.argv.includes('--xxl'); - - if (xxl) { - console.log(chalk.blue(`Resetting all data (with xxl)`)); - await runAndStream('yarn', ['reset:data:xxl'], {cwd: rootFolder}); - } else { - console.log(chalk.blue(`Resetting all data`)); - await runAndStream('yarn', ['reset:data'], {cwd: rootFolder}); - } - } -})(); diff --git a/docs/README.md b/docs/README.md index 82072a0418e..969290ef0df 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,16 +29,14 @@ git remote add origin git@github.com:/Ghost.git #### 2. Install and Setup ```bash -# Run initial setup -# This installs dependencies, initializes the database, -# sets up git hooks, and initializes submodules +# Install dependencies and initialize submodules yarn setup ``` #### 3. Start Ghost ```bash -# Start development server (uses Docker for backend services) +# Start development (runs Docker backend services + frontend dev servers) yarn dev ``` @@ -57,9 +55,8 @@ yarn fix # Update to latest main branch yarn main -# Reset and reinitialize database -yarn knex-migrator reset -yarn knex-migrator init +# Reset running dev data +yarn reset:data ``` ## Repository Structure diff --git a/package.json b/package.json index a75ada6409b..a29cb233d02 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "dev:all": "DEV_COMPOSE_FILES='-f compose.dev.analytics.yaml -f compose.dev.storage.yaml' ./docker/stripe/with-stripe.sh nx run ghost-monorepo:docker:dev", "fix": "yarn cache clean && rimraf -g '**/node_modules' && yarn && yarn nx reset", "knex-migrator": "yarn workspace ghost run knex-migrator", - "setup": "yarn && git submodule update --init && NODE_ENV=development node .github/scripts/setup.js", + "setup": "yarn && git submodule update --init --recursive", "reset:data": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:1000,posts:100 --seed 123'", "reset:data:empty": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:0,posts:0 --seed 123'", "reset:data:xxl": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:2000000,posts:0,emails:0,members_stripe_customers:0,members_login_events:0,members_status_events:0 --seed 123'", From 8a7bae05a7bac1ca8e94c43e70a0334cff5f675c Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 19 Feb 2026 08:24:16 -0600 Subject: [PATCH 08/14] Removed Inbox Links feature flag (#26116) closes https://linear.app/ghost/issue/NY-1003 This change should have no user impact because the flag is on for everyone. --- .../settings/advanced/labs/private-features.tsx | 4 ---- apps/portal/package.json | 2 +- apps/portal/src/components/pages/magic-link-page.js | 10 ++++------ apps/portal/test/signin-flow.test.js | 5 +---- apps/portal/test/signup-flow.test.js | 10 ++-------- ghost/core/core/shared/labs.js | 1 - .../e2e-api/admin/__snapshots__/config.test.js.snap | 1 - .../e2e-api/admin/__snapshots__/settings.test.js.snap | 2 +- 8 files changed, 9 insertions(+), 26 deletions(-) diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx index 3279ac2b95d..1ee3f656240 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx @@ -51,10 +51,6 @@ const features: Feature[] = [{ title: 'Transistor', description: 'Enable Transistor podcast integration', flag: 'transistor' -}, { - title: 'Inbox Links', - description: 'Enable mail app links on signup/signin', - flag: 'inboxlinks' }, { title: 'Retention Offers', description: 'Enable retention offers for canceling members', diff --git a/apps/portal/package.json b/apps/portal/package.json index 56a4de7ed5e..4bd619ffb52 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.64.5", + "version": "2.64.6", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/apps/portal/src/components/pages/magic-link-page.js b/apps/portal/src/components/pages/magic-link-page.js index c93dedd10c6..2ce22170abd 100644 --- a/apps/portal/src/components/pages/magic-link-page.js +++ b/apps/portal/src/components/pages/magic-link-page.js @@ -163,9 +163,8 @@ export default class MagicLinkPage extends React.Component { } renderCloseButton() { - const {site, inboxLinks} = this.context; - const isInboxLinksEnabled = site.labs?.inboxlinks !== false; - if (isInboxLinksEnabled && inboxLinks && !isIos(navigator)) { + const {inboxLinks} = this.context; + if (inboxLinks && !isIos(navigator)) { return ; } else { return ( @@ -234,8 +233,7 @@ export default class MagicLinkPage extends React.Component { } renderOTCForm() { - const {action, actionErrorMessage, otcRef, site, inboxLinks} = this.context; - const isInboxLinksEnabled = site.labs?.inboxlinks !== false; + const {action, actionErrorMessage, otcRef, inboxLinks} = this.context; const errors = this.state.errors || {}; if (!otcRef) { @@ -279,7 +277,7 @@ export default class MagicLinkPage extends React.Component {