Skip to content
Draft
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
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,47 @@ jobs:
- name: Latest Release
run: |
DIR=$(mktemp -d)
ARCHIVE_VERSION=$(tar -xOf ghost.tgz package/package.json | jq -r '.version')
ghost install local -d "$DIR"
CURRENT_VERSION=$(DIR="$DIR" node -e "const fs = require('fs'); const path = require('path'); const current = fs.realpathSync(path.join(process.env.DIR, 'current')); console.log(require(path.join(current, 'package.json')).version);")
if ! node - "$ARCHIVE_VERSION" "$CURRENT_VERSION" <<'NODE'
function parse(version) {
const [main, prerelease = ''] = version.split('-', 2);
return {
numbers: main.split('.').map(Number),
prerelease
};
}

function compare(left, right) {
for (let index = 0; index < 3; index += 1) {
if (left.numbers[index] !== right.numbers[index]) {
return left.numbers[index] - right.numbers[index];
}
}

if (left.prerelease === right.prerelease) {
return 0;
}

if (!left.prerelease) {
return 1;
}

if (!right.prerelease) {
return -1;
}

return left.prerelease.localeCompare(right.prerelease, undefined, {numeric: true});
}

process.exit(compare(parse(process.argv[2]), parse(process.argv[3])) > 0 ? 0 : 1);
NODE
then
echo "Skipping update from $CURRENT_VERSION to $ARCHIVE_VERSION because the archive is not newer"
ghost stop -d "$DIR"
exit 0
fi
ghost update -d "$DIR" --archive "$(pwd)/ghost.tgz"
URL=$(ghost config get url -d "$DIR" --no-prompt --no-color | tail -n1)
curl --retry 10 --retry-connrefused --retry-delay 3 -fsSI "$URL"
Expand Down Expand Up @@ -1349,6 +1389,15 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Restore E2E fixture cache
id: e2e-fixture-cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
e2e/data/cache/mysql
e2e/data/state/auth
key: e2e-fixture-v3-${{ hashFiles('ghost/core/test/unit/server/data/schema/integrity.test.js', 'ghost/core/core/server/data/migrations/versions/**/*.js') }}

- name: Prepare E2E CI job
env:
GHOST_E2E_IMAGE: ${{ steps.load.outputs.image-tag }}
Expand All @@ -1366,8 +1415,19 @@ jobs:
E2E_SHARD_INDEX: ${{ matrix.shardIndex }}
E2E_SHARD_TOTAL: ${{ matrix.shardTotal }}
E2E_RETRIES: 2
E2E_ENABLE_CI_FIXTURE_CACHE: 'true'
run: bash ./e2e/scripts/run-playwright-container.sh

- name: Save E2E fixture cache
if: success() && steps.e2e-fixture-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v') || endsWith(github.ref, '.x')) && matrix.projectName == 'Main' && matrix.shardIndex == 1
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
continue-on-error: true
with:
path: |
e2e/data/cache/mysql
e2e/data/state/auth
key: ${{ steps.e2e-fixture-cache.outputs.cache-primary-key }}

- name: Dump E2E docker logs
if: failure()
run: bash ./e2e/scripts/dump-e2e-docker-logs.sh
Expand Down
1 change: 1 addition & 0 deletions compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ services:
MYSQL_PASSWORD: ghost
volumes:
- mysql-data:/var/lib/mysql
- ./e2e/data/cache/mysql:/mnt/e2e-cache
healthcheck:
test: ["CMD", "mysql", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-root}", "-e", "SELECT 1"]
interval: 1s
Expand Down
9 changes: 5 additions & 4 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,8 @@ Infrastructure (MySQL, Redis, Mailpit, Tinybird) must already be running before
Global setup (`tests/global.setup.ts`) does:
- Validates local fixture cache package (snapshot + role auth states)
- On cache miss: recreates base DB, creates owner + staff users, stores role auth state files, snapshots DB
- On cache hit (local only): skips fixture regeneration and reuses cached package
- In CI: always rebuilds fixture package
- On cache hit: restores the base DB from the cached snapshot, skips fixture regeneration, and reuses cached role auth states
- In CI: restores the same fixture package from an exact GitHub Actions cache key based on DB integrity and migration inputs; cache misses rebuild via the same Playwright global setup flow

