From 9f7ef343fb75ec8aa074216df058b44edcf74cbc Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Wed, 26 Aug 2026 11:45:10 +0000 Subject: [PATCH 1/9] Changed job handler registration to accept dependencies ref https://linear.app/ghost/issue/HKG-1971 Handler registration is a composition boundary, so boot now passes the live jobs service, database, and logger explicitly instead of registration resolving process-global dependencies. This also lets focused tests exercise handlers without mutating the jobs-service singleton. --- ghost/core/core/boot.js | 15 +++++-- .../jobs-service/register-job-handlers.ts | 17 ++++--- .../register-job-handlers.test.ts | 45 +++++++++++-------- 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index e0cdb1026fa..7fa8623af83 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -653,10 +653,17 @@ async function bootGhost({ backend = true, frontend = true, server = true } = {} await initServices({ ghostServer, config, prometheusClient }); debug('Begin: Register job handlers'); - const jobsService = require('./server/services/jobs-service'); - const service = jobsService.init(); - require('./server/services/jobs-service/register-job-handlers').default(); - await service.start(); + const jobsServiceWrapper = require('./server/services/jobs-service'); + const registerJobHandlers = + require('./server/services/jobs-service/register-job-handlers').default; + const db = require('./server/data/db'); + const jobsService = jobsServiceWrapper.init(); + registerJobHandlers({ + jobsService, + db, + logging, + }); + await jobsService.start(); debug('End: Register job handlers'); debug('End: Load Ghost Services & Apps'); diff --git a/ghost/core/core/server/services/jobs-service/register-job-handlers.ts b/ghost/core/core/server/services/jobs-service/register-job-handlers.ts index 1960eeacf91..5e028fe45d2 100644 --- a/ghost/core/core/server/services/jobs-service/register-job-handlers.ts +++ b/ghost/core/core/server/services/jobs-service/register-job-handlers.ts @@ -1,16 +1,21 @@ import errors from '@tryghost/errors'; -import { getInstance } from './index'; +import { JobsService } from './jobs-service'; import CleanTokensJob from '../members/jobs/clean-tokens-job'; import cleanTokens from '../members/jobs/clean-tokens-task'; import * as gifts from '../gifts'; import CleanGiftsJob from '../gifts/jobs/clean-gifts-job'; -const logging = require('@tryghost/logging'); - -export default function registerJobHandlers(): void { - const jobsService = getInstance(); - const db = require('../../data/db'); +interface RegisterJobHandlersDependencies { + jobsService: JobsService; + db: typeof import('../../data/db'); + logging: typeof import('@tryghost/logging'); +} +export default function registerJobHandlers({ + jobsService, + db, + logging, +}: RegisterJobHandlersDependencies): void { jobsService.handle(CleanTokensJob, async () => { await cleanTokens({ db, logging }); }); diff --git a/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts b/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts index a4fe6b1bd2e..445009f70c8 100644 --- a/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts +++ b/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts @@ -1,32 +1,26 @@ import assert from 'node:assert/strict'; import sinon from 'sinon'; import { describe, it, beforeEach, afterEach } from 'vitest'; +import logging from '@tryghost/logging'; +import { JobsService } from '../../../../../core/server/services/jobs-service/jobs-service'; -// require, not import: these must resolve to the same CommonJS module instances -// that register-job-handlers.ts reaches for at execution time. -const jobsService = require('../../../../../core/server/services/jobs-service'); -const adapterManager = require('../../../../../core/server/services/adapter-manager').default; const registerJobHandlers = require('../../../../../core/server/services/jobs-service/register-job-handlers').default; -type Processor = (envelope: { type: string; payload: string }) => Promise; - describe('register-job-handlers', function () { - let deliver: Processor; + let jobsService: sinon.SinonStubbedInstance; + let db: { knex: sinon.SinonStub }; + let loggingStub: sinon.SinonStubbedInstance; - beforeEach(async function () { - jobsService.init(); - const backend = adapterManager.getAdapter('jobs'); - sinon.stub(backend, 'start').callsFake((...args: unknown[]) => { - deliver = (args[0] as { processor: Processor }).processor; - }); + beforeEach(function () { + jobsService = sinon.createStubInstance(JobsService); + db = { knex: sinon.stub() }; + loggingStub = sinon.stub(logging); - registerJobHandlers(); - await jobsService.getInstance().start(); + registerJobHandlers({ jobsService, db, logging: loggingStub }); }); - afterEach(async function () { - await jobsService.shutdown({ timeoutMs: 100 }); + afterEach(function () { sinon.restore(); }); @@ -34,9 +28,24 @@ describe('register-job-handlers', function () { // exists for: a dispatch that lands before boot has built the service must // fail loudly rather than reading undefined off the module. it('fails a clean-gifts delivery when the gift service is not initialised', async function () { + const cleanGiftsHandler = jobsService.handle.secondCall.args[1]; + await assert.rejects( - () => deliver({ type: 'clean-gifts', payload: '{}' }), + () => cleanGiftsHandler({}), /clean-gifts ran before the gifts service was initialised/, ); }); + + it('runs clean-tokens with the injected database and logger', async function () { + const deleteStub = sinon.stub().resolves(2); + const whereStub = sinon.stub().returns({ delete: deleteStub }); + db.knex.withArgs('tokens').returns({ where: whereStub }); + const cleanTokensHandler = jobsService.handle.firstCall.args[1]; + + await cleanTokensHandler({}); + + assert.ok(db.knex.calledOnceWithExactly('tokens')); + assert.ok(loggingStub.info.calledOnce); + assert.equal(loggingStub.info.firstCall.args[0].system.deleted_count, 2); + }); }); From 8c0e9c4d5afe18b4c0bf4ee66e49cbd2b8523e37 Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Wed, 26 Aug 2026 11:45:10 +0000 Subject: [PATCH 2/9] Changed media inliner to expose its initialized instance ref https://linear.app/ghost/issue/HKG-1971 Boot needs the concrete ExternalMediaInliner when wiring the class-based job handler. Retaining the instance alongside the unchanged legacy API makes that composition possible without adding a wrapper service or changing dispatch behavior ahead of the migration. --- .../server/services/media-inliner/service.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ghost/core/core/server/services/media-inliner/service.js b/ghost/core/core/server/services/media-inliner/service.js index 33ebee88304..270705c2921 100644 --- a/ghost/core/core/server/services/media-inliner/service.js +++ b/ghost/core/core/server/services/media-inliner/service.js @@ -1,3 +1,7 @@ +const errors = require('@tryghost/errors'); + +let instance; + module.exports = { async init() { const debug = require('@tryghost/debug')('mediaInliner'); @@ -31,6 +35,8 @@ module.exports = { }, }); + instance = mediaInliner; + this.api = { startMediaInliner: async (domains) => { if (!domains || !domains.length) { @@ -72,4 +78,14 @@ module.exports = { }, }; }, + + getInstance() { + if (!instance) { + throw new errors.IncorrectUsageError({ + message: 'Media inliner used before init(). Call init() from boot first.', + }); + } + + return instance; + }, }; From 9e4d3d6aa2e2a9629a216335ca77ea2546c16b77 Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Wed, 26 Aug 2026 11:45:10 +0000 Subject: [PATCH 3/9] Moved external media inlining to the class-based jobs service ref https://linear.app/ghost/issue/HKG-1971 External media inlining was the remaining inline task on the legacy job manager. The Admin API now resolves a complete domain payload and dispatches a serializable job, while boot injects the initialized ExternalMediaInliner into central handler registration. This keeps execution separate from dispatch and lets JobsService own lifecycle logging and error reporting. --- ghost/core/core/boot.js | 2 + ghost/core/core/server/api/endpoints/db.js | 18 +++- .../jobs-service/register-job-handlers.ts | 8 ++ .../external-media-inliner-job.ts | 12 +++ .../media-inliner/external-media-inliner.d.ts | 7 ++ .../server/services/media-inliner/service.js | 44 ---------- .../external-media-inliner-job.test.ts | 84 +++++++++++++++++++ ghost/core/test/unit/api/endpoints/db.test.js | 62 +++++++++++++- .../register-job-handlers.test.ts | 38 +++++++-- .../external-media-inliner-job.test.ts | 17 ++++ 10 files changed, 237 insertions(+), 55 deletions(-) create mode 100644 ghost/core/core/server/services/media-inliner/external-media-inliner-job.ts create mode 100644 ghost/core/core/server/services/media-inliner/external-media-inliner.d.ts create mode 100644 ghost/core/test/integration/services/media-inliner/external-media-inliner-job.test.ts create mode 100644 ghost/core/test/unit/server/services/media-inliner/external-media-inliner-job.test.ts diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index 7fa8623af83..5a3a4216094 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -656,12 +656,14 @@ async function bootGhost({ backend = true, frontend = true, server = true } = {} const jobsServiceWrapper = require('./server/services/jobs-service'); const registerJobHandlers = require('./server/services/jobs-service/register-job-handlers').default; + const mediaInliner = require('./server/services/media-inliner'); const db = require('./server/data/db'); const jobsService = jobsServiceWrapper.init(); registerJobHandlers({ jobsService, db, logging, + mediaInliner: mediaInliner.getInstance(), }); await jobsService.start(); debug('End: Register job handlers'); diff --git a/ghost/core/core/server/api/endpoints/db.js b/ghost/core/core/server/api/endpoints/db.js index 376155a555e..48ac0d6a960 100644 --- a/ghost/core/core/server/api/endpoints/db.js +++ b/ghost/core/core/server/api/endpoints/db.js @@ -3,12 +3,17 @@ const path = require('path'); const dbBackup = require('../../data/db/backup'); const exporter = require('../../data/exporter'); const importer = require('../../data/importer'); -const mediaInliner = require('../../services/media-inliner'); +const jobsService = require('../../services/jobs-service'); +const ExternalMediaInlinerJob = + require('../../services/media-inliner/external-media-inliner-job').default; const errors = require('@tryghost/errors'); +const logging = require('@tryghost/logging'); const { promisePool } = require('../../lib/promise-pool'); const models = require('../../models'); const settingsCache = require('../../../shared/settings-cache'); +const DEFAULT_MEDIA_INLINER_DOMAINS = ['https://s3.amazonaws.com/revue', 'https://substackcdn.com']; + /** @type {import('@tryghost/api-framework').Controller} */ const controller = { docName: 'db', @@ -125,7 +130,16 @@ const controller = { }, }, async query(frame) { - return mediaInliner.api.startMediaInliner(frame.data.domains); + const domains = frame.data.domains?.length + ? frame.data.domains + : DEFAULT_MEDIA_INLINER_DOMAINS; + + logging.info('[Background Job] external-media-inliner queued'); + await jobsService.getInstance().dispatch(new ExternalMediaInlinerJob({ domains })); + + return { + status: 'success', + }; }, }, diff --git a/ghost/core/core/server/services/jobs-service/register-job-handlers.ts b/ghost/core/core/server/services/jobs-service/register-job-handlers.ts index 5e028fe45d2..932b5a1f87d 100644 --- a/ghost/core/core/server/services/jobs-service/register-job-handlers.ts +++ b/ghost/core/core/server/services/jobs-service/register-job-handlers.ts @@ -4,17 +4,21 @@ import CleanTokensJob from '../members/jobs/clean-tokens-job'; import cleanTokens from '../members/jobs/clean-tokens-task'; import * as gifts from '../gifts'; import CleanGiftsJob from '../gifts/jobs/clean-gifts-job'; +import ExternalMediaInliner from '../media-inliner/external-media-inliner'; +import ExternalMediaInlinerJob from '../media-inliner/external-media-inliner-job'; interface RegisterJobHandlersDependencies { jobsService: JobsService; db: typeof import('../../data/db'); logging: typeof import('@tryghost/logging'); + mediaInliner: ExternalMediaInliner; } export default function registerJobHandlers({ jobsService, db, logging, + mediaInliner, }: RegisterJobHandlersDependencies): void { jobsService.handle(CleanTokensJob, async () => { await cleanTokens({ db, logging }); @@ -28,4 +32,8 @@ export default function registerJobHandlers({ } await gifts.service.cleanup(); }); + + jobsService.handle(ExternalMediaInlinerJob, async (job) => { + await mediaInliner.inline(job.domains); + }); } diff --git a/ghost/core/core/server/services/media-inliner/external-media-inliner-job.ts b/ghost/core/core/server/services/media-inliner/external-media-inliner-job.ts new file mode 100644 index 00000000000..b0856e03ce7 --- /dev/null +++ b/ghost/core/core/server/services/media-inliner/external-media-inliner-job.ts @@ -0,0 +1,12 @@ +import { Job } from '../jobs-service/job'; + +export default class ExternalMediaInlinerJob extends Job { + static type = 'external-media-inliner'; + + readonly domains: string[]; + + constructor({ domains }: { domains: string[] }) { + super(); + this.domains = domains; + } +} diff --git a/ghost/core/core/server/services/media-inliner/external-media-inliner.d.ts b/ghost/core/core/server/services/media-inliner/external-media-inliner.d.ts new file mode 100644 index 00000000000..c7084786661 --- /dev/null +++ b/ghost/core/core/server/services/media-inliner/external-media-inliner.d.ts @@ -0,0 +1,7 @@ +declare class ExternalMediaInliner { + constructor(deps: object); + + inline(domains: string[]): Promise; +} + +export = ExternalMediaInliner; diff --git a/ghost/core/core/server/services/media-inliner/service.js b/ghost/core/core/server/services/media-inliner/service.js index 270705c2921..df2e05de32d 100644 --- a/ghost/core/core/server/services/media-inliner/service.js +++ b/ghost/core/core/server/services/media-inliner/service.js @@ -4,11 +4,8 @@ let instance; module.exports = { async init() { - const debug = require('@tryghost/debug')('mediaInliner'); const MediaInliner = require('./external-media-inliner'); - const logging = require('@tryghost/logging'); const models = require('../../models'); - const jobsService = require('../jobs'); const adapterManager = require('../../services/adapter-manager').default; const mediaStorage = adapterManager.getAdapter('storage:media'); @@ -36,47 +33,6 @@ module.exports = { }); instance = mediaInliner; - - this.api = { - startMediaInliner: async (domains) => { - if (!domains || !domains.length) { - // default domains to inline from if none are provided - domains = ['https://s3.amazonaws.com/revue', 'https://substackcdn.com']; - } - - debug('[Inliner] Starting media inlining job for domains: ', domains); - - // @NOTE: the job is "inline" (aka non-offloaded into a thread), because usecases are currently - // limited to migrational, so there is no expectations for site's availability etc. - logging.info('[Background Job] external-media-inliner queued'); - await jobsService.addJob({ - name: 'external-media-inliner', - job: async (data) => { - const startedAt = Date.now(); - logging.info('[Background Job] external-media-inliner started'); - try { - const result = await mediaInliner.inline(data.domains); - logging.info( - `[Background Job] external-media-inliner completed in ${Date.now() - startedAt}ms`, - ); - return result; - } catch (err) { - logging.error( - err, - `[Background Job] external-media-inliner failed after ${Date.now() - startedAt}ms`, - ); - throw err; - } - }, - data: { domains }, - offloaded: false, - }); - - return { - status: 'success', - }; - }, - }; }, getInstance() { diff --git a/ghost/core/test/integration/services/media-inliner/external-media-inliner-job.test.ts b/ghost/core/test/integration/services/media-inliner/external-media-inliner-job.test.ts new file mode 100644 index 00000000000..f8232931ffb --- /dev/null +++ b/ghost/core/test/integration/services/media-inliner/external-media-inliner-job.test.ts @@ -0,0 +1,84 @@ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import nock from 'nock'; + +const logging = require('@tryghost/logging'); +const { agentProvider, fixtureManager } = require('../../../utils/e2e-framework'); +const models = require('../../../../core/server/models'); +const { getInstance: getJobsService } = require('../../../../core/server/services/jobs-service'); +const ExternalMediaInlinerJob = + require('../../../../core/server/services/media-inliner/external-media-inliner-job').default; + +async function waitFor( + check: () => boolean | Promise, + { timeoutMs = 5000, intervalMs = 25 } = {}, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) { + return true; + } + await new Promise((resolve) => { + setTimeout(resolve, intervalMs); + }); + } + return false; +} + +describe('Job: External media inliner', function () { + beforeAll(async function () { + const agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('posts'); + await agent.loginAsOwner(); + }); + + afterAll(function () { + sinon.restore(); + nock.cleanAll(); + }); + + it('inlines external media when the dispatched job runs', async function () { + const GIF1x1 = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'); + nock('https://external-media.example.com').get('/image.gif').reply(200, GIF1x1); + + const post = await models.Post.add( + { + title: 'Post with external feature image', + status: 'draft', + feature_image: 'https://external-media.example.com/image.gif', + }, + { context: { internal: true } }, + ); + + const loggingInfoSpy = sinon.spy(logging, 'info'); + + await getJobsService().dispatch( + new ExternalMediaInlinerJob({ domains: ['https://external-media.example.com'] }), + ); + + const completed = await waitFor(() => { + return loggingInfoSpy.getCalls().some((call) => { + return ( + call.args[0]?.system?.event === 'job.completed' && + call.args[0]?.system?.job_type === 'external-media-inliner' + ); + }); + }); + assert.ok(completed, 'The external-media-inliner job completed'); + + const updated = await models.Post.findOne({ id: post.id, status: 'all' }); + assert.ok( + updated.get('feature_image')?.includes('/content/images/'), + 'The feature image is replaced with a locally stored copy', + ); + + const lifecycleLog = loggingInfoSpy.getCalls().find((call) => { + return ( + call.args[0]?.system?.event === 'job.completed' && + call.args[0]?.system?.job_type === 'external-media-inliner' + ); + }); + assert.equal(typeof lifecycleLog!.args[0].system.duration_ms, 'number'); + }); +}); diff --git a/ghost/core/test/unit/api/endpoints/db.test.js b/ghost/core/test/unit/api/endpoints/db.test.js index 0063d4b205e..0a6f876f3c4 100644 --- a/ghost/core/test/unit/api/endpoints/db.test.js +++ b/ghost/core/test/unit/api/endpoints/db.test.js @@ -1,11 +1,18 @@ +const assert = require('node:assert/strict'); const sinon = require('sinon'); const models = require('../../../../core/server/models'); -const dbController = require('../../../../core/server/api/endpoints/db'); +const dbControllerPath = require.resolve('../../../../core/server/api/endpoints/db'); +const jobsServicePath = require.resolve('../../../../core/server/services/jobs-service'); +const dbController = require(dbControllerPath); +const jobsService = require(jobsServicePath); +const ExternalMediaInlinerJob = + require('../../../../core/server/services/media-inliner/external-media-inliner-job').default; describe('DB controller', function () { - let settingsCache, importer; + let settingsCache, importer, jobsServiceInitialised; beforeEach(function () { + jobsServiceInitialised = false; settingsCache = require('../../../../core/shared/settings-cache'); importer = require('../../../../core/server/data/importer'); @@ -16,10 +23,19 @@ describe('DB controller', function () { }); }); - afterEach(function () { + afterEach(async function () { + if (jobsServiceInitialised) { + await jobsService.shutdown({ timeoutMs: 100 }); + jobsServiceInitialised = false; + } sinon.restore(); }); + afterAll(function () { + delete require.cache[dbControllerPath]; + delete require.cache[jobsServicePath]; + }); + describe('importContent', function () { it('uses frame.user.email when frame.user is present', async function () { const mockUser = { @@ -68,4 +84,44 @@ describe('DB controller', function () { ); }); }); + + describe('inlineMedia', function () { + let dispatch; + + beforeEach(function () { + const service = jobsService.init(); + jobsServiceInitialised = true; + dispatch = sinon.stub(service, 'dispatch').resolves(); + }); + + it('dispatches explicit domains', async function () { + const result = await dbController.inlineMedia.query({ + data: { domains: ['https://example.com'] }, + }); + + sinon.assert.calledOnce(dispatch); + const job = dispatch.firstCall.firstArg; + assert.ok(job instanceof ExternalMediaInlinerJob); + assert.deepEqual(job.domains, ['https://example.com']); + assert.deepEqual(result, { status: 'success' }); + }); + + it('dispatches the default domains when domains are missing', async function () { + await dbController.inlineMedia.query({ data: {} }); + + assert.deepEqual(dispatch.firstCall.firstArg.domains, [ + 'https://s3.amazonaws.com/revue', + 'https://substackcdn.com', + ]); + }); + + it('dispatches the default domains when domains are empty', async function () { + await dbController.inlineMedia.query({ data: { domains: [] } }); + + assert.deepEqual(dispatch.firstCall.firstArg.domains, [ + 'https://s3.amazonaws.com/revue', + 'https://substackcdn.com', + ]); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts b/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts index 445009f70c8..e5961891449 100644 --- a/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts +++ b/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts @@ -3,6 +3,8 @@ import sinon from 'sinon'; import { describe, it, beforeEach, afterEach } from 'vitest'; import logging from '@tryghost/logging'; import { JobsService } from '../../../../../core/server/services/jobs-service/jobs-service'; +import ExternalMediaInliner from '../../../../../core/server/services/media-inliner/external-media-inliner'; +import ExternalMediaInlinerJob from '../../../../../core/server/services/media-inliner/external-media-inliner-job'; const registerJobHandlers = require('../../../../../core/server/services/jobs-service/register-job-handlers').default; @@ -11,13 +13,15 @@ describe('register-job-handlers', function () { let jobsService: sinon.SinonStubbedInstance; let db: { knex: sinon.SinonStub }; let loggingStub: sinon.SinonStubbedInstance; + let mediaInliner: sinon.SinonStubbedInstance; beforeEach(function () { jobsService = sinon.createStubInstance(JobsService); db = { knex: sinon.stub() }; loggingStub = sinon.stub(logging); + mediaInliner = sinon.createStubInstance(ExternalMediaInliner); - registerJobHandlers({ jobsService, db, logging: loggingStub }); + registerJobHandlers({ jobsService, db, logging: loggingStub, mediaInliner }); }); afterEach(function () { @@ -30,10 +34,9 @@ describe('register-job-handlers', function () { it('fails a clean-gifts delivery when the gift service is not initialised', async function () { const cleanGiftsHandler = jobsService.handle.secondCall.args[1]; - await assert.rejects( - () => cleanGiftsHandler({}), - /clean-gifts ran before the gifts service was initialised/, - ); + await assert.rejects(async () => { + await cleanGiftsHandler({}); + }, /clean-gifts ran before the gifts service was initialised/); }); it('runs clean-tokens with the injected database and logger', async function () { @@ -46,6 +49,29 @@ describe('register-job-handlers', function () { assert.ok(db.knex.calledOnceWithExactly('tokens')); assert.ok(loggingStub.info.calledOnce); - assert.equal(loggingStub.info.firstCall.args[0].system.deleted_count, 2); + const metadata = loggingStub.info.firstCall.args[0] as { + system: { deleted_count: number }; + }; + assert.equal(metadata.system.deleted_count, 2); + }); + + it('runs external-media-inliner with the injected media inliner', async function () { + const externalMediaInlinerHandler = jobsService.handle.thirdCall.args[1]; + const job = new ExternalMediaInlinerJob({ domains: ['https://example.com'] }); + + await externalMediaInlinerHandler(job); + + assert.ok(mediaInliner.inline.calledOnceWithExactly(['https://example.com'])); + }); + + it('propagates external-media-inliner failures', async function () { + const error = new Error('Inlining failed'); + mediaInliner.inline.rejects(error); + const externalMediaInlinerHandler = jobsService.handle.thirdCall.args[1]; + const job = new ExternalMediaInlinerJob({ domains: ['https://example.com'] }); + + await assert.rejects(async () => { + await externalMediaInlinerHandler(job); + }, error); }); }); diff --git a/ghost/core/test/unit/server/services/media-inliner/external-media-inliner-job.test.ts b/ghost/core/test/unit/server/services/media-inliner/external-media-inliner-job.test.ts new file mode 100644 index 00000000000..212aceab42a --- /dev/null +++ b/ghost/core/test/unit/server/services/media-inliner/external-media-inliner-job.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'vitest'; +import ExternalMediaInlinerJob from '../../../../../core/server/services/media-inliner/external-media-inliner-job'; + +describe('ExternalMediaInlinerJob', function () { + it('keeps the legacy job name as its type', function () { + assert.equal(ExternalMediaInlinerJob.type, 'external-media-inliner'); + }); + + it('survives the payload JSON round-trip', function () { + const job = new ExternalMediaInlinerJob({ domains: ['https://example.com'] }); + + const rehydrated = new ExternalMediaInlinerJob(JSON.parse(JSON.stringify(job))); + + assert.deepEqual(rehydrated.domains, ['https://example.com']); + }); +}); From 51d05ab070c929c49cb911f04c929a9806d564d1 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Thu, 27 Aug 2026 10:36:18 +0100 Subject: [PATCH 4/9] =?UTF-8?q?=E2=9C=A8=20Added=20email-able=20gift=20sub?= =?UTF-8?q?scriptions=20and=20Portal=20gift=20sub=20links=20(#30331)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/BER-3900/ - adds links to Portal for purchasing gift subscriptions - can be disabled through Settings - adds 3 and 6 month durations for gift subscriptions - adds ability to have email subscriptions delivered to recipient via email with a customised message, either immediately or on a scheduled date --- ghost/core/core/shared/labs.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 4e8f3bb15ba..888208f51e2 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -27,7 +27,7 @@ const messages = { }; // flags in this list always return `true`, allows quick global enable prior to full flag removal -const GA_FEATURES = ['automationAnalytics']; +const GA_FEATURES = ['automationAnalytics', 'giftSubCustomization']; // These features are considered publicly available and can be enabled/disabled by users const PUBLIC_BETA_FEATURES = [ @@ -53,7 +53,6 @@ const PRIVATE_FEATURES = [ 'getHelperDeduplication', 'membersCustomFields', 'paywallImprovements', - 'giftSubCustomization', 'tagDetailsReact', 'selfServeArchives', 'machinePayments', From 84835b5bd762647dd4a01d0604c8a47cf4f9f3e7 Mon Sep 17 00:00:00 2001 From: Paul Davis Date: Thu, 27 Aug 2026 10:50:06 +0100 Subject: [PATCH 5/9] Added reusable external media imports (#30332) no ref CSV imports need to store one discovered media URL at a time without inheriting the existing batch service's content-matching rules. Expose a typed per-URL result directly from the existing CommonJS service while preserving the batch importer's failure boundaries and established module contract, allowing future callers to reuse media downloading and storage without changing existing batch behavior. --- .../media-inliner/external-media-inliner.d.ts | 3 + .../media-inliner/external-media-inliner.js | 132 +++++++++++---- .../server/services/media-inliner/types.ts | 21 +++ .../test/external-media-inliner.test.js | 154 ++++++++++++++++++ 4 files changed, 281 insertions(+), 29 deletions(-) create mode 100644 ghost/core/core/server/services/media-inliner/types.ts diff --git a/ghost/core/core/server/services/media-inliner/external-media-inliner.d.ts b/ghost/core/core/server/services/media-inliner/external-media-inliner.d.ts index c7084786661..845343d5dfd 100644 --- a/ghost/core/core/server/services/media-inliner/external-media-inliner.d.ts +++ b/ghost/core/core/server/services/media-inliner/external-media-inliner.d.ts @@ -1,6 +1,9 @@ +import type { ExternalMediaImportResult } from './types'; + declare class ExternalMediaInliner { constructor(deps: object); + importUrl(sourceUrl: string): Promise; inline(domains: string[]): Promise; } diff --git a/ghost/core/core/server/services/media-inliner/external-media-inliner.js b/ghost/core/core/server/services/media-inliner/external-media-inliner.js index c31029de91e..2d14dcfd891 100644 --- a/ghost/core/core/server/services/media-inliner/external-media-inliner.js +++ b/ghost/core/core/server/services/media-inliner/external-media-inliner.js @@ -177,6 +177,96 @@ class ExternalMediaInliner { } } + /** + * Download and store one external media URL without deciding where it came from + * or where its replacement belongs. Callers own discovery and replacement. + * + * @param {string} sourceUrl + * @returns {Promise} + */ + async importUrl(sourceUrl) { + let response; + try { + response = await this.getRemoteMedia(sourceUrl); + } catch (error) { + return { + status: 'failed', + sourceUrl, + stage: 'download', + reason: 'The media file could not be downloaded.', + error, + }; + } + + if (!response) { + return { + status: 'failed', + sourceUrl, + stage: 'download', + reason: 'The media file could not be downloaded.', + }; + } + + let media; + try { + media = await this.extractFileDataFromResponse(sourceUrl, response); + } catch (error) { + return { + status: 'failed', + sourceUrl, + stage: 'extract', + reason: 'The downloaded media file could not be read.', + error, + }; + } + + try { + const storedUrl = await this.storeMediaLocally(media); + if (!storedUrl) { + return { + status: 'failed', + sourceUrl, + stage: 'unsupported', + reason: 'No configured storage accepts this media file.', + }; + } + + return { + status: 'stored', + sourceUrl, + storedUrl, + }; + } catch (error) { + return { + status: 'failed', + sourceUrl, + stage: 'storage', + reason: 'The media file could not be stored in Ghost.', + error, + }; + } + } + + /** + * Convert an import result into the replacement URL expected by the existing + * content and field processing. Expected download and unsupported-file failures + * return null so the original URL remains unchanged. Results containing an + * underlying processing error are rethrown so the established per-resource + * catch boundary continues to log and isolate them. + * + * @param {import('./types').ExternalMediaImportResult} result + * @returns {string|null} + */ + #replacementUrlFromImportResult(result) { + if (result.status === 'stored') { + return result.storedUrl; + } + if ('error' in result) { + throw result.error; + } + return null; + } + static findMatches(content, domain) { // NOTE: the src could end with a quote, bracket, apostrophe, double-backslash, or encoded quote. // Backlashes are added to content as an escape character @@ -208,22 +298,14 @@ class ExternalMediaInliner { const matches = this.constructor.findMatches(content, domain); for (const src of matches) { - const response = await this.getRemoteMedia(src); - - let media; - if (response) { - media = await this.extractFileDataFromResponse(src, response); - } - - if (media) { - const inlinedSrc = await this.storeMediaLocally(media); - - if (inlinedSrc) { - // NOTE: does not account for duplicate images in content - // in those cases would be processed twice - content = content.replace(src, inlinedSrc); - logging.info(`Inlined media: ${src} -> ${inlinedSrc}`); - } + const result = await this.importUrl(src); + const replacementUrl = this.#replacementUrlFromImportResult(result); + + if (replacementUrl) { + // NOTE: does not account for duplicate images in content + // in those cases would be processed twice + content = content.replace(src, replacementUrl); + logging.info(`Inlined media: ${src} -> ${replacementUrl}`); } } } @@ -246,20 +328,12 @@ class ExternalMediaInliner { const src = resourceModel.get(field); if (src && src.startsWith(domain)) { - const response = await this.getRemoteMedia(src); - - let media; - if (response) { - media = await this.extractFileDataFromResponse(src, response); - } - - if (media) { - const inlinedSrc = await this.storeMediaLocally(media); + const result = await this.importUrl(src); + const replacementUrl = this.#replacementUrlFromImportResult(result); - if (inlinedSrc) { - updatedFields[field] = inlinedSrc; - logging.info(`Added media to inline: ${src} -> ${inlinedSrc}`); - } + if (replacementUrl) { + updatedFields[field] = replacementUrl; + logging.info(`Added media to inline: ${src} -> ${replacementUrl}`); } } } diff --git a/ghost/core/core/server/services/media-inliner/types.ts b/ghost/core/core/server/services/media-inliner/types.ts new file mode 100644 index 00000000000..792cdebf750 --- /dev/null +++ b/ghost/core/core/server/services/media-inliner/types.ts @@ -0,0 +1,21 @@ +export type ExternalMediaFailureStage = 'download' | 'extract' | 'unsupported' | 'storage'; + +export interface ExternalMediaStored { + status: 'stored'; + sourceUrl: string; + storedUrl: string; +} + +export interface ExternalMediaFailed { + status: 'failed'; + sourceUrl: string; + stage: ExternalMediaFailureStage; + reason: string; + error?: unknown; +} + +export type ExternalMediaImportResult = ExternalMediaStored | ExternalMediaFailed; + +export interface ExternalMediaImporter { + importUrl(sourceUrl: string): Promise; +} diff --git a/ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js b/ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js index b601c0c571e..f024481e9cc 100644 --- a/ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js +++ b/ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js @@ -67,6 +67,160 @@ describe('ExternalMediaInliner', function () { assert.ok(new ExternalMediaInliner({})); }); + describe('importUrl', function () { + function harness() { + const response = { body: Buffer.from('media') }; + const media = { fileBuffer: response.body, filename: 'media.jpg', extension: '.jpg' }; + const inliner = new ExternalMediaInliner({}); + const getRemoteMedia = sinon.stub(inliner, 'getRemoteMedia').resolves(response); + const extractFileDataFromResponse = sinon + .stub(inliner, 'extractFileDataFromResponse') + .resolves(media); + const storeMediaLocally = sinon + .stub(inliner, 'storeMediaLocally') + .resolves('__GHOST_URL__/content/images/media.jpg'); + + return { + inliner, + response, + media, + getRemoteMedia, + extractFileDataFromResponse, + storeMediaLocally, + }; + } + + it('downloads, extracts, and stores one source URL', async function () { + const h = harness(); + const sourceUrl = 'https://example.com/media.jpg'; + + const result = await h.inliner.importUrl(sourceUrl); + + assert.deepEqual(result, { + status: 'stored', + sourceUrl, + storedUrl: '__GHOST_URL__/content/images/media.jpg', + }); + sinon.assert.calledWithExactly(h.getRemoteMedia, sourceUrl); + sinon.assert.calledWithExactly(h.extractFileDataFromResponse, sourceUrl, h.response); + sinon.assert.calledWithExactly(h.storeMediaLocally, h.media); + }); + + it('returns a typed download failure when fetching throws', async function () { + const h = harness(); + const error = new Error('network unavailable'); + h.getRemoteMedia.rejects(error); + + const result = await h.inliner.importUrl('https://example.com/media.jpg'); + + assert.deepEqual(result, { + status: 'failed', + sourceUrl: 'https://example.com/media.jpg', + stage: 'download', + reason: 'The media file could not be downloaded.', + error, + }); + sinon.assert.notCalled(h.extractFileDataFromResponse); + }); + + it('returns a typed download failure when fetching produces no response', async function () { + const h = harness(); + h.getRemoteMedia.resolves(null); + + const result = await h.inliner.importUrl('https://example.com/media.jpg'); + + assert.deepEqual(result, { + status: 'failed', + sourceUrl: 'https://example.com/media.jpg', + stage: 'download', + reason: 'The media file could not be downloaded.', + }); + sinon.assert.notCalled(h.extractFileDataFromResponse); + }); + + it('returns a typed extraction failure', async function () { + const h = harness(); + const error = new Error('invalid media'); + h.extractFileDataFromResponse.rejects(error); + + const result = await h.inliner.importUrl('https://example.com/media.jpg'); + + assert.deepEqual(result, { + status: 'failed', + sourceUrl: 'https://example.com/media.jpg', + stage: 'extract', + reason: 'The downloaded media file could not be read.', + error, + }); + sinon.assert.notCalled(h.storeMediaLocally); + }); + + it('returns a typed unsupported-media failure', async function () { + const h = harness(); + h.storeMediaLocally.resolves(null); + + const result = await h.inliner.importUrl('https://example.com/media.exe'); + + assert.deepEqual(result, { + status: 'failed', + sourceUrl: 'https://example.com/media.exe', + stage: 'unsupported', + reason: 'No configured storage accepts this media file.', + }); + }); + + it('returns a typed storage failure', async function () { + const h = harness(); + const error = new Error('storage unavailable'); + h.storeMediaLocally.rejects(error); + + const result = await h.inliner.importUrl('https://example.com/media.jpg'); + + assert.deepEqual(result, { + status: 'failed', + sourceUrl: 'https://example.com/media.jpg', + stage: 'storage', + reason: 'The media file could not be stored in Ghost.', + error, + }); + }); + + it('preserves legacy processing errors at the existing catch boundary', async function () { + const inliner = new ExternalMediaInliner({}); + const error = new Error('could not inspect media'); + sinon.stub(inliner, 'importUrl').resolves({ + status: 'failed', + sourceUrl: 'https://example.com/media.jpg', + stage: 'extract', + reason: 'The downloaded media file could not be read.', + error, + }); + + await assert.rejects( + inliner.inlineContent('{"src":"https://example.com/media.jpg"}', ['https://example.com']), + (thrown) => thrown === error, + ); + }); + + it('preserves legacy falsy thrown values at the existing catch boundary', async function () { + const inliner = new ExternalMediaInliner({}); + sinon.stub(inliner, 'importUrl').resolves({ + status: 'failed', + sourceUrl: 'https://example.com/media.jpg', + stage: 'extract', + reason: 'The downloaded media file could not be read.', + error: null, + }); + + await inliner + .inlineContent('{"src":"https://example.com/media.jpg"}', ['https://example.com']) + .then( + () => assert.fail('Expected inlineContent to reject'), + (error) => assert.equal(error, null), + ); + }); + }); + describe('inline', function () { it("inlines image in the post's mobiledoc content", async function () { const imageURL = 'https://img.stockfresh.com/files/f/image.jpg'; From c7b4266124a7816e302b17cb1654603a1c623331 Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:04:02 +0000 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=8E=A8=20Updated=20Casper=20to=20v5.1?= =?UTF-8?q?2.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ghost/core/content/themes/casper | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghost/core/content/themes/casper b/ghost/core/content/themes/casper index bb3d0de3c70..f968f19dc9b 160000 --- a/ghost/core/content/themes/casper +++ b/ghost/core/content/themes/casper @@ -1 +1 @@ -Subproject commit bb3d0de3c7065677bb18b38d8185a8a980b163bd +Subproject commit f968f19dc9b2fa8cda4508c40d82d86c03bb6c2f From c56ffbe00394cd7b0af8ba22795e480d56802704 Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:04:02 +0000 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=8E=A8=20Updated=20Source=20to=20v1.7?= =?UTF-8?q?.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ghost/core/content/themes/source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghost/core/content/themes/source b/ghost/core/content/themes/source index 3a5643b42a4..fb629deb392 160000 --- a/ghost/core/content/themes/source +++ b/ghost/core/content/themes/source @@ -1 +1 @@ -Subproject commit 3a5643b42a4700e961e65ebda92f6a6c56bf8c86 +Subproject commit fb629deb3922e24bc27ab8aea566ddc76db93ceb From 79edb8c3785a5710e156f84b33ccdca7798d1b9f Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:04:04 +0000 Subject: [PATCH 8/9] v6.61.0 --- .changeset/brown-rules-cut.md | 6 - ...@tryghost!adapter-base-scheduling@0.2.3.md | 5 - .../@tryghost!adapter-base-sso@0.1.3.md | 5 - .../@tryghost!koenig-lexical@1.9.3.md | 5 - .changeset/plenty-moons-smile.md | 5 - apps/ember-admin/package.json | 478 +++++++++--------- ghost/core/package.json | 2 +- 7 files changed, 240 insertions(+), 266 deletions(-) delete mode 100644 .changeset/brown-rules-cut.md delete mode 100644 .changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.3.md delete mode 100644 .changeset/changelogs/@tryghost!adapter-base-sso@0.1.3.md delete mode 100644 .changeset/changelogs/@tryghost!koenig-lexical@1.9.3.md delete mode 100644 .changeset/plenty-moons-smile.md diff --git a/.changeset/brown-rules-cut.md b/.changeset/brown-rules-cut.md deleted file mode 100644 index 3c6251b5d0e..00000000000 --- a/.changeset/brown-rules-cut.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@tryghost/adapter-base-scheduling": patch -"@tryghost/adapter-base-sso": patch ---- - -Bumped framework dependencies diff --git a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.3.md b/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.3.md deleted file mode 100644 index 5a7b781d07b..00000000000 --- a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.3.md +++ /dev/null @@ -1,5 +0,0 @@ -## 0.2.3 - -### Patch Changes - -- Bumped framework dependencies diff --git a/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.3.md b/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.3.md deleted file mode 100644 index 2c89801d072..00000000000 --- a/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.3.md +++ /dev/null @@ -1,5 +0,0 @@ -## 0.1.3 - -### Patch Changes - -- Bumped framework dependencies diff --git a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.3.md b/.changeset/changelogs/@tryghost!koenig-lexical@1.9.3.md deleted file mode 100644 index 3c51919165f..00000000000 --- a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.3.md +++ /dev/null @@ -1,5 +0,0 @@ -## 1.9.3 - -### Patch Changes - -- Improved the emoji picker search field, which had text that was too light to read. Clicking outside the picker now closes it. diff --git a/.changeset/plenty-moons-smile.md b/.changeset/plenty-moons-smile.md deleted file mode 100644 index ec0389c7b31..00000000000 --- a/.changeset/plenty-moons-smile.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Improved the emoji picker search field, which had text that was too light to read. Clicking outside the picker now closes it. diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json index f5fa52610d6..8a43353b0fe 100644 --- a/apps/ember-admin/package.json +++ b/apps/ember-admin/package.json @@ -1,244 +1,244 @@ { - "name": "ghost-admin", - "version": "6.60.1-rc.0", - "description": "Ember.js admin client for Ghost", - "author": "Ghost Foundation", - "homepage": "http://ghost.org", - "repository": { - "type": "git", - "url": "git://github.com/TryGhost/Admin.git" - }, - "bugs": "https://github.com/TryGhost/Ghost/issues", - "contributors": "https://github.com/TryGhost/Admin/graphs/contributors", - "license": "MIT", - "private": true, - "directories": { - "test": "tests" - }, - "scripts": { - "dev": "SKIP_DEPENDENCY_CHECKER=true NODE_OPTIONS=--disable-warning=DEP0179 ember serve", - "build": "ember build --environment=production --silent", - "build:dev": "SKIP_DEPENDENCY_CHECKER=true pnpm build --environment=development", - "test": "ember exam --split 2 --parallel", - "lint:js": "eslint . --cache", - "lint:hbs": "ember-template-lint .", - "lint": "pnpm run '/^lint:/'" - }, - "engines": { - "node": "^22.23.1" - }, - "devDependencies": { - "@babel/core": "7.29.7", - "@babel/eslint-parser": "7.28.6", - "@babel/plugin-proposal-class-properties": "7.18.6", - "@babel/plugin-proposal-decorators": "7.29.0", - "@ember/jquery": "2.0.0", - "@ember/optional-features": "2.1.0", - "@ember/render-modifiers": "2.1.0", - "@ember/test-helpers": "2.9.6", - "@ember/test-waiters": "3.1.0", - "@embroider/macros": "1.16.13", - "@eslint/js": "catalog:", - "@faker-js/faker": "10.4.0", - "@glimmer/component": "1.1.2", - "@html-next/vertical-collection": "3.0.0", - "@internal/cfg-eslint": "workspace:*", - "@sentry/ember": "7.120.3", - "@sentry/integrations": "7.114.0", - "@sentry/replay": "7.116.0", - "@tryghost/admin-x-framework": "workspace:*", - "@tryghost/color-utils": "catalog:", - "@tryghost/ember-promise-modals": "2.0.1", - "@tryghost/helpers": "catalog:", - "@tryghost/kg-clean-basic-html": "workspace:*", - "@tryghost/kg-converters": "workspace:*", - "@tryghost/koenig-lexical": "workspace:*", - "@tryghost/limit-service": "catalog:", - "@tryghost/nql": "catalog:", - "@tryghost/nql-string": "workspace:*", - "@tryghost/string": "catalog:", - "@tryghost/timezone-data": "catalog:", - "animejs": "3.2.2", - "autoprefixer": "9.8.6", - "babel-plugin-transform-class-properties": "6.24.1", - "babel-plugin-transform-react-jsx": "6.24.1", - "broccoli-asset-rev": "3.0.0", - "broccoli-concat": "4.2.7", - "broccoli-funnel": "3.0.8", - "broccoli-merge-trees": "4.2.0", - "broccoli-terser-sourcemap": "4.1.1", - "chai": "catalog:", - "chai-dom": "1.12.1", - "chalk": "catalog:", - "codemirror": "5.65.21", - "cssnano": "4.1.10", - "element-resize-detector": "1.2.4", - "ember-ajax": "5.1.2", - "ember-assign-helper": "0.5.0", - "ember-auto-import": "2.10.0", - "ember-classic-decorator": "3.0.1", - "ember-cli": "3.24.0", - "ember-cli-app-version": "5.0.0", - "ember-cli-babel": "8.2.0", - "ember-cli-chart": "3.7.2", - "ember-cli-code-coverage": "1.0.3", - "ember-cli-dependency-checker": "3.3.2", - "ember-cli-deprecation-workflow": "2.2.0", - "ember-cli-htmlbars": "6.3.0", - "ember-cli-inject-live-reload": "2.1.0", - "ember-cli-mirage": "2.4.0", - "ember-cli-node-assets": "0.2.2", - "ember-cli-postcss": "6.0.1", - "ember-cli-shims": "1.2.0", - "ember-cli-string-helpers": "6.1.0", - "ember-cli-terser": "4.0.1", - "ember-cli-test-loader": "3.1.0", - "ember-composable-helpers": "5.0.0", - "ember-concurrency": "2.3.7", - "ember-could-get-used-to-this": "1.0.1", - "ember-css-transitions": "4.4.1", - "ember-data": "3.24.0", - "ember-decorators": "6.1.1", - "ember-drag-drop": "0.4.8", - "ember-exam": "6.0.1", - "ember-export-application-global": "2.0.1", - "ember-fetch": "8.1.2", - "ember-in-viewport": "4.1.0", - "ember-infinity": "2.3.0", - "ember-keyboard": "8.2.1", - "ember-load": "0.0.17", - "ember-load-initializers": "2.1.2", - "ember-mocha": "0.16.2", - "ember-modifier": "4.2.0", - "ember-moment": "10.0.1", - "ember-one-way-select": "4.0.1", - "ember-power-datepicker": "0.8.1", - "ember-power-select": "6.0.1", - "ember-resolver": "8.1.0", - "ember-simple-auth": "5.0.0", - "ember-sinon": "5.0.0", - "ember-source": "3.24.0", - "ember-svg-jar": "2.7.1", - "ember-template-lint": "5.13.0", - "ember-test-selectors": "6.0.0", - "ember-tooltips": "3.6.0", - "ember-truth-helpers": "3.1.1", - "eslint": "catalog:", - "eslint-plugin-babel": "5.3.1", - "eslint-plugin-ghost": "3.5.0", - "eslint-plugin-react": "7.37.5", - "flexsearch": "0.7.43", - "fs-extra": "catalog:", - "ghost": "workspace:*", - "globals": "17.6.0", - "google-caja-bower": "https://github.com/acburdine/google-caja-bower#ghost", - "keymaster": "https://github.com/madrobby/keymaster.git", - "liquid-fire": "0.34.0", - "liquid-wormhole": "3.0.1", - "loader.js": "4.7.0", - "microdiff": "1.5.0", - "miragejs": "0.1.48", - "moment-timezone": "0.5.45", - "normalize.css": "3.0.3", - "postcss-color-mod-function": "3.0.3", - "postcss-custom-media": "7.0.8", - "postcss-custom-properties": "10.0.0", - "postcss-import": "12.0.1", - "pretender": "3.4.7", - "process": "0.11.10", - "react": "catalog:", - "react-dom": "catalog:", - "reframe.js": "4.0.2", - "semver": "7.7.4", - "sentry-testkit": "6.4.1", - "sinon-chai": "4.0.1", - "testem": "3.19.1", - "tracked-built-ins": "3.4.0", - "util": "0.12.5", - "validator": "catalog:", - "walk-sync": "4.0.2" - }, - "ember-addon": { - "paths": [ - "lib/asset-delivery", - "lib/ember-power-calendar-moment", - "lib/ember-power-calendar-utils" + "name": "ghost-admin", + "version": "6.61.0", + "description": "Ember.js admin client for Ghost", + "author": "Ghost Foundation", + "homepage": "http://ghost.org", + "repository": { + "type": "git", + "url": "git://github.com/TryGhost/Admin.git" + }, + "bugs": "https://github.com/TryGhost/Ghost/issues", + "contributors": "https://github.com/TryGhost/Admin/graphs/contributors", + "license": "MIT", + "private": true, + "directories": { + "test": "tests" + }, + "scripts": { + "dev": "SKIP_DEPENDENCY_CHECKER=true NODE_OPTIONS=--disable-warning=DEP0179 ember serve", + "build": "ember build --environment=production --silent", + "build:dev": "SKIP_DEPENDENCY_CHECKER=true pnpm build --environment=development", + "test": "ember exam --split 2 --parallel", + "lint:js": "eslint . --cache", + "lint:hbs": "ember-template-lint .", + "lint": "pnpm run '/^lint:/'" + }, + "engines": { + "node": "^22.23.1" + }, + "devDependencies": { + "@babel/core": "7.29.7", + "@babel/eslint-parser": "7.28.6", + "@babel/plugin-proposal-class-properties": "7.18.6", + "@babel/plugin-proposal-decorators": "7.29.0", + "@ember/jquery": "2.0.0", + "@ember/optional-features": "2.1.0", + "@ember/render-modifiers": "2.1.0", + "@ember/test-helpers": "2.9.6", + "@ember/test-waiters": "3.1.0", + "@embroider/macros": "1.16.13", + "@eslint/js": "catalog:", + "@faker-js/faker": "10.4.0", + "@glimmer/component": "1.1.2", + "@html-next/vertical-collection": "3.0.0", + "@internal/cfg-eslint": "workspace:*", + "@sentry/ember": "7.120.3", + "@sentry/integrations": "7.114.0", + "@sentry/replay": "7.116.0", + "@tryghost/admin-x-framework": "workspace:*", + "@tryghost/color-utils": "catalog:", + "@tryghost/ember-promise-modals": "2.0.1", + "@tryghost/helpers": "catalog:", + "@tryghost/kg-clean-basic-html": "workspace:*", + "@tryghost/kg-converters": "workspace:*", + "@tryghost/koenig-lexical": "workspace:*", + "@tryghost/limit-service": "catalog:", + "@tryghost/nql": "catalog:", + "@tryghost/nql-string": "workspace:*", + "@tryghost/string": "catalog:", + "@tryghost/timezone-data": "catalog:", + "animejs": "3.2.2", + "autoprefixer": "9.8.6", + "babel-plugin-transform-class-properties": "6.24.1", + "babel-plugin-transform-react-jsx": "6.24.1", + "broccoli-asset-rev": "3.0.0", + "broccoli-concat": "4.2.7", + "broccoli-funnel": "3.0.8", + "broccoli-merge-trees": "4.2.0", + "broccoli-terser-sourcemap": "4.1.1", + "chai": "catalog:", + "chai-dom": "1.12.1", + "chalk": "catalog:", + "codemirror": "5.65.21", + "cssnano": "4.1.10", + "element-resize-detector": "1.2.4", + "ember-ajax": "5.1.2", + "ember-assign-helper": "0.5.0", + "ember-auto-import": "2.10.0", + "ember-classic-decorator": "3.0.1", + "ember-cli": "3.24.0", + "ember-cli-app-version": "5.0.0", + "ember-cli-babel": "8.2.0", + "ember-cli-chart": "3.7.2", + "ember-cli-code-coverage": "1.0.3", + "ember-cli-dependency-checker": "3.3.2", + "ember-cli-deprecation-workflow": "2.2.0", + "ember-cli-htmlbars": "6.3.0", + "ember-cli-inject-live-reload": "2.1.0", + "ember-cli-mirage": "2.4.0", + "ember-cli-node-assets": "0.2.2", + "ember-cli-postcss": "6.0.1", + "ember-cli-shims": "1.2.0", + "ember-cli-string-helpers": "6.1.0", + "ember-cli-terser": "4.0.1", + "ember-cli-test-loader": "3.1.0", + "ember-composable-helpers": "5.0.0", + "ember-concurrency": "2.3.7", + "ember-could-get-used-to-this": "1.0.1", + "ember-css-transitions": "4.4.1", + "ember-data": "3.24.0", + "ember-decorators": "6.1.1", + "ember-drag-drop": "0.4.8", + "ember-exam": "6.0.1", + "ember-export-application-global": "2.0.1", + "ember-fetch": "8.1.2", + "ember-in-viewport": "4.1.0", + "ember-infinity": "2.3.0", + "ember-keyboard": "8.2.1", + "ember-load": "0.0.17", + "ember-load-initializers": "2.1.2", + "ember-mocha": "0.16.2", + "ember-modifier": "4.2.0", + "ember-moment": "10.0.1", + "ember-one-way-select": "4.0.1", + "ember-power-datepicker": "0.8.1", + "ember-power-select": "6.0.1", + "ember-resolver": "8.1.0", + "ember-simple-auth": "5.0.0", + "ember-sinon": "5.0.0", + "ember-source": "3.24.0", + "ember-svg-jar": "2.7.1", + "ember-template-lint": "5.13.0", + "ember-test-selectors": "6.0.0", + "ember-tooltips": "3.6.0", + "ember-truth-helpers": "3.1.1", + "eslint": "catalog:", + "eslint-plugin-babel": "5.3.1", + "eslint-plugin-ghost": "3.5.0", + "eslint-plugin-react": "7.37.5", + "flexsearch": "0.7.43", + "fs-extra": "catalog:", + "ghost": "workspace:*", + "globals": "17.6.0", + "google-caja-bower": "https://github.com/acburdine/google-caja-bower#ghost", + "keymaster": "https://github.com/madrobby/keymaster.git", + "liquid-fire": "0.34.0", + "liquid-wormhole": "3.0.1", + "loader.js": "4.7.0", + "microdiff": "1.5.0", + "miragejs": "0.1.48", + "moment-timezone": "0.5.45", + "normalize.css": "3.0.3", + "postcss-color-mod-function": "3.0.3", + "postcss-custom-media": "7.0.8", + "postcss-custom-properties": "10.0.0", + "postcss-import": "12.0.1", + "pretender": "3.4.7", + "process": "0.11.10", + "react": "catalog:", + "react-dom": "catalog:", + "reframe.js": "4.0.2", + "semver": "7.7.4", + "sentry-testkit": "6.4.1", + "sinon-chai": "4.0.1", + "testem": "3.19.1", + "tracked-built-ins": "3.4.0", + "util": "0.12.5", + "validator": "catalog:", + "walk-sync": "4.0.2" + }, + "ember-addon": { + "paths": [ + "lib/asset-delivery", + "lib/ember-power-calendar-moment", + "lib/ember-power-calendar-utils" + ] + }, + "ember": { + "edition": "octane" + }, + "lint-staged": { + "*.hbs": "ember-template-lint", + "*.js": "eslint" + }, + "dependencies": { + "dompurify": "catalog:", + "lodash": "catalog:", + "path-browserify": "1.0.1", + "webpack": "5.105.4" + }, + "nx": { + "implicitDependencies": [ + "!ghost" + ], + "targets": { + "dev": { + "continuous": true, + "executor": "nx:run-commands", + "options": { + "cwd": "apps/ember-admin", + "command": "JOBS=4 SKIP_DEPENDENCY_CHECKER=true NODE_OPTIONS=--disable-warning=DEP0179 ember serve" + }, + "dependsOn": [ + "^build" ] - }, - "ember": { - "edition": "octane" - }, - "lint-staged": { - "*.hbs": "ember-template-lint", - "*.js": "eslint" - }, - "dependencies": { - "dompurify": "catalog:", - "lodash": "catalog:", - "path-browserify": "1.0.1", - "webpack": "5.105.4" - }, - "nx": { - "implicitDependencies": [ - "!ghost" + }, + "build:dev": { + "dependsOn": [ + "build:dev", + "^build", + { + "projects": [ + "@tryghost/admin-x-framework", + "@tryghost/activitypub" + ], + "target": "build" + } + ] + }, + "build": { + "inputs": [ + { + "env": "GHOST_CDN_URL" + }, + { + "env": "IS_SHIPPING" + }, + "default", + "^default" + ], + "outputs": [ + "{projectRoot}/dist", + "{workspaceRoot}/ghost/core/core/built/admin" ], - "targets": { - "dev": { - "continuous": true, - "executor": "nx:run-commands", - "options": { - "cwd": "apps/ember-admin", - "command": "JOBS=4 SKIP_DEPENDENCY_CHECKER=true NODE_OPTIONS=--disable-warning=DEP0179 ember serve" - }, - "dependsOn": [ - "^build" - ] - }, - "build:dev": { - "dependsOn": [ - "build:dev", - "^build", - { - "projects": [ - "@tryghost/admin-x-framework", - "@tryghost/activitypub" - ], - "target": "build" - } - ] - }, - "build": { - "inputs": [ - { - "env": "GHOST_CDN_URL" - }, - { - "env": "IS_SHIPPING" - }, - "default", - "^default" - ], - "outputs": [ - "{projectRoot}/dist", - "{workspaceRoot}/ghost/core/core/built/admin" - ], - "dependsOn": [ - "build", - "^build", - { - "projects": [ - "@tryghost/admin-x-framework", - "@tryghost/activitypub" - ], - "target": "build" - } - ] - }, - "test": { - "dependsOn": [ - "^build" - ] - } - } + "dependsOn": [ + "build", + "^build", + { + "projects": [ + "@tryghost/admin-x-framework", + "@tryghost/activitypub" + ], + "target": "build" + } + ] + }, + "test": { + "dependsOn": [ + "^build" + ] + } } + } } diff --git a/ghost/core/package.json b/ghost/core/package.json index 2ebf7832eb4..7e233b14e4f 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.61.0-rc.0", + "version": "6.61.0", "description": "The professional publishing platform", "keywords": [ "blog", From b208624978d2a1749c9a085070881263c6bea477 Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:04:09 +0000 Subject: [PATCH 9/9] Bumped version to 6.61.1-rc.0 --- apps/ember-admin/package.json | 2 +- ghost/core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json index 8a43353b0fe..2ff1b9922dd 100644 --- a/apps/ember-admin/package.json +++ b/apps/ember-admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "6.61.0", + "version": "6.61.1-rc.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/ghost/core/package.json b/ghost/core/package.json index 7e233b14e4f..64badeefb61 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.61.0", + "version": "6.61.1-rc.0", "description": "The professional publishing platform", "keywords": [ "blog",