diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2b5e9aa162..465528258cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" @@ -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 }} @@ -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 diff --git a/compose.dev.yaml b/compose.dev.yaml index 5c1bf1d2e75..0c7732c0807 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -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 diff --git a/e2e/README.md b/e2e/README.md index ce7c9b6205d..b1c67d47e0c 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -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 @@ -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 diff --git a/e2e/helpers/environment/environment-manager.ts b/e2e/helpers/environment/environment-manager.ts index d22040a1f4f..4cc7f7057a6 100644 --- a/e2e/helpers/environment/environment-manager.ts +++ b/e2e/helpers/environment/environment-manager.ts @@ -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'; @@ -18,7 +18,7 @@ export type EnvironmentMode = 'dev' | 'build'; type GhostEnvOverrides = GhostConfig | Record; 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}; /** @@ -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 { @@ -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'); } @@ -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`); } @@ -194,7 +198,7 @@ export class EnvironmentManager { } private async getCacheStatus(): Promise { - if (this.isCI()) { + if (this.isCI() && !shouldUseCIFixtureCache()) { return {isValid: false, reason: 'ci_always_rebuild'}; } @@ -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) { @@ -238,4 +233,33 @@ export class EnvironmentManager { return {isValid: true, reason: 'cache_hit'}; } + + private async restoreBaseDatabaseFromSnapshot(): Promise { + 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; + } + } } diff --git a/e2e/helpers/environment/service-managers/mysql-manager.ts b/e2e/helpers/environment/service-managers/mysql-manager.ts index 8b29f1c7ac7..dbb4d6b6bce 100644 --- a/e2e/helpers/environment/service-managers/mysql-manager.ts +++ b/e2e/helpers/environment/service-managers/mysql-manager.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import Docker from 'dockerode'; import baseDebug from '@tryghost/debug'; import logging from '@tryghost/logging'; @@ -116,7 +117,8 @@ export class MySQLManager { async createSnapshot(sourceDatabase: string = 'ghost_testing', outputPath: string = SNAPSHOT_PATH): Promise { 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'); } @@ -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)}`); } @@ -156,7 +159,7 @@ export class MySQLManager { async restoreDatabaseFromSnapshot(database: string, snapshotPath: string = SNAPSHOT_PATH): Promise { 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); } @@ -165,6 +168,10 @@ export class MySQLManager { return `${snapshotPath}.meta.json`; } + private async ensureSnapshotDirectory(snapshotPath: string): Promise { + await this.exec(`mkdir -p ${this.shellQuote(path.posix.dirname(snapshotPath))}`); + } + private shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } @@ -188,7 +195,7 @@ export class MySQLManager { async snapshotExists(snapshotPath: string = SNAPSHOT_PATH): Promise { 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; diff --git a/e2e/helpers/utils/fixture-cache.ts b/e2e/helpers/utils/fixture-cache.ts index f470cf74777..4a595f14c30 100644 --- a/e2e/helpers/utils/fixture-cache.ts +++ b/e2e/helpers/utils/fixture-cache.ts @@ -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; @@ -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'); @@ -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)); } diff --git a/e2e/scripts/infra-up.sh b/e2e/scripts/infra-up.sh index 31b4bf8475e..de3c4941870 100755 --- a/e2e/scripts/infra-up.sh +++ b/e2e/scripts/infra-up.sh @@ -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}" diff --git a/e2e/scripts/run-playwright-container.sh b/e2e/scripts/run-playwright-container.sh index b2eb64c5bf5..a5316049e39 100755 --- a/e2e/scripts/run-playwright-container.sh +++ b/e2e/scripts/run-playwright-container.sh @@ -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}"