Per-file mode (`helpers/playwright/fixture.ts`) does:
- Clones a new database from snapshot at file boundary
Expand Down Expand Up @@ -277,8 +277,9 @@ Tests run automatically in GitHub Actions on every PR and commit to `main`.
2. **Build Assets**: Build server/admin assets and public app UMD bundles
3. **Build E2E Image**: `pnpm --filter @tryghost/e2e build:docker` (layers public apps into `/content/files`)
4. **Prepare E2E Runtime**: Pull Playwright/gateway images in parallel, start infra, and sync Tinybird state (`pnpm --filter @tryghost/e2e preflight:build`)
5. **Test Execution**: Run Playwright E2E tests inside the official Playwright container
6. **Artifacts**: Upload Playwright traces and reports on failure
5. **Fixture Cache**: Restore the DB snapshot + role auth states from GitHub Actions cache when the exact fixture key exists
6. **Test Execution**: Run Playwright E2E tests inside the official Playwright container
7. **Artifacts**: Upload Playwright traces and reports on failure

## Available Scripts

Expand Down
52 changes: 38 additions & 14 deletions e2e/helpers/environment/environment-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import baseDebug from '@tryghost/debug';
import logging from '@tryghost/logging';
import {GhostInstance, MySQLManager} from './service-managers';
import {GhostManager} from './service-managers/ghost-manager';
import {createFixtureCacheManifest, getLatestMigrationFileName, getMissingAuthStateFiles, isFixtureCacheManifestCurrent, shouldForceFixtureReset} from '@/helpers/utils/fixture-cache';
import {createFixtureCacheManifest, getLatestMigrationFileName, getMissingAuthStateFiles, isFixtureCacheManifestCurrent, shouldForceFixtureReset, shouldUseCIFixtureCache} from '@/helpers/utils/fixture-cache';
import {randomUUID} from 'crypto';
import type {GhostConfig} from '@/helpers/playwright/fixture';

Expand All @@ -18,7 +18,7 @@ export type EnvironmentMode = 'dev' | 'build';
type GhostEnvOverrides = GhostConfig | Record<string, string>;
type CacheStatus =
| {isValid: true; reason: 'cache_hit'}
| {isValid: false; reason: 'ci_always_rebuild' | 'forced_fixture_reset' | 'missing_snapshot' | 'missing_auth_state' | 'missing_migration_files' | 'missing_migrations_table' | 'migration_mismatch' | 'missing_fixture_manifest' | 'fixture_manifest_mismatch'};
| {isValid: false; reason: 'ci_always_rebuild' | 'forced_fixture_reset' | 'missing_snapshot' | 'missing_auth_state' | 'missing_migration_files' | 'migration_mismatch' | 'missing_fixture_manifest' | 'fixture_manifest_mismatch'};
type GlobalSetupResult = {baseUrl: string; cacheHit: boolean};

