Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -210,15 +210,22 @@
},

// Allow internal Docker digest pins to automerge once the relevant
// CI checks have gone green.
// CI checks have gone green. Scoped to base images we control or
// pin purely for build reproducibility, so digest rotations don't
// change runtime behavior.
{
"description": "Automerge internal Docker digest updates after CI passes",
"matchDatasources": [
"docker"
],
"matchPackageNames": [
"ghost/traffic-analytics",
"tinybirdco/tinybird-local"
"tinybirdco/tinybird-local",
"python",
"ghcr.io/astral-sh/uv",
"axllent/mailpit",
"caddy",
"stripe/stripe-cli"
],
"matchUpdateTypes": [
"digest"
Expand Down
26 changes: 5 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ jobs:
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --ignore-scripts

- name: Determine Affected Projects
id: affected
Expand Down Expand Up @@ -302,10 +302,10 @@ jobs:
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --filter @tryghost/i18n... --ignore-scripts

- name: Run i18n tests
run: pnpm nx run @tryghost/i18n:test
run: pnpm --filter @tryghost/i18n test

job_admin-tests:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -383,19 +383,9 @@ jobs:
timezoneLinux: "America/New_York"

- name: Run unit tests
# ghost/core's unit tests run on vitest (see ghost/core/vitest.config.ts);
# other packages run their own test:unit target.
run: pnpm nx run-many -t test:unit -p "${{ needs.job_setup.outputs.affected_projects_str }}"
env:
FORCE_COLOR: 0
GHOST_UNIT_TEST_VARIANT: ci
NX_SKIP_LOG_GROUPING: true
logging__level: fatal

- name: Run vitest unit tests
# ghost/core is mid-migration from mocha to vitest. The vitest
# target covers a scoped subset (see ghost/core/vitest.config.ts)
# and runs additively alongside mocha — both runners run the same
# files during the migration as a divergence safety net.
run: pnpm nx run-many -t test:vitest -p "${{ needs.job_setup.outputs.affected_projects_str }}"
env:
FORCE_COLOR: 0
NX_SKIP_LOG_GROUPING: true
Expand All @@ -410,12 +400,6 @@ jobs:
exit 1
fi

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: matrix.node == env.NODE_VERSION
with:
name: unit-coverage
path: ghost/*/coverage/cobertura-coverage.xml

- uses: tryghost/actions/actions/slack-build@598d6328d89dbd796aa02ae2ea66308f9d942224 # main
if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main'
with:
Expand Down
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This file provides guidance to AI Agents when working with code in this reposito

**Always use `pnpm` for all commands.** This repository uses pnpm workspaces, not npm.

Shared dependency versions are pinned in `pnpm-workspace.yaml` under `catalog:` and referenced as `"pkg": "catalog:"` (or `catalog:<name>` for named catalogs). `catalogMode` is `strict`, so `pnpm add` routes new deps into the catalog automatically — don't inline the version.

## Monorepo Structure

Ghost is a pnpm + Nx monorepo with three workspace groups:
Expand Down Expand Up @@ -57,10 +59,12 @@ pnpm build:clean # Clean build artifacts and rebuild
```bash
# Unit tests (from root)
pnpm test:unit # Run all unit tests in all packages
pnpm test:watch # Watch mode — unified Vitest watcher (ghost/core + all apps)

# Ghost core tests (from ghost/core/)
cd ghost/core
pnpm test:unit # Unit tests only
pnpm test:unit # Unit tests only (Vitest, run once)
pnpm test:watch # Watch mode — ghost/core unit tests only
pnpm test:integration # Integration tests
pnpm test:e2e # E2E API tests (not browser)
pnpm test:all # All test types
Expand Down
4 changes: 2 additions & 2 deletions apps/activitypub/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tryghost/activitypub",
"version": "3.1.26",
"version": "3.1.27",
"license": "MIT",
"repository": {
"type": "git",
Expand Down Expand Up @@ -48,7 +48,7 @@
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
"jest": "29.7.0",
"jsdom": "catalog:",
"tailwindcss": "4.2.2",
"tailwindcss": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
Expand Down
4 changes: 2 additions & 2 deletions apps/admin-x-design-system/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"@storybook/addon-docs": "catalog:",
"@storybook/addon-links": "catalog:",
"@storybook/react-vite": "catalog:",
"@tailwindcss/postcss": "4.2.1",
"@tailwindcss/postcss": "catalog:",
"@testing-library/react": "14.3.1",
"@testing-library/react-hooks": "8.0.1",
"@types/lodash-es": "4.17.12",
Expand All @@ -56,7 +56,7 @@
"react-dom": "18.3.1",
"sinon": "catalog:",
"storybook": "catalog:",
"tailwindcss": "4.2.1",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"validator": "catalog:",
"vite": "catalog:",
Expand Down
14 changes: 14 additions & 0 deletions apps/admin-x-framework/src/api/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {createMutation} from '../utils/api/hooks';

export interface ResetAuthResponse {
security_action: Array<{
action: 'reset_authentication';
api_keys_rotated: number;
users_locked: number;
}>;
}

export const useResetAuth = createMutation<ResetAuthResponse, null>({
method: 'POST',
path: () => '/authentication/reset/'
});
9 changes: 7 additions & 2 deletions apps/admin-x-framework/src/test/vitest-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {defineConfig} from 'vitest/config';
export interface VitestConfigOptions {
/** Custom setup files (defaults to ['./test/setup.ts']) */
setupFiles?: string[];
/** Project root for resolving the @src/@test aliases (defaults to process.cwd()) */
root?: string;
/** Additional path aliases beyond the defaults (@src, @test) */
aliases?: Record<string, string>;
/** Test file patterns to include */
Expand All @@ -26,6 +28,9 @@ export interface VitestConfigOptions {
export function createVitestConfig(options: VitestConfigOptions = {}) {
const {
setupFiles = ['./test/setup.ts'],
// process.cwd() is correct when an app runs its own tests; the unified
// root watcher passes an explicit root so aliases stay package-local.
root = process.cwd(),
aliases = {},
include = [
'./test/unit/**/*.{test,spec}.{js,ts,jsx,tsx}',
Expand Down Expand Up @@ -68,8 +73,8 @@ export function createVitestConfig(options: VitestConfigOptions = {}) {
},
resolve: {
alias: {
'@src': path.resolve(process.cwd(), './src'),
'@test': path.resolve(process.cwd(), './test'),
'@src': path.resolve(root, './src'),
'@test': path.resolve(root, './test'),
...aliases
}
}
Expand Down
2 changes: 1 addition & 1 deletion apps/admin-x-settings/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
"eslint-plugin-react-refresh": "catalog:",
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
"jsdom": "catalog:",
"tailwindcss": "4.2.2",
"tailwindcss": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const searchKeywords = {
codeInjection: ['advanced', 'code injection', 'head', 'footer'],
labs: ['advanced', 'labs', 'alpha', 'private', 'beta', 'flag', 'routes', 'redirect', 'translation', 'editor', 'portal'],
history: ['advanced', 'history', 'log', 'events', 'user events', 'staff', 'audit', 'action'],
dangerzone: ['danger', 'danger zone', 'delete', 'content', 'delete all content', 'delete site']
dangerzone: ['danger zone', 'delete all content', 'delete site', 'reset all authentication', 'reset api keys', 'reset password', 'compromised credentials', 'lock staff users', 'sign out all staff']
};

const AdvancedSettings: React.FC = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import TopLevelGroup from '../../top-level-group';
import {Button, ConfirmationModal, SettingGroupHeader, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system';
import useStaffUsers from '../../../hooks/use-staff-users';
import {Button, ConfirmationModal, ListItem, SettingGroupHeader, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {getGhostPaths} from '@tryghost/admin-x-framework/helpers';
import {useDeleteAllContent} from '@tryghost/admin-x-framework/api/db';
import {useGlobalData} from '../../providers/global-data-provider';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
import {useQueryClient} from '@tryghost/admin-x-framework';
import {useResetAuth} from '@tryghost/admin-x-framework/api/security';

const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {mutateAsync: deleteAllContent} = useDeleteAllContent();
const {mutateAsync: resetAuth} = useResetAuth();
const client = useQueryClient();
const handleError = useHandleError();
const {config} = useGlobalData();
const {totalUsers} = useStaffUsers();

const resetAuthEnabled = Boolean(config?.labs?.dangerZoneResetAuth);

const resetAuthStaffSentence = totalUsers === 1
? 'You will be signed out and must reset your password before signing back in.'
: totalUsers > 1
? `All ${totalUsers} staff users, including you, will be signed out and must reset their password before signing back in.`
: 'All staff users, including you, will be signed out and must reset their password before signing back in.';

const handleDeleteAllContent = () => {
NiceModal.show(ConfirmationModal, {
Expand All @@ -33,17 +48,67 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
});
};

const handleResetAuth = () => {
NiceModal.show(ConfirmationModal, {
title: 'Reset all authentication?',
prompt: (
<>
<p className='mb-4'>
This rotates every API key on your site. Any integration using one will stop working until you reconfigure it with the new key from <strong>Settings → Advanced → Integrations</strong>.
</p>
<p>
{resetAuthStaffSentence} Your members aren&apos;t affected.
</p>
</>
),
okLabel: 'Reset all authentication',
okRunningLabel: 'Resetting...',
okColor: 'red',
onOk: async (modal) => {
try {
const response = await resetAuth(null);
const result = response?.security_action?.[0];
const keys = result?.api_keys_rotated ?? 0;
const users = result?.users_locked ?? 0;
showToast({
title: `Rotated ${keys} API ${keys === 1 ? 'key' : 'keys'} and locked ${users} ${users === 1 ? 'user' : 'users'}. You will be signed out shortly.`,
type: 'success'
});
modal?.remove();
window.location.href = getGhostPaths().adminRoot;
} catch (e) {
handleError(e);
}
}
});
};

return (
<TopLevelGroup
customHeader={
<SettingGroupHeader description='Permanently delete all posts and tags from the database, a hard reset' title='Danger zone' />
<SettingGroupHeader description='Destructive actions that affect your entire site.' title='Danger zone' />
}
keywords={keywords}
navid='dangerzone'
testId='dangerzone'
>
<div>
<Button color='red' label='Delete all content' onClick={handleDeleteAllContent} />
<div className='flex flex-col'>
<ListItem
action={<Button aria-label='Delete all content' color='red' label='Delete' onClick={handleDeleteAllContent} />}
bgOnHover={false}
detail='Permanently delete all posts and tags from the database.'
testId='delete-all-content'
title='Delete all content'
/>
{resetAuthEnabled && (
<ListItem
action={<Button aria-label='Reset all authentication' color='red' label='Reset' onClick={handleResetAuth} />}
bgOnHover={false}
detail='Rotate every API key, sign out every staff user, and require a password reset. Use after a suspected credential compromise.'
testId='reset-all-authentication'
title='Reset all authentication'
/>
)}
</div>
</TopLevelGroup>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {useHandleError} from '@tryghost/admin-x-framework/hooks';
import {usePostsExports} from '@tryghost/admin-x-framework/api/posts';

const MigrationToolsExport: React.FC = () => {
const [isExportingPosts, setIsExportingPosts] = React.useState(false);
const {refetch: postsData} = usePostsExports({
searchParams: {
limit: '1000'
Expand All @@ -14,6 +15,12 @@ const MigrationToolsExport: React.FC = () => {
const handleError = useHandleError();

const exportPosts = async () => {
if (isExportingPosts) {
return;
}

setIsExportingPosts(true);

try {
const {data} = await postsData();
if (data) {
Expand All @@ -30,13 +37,15 @@ const MigrationToolsExport: React.FC = () => {
}
} catch (e) {
handleError(e);
} finally {
setIsExportingPosts(false);
}
};

return (
<div className='grid grid-cols-1 gap-4 pt-4 md:grid-cols-2 lg:grid-cols-3'>
<Button className='h-9! font-semibold!' color='grey' icon='export' iconColorClass='h-4! w-auto!' label='Content & settings' onClick={() => downloadAllContent()} />
<Button className='h-9! font-semibold!' color='grey' icon='baseline-chart' iconColorClass='h-4! w-auto!' label='Post analytics' onClick={exportPosts} />
<Button className='h-9! font-semibold!' color='grey' disabled={isExportingPosts} icon='baseline-chart' iconColorClass='h-4! w-auto!' label='Post analytics' loading={isExportingPosts} testId='post-analytics-export-button' onClick={exportPosts} />
</div>
);
};
Expand Down
1 change: 1 addition & 0 deletions apps/admin-x-settings/src/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const Sidebar: React.FC = () => {
<NavItem icon='brackets' keywords={advancedSearchKeywords.codeInjection} navid='code-injection' title="Code injection" onClick={handleSectionClick} />
<NavItem icon='labs-flask' keywords={advancedSearchKeywords.labs} navid='labs' title="Labs" onClick={handleSectionClick} />
<NavItem icon='time-back' keywords={advancedSearchKeywords.history} navid='history' title="History" onClick={handleSectionClick} />
<NavItem icon='warning' keywords={advancedSearchKeywords.dangerzone} navid='dangerzone' title="Danger zone" onClick={handleSectionClick} />
</SettingNavSection>

{!filter &&
Expand Down
Loading