From 43e79436a097d96589daa9cedad47780f9236dab Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 19 Aug 2026 17:58:48 +0100 Subject: [PATCH 01/11] Fixed a members import mapping being discarded without asking (#30124) no issue The custom fields import dialog should ask before closing when the mapping has been changed. It could miss that check if Escape was pressed immediately after an edit, and close without showing "Leave without importing?". `hasEdits` was React state read from the `handleOpenChange` render closure. Radix keeps `onOpenChange` in a callback ref that it updates in a passive effect, while its Escape listener runs on `document` outside React event dispatch. If Escape arrived before React committed and flushed the edit, Radix could call the old closure and see `hasEdits` as false. Nothing renders from this flag, so it uses a ref now. The edit updates the ref immediately, and `handleOpenChange` reads the current value. --- .../custom-fields/import-members-modal.tsx | 28 +++++++++++-------- ...-members-custom-fields.acceptance.test.tsx | 15 ++++++++++ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/import-members-modal.tsx b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/import-members-modal.tsx index 3e0e4caecf2..ac47c94e19e 100644 --- a/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/import-members-modal.tsx +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/import-members-modal.tsx @@ -15,7 +15,7 @@ import {createInitialImportState, importReducer} from '@/members/components/bulk import {isImportMembersCompleteResponse, useImportMembers} from '@tryghost/admin-x-framework/api/members'; import {memberCustomFieldCsvColumns, useBrowseMemberCustomFields} from '@tryghost/admin-x-framework/api/member-custom-fields'; import {parseCSV} from '@/members/components/bulk-action-modals/import-members/csv'; -import {useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState} from 'react'; +import {useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef} from 'react'; import {useFeatureFlag} from '@tryghost/admin-x-framework/hooks'; import {useLabelPicker} from '@/members/hooks/use-label-picker'; @@ -82,7 +82,7 @@ export function ImportMembersModal({ const labelPicker = useLabelPicker({ selectedSlugs: state.selectedLabelSlugs, onSelectionChange: (slugs: string[]) => { - setHasEdits(true); + hasEditsRef.current = true; dispatch({type: 'SET_SELECTED_LABEL_SLUGS', selectedLabelSlugs: slugs}); } }); @@ -100,7 +100,7 @@ export function ImportMembersModal({ revokeErrorCsvUrl(); // The modal is never unmounted (members-actions keeps it mounted and toggles `open`), // so anything held outside the reducer outlives Start over unless it is cleared here. - setHasEdits(false); + hasEditsRef.current = false; dispatch({type: 'RESET'}); }, [revokeErrorCsvUrl]); @@ -115,10 +115,14 @@ export function ImportMembersModal({ // a mapped file: which columns are in, what each fills, the labels chosen. So dismissal // asks first, using the same confirmation the settings modals use. const {confirm, dialogProps} = useDirtyConfirmation(); - // Whether anything would actually be lost by leaving. A file that has only been parsed is - // not worth asking about: re-uploading it reproduces the same detected mapping. What cannot - // be reproduced is what the publisher changed since, so that is what this tracks. - const [hasEdits, setHasEdits] = useState(false); + // Tracks mapping changes that would be lost on close. Parsing a file does not count because + // re-uploading it recreates the detected mapping. + // + // Radix refreshes its onOpenChange callback ref in a passive effect, and handles Escape on + // document outside React's event dispatch. Escape can therefore call the old closure before + // React commits the edit. Nothing renders from this flag, so a ref gives the handler the current + // value. + const hasEditsRef = useRef(false); const handleOpenChange = useCallback((isOpen: boolean) => { if (!isOpen && state.status === 'UPLOADING') { @@ -127,7 +131,7 @@ export function ImportMembersModal({ if (!isOpen) { // Only the mapping step holds anything: every other one is either empty or shows a // result that closing is the natural end of. - confirm(state.status === 'MAPPING' && hasEdits, () => { + confirm(state.status === 'MAPPING' && hasEditsRef.current, () => { const importResponse = state.importResponse ?? undefined; reset(); onClose?.(importResponse); @@ -136,7 +140,7 @@ export function ImportMembersModal({ return; } onOpenChange(isOpen); - }, [confirm, hasEdits, onClose, onOpenChange, reset, state.importResponse, state.status]); + }, [confirm, onClose, onOpenChange, reset, state.importResponse, state.status]); useEffect(() => { if (!state.file || !customFieldsReady) { @@ -231,7 +235,7 @@ export function ImportMembersModal({ return; } - setHasEdits(true); + hasEditsRef.current = true; const nextMapping = state.mapping.updateMapping(from, to); const nextError = state.fileData && state.fileData.length === 0 @@ -411,7 +415,9 @@ export function ImportMembersModal({ showMappingErrors={state.showMappingErrors} status={state.status} targets={targets} - onColumnsChanged={() => setHasEdits(true)} + onColumnsChanged={() => { + hasEditsRef.current = true; + }} onDataPreviewIndexChange={(nextIndex) => { dispatch({ type: 'SET_DATA_PREVIEW_INDEX', diff --git a/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx b/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx index 5c5bc06e655..76b356de4a7 100644 --- a/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx +++ b/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx @@ -641,4 +641,19 @@ describe('Import members custom fields', () => { await expect.element(fieldSelect('email')).not.toBeInTheDocument(); }); + // Fire both DOM events in one task to cover Escape arriving before React commits the edit. + // userEvent adds task boundaries that let React settle and hide this race. + it('asks even when the dismissal lands before React has settled', async () => { + fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + await expect.element(fieldSelect('email')).toBeVisible(); + + const toggle = importToggle('nickname').element() as HTMLElement; + toggle.click(); + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true})); + + await expect.element(page.getByText('Leave without importing?')).toBeVisible(); + }); + }); \ No newline at end of file From ae285e7bc97facb12bfd90c95d7fb9dbe7d4094f Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 19 Aug 2026 13:54:12 -0400 Subject: [PATCH 02/11] Removed unused Portal contrast color utility (#30120) no refs ## History and intent Portal introduced `contrast-color.js` in July 2020 to calculate complementary text and icon colours for accent-coloured controls: https://github.com/TryGhost/Ghost/commit/fb6a2c950fcfc5ebafd5be1a9a62f0555f79dacf Portal deliberately removed that automatic colour inversion in October 2020: https://github.com/TryGhost/Ghost/commit/b037d98fb8f19973cadabcbb4151d1eddf6a567d The second commit provides direct evidence that making this utility unreachable was intentional rather than accidental: its message is `Removed auto-invert color from primary buttons`, and its atomic diff removes every import of the utility while replacing each computed foreground colour with `#fff` across trigger icons, trigger text, and primary action buttons. The historical record does **not** explain the deeper UX rationale for preferring white. The commit body contains only `no refs`, GitHub has no associated pull request or commit discussion, and adjacent styling work provides no written rationale. Therefore, the supported conclusion is limited to this: automatic inversion was intentionally retired in favour of a fixed-white treatment. Design consistency is suggested by the diff, but is an inference rather than documented intent. ## Why deletion is safe The utility remained in the tree after its final consumers were deliberately removed and has been unreachable for nearly six years. A repository-wide usage search finds no remaining consumer, and Knip independently reports the file as unused. Portal has a single `src/index.jsx` build entry, and the published package includes only built UMD output rather than internal source paths, so the utility cannot be reached through a secondary entry point or public export. Deleting the orphan preserves the intentional fixed-white behavior established by the October 2020 change. ## Validation - `pnpm --dir apps/portal lint` - `pnpm --dir apps/portal test` (592 passed, 1 skipped) - `pnpm --dir apps/portal build` - `pnpm check` (lint and all other project tests passed; the aggregate run reported `ghost:test` as flaky) - `pnpm nx test ghost --skip-nx-cache` (604 files and 7,852 tests passed on retry; Nx classified the prior failure as flaky) --- apps/portal/src/utils/contrast-color.js | 39 ------------------------- 1 file changed, 39 deletions(-) delete mode 100644 apps/portal/src/utils/contrast-color.js diff --git a/apps/portal/src/utils/contrast-color.js b/apps/portal/src/utils/contrast-color.js deleted file mode 100644 index 74b77560bb4..00000000000 --- a/apps/portal/src/utils/contrast-color.js +++ /dev/null @@ -1,39 +0,0 @@ -function padZero(str, len) { - len = len || 2; - const zeros = new Array(len).join('0'); - return (zeros + str).slice(-len); -} - -function invertColor(hex = '', bw = true) { - if (!hex || !hex.match(/#[0-9A-Fa-f]{6}$/)) { - // Return white in case not a valid hex - return '#000000'; - } - if (hex.indexOf('#') === 0) { - hex = hex.slice(1); - } - // convert 3-digit hex to 6-digits. - if (hex.length === 3) { - hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; - } - if (hex.length !== 6) { - throw new Error('Invalid HEX color.'); - } - let r = parseInt(hex.slice(0, 2), 16), - g = parseInt(hex.slice(2, 4), 16), - b = parseInt(hex.slice(4, 6), 16); - if (bw) { - // http://stackoverflow.com/a/3943023/112731 - return (r * 0.299 + g * 0.587 + b * 0.114) > 186 - ? '#000000' - : '#FFFFFF'; - } - // invert color components - r = (255 - r).toString(16); - g = (255 - g).toString(16); - b = (255 - b).toString(16); - // pad each with zeros and return - return '#' + padZero(r) + padZero(g) + padZero(b); -} - -export default invertColor; From cef5922d0954414192c94bc1a360ae3066722c7e Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Wed, 19 Aug 2026 14:15:28 -0400 Subject: [PATCH 03/11] Fixed the flaky Koenig font install by caching MS core fonts (#30127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache the extracted `.ttf` files with `actions/cache` (key `msttcorefonts-ttf-v1`; the fonts never change). - Cache hit → copy the fonts into place, no apt, no network. - Cache miss → install from apt with the existing bounded retry/timeout, then stash the `.ttf`s for the cache to save. - `fc-cache` after placing the fonts; then install the firefox media codecs the video-card tests need. Only a cache miss (first run, or after ~7-day cache eviction) touches SourceForge, and that path still fails fast instead of hanging. Ghost's CI runs frequently enough to keep the cache warm. --- .github/workflows/ci.yml | 70 ++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8733703013..fffe7021525 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1031,39 +1031,59 @@ jobs: - name: Setup Playwright uses: ./.github/actions/setup-playwright - # The Koenig editor suite is the only one that needs apt packages on top - # of the runner image (setup-playwright deliberately avoids apt-get, see - # its README note about hangs — scoping this to one matrix leg contains - # that risk): - # - MS core fonts: caret-position assertions depend on where text wraps, - # which depends on Arial's font metrics - # - playwright firefox deps: system media codecs so firefox can decode - # the H.264 fixtures in the video card tests - # msttcorefonts downloads the font files from SourceForge at install time; - # mirror-redirect roulette there can hang indefinitely. timeout-minutes is - # the backstop, and each network op is bounded + retried so a stuck fetch - # fails fast and re-rolls onto a different mirror instead of sitting. - - name: Install Koenig editor test dependencies (fonts + media codecs) + # Cache the msttcorefonts .ttf files so only a cache miss hits the + # SourceForge download that regularly hangs the koenig-lexical job. Save + # is a separate step right after the fonts are in place, so a later codec + # or test failure on a cache-miss run still populates the cache. + - name: Restore MS core fonts cache + if: matrix.app == '@tryghost/koenig-lexical' + id: mscorefonts-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/msttcorefonts + key: msttcorefonts-ttf-v1 + - name: Install Koenig editor test fonts if: matrix.app == '@tryghost/koenig-lexical' timeout-minutes: 10 env: DEBIAN_FRONTEND: noninteractive DEBCONF_NONINTERACTIVE_SEEN: "true" run: | - sudo sh -c "echo ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true | debconf-set-selections" - sudo apt-get update -yq -o Acquire::Retries=3 -o Acquire::http::Timeout=30 - installed= - for i in 1 2 3; do - if sudo timeout -k 5 120 apt-get install -yq \ - -o Acquire::Retries=3 -o Acquire::http::Timeout=30 msttcorefonts; then - installed=1; break + FONT_CACHE="$HOME/.cache/msttcorefonts" + FONT_DIR=/usr/share/fonts/truetype/msttcorefonts + sudo mkdir -p "$FONT_DIR" + if ls "$FONT_CACHE"/*.ttf >/dev/null 2>&1; then + echo "Restoring MS core fonts from cache" + sudo cp "$FONT_CACHE"/*.ttf "$FONT_DIR"/ + else + echo "Cache miss — installing msttcorefonts from apt" + sudo sh -c "echo ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true | debconf-set-selections" + sudo apt-get update -yq -o Acquire::Retries=3 -o Acquire::http::Timeout=30 + installed= + for i in 1 2 3; do + if sudo timeout -k 5 120 apt-get install -yq \ + -o Acquire::Retries=3 -o Acquire::http::Timeout=30 msttcorefonts; then + installed=1; break + fi + echo "msttcorefonts attempt $i failed/stuck, retrying..."; sleep 5 + done + if [ -z "$installed" ]; then + echo "msttcorefonts failed after 3 attempts"; exit 1 fi - echo "msttcorefonts attempt $i failed/stuck, retrying..."; sleep 5 - done - if [ -z "$installed" ]; then - echo "msttcorefonts failed after 3 attempts"; exit 1 + mkdir -p "$FONT_CACHE" + cp "$FONT_DIR"/*.ttf "$FONT_CACHE"/ fi - pnpm exec playwright install-deps firefox + sudo fc-cache -f + - name: Save MS core fonts cache + if: matrix.app == '@tryghost/koenig-lexical' && steps.mscorefonts-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/msttcorefonts + key: msttcorefonts-ttf-v1 + - name: Install Koenig editor test media codecs + if: matrix.app == '@tryghost/koenig-lexical' + timeout-minutes: 10 + run: pnpm exec playwright install-deps firefox - name: Run Playwright tests run: pnpm nx run ${{ matrix.app }}:test:acceptance From 94e132108f189d80b343f9d05ffa8ceb4e1bf643 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Wed, 19 Aug 2026 14:35:53 -0400 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20navigation=20marku?= =?UTF-8?q?p=20changing=20for=20themes=20without=20the=20icons=20flag=20(#?= =?UTF-8?q?30129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref #28368 added icons and visibility controls to navigation behind the `navigationIcons` lab flag, but one part of it shipped ungated: the built-in navigation partial started wrapping every label in ``. That affects every theme that uses `{{navigation}}` without shipping its own `partials/navigation.hbs`, whether or not the flag is on. CSS and JS that target the anchor's text directly — `.nav a` flex/gap layouts, `::before` tricks, truncation on the anchor, `a.textContent` reads — see different markup than before. The helper now passes the flag to the template as `@navigationIcons`, and the template renders the icon plus the label wrapper only when it is set. With the flag off, output is byte-identical to the pre-#28368 markup. The flag-off tests now assert the bare label, plus a new test that asserts the full rendered string matches the old markup exactly. --- .../core/core/frontend/helpers/navigation.js | 2 ++ .../core/frontend/helpers/tpl/navigation.hbs | 2 +- .../unit/frontend/helpers/navigation.test.js | 19 ++++++++++++++++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/ghost/core/core/frontend/helpers/navigation.js b/ghost/core/core/frontend/helpers/navigation.js index c949c43660c..85497f9d646 100644 --- a/ghost/core/core/frontend/helpers/navigation.js +++ b/ghost/core/core/frontend/helpers/navigation.js @@ -172,6 +172,8 @@ module.exports = function navigation(options) { // CASE: The navigation helper will forward attributes passed to it. _.merge(this, options.hash); const data = createFrame(options.data); + // The template only wraps labels/renders icons when the flag is on, so pre-flag markup is unchanged + data.navigationIcons = navigationIconsEnabled; return templates.execute('navigation', this, {data}); }; diff --git a/ghost/core/core/frontend/helpers/tpl/navigation.hbs b/ghost/core/core/frontend/helpers/tpl/navigation.hbs index 08f3344f0bd..3faccca4180 100644 --- a/ghost/core/core/frontend/helpers/tpl/navigation.hbs +++ b/ghost/core/core/frontend/helpers/tpl/navigation.hbs @@ -1,5 +1,5 @@ diff --git a/ghost/core/test/unit/frontend/helpers/navigation.test.js b/ghost/core/test/unit/frontend/helpers/navigation.test.js index 47a8388ad56..16209b88169 100644 --- a/ghost/core/test/unit/frontend/helpers/navigation.test.js +++ b/ghost/core/test/unit/frontend/helpers/navigation.test.js @@ -327,7 +327,8 @@ describe('{{navigation}} helper', function () { assertExists(rendered); assert(!rendered.string.includes('class="nav-icon"')); - assert(rendered.string.includes('Foo')); + assert(!rendered.string.includes('class="nav-label"')); + assert(rendered.string.includes('/foo">Foo')); }); it('does not filter items by visibility', function () { @@ -339,8 +340,20 @@ describe('{{navigation}} helper', function () { const rendered = runHelper(optionsData); assertExists(rendered); - assert(rendered.string.includes('Hidden')); - assert(rendered.string.includes('Paid')); + assert(rendered.string.includes('/hidden">Hidden')); + assert(rendered.string.includes('/paid">Paid')); + }); + + it('renders the same markup as before the flag existed', function () { + optionsData.data.site.navigation = [{label: 'Foo', url: '/foo'}]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert.equal( + rendered.string.trim(), + '' + ); }); }); From 289f575bc48074ddf935be56e0fa2e35c1b2d84d Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Wed, 19 Aug 2026 14:44:59 -0400 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20migrations=20faili?= =?UTF-8?q?ng=20on=20MySQL=20older=20than=208.0.28=20(#30130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes #30100 The 6.58 rename of `email_batches.provider_id` asked for `ALGORITHM=INSTANT`, which MySQL only accepts for RENAME COLUMN from 8.0.28 onwards. Anyone on an older 8.0 server — still supported per our docs — hit ER_ALTER_OPERATION_NOT_SUPPORTED and could not upgrade past 6.58. That migration now asks for `auto`, which is what every other algorithm-bearing migration uses and costs nothing here: a plain RENAME COLUMN is metadata-only on INPLACE too, and 8.0.28+ servers still pick INSTANT themselves. Editing a shipped migration is safe because knex-migrator tracks migrations by name with no checksum, so sites that already ran it skip it and stuck sites run the fixed version. To stop this recurring, the schema commands now retry an ALTER without the algorithm clause when the server rejects it. INSTANT support is per-operation (ADD COLUMN 8.0.12, RENAME COLUMN 8.0.28, DROP COLUMN 8.0.29), so a version matrix in the migration utils would be a maintenance trap; reacting to the server's own error is version- and vendor-agnostic and costs nothing on the happy path. Verified against real MySQL 8.0.21 and 8.4 containers. --- ...-22-48-rename-email-batches-provider-id.js | 2 +- .../core/core/server/data/schema/commands.js | 72 ++++++++++++------- .../unit/server/data/schema/commands.test.js | 49 ++++++++++++- 3 files changed, 96 insertions(+), 27 deletions(-) diff --git a/ghost/core/core/server/data/migrations/versions/6.58/2026-08-11-21-22-48-rename-email-batches-provider-id.js b/ghost/core/core/server/data/migrations/versions/6.58/2026-08-11-21-22-48-rename-email-batches-provider-id.js index 1756a7806ee..d22113277ff 100644 --- a/ghost/core/core/server/data/migrations/versions/6.58/2026-08-11-21-22-48-rename-email-batches-provider-id.js +++ b/ghost/core/core/server/data/migrations/versions/6.58/2026-08-11-21-22-48-rename-email-batches-provider-id.js @@ -4,5 +4,5 @@ module.exports = createRenameColumnMigration( 'email_batches', 'provider_id', 'mailgun_message_id', - {algorithm: 'instant'} + {algorithm: 'auto'} ); diff --git a/ghost/core/core/server/data/schema/commands.js b/ghost/core/core/server/data/schema/commands.js index b2075a55dc5..999885a013b 100644 --- a/ghost/core/core/server/data/schema/commands.js +++ b/ghost/core/core/server/data/schema/commands.js @@ -99,6 +99,38 @@ function dropNullable(tableName, column, transaction = db.knex) { }); } +// MySQL raises these when an explicit ALGORITHM isn't supported for the operation, +// which varies by server version (RENAME COLUMN only accepts INSTANT from 8.0.28). +const UNSUPPORTED_ALGORITHM_ERRORS = new Set([ + 'ER_ALTER_OPERATION_NOT_SUPPORTED', + 'ER_ALTER_OPERATION_NOT_SUPPORTED_REASON' +]); + +/** + * Runs an ALTER TABLE with an explicit MySQL algorithm, retrying without it if the + * server doesn't support that algorithm for the operation. + * + * @param {import('knex').Knex} transaction + * @param {string} sql - ALTER statement without a trailing semicolon or algorithm clause + * @param {'instant'|'inplace'|'copy'|'auto'} [algorithm] + */ +async function rawWithAlgorithm(transaction, sql, algorithm) { + if (!algorithm || algorithm === 'auto') { + return await transaction.raw(sql); + } + + try { + return await transaction.raw(`${sql}, algorithm=${algorithm}`); + } catch (err) { + if (!UNSUPPORTED_ALGORITHM_ERRORS.has(err.code)) { + throw err; + } + + logging.warn(`ALGORITHM=${algorithm} is not supported by this server for: ${sql} - retrying without it`); + return await transaction.raw(sql); + } +} + /** * @param {string} tableName * @param {string} column @@ -120,19 +152,15 @@ async function addColumn(tableName, column, transaction = db.knex, columnSpec, o } for (const sqlQuery of addColumnBuilder.toSQL()) { - let sql = sqlQuery.sql; - - if (DatabaseInfo.isMySQL(transaction)) { - // Guard against an ending semicolon - sql = sql.replace(/;\s*$/, ''); - if (options?.algorithm !== 'auto') { - // default to copy if not specified - const algorithm = options?.algorithm || 'copy'; - sql += `, algorithm=${algorithm}`; - } + if (!DatabaseInfo.isMySQL(transaction)) { + await transaction.raw(sqlQuery.sql); + continue; } - await transaction.raw(sql); + // Guard against an ending semicolon + const sql = sqlQuery.sql.replace(/;\s*$/, ''); + // default to copy if not specified + await rawWithAlgorithm(transaction, sql, options?.algorithm || 'copy'); } } @@ -162,19 +190,15 @@ async function dropColumn(tableName, column, transaction = db.knex, columnSpec = } for (const sqlQuery of dropColumnBuilder.toSQL()) { - let sql = sqlQuery.sql; - - if (DatabaseInfo.isMySQL(transaction)) { - // Guard against an ending semicolon - sql = sql.replace(/;\s*$/, ''); - if (options?.algorithm !== 'auto') { - // default to copy if not specified - const algorithm = options?.algorithm || 'copy'; - sql += `, algorithm=${algorithm}`; - } + if (!DatabaseInfo.isMySQL(transaction)) { + await transaction.raw(sqlQuery.sql); + continue; } - await transaction.raw(sql); + // Guard against an ending semicolon + const sql = sqlQuery.sql.replace(/;\s*$/, ''); + // default to copy if not specified + await rawWithAlgorithm(transaction, sql, options?.algorithm || 'copy'); } } @@ -191,8 +215,8 @@ async function renameColumn(tableName, from, to, transaction = db.knex, options if (DatabaseInfo.isMySQL(transaction)) { // The knex helper does a lot of interesting things with foreign keys that are slow on bigger MySQL clusters - const algorithm = options.algorithm && options.algorithm !== 'auto' ? `, algorithm=${options.algorithm}` : ''; - return await transaction.raw(`ALTER TABLE \`${tableName}\` RENAME COLUMN \`${from}\` TO \`${to}\`${algorithm};`); + const sql = `ALTER TABLE \`${tableName}\` RENAME COLUMN \`${from}\` TO \`${to}\``; + return await rawWithAlgorithm(transaction, sql, options.algorithm); } return await transaction.schema.table(tableName, function (table) { diff --git a/ghost/core/test/unit/server/data/schema/commands.test.js b/ghost/core/test/unit/server/data/schema/commands.test.js index a98f4f6e6b3..d5e421e8283 100644 --- a/ghost/core/test/unit/server/data/schema/commands.test.js +++ b/ghost/core/test/unit/server/data/schema/commands.test.js @@ -154,7 +154,7 @@ describe('schema commands', function () { await commands.renameColumn('email_batches', 'provider_id', 'mailgun_message_id', fakeKnex, {algorithm: 'instant'}); assert.deepEqual(rawStatements, [ - 'ALTER TABLE `email_batches` RENAME COLUMN `provider_id` TO `mailgun_message_id`, algorithm=instant;' + 'ALTER TABLE `email_batches` RENAME COLUMN `provider_id` TO `mailgun_message_id`, algorithm=instant' ]); }); @@ -171,8 +171,53 @@ describe('schema commands', function () { await commands.renameColumn('table', 'old_column', 'new_column', fakeKnex); assert.deepEqual(rawStatements, [ - 'ALTER TABLE `table` RENAME COLUMN `old_column` TO `new_column`;' + 'ALTER TABLE `table` RENAME COLUMN `old_column` TO `new_column`' ]); }); + + it('retries without the algorithm when the server does not support it', async function () { + const rawStatements = []; + const fakeKnex = { + client: {config: {client: 'mysql2'}}, + raw: (sql) => { + rawStatements.push(sql); + + if (sql.includes('algorithm=')) { + const error = new Error('ALGORITHM=INSTANT is not supported for this operation. Try ALGORITHM=COPY/INPLACE.'); + error.code = 'ER_ALTER_OPERATION_NOT_SUPPORTED'; + return Promise.reject(error); + } + + return Promise.resolve(); + } + }; + + await commands.renameColumn('email_batches', 'provider_id', 'mailgun_message_id', fakeKnex, {algorithm: 'instant'}); + + assert.deepEqual(rawStatements, [ + 'ALTER TABLE `email_batches` RENAME COLUMN `provider_id` TO `mailgun_message_id`, algorithm=instant', + 'ALTER TABLE `email_batches` RENAME COLUMN `provider_id` TO `mailgun_message_id`' + ]); + }); + + it('does not retry on unrelated errors', async function () { + const rawStatements = []; + const fakeKnex = { + client: {config: {client: 'mysql2'}}, + raw: (sql) => { + rawStatements.push(sql); + const error = new Error("Table 'email_batches' doesn't exist"); + error.code = 'ER_NO_SUCH_TABLE'; + return Promise.reject(error); + } + }; + + await assert.rejects( + commands.renameColumn('email_batches', 'provider_id', 'mailgun_message_id', fakeKnex, {algorithm: 'instant'}), + /doesn't exist/ + ); + + assert.equal(rawStatements.length, 1); + }); }); }); From df5535b80aac8ded08e672839bb1e0196965aabc Mon Sep 17 00:00:00 2001 From: Troy Ciesco Date: Wed, 19 Aug 2026 14:47:34 -0400 Subject: [PATCH 06/11] Added automation entry stats to the list view (#30086) closes https://linear.app/ghost/issue/NY-1526 Added Last entry, Total entries, and In progress columns using stats from the automation browse API. Kept the mobile list focused on name and status, and gated the new stats behind automationRunAnalytics while run analytics remains experimental. image --- apps/admin-x-framework/src/api/automations.ts | 12 ++- .../automations.acceptance.test.tsx | 31 ++++++- .../src/automations/automations.screen.ts | 1 + .../src/automations/automations.test.tsx | 36 +++++++- apps/admin/src/automations/automations.tsx | 4 +- .../components/automations-list.test.tsx | 52 +++++++++-- .../components/automations-list.tsx | 87 +++++++++++++++---- .../test-data/src/builders/automation.ts | 12 ++- 8 files changed, 204 insertions(+), 31 deletions(-) diff --git a/apps/admin-x-framework/src/api/automations.ts b/apps/admin-x-framework/src/api/automations.ts index 770f20a88cc..e0960805e9c 100644 --- a/apps/admin-x-framework/src/api/automations.ts +++ b/apps/admin-x-framework/src/api/automations.ts @@ -12,6 +12,16 @@ export type Automation = { status: AutomationStatus; } +export type AutomationStats = { + last_run_created_at: string | null; + total_run_count: number; + in_progress_run_count: number; +} + +export type AutomationBrowseItem = Automation & { + stats: AutomationStats; +} + export type AutomationWaitAction = { id: string; type: 'wait'; @@ -62,7 +72,7 @@ export type EditAutomationPayload = { export interface AutomationsResponseType { meta?: Meta; - automations: Automation[]; + automations: AutomationBrowseItem[]; } export interface AutomationDetailResponseType { diff --git a/apps/admin/src/automations/automations.acceptance.test.tsx b/apps/admin/src/automations/automations.acceptance.test.tsx index 1f6615884c0..fc13c8badaa 100644 --- a/apps/admin/src/automations/automations.acceptance.test.tsx +++ b/apps/admin/src/automations/automations.acceptance.test.tsx @@ -5,6 +5,7 @@ import { automationsScreen } from "./automations.screen"; // Automations ships behind the `automations` beta labs flag. const AUTOMATIONS_ENABLED = { labs: { automations: true } }; +const RUN_ANALYTICS_ENABLED = { labs: { automations: true, automationRunAnalytics: true } }; describe("Automations list", () => { it("renders the automations page", async () => { @@ -12,18 +13,42 @@ describe("Automations list", () => { await renderAdminApp("/automations", AUTOMATIONS_ENABLED); await expect.element(automationsScreen.heading()).toBeVisible(); + await expect.element(automationsScreen.columnHeader("Last entry")).not.toBeInTheDocument(); }); it("lists the welcome automations", async () => { fakeAutomations([ - automation({ name: "Free member welcome flow", slug: "member-welcome-email-free", status: "active" }), - automation({ name: "Paid member welcome flow", slug: "member-welcome-email-paid", status: "inactive" }), + automation({ + name: "Free member welcome flow", + slug: "member-welcome-email-free", + status: "active", + stats: { + last_run_created_at: "2026-07-21T07:12:00.000Z", + total_run_count: 1432, + in_progress_run_count: 118, + }, + }), + automation({ + name: "Paid member welcome flow", + slug: "member-welcome-email-paid", + status: "inactive", + stats: { + last_run_created_at: null, + total_run_count: 0, + in_progress_run_count: 0, + }, + }), ]); - await renderAdminApp("/automations", AUTOMATIONS_ENABLED); + await renderAdminApp("/automations", RUN_ANALYTICS_ENABLED); await expect.element(automationsScreen.link("Free member welcome flow")).toBeVisible(); + await expect.element(automationsScreen.columnHeader("Last entry")).toBeVisible(); + await expect.element(automationsScreen.columnHeader("Total entries")).toBeVisible(); + await expect.element(automationsScreen.columnHeader("In progress")).toBeVisible(); const row = automationsScreen.rows(); await expect.element(row).toHaveTextContent("Welcome new free members after they sign up."); + await expect.element(row).toHaveTextContent("1,432"); + await expect.element(row).toHaveTextContent("118"); await expect.element(row).toHaveTextContent("Live"); // Stripe is disconnected in the default boot, which hides the paid welcome flow. await expect(automationsScreen.rows()).toHaveCount(1); diff --git a/apps/admin/src/automations/automations.screen.ts b/apps/admin/src/automations/automations.screen.ts index 603edbcf0fd..5a335c64562 100644 --- a/apps/admin/src/automations/automations.screen.ts +++ b/apps/admin/src/automations/automations.screen.ts @@ -5,6 +5,7 @@ import { automationListRow, automationsList, automationsPage } from "@tryghost/t export const automationsScreen = { heading: () => page.getByTestId(automationsPage).getByRole("heading", { name: "Automations" }), list: () => page.getByTestId(automationsList), + columnHeader: (name: string) => page.getByTestId(automationsList).getByRole("columnheader", { name }), rows: () => page.getByTestId(automationListRow), link: (name: string) => page.getByRole("link", { name, exact: true }), }; diff --git a/apps/admin/src/automations/automations.test.tsx b/apps/admin/src/automations/automations.test.tsx index eec29da4979..f3150abf7ae 100644 --- a/apps/admin/src/automations/automations.test.tsx +++ b/apps/admin/src/automations/automations.test.tsx @@ -3,6 +3,16 @@ import {MemoryRouter} from 'react-router'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import {render, screen} from '@testing-library/react'; +const mockRunAnalyticsFlag = vi.hoisted(() => ({enabled: true})); + +vi.mock('@tryghost/admin-x-framework/hooks', async () => { + const actual = await vi.importActual('@tryghost/admin-x-framework/hooks'); + return { + ...actual, + useFeatureFlag: () => mockRunAnalyticsFlag.enabled + }; +}); + const {mockUseBrowseAutomations, mockUseBrowseSettings, mockUseBrowseConfig, mockUseCurrentUser} = vi.hoisted(() => ({ mockUseBrowseAutomations: vi.fn(), mockUseBrowseSettings: vi.fn(), @@ -65,12 +75,22 @@ const automations = [{ id: 'automation-id-1', name: 'Free member welcome flow', slug: 'member-welcome-email-free', - status: 'active' as const + status: 'active' as const, + stats: { + last_run_created_at: '2026-07-21T07:12:00.000Z', + total_run_count: 1432, + in_progress_run_count: 118 + } }, { id: 'automation-id-2', name: 'Paid member welcome flow', slug: 'member-welcome-email-paid', - status: 'inactive' as const + status: 'inactive' as const, + stats: { + last_run_created_at: null, + total_run_count: 0, + in_progress_run_count: 0 + } }]; const stripeConnectedSettings = { @@ -85,12 +105,24 @@ const renderPage = () => render(); describe('Automations', () => { beforeEach(() => { vi.clearAllMocks(); + mockRunAnalyticsFlag.enabled = true; mockUseBrowseAutomations.mockReturnValue({data: {automations}, isError: false, isLoading: false}); mockUseBrowseSettings.mockReturnValue({data: stripeConnectedSettings, isLoading: false}); mockUseBrowseConfig.mockReturnValue({data: {config: {}}, isLoading: false}); mockUseCurrentUser.mockReturnValue({data: {id: 'user-1', roles: [{name: 'Owner'}]}}); }); + it('hides run analytics when the private feature is disabled', () => { + mockRunAnalyticsFlag.enabled = false; + + renderPage(); + + expect(screen.queryByRole('columnheader', {name: 'Last entry'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'Total entries'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'In progress'})).not.toBeInTheDocument(); + expect(screen.queryByText('1,432')).not.toBeInTheDocument(); + }); + it('shows free and paid sequences when Stripe is connected', () => { renderPage(); diff --git a/apps/admin/src/automations/automations.tsx b/apps/admin/src/automations/automations.tsx index 1e48a183597..008834b19dc 100644 --- a/apps/admin/src/automations/automations.tsx +++ b/apps/admin/src/automations/automations.tsx @@ -6,9 +6,11 @@ import {Box, Container} from '@tryghost/shade/primitives'; import {ListPage} from '@tryghost/shade/page-templates'; import {PageHeader} from '@tryghost/shade/patterns'; import {useVisibleAutomations} from './hooks/use-visible-automations'; +import {useFeatureFlag} from '@tryghost/admin-x-framework/hooks'; const Automations: React.FC = () => { const {automations, error, isError, isLoading} = useVisibleAutomations(); + const showRunAnalytics = useFeatureFlag('automationRunAnalytics'); if (isError) { throw error instanceof Error ? error : new Error('Failed to load automations'); @@ -31,7 +33,7 @@ const Automations: React.FC = () => { - + diff --git a/apps/admin/src/automations/components/automations-list.test.tsx b/apps/admin/src/automations/components/automations-list.test.tsx index f5de0d8b84c..b6d20714d99 100644 --- a/apps/admin/src/automations/components/automations-list.test.tsx +++ b/apps/admin/src/automations/components/automations-list.test.tsx @@ -1,26 +1,45 @@ import AutomationsList from './automations-list'; import React from 'react'; import {MemoryRouter} from 'react-router'; -import {describe, expect, it} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {render, screen} from '@testing-library/react'; const automations = [{ id: 'automation-id-1', name: 'Free member welcome flow', slug: 'member-welcome-email-free', - status: 'active' as const + status: 'active' as const, + stats: { + last_run_created_at: '2026-07-21T07:12:00.000Z', + total_run_count: 1432, + in_progress_run_count: 118 + } }, { id: 'automation-id-2', name: 'Paid member welcome flow', slug: 'member-welcome-email-paid', - status: 'inactive' as const + status: 'inactive' as const, + stats: { + last_run_created_at: null, + total_run_count: 0, + in_progress_run_count: 0 + } }]; const renderWithRouter = (ui: React.ReactElement) => render({ui}); describe('AutomationsList', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-04T07:12:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it('renders fetched automations with private beta copy and status labels', () => { - renderWithRouter(); + renderWithRouter(); expect(screen.getAllByTestId('automation-list-row')).toHaveLength(2); expect(screen.getByText('Free member welcome flow')).toBeInTheDocument(); @@ -29,17 +48,40 @@ describe('AutomationsList', () => { expect(screen.getByText('Welcome new paid members after they start their subscription.')).toBeInTheDocument(); expect(screen.getByText('Live')).toBeInTheDocument(); expect(screen.getByText('Off')).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Last entry'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Total entries'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'In progress'})).toBeInTheDocument(); + expect(screen.getByText('1,432')).toBeInTheDocument(); + expect(screen.getByText('118')).toBeInTheDocument(); + expect(screen.getByText('14 days ago')).toHaveAttribute('datetime', '2026-07-21T07:12:00.000Z'); + }); + + it('renders Never when an automation has no last entry', () => { + renderWithRouter(); + + expect(screen.getByText('Never')).toBeInTheDocument(); + }); + + it('hides run analytics when the feature is disabled', () => { + renderWithRouter(); + + expect(screen.queryByRole('columnheader', {name: 'Last entry'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'Total entries'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'In progress'})).not.toBeInTheDocument(); + expect(screen.queryByText('1,432')).not.toBeInTheDocument(); }); it('links each row to the automation sequence by id', () => { renderWithRouter(); + expect(screen.getByRole('table', {name: 'Automations'})).toBeInTheDocument(); + expect(screen.getAllByRole('rowheader')).toHaveLength(2); expect(screen.getByRole('link', {name: 'Free member welcome flow'})).toHaveAttribute('href', '/automations/automation-id-1'); expect(screen.getByRole('link', {name: 'Paid member welcome flow'})).toHaveAttribute('href', '/automations/automation-id-2'); }); it('renders a table skeleton while loading', () => { - renderWithRouter(); + renderWithRouter(); expect(screen.getByTestId('automations-list-loading')).toBeInTheDocument(); }); diff --git a/apps/admin/src/automations/components/automations-list.tsx b/apps/admin/src/automations/components/automations-list.tsx index 6ad5ec513d0..3179c0d617b 100644 --- a/apps/admin/src/automations/components/automations-list.tsx +++ b/apps/admin/src/automations/components/automations-list.tsx @@ -1,35 +1,49 @@ import AutomationStatusBadge from './automation-status-badge'; import React from 'react'; -import type {Automation} from '@tryghost/admin-x-framework/api/automations'; +import type {AutomationBrowseItem} from '@tryghost/admin-x-framework/api/automations'; import {Link} from '@tryghost/admin-x-framework'; -import {Skeleton, Table, TableBody, TableCell, TableRow} from '@tryghost/shade/components'; +import {Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@tryghost/shade/components'; +import {cn, formatNumber} from '@tryghost/shade/utils'; +import moment from 'moment'; const AUTOMATION_DESCRIPTIONS: Record = { 'member-welcome-email-free': 'Welcome new free members after they sign up.', 'member-welcome-email-paid': 'Welcome new paid members after they start their subscription.' }; +const AUTOMATION_STAT_COLUMNS = [ + {key: 'lastEntry', label: 'Last entry', widthClassName: 'w-40', skeletonWidthClassName: 'w-20'}, + {key: 'totalEntries', label: 'Total entries', widthClassName: 'w-32', skeletonWidthClassName: 'w-10'}, + {key: 'inProgressEntries', label: 'In progress', widthClassName: 'w-32', skeletonWidthClassName: 'w-10'} +] as const; + interface AutomationsListProps { - automations?: Automation[]; + automations?: AutomationBrowseItem[]; isLoading?: boolean; + showRunAnalytics?: boolean; } -const AutomationsListSkeleton: React.FC = () => { +const AutomationsListSkeleton: React.FC<{showRunAnalytics: boolean}> = ({showRunAnalytics}) => { return ( - - +
+ {Array.from({length: 2}, (_, index) => ( ))} @@ -38,24 +52,52 @@ const AutomationsListSkeleton: React.FC = () => { ); }; -const AutomationsList: React.FC = ({automations = [], isLoading = false}) => { +const AutomationsList: React.FC = ({automations = [], isLoading = false, showRunAnalytics = false}) => { if (isLoading) { - return ; + return ; } return ( -
- +
+ {showRunAnalytics && ( + + + Name + {AUTOMATION_STAT_COLUMNS.map(column => ( + {column.label} + ))} + Status + + + )} + {automations.map((automation) => { const description = AUTOMATION_DESCRIPTIONS[automation.slug]; + const lastEntry = automation.stats.last_run_created_at; + const totalEntries = automation.stats.total_run_count; + const inProgressEntries = automation.stats.in_progress_run_count; + const statCells = { + lastEntry: { + content: lastEntry ? : 'Never', + isEmpty: !lastEntry + }, + totalEntries: { + content: formatNumber(totalEntries), + isEmpty: totalEntries === 0 + }, + inProgressEntries: { + content: formatNumber(inProgressEntries), + isEmpty: inProgressEntries === 0 + } + }; return ( - + = ({automations = [], isLo {description} )} - - + + {showRunAnalytics && AUTOMATION_STAT_COLUMNS.map((column) => { + const cell = statCells[column.key]; + + return ( + + {cell.content} + + ); + })} + diff --git a/packages/testing/test-data/src/builders/automation.ts b/packages/testing/test-data/src/builders/automation.ts index 20f914c79c2..47f6078c206 100644 --- a/packages/testing/test-data/src/builders/automation.ts +++ b/packages/testing/test-data/src/builders/automation.ts @@ -8,6 +8,11 @@ export interface Automation { name: string; slug: string; status: "active" | "inactive"; + stats: { + last_run_created_at: string | null; + total_run_count: number; + in_progress_run_count: number; + }; } export const automation = createBuilder(() => { @@ -17,6 +22,11 @@ export const automation = createBuilder(() => { id: generateId(), name, slug: `${generateSlug(name)}-${faker.string.alphanumeric(6).toLowerCase()}`, - status: "inactive" + status: "inactive", + stats: { + last_run_created_at: null, + total_run_count: 0, + in_progress_run_count: 0 + } }; }); From 083f23d02dd4d9dc9bed0535cb0129e0df12f23d Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Wed, 19 Aug 2026 14:56:38 -0400 Subject: [PATCH 07/11] Fixed icon-only navigation items rendering as empty links (#30131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref Follow-up to #30129. The navigation settings validator accepts an item with an icon and no label regardless of the `navigationIcons` flag. So enabling the flag, adding an icon-only item, then disabling the flag leaves the front end rendering `` — an empty, unlabelled link, since the icon is dropped when the flag is off. Before #28368 that data threw an `IncorrectUsageError`, which is the worse option: it 500s the whole front end for data Admin allowed to be saved. So items with nothing to render are now dropped instead, and a nav where every item drops out renders nothing, matching the existing empty-nav behaviour. Note this also drops an item with a null label and no icon, which previously rendered the same empty link. That shape is only reachable by writing to the database directly — Admin has always required a label (now label-or-icon). Tests: an icon-only item is dropped while a sibling label item still renders, and an all-icon-only nav renders an empty string. Flag-on icon-only rendering is unchanged and still covered. --- .../core/core/frontend/helpers/navigation.js | 6 ++- .../unit/frontend/helpers/navigation.test.js | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/ghost/core/core/frontend/helpers/navigation.js b/ghost/core/core/frontend/helpers/navigation.js index 85497f9d646..8f7b1a75ca3 100644 --- a/ghost/core/core/frontend/helpers/navigation.js +++ b/ghost/core/core/frontend/helpers/navigation.js @@ -157,11 +157,13 @@ module.exports = function navigation(options) { out.current = isCurrentNavigationUrl(e.url, currentUrl); out.icon = icon || null; out.iconAlt = hasLabel ? '' : iconName; - out.label = e.label; + out.label = hasLabel ? e.label : null; out.slug = slugify(hasLabel ? e.label : iconName); out.url = e.url; return out; - }); + }) + // Nothing to render: an icon-only item with the icons flag off would be an empty link + .filter(item => item.icon || item.label); if (output.length === 0) { return new SafeString(''); diff --git a/ghost/core/test/unit/frontend/helpers/navigation.test.js b/ghost/core/test/unit/frontend/helpers/navigation.test.js index 16209b88169..b6c7dc0d41f 100644 --- a/ghost/core/test/unit/frontend/helpers/navigation.test.js +++ b/ghost/core/test/unit/frontend/helpers/navigation.test.js @@ -344,6 +344,44 @@ describe('{{navigation}} helper', function () { assert(rendered.string.includes('/paid">Paid')); }); + it('drops icon-only items rather than rendering an empty link', function () { + optionsData.data.site.navigation = [ + {url: '/icon-only', icon: 'https://example.com/icon.svg'}, + {label: 'Foo', url: '/foo'} + ]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert(!rendered.string.includes('/icon-only')); + assert(!rendered.string.includes('class="nav-"')); + assert(rendered.string.includes('/foo">Foo')); + }); + + it('drops items whose label is only whitespace', function () { + optionsData.data.site.navigation = [ + {label: ' ', url: '/blank', icon: 'https://example.com/icon.svg'}, + {label: 'Foo', url: '/foo'} + ]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert(!rendered.string.includes('/blank')); + assert(rendered.string.includes('/foo">Foo')); + }); + + it('renders empty nav when every item is icon-only', function () { + optionsData.data.site.navigation = [ + {url: '/icon-only', icon: 'https://example.com/icon.svg'} + ]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert.equal(rendered.string, ''); + }); + it('renders the same markup as before the flag existed', function () { optionsData.data.site.navigation = [{label: 'Foo', url: '/foo'}]; From 74240feac7dd3da2c3ac1155b8f35fbc3313e08f Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:18:19 +0000 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=8E=A8=20Updated=20Casper=20to=20v5?= =?UTF-8?q?.12.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 90fa164372dade68fee1d252bcb45cd9414f930c Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:18:19 +0000 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=8E=A8=20Updated=20Source=20to=20v1?= =?UTF-8?q?.7.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 c40eeac61f17e6e8ab2e2b9e7a111d7c137f3e1e Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:18:21 +0000 Subject: [PATCH 10/11] v6.59.0 --- .../@tryghost!adapter-base-scheduling@0.2.2.md | 7 ------- .../@tryghost!adapter-base-sso@0.1.2.md | 5 ----- .../@tryghost!kg-default-nodes@2.2.0.md | 11 ----------- .../@tryghost!kg-html-to-lexical@1.4.0.md | 12 ------------ .../@tryghost!kg-lexical-html-renderer@1.5.0.md | 12 ------------ .../@tryghost!koenig-lexical@1.9.2.md | 17 ----------------- .changeset/clean-flags-retire.md | 5 ----- .changeset/easy-wasps-appear.md | 13 ------------- .changeset/funky-bikes-chew.md | 5 ----- .changeset/funky-rice-shine.md | 5 ----- .changeset/loose-pans-argue.md | 5 ----- .changeset/olive-regions-stop.md | 5 ----- .changeset/open-shirts-arrive.md | 6 ------ .changeset/petite-forks-lick.md | 5 ----- .changeset/young-spiders-request.md | 5 ----- apps/ember-admin/package.json | 2 +- ghost/core/package.json | 2 +- 17 files changed, 2 insertions(+), 120 deletions(-) delete mode 100644 .changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md delete mode 100644 .changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md delete mode 100644 .changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md delete mode 100644 .changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md delete mode 100644 .changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md delete mode 100644 .changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md delete mode 100644 .changeset/clean-flags-retire.md delete mode 100644 .changeset/easy-wasps-appear.md delete mode 100644 .changeset/funky-bikes-chew.md delete mode 100644 .changeset/funky-rice-shine.md delete mode 100644 .changeset/loose-pans-argue.md delete mode 100644 .changeset/olive-regions-stop.md delete mode 100644 .changeset/open-shirts-arrive.md delete mode 100644 .changeset/petite-forks-lick.md delete mode 100644 .changeset/young-spiders-request.md diff --git a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md b/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md deleted file mode 100644 index fbff870d83f..00000000000 --- a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md +++ /dev/null @@ -1,7 +0,0 @@ -## 0.2.2 - -### Patch Changes - -- Update framework dependencies - -- Updated logging requests to avoid process crashes on redirects. diff --git a/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md b/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md deleted file mode 100644 index a7e11d25ee4..00000000000 --- a/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md +++ /dev/null @@ -1,5 +0,0 @@ -## 0.1.2 - -### Patch Changes - -- Update framework dependencies diff --git a/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md b/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md deleted file mode 100644 index 25df9398a6f..00000000000 --- a/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md +++ /dev/null @@ -1,11 +0,0 @@ -## 2.2.0 - -### Minor Changes - -- Update jsdom to 30 & node engines to match jsdom's - -### Patch Changes - -- Removed the unused emailCustomization and emailCustomizationAlpha feature options. - -- Documented the package API and corrected the development instructions in the README diff --git a/.changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md b/.changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md deleted file mode 100644 index c80a8b4cc25..00000000000 --- a/.changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md +++ /dev/null @@ -1,12 +0,0 @@ -## 1.4.0 - -### Minor Changes - -- Update jsdom to 30 & node engines to match jsdom's - -### Patch Changes - -- Documented the package API and corrected the development instructions in the README - -- Updated dependencies: - - @tryghost/kg-default-nodes@2.2.0 diff --git a/.changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md b/.changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md deleted file mode 100644 index bdd78e70a09..00000000000 --- a/.changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md +++ /dev/null @@ -1,12 +0,0 @@ -## 1.5.0 - -### Minor Changes - -- Update jsdom to 30 & node engines to match jsdom's - -### Patch Changes - -- Documented the package API and corrected the development instructions in the README - -- Updated dependencies: - - @tryghost/kg-default-nodes@2.2.0 diff --git a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md b/.changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md deleted file mode 100644 index 43c4597d652..00000000000 --- a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md +++ /dev/null @@ -1,17 +0,0 @@ -## 1.9.2 - -### Patch Changes - -- Updated the test commands in the README - -- Updated dependencies - -- Updated Koenig Lexical testing documentation. - -- Added a package description for npm - -- Updated dependencies - -- Updated dependencies - -- Update jsdom to 30 & node engines to match jsdom's diff --git a/.changeset/clean-flags-retire.md b/.changeset/clean-flags-retire.md deleted file mode 100644 index 8af68dbcdd8..00000000000 --- a/.changeset/clean-flags-retire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/kg-default-nodes": patch ---- - -Removed the unused emailCustomization and emailCustomizationAlpha feature options. diff --git a/.changeset/easy-wasps-appear.md b/.changeset/easy-wasps-appear.md deleted file mode 100644 index 57ee67be712..00000000000 --- a/.changeset/easy-wasps-appear.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@tryghost/kg-utils": none -"@tryghost/kg-unsplash-selector": none -"@tryghost/kg-markdown-html-renderer": none -"@tryghost/kg-html-to-lexical": none -"@tryghost/kg-default-transforms": none -"@tryghost/kg-default-cards": none -"@tryghost/kg-converters": none -"@tryghost/kg-clean-basic-html": none -"@tryghost/kg-card-factory": none ---- - -Switched lint config to formatter-neutral rules; no runtime change diff --git a/.changeset/funky-bikes-chew.md b/.changeset/funky-bikes-chew.md deleted file mode 100644 index 844f52d6738..00000000000 --- a/.changeset/funky-bikes-chew.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Updated the test commands in the README diff --git a/.changeset/funky-rice-shine.md b/.changeset/funky-rice-shine.md deleted file mode 100644 index 2f762631c3a..00000000000 --- a/.changeset/funky-rice-shine.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Updated dependencies diff --git a/.changeset/loose-pans-argue.md b/.changeset/loose-pans-argue.md deleted file mode 100644 index 2da772de325..00000000000 --- a/.changeset/loose-pans-argue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Updated Koenig Lexical testing documentation. diff --git a/.changeset/olive-regions-stop.md b/.changeset/olive-regions-stop.md deleted file mode 100644 index 286b0090c18..00000000000 --- a/.changeset/olive-regions-stop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Added a package description for npm diff --git a/.changeset/open-shirts-arrive.md b/.changeset/open-shirts-arrive.md deleted file mode 100644 index bb7ecca6cad..00000000000 --- a/.changeset/open-shirts-arrive.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@tryghost/adapter-base-scheduling": patch -"@tryghost/adapter-base-sso": patch ---- - -Update framework dependencies diff --git a/.changeset/petite-forks-lick.md b/.changeset/petite-forks-lick.md deleted file mode 100644 index 2f762631c3a..00000000000 --- a/.changeset/petite-forks-lick.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Updated dependencies diff --git a/.changeset/young-spiders-request.md b/.changeset/young-spiders-request.md deleted file mode 100644 index 22c87141a74..00000000000 --- a/.changeset/young-spiders-request.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/adapter-base-scheduling": patch ---- - -Updated logging requests to avoid process crashes on redirects. diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json index 707121fc837..e0f8211507b 100644 --- a/apps/ember-admin/package.json +++ b/apps/ember-admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "6.58.1-rc.0", + "version": "6.59.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 39e1cafca2d..159e27e8b52 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.59.0-rc.0", + "version": "6.59.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", From 3580c2d903f0a36357eb3ce7ff9420eb8e16b610 Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:18:24 +0000 Subject: [PATCH 11/11] Bumped version to 6.59.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 e0f8211507b..9f0a6225630 100644 --- a/apps/ember-admin/package.json +++ b/apps/ember-admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "6.59.0", + "version": "6.59.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 159e27e8b52..9f443a4031e 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.59.0", + "version": "6.59.1-rc.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org",