/**
Expand Down Expand Up @@ -75,7 +75,7 @@ export class EnvironmentManager {
logging.info(`Starting ${this.mode} environment global setup...`);

const cacheStatus = await this.getCacheStatus();
const cacheHit = cacheStatus.isValid;
let cacheHit = cacheStatus.isValid;
if (cacheHit) {
logging.info('Fixture cache hit - reusing snapshot + auth state package');
} else {
Expand All @@ -84,6 +84,10 @@ export class EnvironmentManager {

await this.cleanupResources({deleteSnapshot: !cacheHit});

if (cacheHit) {
cacheHit = await this.restoreBaseDatabaseFromSnapshot();
}

if (!cacheHit) {
await this.mysql.recreateBaseDatabase('ghost_e2e_base');
}
Expand Down Expand Up @@ -119,7 +123,7 @@ export class EnvironmentManager {
}

logging.info(`Starting ${this.mode} environment global teardown...`);
await this.cleanupResources({deleteSnapshot: this.isCI()});
await this.cleanupResources({deleteSnapshot: this.isCI() && !shouldUseCIFixtureCache()});
logging.info(`${this.mode} environment global teardown complete`);
}

Expand Down Expand Up @@ -194,7 +198,7 @@ export class EnvironmentManager {
}

private async getCacheStatus(): Promise<CacheStatus> {
if (this.isCI()) {
if (this.isCI() && !shouldUseCIFixtureCache()) {
return {isValid: false, reason: 'ci_always_rebuild'};
}

Expand All @@ -217,15 +221,6 @@ export class EnvironmentManager {
return {isValid: false, reason: 'missing_migration_files'};
}

const latestAppliedMigration = await this.mysql.getLatestMigrationName('ghost_e2e_base');
if (!latestAppliedMigration) {
return {isValid: false, reason: 'missing_migrations_table'};
}

if (latestAppliedMigration !== latestMigrationFileName) {
return {isValid: false, reason: 'migration_mismatch'};
}

const expectedManifest = await createFixtureCacheManifest(latestMigrationFileName);
const actualManifest = await this.mysql.getSnapshotMetadata();
if (!actualManifest) {
Expand All @@ -238,4 +233,33 @@ export class EnvironmentManager {

return {isValid: true, reason: 'cache_hit'};
}

private async restoreBaseDatabaseFromSnapshot(): Promise<boolean> {
const latestMigrationFileName = await getLatestMigrationFileName();
if (!latestMigrationFileName) {
logging.warn('Fixture cache could not be restored because migration files are missing.');
return false;
}

try {
await this.mysql.recreateBaseDatabase('ghost_e2e_base');
await this.mysql.restoreDatabaseFromSnapshot('ghost_e2e_base');

const latestAppliedMigration = await this.mysql.getLatestMigrationName('ghost_e2e_base');
if (latestAppliedMigration !== latestMigrationFileName) {
logging.warn(
`Fixture cache migration mismatch after restore: expected ${latestMigrationFileName}, received ${latestAppliedMigration || 'none'}`
);
await this.mysql.deleteSnapshot();
return false;
}

return true;
} catch (error) {
logging.warn('Fixture cache restore failed - rebuilding fixture package');
logging.warn(error);
await this.mysql.deleteSnapshot();
return false;
}
}
}
13 changes: 10 additions & 3 deletions e2e/helpers/environment/service-managers/mysql-manager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as path from 'path';
import Docker from 'dockerode';
import baseDebug from '@tryghost/debug';
import logging from '@tryghost/logging';
Expand Down Expand Up @@ -116,7 +117,8 @@ export class MySQLManager {
async createSnapshot(sourceDatabase: string = 'ghost_testing', outputPath: string = SNAPSHOT_PATH): Promise<void> {
logging.info('Creating database snapshot...');

await this.exec(`mysqldump -uroot -proot --opt --single-transaction ${sourceDatabase} > ${outputPath}`);
await this.ensureSnapshotDirectory(outputPath);
await this.exec(`mysqldump -uroot -proot --opt --single-transaction ${sourceDatabase} > ${this.shellQuote(outputPath)}`);

logging.info('Database snapshot created');
}
Expand All @@ -125,6 +127,7 @@ export class MySQLManager {
const metadataPath = this.getSnapshotMetadataPath(snapshotPath);
const json = JSON.stringify(metadata);

await this.ensureSnapshotDirectory(snapshotPath);
await this.exec(`printf %s ${this.shellQuote(json)} > ${this.shellQuote(metadataPath)}`);
}

Expand Down Expand Up @@ -156,7 +159,7 @@ export class MySQLManager {
async restoreDatabaseFromSnapshot(database: string, snapshotPath: string = SNAPSHOT_PATH): Promise<void> {
debug('Restoring database from snapshot:', database);

await this.exec('mysql -uroot -proot ' + database + ' < ' + snapshotPath);
await this.exec('mysql -uroot -proot ' + database + ' < ' + this.shellQuote(snapshotPath));

debug('Database restored from snapshot:', database);
}
Expand All @@ -165,6 +168,10 @@ export class MySQLManager {
return `${snapshotPath}.meta.json`;
}

private async ensureSnapshotDirectory(snapshotPath: string): Promise<void> {
await this.exec(`mkdir -p ${this.shellQuote(path.posix.dirname(snapshotPath))}`);
}

private shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
Expand All @@ -188,7 +195,7 @@ export class MySQLManager {

async snapshotExists(snapshotPath: string = SNAPSHOT_PATH): Promise<boolean> {
try {
const output = await this.exec(`[ -f "${snapshotPath}" ] && echo "exists"`);
const output = await this.exec(`[ -f ${this.shellQuote(snapshotPath)} ] && echo "exists"`);
return output.trim() === 'exists';
} catch {
return false;
Expand Down
30 changes: 9 additions & 21 deletions e2e/helpers/utils/fixture-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ const __dirname = path.dirname(__filename);
const E2E_ROOT = path.resolve(__dirname, '../..');
const REPO_ROOT = path.resolve(E2E_ROOT, '..');

export const SNAPSHOT_PATH = '/tmp/dump.sql';
export const SNAPSHOT_PATH = '/mnt/e2e-cache/dump.sql';
export const FORCE_FIXTURE_RESET_ENV = 'E2E_FORCE_FIXTURE_RESET';
export const FIXTURE_CACHE_VERSION = 2;
export const CI_FIXTURE_CACHE_ENV = 'E2E_ENABLE_CI_FIXTURE_CACHE';
export const FIXTURE_CACHE_VERSION = 3;

export type FixtureRole = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor';
export type StaffFixtureRole = Exclude<FixtureRole, 'owner'>;
Expand All @@ -27,25 +28,7 @@ const MIGRATIONS_DIR = path.join(
);

const CACHE_FINGERPRINT_FILES = [
'docker/dev-gateway/Caddyfile',
'docker/dev-gateway/Caddyfile.build',
'e2e/package.json',
'e2e/playwright.config.mjs',
'e2e/data-factory/factories/user-factory.ts',
'e2e/data-factory/index.ts',
'e2e/helpers/pages/admin/admin-page.ts',
'e2e/helpers/pages/admin/analytics/analytics-overview-page.ts',
'e2e/helpers/pages/admin/login-page.ts',
'e2e/helpers/pages/admin/settings/settings-page.ts',
'e2e/helpers/pages/admin/settings/sections/staff-section.ts',
'e2e/helpers/pages/admin/signup-page.ts',
'e2e/helpers/playwright/context-with-auth-state.ts',
'e2e/helpers/playwright/flows/sign-in.ts',
'e2e/helpers/playwright/fixture.ts',
'e2e/helpers/services/email/utils.ts',
'e2e/helpers/utils/fixture-cache.ts',
'e2e/helpers/utils/setup-user.ts',
'e2e/tests/global.setup.ts'
'ghost/core/test/unit/server/data/schema/integrity.test.js'
];

export const STATE_DIR = path.join(E2E_ROOT, 'data', 'state');
Expand Down Expand Up @@ -74,6 +57,11 @@ export function shouldForceFixtureReset(): boolean {
return raw === '1' || raw === 'true';
}

export function shouldUseCIFixtureCache(): boolean {
const raw = process.env[CI_FIXTURE_CACHE_ENV];
return raw === '1' || raw === 'true';
}

export function getMissingAuthStateFiles(): string[] {
return Object.values(AUTH_STATE_BY_ROLE).filter(file => !fs.existsSync(file));
}
Expand Down
2 changes: 2 additions & 0 deletions e2e/scripts/infra-up.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ source "$SCRIPT_DIR/resolve-e2e-mode.sh"

cd "$REPO_ROOT"

mkdir -p e2e/data/cache/mysql e2e/data/state/auth

MODE="$(resolve_e2e_mode)"
export GHOST_E2E_MODE="$MODE"
ANALYTICS_ENABLED="${GHOST_E2E_ANALYTICS:-true}"
Expand Down
3 changes: 3 additions & 0 deletions e2e/scripts/run-playwright-container.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ docker run --rm --network host --ipc host \
-v "${WORKSPACE_PATH}:${WORKSPACE_PATH}" \
-w "${WORKSPACE_PATH}/e2e" \
-e CI=true \
-e NODE_VERSION="${NODE_VERSION:-}" \
-e TEST_WORKERS_COUNT="${TEST_WORKERS_COUNT:-1}" \
-e COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-ghost-dev}" \
-e GHOST_E2E_MODE="${GHOST_E2E_MODE:-build}" \
-e GHOST_E2E_IMAGE="${GHOST_E2E_IMAGE:-ghost-e2e:local}" \
-e GHOST_E2E_GATEWAY_IMAGE="${GHOST_E2E_GATEWAY_IMAGE:-caddy:2-alpine}" \
-e GHOST_E2E_ANALYTICS="${GHOST_E2E_ANALYTICS:-true}" \
-e E2E_ENABLE_CI_FIXTURE_CACHE="${E2E_ENABLE_CI_FIXTURE_CACHE:-false}" \
-e E2E_FORCE_FIXTURE_RESET="${E2E_FORCE_FIXTURE_RESET:-false}" \
"$PLAYWRIGHT_IMAGE" \
bash -c "corepack enable && bash ./scripts/run-playwright-host.sh pnpm exec playwright test ${project_args_string}--shard=${SHARD_INDEX}/${SHARD_TOTAL} --retries=${RETRIES}"
Loading